Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

---

## [Unreleased] — Domain Coverage Expansion (P0–P3)
## [Unreleased] — Domain Coverage Expansion (P0–P4)

### Added

Expand Down Expand Up @@ -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=<id>|--provider=<id>` — unified Codex pool activity lookup; legacy agent/provider commands remain as deprecated aliases.
- `goclaw api-keys rotate <id>` — 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 <agent> --session=<key>` and `goclaw chat sessions resume <agent> --session=<key>` — discoverability wrappers over existing chat session contracts.
- `goclaw tools invoke <name> --args=<json|@file>` — 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.
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 |
Expand Down Expand Up @@ -300,10 +301,31 @@ goclaw api-keys list

# Revoke a key
goclaw api-keys revoke <key-id>

# Rotate a key by creating a replacement and revoking the old key
goclaw api-keys rotate <key-id> --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
Expand Down
5 changes: 3 additions & 2 deletions cmd/agents_misc.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ Example:
}

var agentsCodexPoolActivityCmd = &cobra.Command{
Use: "codex-pool-activity <id>",
Short: "Get codex pool activity for an agent",
Use: "codex-pool-activity <id>",
Short: "Get codex pool activity for an agent",
Deprecated: "use 'goclaw codex-pool activity --agent=<id>' instead",
Long: `Retrieve recent codex (context pool) activity for an agent.

GET /v1/agents/{id}/codex-pool-activity
Expand Down
13 changes: 3 additions & 10 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions cmd/api_keys_helpers.go
Original file line number Diff line number Diff line change
@@ -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
}
85 changes: 85 additions & 0 deletions cmd/api_keys_rotate.go
Original file line number Diff line number Diff line change
@@ -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 <id>",
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)
}
27 changes: 1 addition & 26 deletions cmd/chat_ai_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}

Expand Down
52 changes: 52 additions & 0 deletions cmd/chat_replay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package cmd

import "github.com/spf13/cobra"

var chatReplayCmd = &cobra.Command{
Use: "replay <agent>",
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)
}
29 changes: 29 additions & 0 deletions cmd/chat_sessions.go
Original file line number Diff line number Diff line change
@@ -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 <agent>",
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)
}
2 changes: 2 additions & 0 deletions cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ func TestAllCommandsRegistered(t *testing.T) {
"chat",
"config",
"contacts",
"codex-pool",
"credentials",
"cron",
"delegations",
Expand Down Expand Up @@ -100,6 +101,7 @@ func TestCommandUseFields(t *testing.T) {
{"chat"},
{"config"},
{"contacts"},
{"codex-pool"},
{"credentials"},
{"cron"},
{"delegations"},
Expand Down
45 changes: 45 additions & 0 deletions cmd/codex_pool.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading