Skip to content

Commit 6b05143

Browse files
foekenclaude
andcommitted
Reorganize CLI into separate files for maintainability
Split the monolithic main.go into logical modules: - main.go: Entry point and root command setup - client.go: HTTP client and API request handling - output.go: Output formatting (JSON and human-readable) - cmd_focus.go: Focus command - cmd_search.go: Search command - cmd_accounts.go: Accounts commands - cmd_chats.go: Chats commands - cmd_messages.go: Messages commands - cmd_assets.go: Assets commands Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 834bfb4 commit 6b05143

10 files changed

Lines changed: 1544 additions & 1517 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Binary
22
beeper
3+
beeper-cli
34
beeper.exe
45

56
# Build artifacts

client.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"mime/multipart"
9+
"net/http"
10+
"net/url"
11+
"os"
12+
"path/filepath"
13+
"strconv"
14+
"strings"
15+
"time"
16+
)
17+
18+
// Client handles API requests
19+
type Client struct {
20+
baseURL string
21+
accessToken string
22+
httpClient *http.Client
23+
}
24+
25+
// NewClient creates a new API client
26+
func NewClient(baseURL, accessToken string) *Client {
27+
return &Client{
28+
baseURL: strings.TrimSuffix(baseURL, "/"),
29+
accessToken: accessToken,
30+
httpClient: &http.Client{Timeout: 30 * time.Second},
31+
}
32+
}
33+
34+
// request makes an HTTP request to the API
35+
func (c *Client) request(method, path string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
36+
urlStr := c.baseURL + path
37+
38+
// Add query parameters
39+
if len(params) > 0 && (method == "GET" || method == "DELETE") {
40+
u, _ := url.Parse(urlStr)
41+
q := u.Query()
42+
for k, v := range params {
43+
if v == nil {
44+
continue
45+
}
46+
switch val := v.(type) {
47+
case string:
48+
if val != "" {
49+
q.Set(k, val)
50+
}
51+
case []string:
52+
for _, s := range val {
53+
q.Add(k, s)
54+
}
55+
case int:
56+
q.Set(k, strconv.Itoa(val))
57+
case bool:
58+
q.Set(k, strconv.FormatBool(val))
59+
default:
60+
q.Set(k, fmt.Sprintf("%v", val))
61+
}
62+
}
63+
u.RawQuery = q.Encode()
64+
urlStr = u.String()
65+
}
66+
67+
var reqBody io.Reader
68+
if body != nil {
69+
jsonData, err := json.Marshal(body)
70+
if err != nil {
71+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
72+
}
73+
reqBody = bytes.NewBuffer(jsonData)
74+
}
75+
76+
req, err := http.NewRequest(method, urlStr, reqBody)
77+
if err != nil {
78+
return nil, fmt.Errorf("failed to create request: %w", err)
79+
}
80+
81+
req.Header.Set("Authorization", "Bearer "+c.accessToken)
82+
if body != nil {
83+
req.Header.Set("Content-Type", "application/json")
84+
}
85+
86+
resp, err := c.httpClient.Do(req)
87+
if err != nil {
88+
return nil, fmt.Errorf("request failed: %w", err)
89+
}
90+
defer resp.Body.Close()
91+
92+
respBody, err := io.ReadAll(resp.Body)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to read response: %w", err)
95+
}
96+
97+
if resp.StatusCode >= 400 {
98+
var errResp map[string]interface{}
99+
if json.Unmarshal(respBody, &errResp) == nil {
100+
if errMsg, ok := errResp["error"].(string); ok {
101+
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errMsg)
102+
}
103+
}
104+
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
105+
}
106+
107+
if len(respBody) == 0 || resp.StatusCode == 204 {
108+
return map[string]interface{}{"success": true}, nil
109+
}
110+
111+
// Try parsing as object first, then as array
112+
var result map[string]interface{}
113+
if err := json.Unmarshal(respBody, &result); err != nil {
114+
// Try parsing as array
115+
var arrayResult []interface{}
116+
if err := json.Unmarshal(respBody, &arrayResult); err != nil {
117+
return nil, fmt.Errorf("failed to parse response: %w", err)
118+
}
119+
// Wrap array in a map for consistent handling
120+
return map[string]interface{}{"items": arrayResult}, nil
121+
}
122+
123+
return result, nil
124+
}
125+
126+
// uploadFile uploads a file using multipart form
127+
func (c *Client) uploadFile(path, filePath string) (map[string]interface{}, error) {
128+
file, err := os.Open(filePath)
129+
if err != nil {
130+
return nil, fmt.Errorf("failed to open file: %w", err)
131+
}
132+
defer file.Close()
133+
134+
body := &bytes.Buffer{}
135+
writer := multipart.NewWriter(body)
136+
part, err := writer.CreateFormFile("file", filepath.Base(filePath))
137+
if err != nil {
138+
return nil, fmt.Errorf("failed to create form file: %w", err)
139+
}
140+
141+
if _, err := io.Copy(part, file); err != nil {
142+
return nil, fmt.Errorf("failed to copy file: %w", err)
143+
}
144+
writer.Close()
145+
146+
req, err := http.NewRequest("POST", c.baseURL+path, body)
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to create request: %w", err)
149+
}
150+
151+
req.Header.Set("Authorization", "Bearer "+c.accessToken)
152+
req.Header.Set("Content-Type", writer.FormDataContentType())
153+
154+
resp, err := c.httpClient.Do(req)
155+
if err != nil {
156+
return nil, fmt.Errorf("request failed: %w", err)
157+
}
158+
defer resp.Body.Close()
159+
160+
respBody, err := io.ReadAll(resp.Body)
161+
if err != nil {
162+
return nil, fmt.Errorf("failed to read response: %w", err)
163+
}
164+
165+
if resp.StatusCode >= 400 {
166+
return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody))
167+
}
168+
169+
var result map[string]interface{}
170+
if err := json.Unmarshal(respBody, &result); err != nil {
171+
return nil, fmt.Errorf("failed to parse response: %w", err)
172+
}
173+
174+
return result, nil
175+
}
176+
177+
func getClient() (*Client, error) {
178+
token := accessToken
179+
if token == "" {
180+
token = os.Getenv("BEEPER_ACCESS_TOKEN")
181+
}
182+
if token == "" {
183+
return nil, fmt.Errorf("access token required. Set BEEPER_ACCESS_TOKEN or use --token")
184+
}
185+
186+
url := baseURL
187+
if url == "" {
188+
url = os.Getenv("BEEPER_DESKTOP_BASE_URL")
189+
}
190+
if url == "" {
191+
url = defaultBaseURL
192+
}
193+
194+
return NewClient(url, token), nil
195+
}

cmd_accounts.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package main
2+
3+
import (
4+
"net/url"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
func accountsCmd() *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "accounts",
12+
Short: "Manage connected chat accounts",
13+
}
14+
15+
cmd.AddCommand(accountsListCmd())
16+
cmd.AddCommand(accountsContactsCmd())
17+
18+
return cmd
19+
}
20+
21+
func accountsListCmd() *cobra.Command {
22+
return &cobra.Command{
23+
Use: "list",
24+
Short: "List all connected messaging accounts",
25+
Long: `List all connected messaging accounts.
26+
27+
Shows accounts from all networks (WhatsApp, Telegram, iMessage, etc.)
28+
actively connected to Beeper Desktop.
29+
30+
Examples:
31+
beeper accounts list`,
32+
RunE: func(cmd *cobra.Command, args []string) error {
33+
client, err := getClient()
34+
if err != nil {
35+
return err
36+
}
37+
38+
result, err := client.request("GET", "/v1/accounts", nil, nil)
39+
if err != nil {
40+
return err
41+
}
42+
outputResult(result)
43+
return nil
44+
},
45+
}
46+
}
47+
48+
func accountsContactsCmd() *cobra.Command {
49+
cmd := &cobra.Command{
50+
Use: "contacts",
51+
Short: "Manage contacts on accounts",
52+
}
53+
54+
cmd.AddCommand(contactsSearchCmd())
55+
56+
return cmd
57+
}
58+
59+
func contactsSearchCmd() *cobra.Command {
60+
return &cobra.Command{
61+
Use: "search <account-id> <query>",
62+
Short: "Search contacts on a specific account",
63+
Long: `Search contacts on a specific account.
64+
65+
Uses the network's search API to find contacts.
66+
67+
Examples:
68+
beeper accounts contacts search "whatsapp:123456" "John"`,
69+
Args: cobra.ExactArgs(2),
70+
RunE: func(cmd *cobra.Command, args []string) error {
71+
client, err := getClient()
72+
if err != nil {
73+
return err
74+
}
75+
76+
params := map[string]interface{}{"query": args[1]}
77+
result, err := client.request("GET", "/v1/accounts/"+url.PathEscape(cleanID(args[0]))+"/contacts", params, nil)
78+
if err != nil {
79+
return err
80+
}
81+
outputResult(result)
82+
return nil
83+
},
84+
}
85+
}

0 commit comments

Comments
 (0)