Skip to content

Commit 4e1a0a5

Browse files
authored
feat: add raw API command (#13)
* feat: add raw API command with write gate * feat: add swagger to SKILL.md * feat: remove write config * feat: update skills * feat: add better epilogue
1 parent 24a64e2 commit 4e1a0a5

8 files changed

Lines changed: 380 additions & 12 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ chatwoot contact 456 conversations
5757

5858
chatwoot inboxes / agents / labels / teams # List
5959
chatwoot me # Your profile
60+
61+
chatwoot api /conversations/123 # Expands to /api/v1/accounts/<id>/conversations/123
62+
chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}'
6063
```
6164

6265
Run `chatwoot --help` or see the [full command reference](https://developers.chatwoot.com/cli/commands).

internal/cmd/api.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/textproto"
10+
"net/url"
11+
"os"
12+
"strings"
13+
)
14+
15+
type ApiCmd struct {
16+
Method string `short:"X" placeholder:"METHOD" help:"HTTP method. Defaults to POST with --data, otherwise GET."`
17+
Data string `short:"d" placeholder:"JSON|@FILE" help:"JSON request body, or @file to read body from a file."`
18+
Header []string `short:"H" placeholder:"HEADER" help:"Additional request header, as 'Name: value'. Repeatable."`
19+
Exact bool `help:"Use the path exactly as provided under the configured base URL."`
20+
Path string `arg:"" help:"Endpoint path. /conversations/123 expands under the configured account."`
21+
}
22+
23+
func (c *ApiCmd) Help() string {
24+
return `Account-relative paths such as /conversations/123 are expanded under
25+
/api/v1/accounts/<account_id>. Use --exact for non-account-scoped paths.
26+
27+
The default method is GET, or POST when --data is provided. Override with -X.
28+
29+
Examples:
30+
chatwoot api /conversations/123
31+
chatwoot api -X PATCH /conversations/123 --data '{"status":"open"}'
32+
chatwoot api --exact /api/v1/profile`
33+
}
34+
35+
func (c *ApiCmd) Run(app *App) error {
36+
method := strings.ToUpper(c.Method)
37+
if method == "" {
38+
if c.Data != "" {
39+
method = http.MethodPost
40+
} else {
41+
method = http.MethodGet
42+
}
43+
}
44+
45+
path, accountScoped, err := normalizeAPIPath(c.Path, c.Exact)
46+
if err != nil {
47+
return err
48+
}
49+
50+
body, err := apiRequestBody(c.Data)
51+
if err != nil {
52+
return err
53+
}
54+
55+
headers, err := parseAPIHeaders(c.Header)
56+
if err != nil {
57+
return err
58+
}
59+
60+
resp, err := app.Client.RequestRaw(method, path, body, accountScoped, headers)
61+
if err != nil {
62+
return err
63+
}
64+
65+
printAPIResponse(app.Printer.Writer, resp.Body)
66+
return nil
67+
}
68+
69+
func normalizeAPIPath(path string, exact bool) (string, bool, error) {
70+
if strings.TrimSpace(path) == "" {
71+
return "", false, fmt.Errorf("path is required")
72+
}
73+
74+
parsed, err := url.Parse(path)
75+
if err != nil {
76+
return "", false, fmt.Errorf("invalid path: %w", err)
77+
}
78+
if parsed.IsAbs() {
79+
return "", false, fmt.Errorf("absolute URLs are not supported; pass a path under the configured Chatwoot base URL")
80+
}
81+
82+
if !strings.HasPrefix(path, "/") {
83+
path = "/" + path
84+
}
85+
if exact || strings.HasPrefix(path, "/api/") {
86+
return path, false, nil
87+
}
88+
return path, true, nil
89+
}
90+
91+
func apiRequestBody(data string) (io.Reader, error) {
92+
if data == "" {
93+
return nil, nil
94+
}
95+
if !strings.HasPrefix(data, "@") {
96+
return strings.NewReader(data), nil
97+
}
98+
if data == "@-" {
99+
return os.Stdin, nil
100+
}
101+
102+
body, err := os.ReadFile(strings.TrimPrefix(data, "@"))
103+
if err != nil {
104+
return nil, fmt.Errorf("read request body: %w", err)
105+
}
106+
return bytes.NewReader(body), nil
107+
}
108+
109+
func parseAPIHeaders(headerArgs []string) (http.Header, error) {
110+
headers := make(http.Header)
111+
for _, arg := range headerArgs {
112+
name, value, ok := strings.Cut(arg, ":")
113+
if !ok || strings.TrimSpace(name) == "" {
114+
return nil, fmt.Errorf("invalid header %q; expected 'Name: value'", arg)
115+
}
116+
headers.Add(textproto.CanonicalMIMEHeaderKey(strings.TrimSpace(name)), strings.TrimSpace(value))
117+
}
118+
return headers, nil
119+
}
120+
121+
func printAPIResponse(w io.Writer, body []byte) {
122+
if len(strings.TrimSpace(string(body))) == 0 {
123+
return
124+
}
125+
126+
var decoded any
127+
if err := json.Unmarshal(body, &decoded); err == nil {
128+
enc := json.NewEncoder(w)
129+
enc.SetIndent("", " ")
130+
_ = enc.Encode(decoded)
131+
return
132+
}
133+
134+
_, _ = w.Write(body)
135+
if body[len(body)-1] != '\n' {
136+
_, _ = fmt.Fprintln(w)
137+
}
138+
}

internal/cmd/api_test.go

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"strings"
10+
"testing"
11+
12+
"github.com/chatwoot/cli/internal/config"
13+
)
14+
15+
func TestApiCmdCallsAccountScopedEndpoint(t *testing.T) {
16+
setupTestEnv(t)
17+
18+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19+
if r.URL.Path != "/api/v1/accounts/1/conversations/123" {
20+
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
21+
return
22+
}
23+
if r.Header.Get("api_access_token") != "test-token" {
24+
t.Errorf("api_access_token = %q, want test-token", r.Header.Get("api_access_token"))
25+
}
26+
w.Header().Set("Content-Type", "application/json")
27+
_, _ = w.Write([]byte(`{"id":123,"status":"open"}`))
28+
}))
29+
defer server.Close()
30+
31+
if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
32+
t.Fatalf("config.Save: %v", err)
33+
}
34+
app, err := NewApp(&CLI{Output: "text"}, false, "test")
35+
if err != nil {
36+
t.Fatalf("NewApp: %v", err)
37+
}
38+
39+
var out bytes.Buffer
40+
app.Printer.Writer = &out
41+
42+
if err := (&ApiCmd{Path: "/conversations/123"}).Run(app); err != nil {
43+
t.Fatalf("Run: %v", err)
44+
}
45+
46+
var got map[string]any
47+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
48+
t.Fatalf("output is not JSON: %v\n%s", err, out.String())
49+
}
50+
if got["id"] != float64(123) || got["status"] != "open" {
51+
t.Fatalf("output = %#v, want conversation JSON", got)
52+
}
53+
}
54+
55+
func TestApiCmdUsesExactAPIPath(t *testing.T) {
56+
setupTestEnv(t)
57+
58+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59+
if r.URL.Path != "/api/v1/profile" {
60+
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusNotFound)
61+
return
62+
}
63+
w.Header().Set("Content-Type", "application/json")
64+
_, _ = w.Write([]byte(`{"id":7,"name":"Ada"}`))
65+
}))
66+
defer server.Close()
67+
68+
if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
69+
t.Fatalf("config.Save: %v", err)
70+
}
71+
app, err := NewApp(&CLI{Output: "text"}, false, "test")
72+
if err != nil {
73+
t.Fatalf("NewApp: %v", err)
74+
}
75+
app.Printer.Writer = &bytes.Buffer{}
76+
77+
if err := (&ApiCmd{Path: "/api/v1/profile"}).Run(app); err != nil {
78+
t.Fatalf("Run: %v", err)
79+
}
80+
}
81+
82+
func TestApiCmdSendsMethodBodyAndHeaders(t *testing.T) {
83+
setupTestEnv(t)
84+
85+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
86+
if r.Method != http.MethodPatch {
87+
t.Errorf("method = %s, want PATCH", r.Method)
88+
}
89+
if r.URL.Path != "/api/v1/accounts/1/conversations/123/custom_attributes" {
90+
t.Errorf("path = %s", r.URL.Path)
91+
}
92+
if r.Header.Get("X-Test") != "yes" {
93+
t.Errorf("X-Test header = %q, want yes", r.Header.Get("X-Test"))
94+
}
95+
body, err := io.ReadAll(r.Body)
96+
if err != nil {
97+
t.Errorf("read body: %v", err)
98+
}
99+
if strings.TrimSpace(string(body)) != `{"priority":"urgent"}` {
100+
t.Errorf("body = %q", string(body))
101+
}
102+
w.WriteHeader(http.StatusNoContent)
103+
}))
104+
defer server.Close()
105+
106+
if err := config.Save(&config.Config{BaseURL: server.URL, AccountID: 1}); err != nil {
107+
t.Fatalf("config.Save: %v", err)
108+
}
109+
app, err := NewApp(&CLI{Output: "text"}, false, "test")
110+
if err != nil {
111+
t.Fatalf("NewApp: %v", err)
112+
}
113+
app.Printer.Writer = &bytes.Buffer{}
114+
115+
err = (&ApiCmd{
116+
Method: "patch",
117+
Path: "conversations/123/custom_attributes",
118+
Data: `{"priority":"urgent"}`,
119+
Header: []string{"X-Test: yes"},
120+
}).Run(app)
121+
if err != nil {
122+
t.Fatalf("Run: %v", err)
123+
}
124+
}
125+
126+
func TestNormalizeAPIPathRejectsAbsoluteURLs(t *testing.T) {
127+
if _, _, err := normalizeAPIPath("https://example.com/api/v1/profile", false); err == nil {
128+
t.Fatal("expected absolute URL error")
129+
}
130+
}

internal/cmd/app.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,12 @@ func NewApp(cli *CLI, skipAuth bool, version string) (*App, error) {
4444
return nil, fmt.Errorf("not authenticated: %w", err)
4545
}
4646

47-
client := sdk.NewClient(effectiveCfg.BaseURL, apiKey, effectiveCfg.AccountID, sdk.WithVerbose(cli.Verbose))
47+
client := sdk.NewClient(
48+
effectiveCfg.BaseURL,
49+
apiKey,
50+
effectiveCfg.AccountID,
51+
sdk.WithVerbose(cli.Verbose),
52+
)
4853

4954
return &App{
5055
Client: client,

internal/cmd/cli.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ type CLI struct {
3434
// Workflow. `me` and `whoami` are aliases of `auth status`.
3535
Me MeCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."`
3636
Whoami WhoamiCmd `cmd:"" help:"Show identity and connection (alias of 'auth status')."`
37+
Api ApiCmd `cmd:"" help:"Make an HTTP request to the Chatwoot API."`
3738

3839
// Setup.
3940
Auth AuthCmd `cmd:"" help:"Login, logout, and status."`

internal/cmd/conversation_test.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,4 +236,3 @@ func TestConvUnassignPostsZeroAssignee(t *testing.T) {
236236
t.Errorf("posted assignee_id = %v, want 0 (Chatwoot's unassign sentinel)", got.AssigneeID)
237237
}
238238
}
239-

0 commit comments

Comments
 (0)