diff --git a/.gitignore b/.gitignore index 13b643ec..bf7d3f55 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ node_modules/ /beeper-desktop-cli .upstream/ *.exe + +.claude/ diff --git a/README.md b/README.md index 6e4e5cc5..385ff825 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# beeper — One CLI for all your chats +
--target work --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify`
Finish setup verification or verify another device
@@ -970,7 +988,7 @@ beeper verify
beeper verify --user @alice:beeper.com
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify status`
Show encryption and device-verification readiness
@@ -985,7 +1003,7 @@ Examples:
beeper verify status --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify approve`
Approve a pending device verification request
@@ -1006,7 +1024,7 @@ Examples:
beeper verify approve --id active
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify recovery-key`
Unlock encrypted messages with a recovery key
@@ -1027,7 +1045,7 @@ Examples:
beeper verify recovery-key --key ABCD-EFGH-IJKL
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify reset-recovery-key`
Create a new encrypted-messages recovery key
@@ -1042,7 +1060,7 @@ Examples:
beeper verify reset-recovery-key
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify cancel`
Cancel an in-progress device verification
@@ -1063,7 +1081,7 @@ Examples:
beeper verify cancel
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify list`
List active verification work
@@ -1078,7 +1096,7 @@ Examples:
beeper verify list
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify start`
Start a device verification request
@@ -1099,7 +1117,7 @@ Examples:
beeper verify start --user @alice:beeper.com
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify show`
Show the current active verification request
@@ -1114,7 +1132,7 @@ Examples:
beeper verify show --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify sas`
Start emoji verification
@@ -1135,7 +1153,7 @@ Examples:
beeper verify sas
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify sas-confirm`
Confirm matching emoji verification
@@ -1156,7 +1174,7 @@ Examples:
beeper verify sas-confirm
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify qr-scan`
Submit a scanned QR-code verification payload
@@ -1178,7 +1196,7 @@ Examples:
beeper verify qr-scan --payload "..."
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper verify qr-confirm`
Confirm that the other device scanned your QR code
@@ -1199,7 +1217,7 @@ Examples:
beeper verify qr-confirm
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper accounts list`
List connected accounts
@@ -1222,7 +1240,7 @@ beeper accounts list
beeper accounts list --account whatsapp --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper accounts add`
Connect a chat account by bridge
@@ -1262,7 +1280,7 @@ beeper accounts add discord --non-interactive --cookie sessiontoken=...
beeper accounts add discord --webview --webview-backend chrome
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper accounts show`
Show account details
@@ -1283,7 +1301,7 @@ Examples:
beeper accounts show whatsapp-main
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper accounts remove`
Remove an account
@@ -1304,7 +1322,7 @@ Examples:
beeper accounts remove whatsapp-main
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper accounts use`
Select a default account for account-scoped commands
@@ -1327,7 +1345,7 @@ Examples:
beeper accounts use whatsapp-main
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats list`
List chats
@@ -1354,10 +1372,11 @@ Examples:
```sh
beeper chats list
beeper chats list --pinned --limit 50
-beeper chats list --unread --no-muted --json
+beeper chats list --unread --no-muted --format json
+beeper ls --format ids
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats search`
Search chats
@@ -1386,7 +1405,7 @@ Examples:
beeper chats search Family
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats show`
Show chat details
@@ -1410,7 +1429,7 @@ beeper chats show --chat 10313
beeper chats show --chat '!plUOsWkvMmJmJPVAjS:beeper.com'
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats start`
Start a chat
@@ -1439,7 +1458,7 @@ beeper chats start +15551234567
beeper chats start @alice:beeper.com --title "Alice"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats archive`
Archive a chat
@@ -1461,7 +1480,7 @@ Examples:
beeper chats archive --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats unarchive`
Unarchive a chat
@@ -1483,7 +1502,7 @@ Examples:
beeper chats unarchive --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats pin`
Pin a chat
@@ -1505,7 +1524,7 @@ Examples:
beeper chats pin --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats unpin`
Unpin a chat
@@ -1527,7 +1546,7 @@ Examples:
beeper chats unpin --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats mute`
Mute a chat
@@ -1549,7 +1568,7 @@ Examples:
beeper chats mute --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats unmute`
Unmute a chat
@@ -1571,7 +1590,7 @@ Examples:
beeper chats unmute --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats mark-read`
Mark a chat as read
@@ -1594,7 +1613,7 @@ Examples:
beeper chats mark-read --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats mark-unread`
Mark a chat as unread
@@ -1617,7 +1636,7 @@ Examples:
beeper chats mark-unread --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats priority`
Move a chat to the Inbox or Low Priority
@@ -1641,7 +1660,7 @@ beeper chats priority --chat 10313 --level inbox
beeper chats priority --chat '!plUOsWkvMmJmJPVAjS:beeper.com' --level low
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats notify-anyway`
Send an iMessage Notify Anyway alert
@@ -1663,7 +1682,7 @@ Examples:
beeper chats notify-anyway --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats rename`
Rename a chat
@@ -1686,7 +1705,7 @@ Examples:
beeper chats rename --chat 10313 --title "Family"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats description`
Set a chat description
@@ -1711,7 +1730,7 @@ beeper chats description --chat 10313 --description "Engineering chat"
beeper chats description --chat 10313 --clear
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats avatar`
Set a chat avatar
@@ -1735,7 +1754,7 @@ Examples:
beeper chats avatar --chat 10313 --file ./team.png
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats draft`
Set or clear a chat draft
@@ -1763,7 +1782,7 @@ beeper chats draft --chat 10313 --text "on my way"
beeper chats draft --chat 10313 --clear
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats disappear`
Set disappearing-message expiry
@@ -1786,7 +1805,7 @@ Examples:
beeper chats disappear --chat 10313 --seconds 86400
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats remind`
Set a chat reminder
@@ -1811,7 +1830,7 @@ beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z
beeper chats remind --chat 10313 --when 2026-06-01T09:00:00Z --dismiss-on-message
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats unremind`
Clear a chat reminder
@@ -1833,7 +1852,7 @@ Examples:
beeper chats unremind --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper chats focus`
Focus Beeper Desktop on a chat
@@ -1858,7 +1877,7 @@ Examples:
beeper chats focus --chat 10313
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages list`
List chat messages
@@ -1888,7 +1907,7 @@ beeper messages list --chat 10313 --before-cursor "" --limit 100
beeper messages list --chat 10313 --sender me --asc
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages search`
Search messages across chats
@@ -1923,11 +1942,12 @@ Examples:
```sh
beeper messages search invoice
+beeper search invoice --format jsonl --select id,chatID,text
beeper messages search --chat 10313 --sender me --media image
beeper messages search "flight" --after 2026-01-01 --before 2026-02-01
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages show`
Show one message
@@ -1950,7 +1970,7 @@ Examples:
beeper messages show --chat 10313 --id
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages context`
Show message context
@@ -1975,7 +1995,7 @@ Examples:
beeper messages context --chat 10313 --id --before 5 --after 5
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages edit`
Edit a message
@@ -1999,7 +2019,7 @@ Examples:
beeper messages edit --chat 10313 --id --message "fixed"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages delete`
Delete a message
@@ -2023,7 +2043,7 @@ Examples:
beeper messages delete --chat 10313 --id --for-everyone
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper messages export`
Export one chat to JSON
@@ -2056,7 +2076,7 @@ beeper messages export --chat 8951 --after 2026-01-01T00:00:00Z --output -
beeper messages export --chat '!plUOsWkvMmJmJPVAjS:beeper.com' --before-cursor "" --limit 500
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send text`
Send a text message
@@ -2083,12 +2103,13 @@ Flags:
Examples:
```sh
+beeper send --to 10313 --message "on my way" --dry-run --format json
beeper send text --to 10313 --message "on my way"
beeper send text --to 8951 --message "hi"
beeper send text --to "Family" --message "hi" --pick 1
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send file`
Send a file
@@ -2119,7 +2140,7 @@ Examples:
beeper send file --to 8951 --file ./photo.jpg --caption "from today"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send react`
Send a reaction to a message
@@ -2144,7 +2165,7 @@ Examples:
beeper send react --to 10313 --id --reaction "+1"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send sticker`
Send a sticker
@@ -2174,7 +2195,7 @@ Examples:
beeper send sticker --to 10313 --file ./hi.webp
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send unreact`
Remove a reaction from a message
@@ -2199,7 +2220,7 @@ Examples:
beeper send unreact --to 10313 --id --reaction "+1"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper send voice`
Send a voice note
@@ -2231,7 +2252,7 @@ beeper send voice --to 10313 --file ./note.ogg
beeper send voice --to 10313 --file ./note.ogg --duration 12
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper presence`
Send a typing (or paused) indicator to a chat
@@ -2259,7 +2280,7 @@ beeper presence --chat 10313 --state paused
beeper presence --chat 10313 --duration 5
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper contacts list`
List contacts
@@ -2285,7 +2306,7 @@ Examples:
beeper contacts list --account whatsapp --query alice
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper contacts search`
Search contacts
@@ -2314,7 +2335,7 @@ Examples:
beeper contacts search alice
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper contacts show`
Show contact details
@@ -2341,7 +2362,147 @@ Examples:
beeper contacts show "Alice" --account whatsapp
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper resolve chat`
+Resolve a chat selector to concrete chat candidates
+
+```sh
+beeper resolve chat
+```
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `selector` | yes | Chat ID, local ID, exact title, or search text |
+
+Flags:
+
+| Flag | Type | Description |
+| --- | --- | --- |
+| `--account=...` | option | Limit to account selector. Repeat for multiple. |
+| `--limit=` | option | Maximum candidates to return Default: 10 |
+| `--pick=` | option | Select the Nth candidate (1-indexed) |
+
+Examples:
+
+```sh
+beeper resolve chat Family --format json
+beeper resolve chat Family --pick 1 --results-only
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper resolve account`
+Resolve an account selector
+
+```sh
+beeper resolve account
+```
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `selector` | yes | Account ID, network, bridge, or account user selector |
+
+Flags:
+
+| Flag | Type | Description |
+| --- | --- | --- |
+| `--pick=` | option | Select the Nth candidate (1-indexed) |
+
+Examples:
+
+```sh
+beeper resolve account whatsapp --format json
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper resolve contact`
+Resolve a contact selector
+
+```sh
+beeper resolve contact
+```
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `selector` | yes | Contact name, username, phone, email, or ID |
+
+Flags:
+
+| Flag | Type | Description |
+| --- | --- | --- |
+| `--account=...` | option | Limit to account selector. Repeat for multiple. |
+| `--limit=` | option | Maximum candidates to return per account Default: 10 |
+| `--pick=` | option | Select the Nth candidate (1-indexed) |
+
+Examples:
+
+```sh
+beeper resolve contact Alice --account whatsapp --format json
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper resolve target`
+Resolve a target selector
+
+```sh
+beeper resolve target
+```
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `selector` | yes | Target name, ID, type, or base URL |
+
+Flags:
+
+| Flag | Type | Description |
+| --- | --- | --- |
+| `--pick=` | option | Select the Nth candidate (1-indexed) |
+
+Examples:
+
+```sh
+beeper resolve target desktop --format json
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper resolve bridge`
+Resolve a bridge selector
+
+```sh
+beeper resolve bridge
+```
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `selector` | yes | Bridge ID, type, provider, or display name |
+
+Flags:
+
+| Flag | Type | Description |
+| --- | --- | --- |
+| `--pick=` | option | Select the Nth candidate (1-indexed) |
+
+Examples:
+
+```sh
+beeper resolve bridge whatsapp --format json
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper media download`
Download message media
@@ -2369,7 +2530,7 @@ beeper media download mxc://beeper.com/abc --out ./downloads
beeper media download mxc://beeper.com/abc -o - > photo.jpg
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper export`
Export accounts, chats, messages, Markdown transcripts, and attachments
@@ -2386,7 +2547,6 @@ Flags:
| --- | --- | --- |
| `--account=...` | option | Limit to an account selector. Repeat to include more accounts. |
| `--chat=...` | option | Limit to a chat selector. Repeat to include more chats. |
-| `--force` | boolean | Re-export chats even if checkpoint state says they are complete. |
| `--limit-chats=` | option | Maximum chats to export. Intended for testing large exports. |
| `--limit-messages=` | option | Maximum messages per chat. Intended for testing large exports. |
| `--max-participants=` | option | Maximum participants to include in each chat.json. Default: 500 |
@@ -2401,7 +2561,7 @@ beeper export --out ./beeper-export
beeper export --chat 10313 --out ./chat
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper watch`
Stream Desktop API WebSocket events
@@ -2415,8 +2575,8 @@ Flags:
| Flag | Type | Description |
| --- | --- | --- |
| `-c, --chat=...` | option | Chat ID to subscribe to. Defaults to all chats. |
-| `--exclude-type=...` | option | Drop events of these types. Repeat for multiple. |
-| `--include-type=...` | option | Only forward events of these types. Repeat for multiple. |
+| `--exclude-type=...` | option | Drop events of these types. Repeat for multiple. |
+| `--include-type=...` | option | Only forward events of these types. Repeat for multiple. |
| `--webhook=` | option | Forward each event to this URL as a POST request (best-effort, fire-and-forget) |
| `--webhook-queue=` | option | Maximum pending webhook deliveries before dropping events Default: 64 |
| `--webhook-secret=` | option | HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256= |
@@ -2430,7 +2590,7 @@ beeper watch --include-type message.upserted --include-type message.deleted
beeper watch --webhook https://example.com/hook --webhook-secret "$BEEPER_WEBHOOK_SECRET"
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper rpc`
Run newline-delimited JSON command RPC over stdin/stdout
@@ -2447,7 +2607,7 @@ Examples:
printf '{"id":1,"command":"chats list --json"}\n' | beeper rpc
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper man`
Print the command manual
@@ -2460,10 +2620,36 @@ Examples:
```sh
beeper man
-beeper man --json
+beeper man --format json
+beeper man --format ids
+```
+
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
+
+### `beeper schema`
+Print machine-readable command/flag schema
+
+```sh
+beeper schema [command]
+```
+
+Agent-first schema for commands, flags, args, examples, mutation metadata, selectors, output shapes, and related commands.
+
+Arguments:
+
+| Name | Required | Description |
+| --- | --- | --- |
+| `command` | no | Optional command path, such as "messages search" |
+
+Examples:
+
+```sh
+beeper schema
+beeper schema send --results-only
+beeper schema --select commands.path,commands.flags.name --results-only
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper doctor`
Probe the target live and report diagnostics
@@ -2481,7 +2667,7 @@ beeper doctor
beeper doctor --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper status`
Show selected target and setup readiness
@@ -2499,7 +2685,7 @@ beeper status
beeper status --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper docs`
Open Beeper CLI docs
@@ -2514,7 +2700,7 @@ Examples:
beeper docs
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper version`
Print CLI version
@@ -2529,7 +2715,7 @@ Examples:
beeper version
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper completion`
Print shell completion setup
@@ -2589,7 +2775,7 @@ beeper plugins available
beeper plugins available --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper update`
Check and install Beeper updates
@@ -2615,7 +2801,7 @@ beeper update --cli
beeper update --server
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper config get`
Print CLI configuration
@@ -2637,7 +2823,7 @@ beeper config get
beeper config get defaultTarget
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper config set`
Set a CLI configuration value
@@ -2659,7 +2845,7 @@ Examples:
beeper config set defaultTarget work
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper config path`
Print the CLI config path
@@ -2674,7 +2860,7 @@ Examples:
beeper config path
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper config reset`
Reset CLI configuration
@@ -2689,7 +2875,7 @@ Examples:
beeper config reset
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper api get`
Call a raw Desktop API GET path
@@ -2717,7 +2903,7 @@ beeper api get /v1/info
beeper api get /v1/chats --json
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper api post`
Call a raw Desktop API POST path with a JSON body
@@ -2745,7 +2931,7 @@ Examples:
beeper api post /v1/chats/abc/read --body '{"messageID":"x"}'
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
### `beeper api request`
Call a raw Desktop API path with any supported HTTP method
@@ -2774,7 +2960,7 @@ Examples:
beeper api request DELETE /v1/chats/abc/messages/def/reactions --body '{"reactionKey":"👍"}'
```
-Global flags: `--base-url`, `--target`, `--debug`, `--events`, `--full`, `--json`, `--quiet`, `--read-only`, `--timeout`, `--yes`.
+Global flags: `--base-url`, `--target`, `--debug`, `--dry-run`, `--events`, `--force`, `--format`, `--full`, `--json`, `--no-input`, `--quiet`, `--read-only`, `--results-only`, `--select`, `--timeout`, `--yes`.
## Publishing
diff --git a/packages/cli/docs/api.md b/packages/cli/docs/api.md
deleted file mode 100644
index b9cf1de1..00000000
--- a/packages/cli/docs/api.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# api
-
-Read when: calling raw Desktop API endpoints that the CLI doesn't yet wrap
-with a workflow command.
-
-## Commands
-
-```sh
-beeper api get [--no-auth]
-beeper api post [--body JSON] [--no-auth]
-beeper api request [--body JSON] [--no-auth]
-```
-
-## Notes
-
-- `` is a Desktop API path, e.g. `/v1/info` or `/v1/chats/{chatID}/read`.
-- `--no-auth` calls a public path without the bearer token.
-- `--body` is sent as `application/json`; default is `{}` for `post`.
-- `api request` lets you hit `GET | POST | PUT | PATCH | DELETE`; the others are convenience shortcuts.
-- `--read-only` blocks `api post` / `api put` / `api patch` / `api delete` / `api request `.
-
-## Examples
-
-```sh
-beeper api get /v1/info
-beeper api get /v1/chats --json
-beeper api post /v1/chats/abc/read --body '{"messageID":"x"}'
-beeper api request PATCH /v1/chats/abc --body '{"isPinned":true}'
-```
diff --git a/packages/cli/docs/config.md b/packages/cli/docs/config.md
deleted file mode 100644
index 333135a7..00000000
--- a/packages/cli/docs/config.md
+++ /dev/null
@@ -1,32 +0,0 @@
-# config
-
-Read when: inspecting, changing, or resetting the CLI's local configuration
-file (`~/.beeper/config.json`, or wherever `BEEPER_CLI_CONFIG_DIR` points).
-
-## Commands
-
-```sh
-beeper config path
-beeper config get [defaultTarget | defaultAccount | baseURL | auth]
-beeper config set
-beeper config reset
-```
-
-## Notes
-
-- `config path` prints the JSON config path (suitable for `cat` or `cd $(dirname …)`).
-- `config get` without a key prints the full config; passing a key prints just that field.
-- `auth.accessToken` is always redacted in `config get` output.
-- `config set ""` clears the field. Only `defaultTarget` and `defaultAccount` are settable here; other fields are written by commands like `targets use` and `auth verify`.
-- `config reset` deletes the config file.
-
-## Examples
-
-```sh
-beeper config path
-beeper config get --json
-beeper config get defaultTarget
-beeper config set defaultTarget work
-beeper config set defaultAccount ""
-beeper config reset
-```
diff --git a/packages/cli/docs/export.md b/packages/cli/docs/export.md
deleted file mode 100644
index f3830af0..00000000
--- a/packages/cli/docs/export.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# export
-
-Read when: making a heavy, multi-chat, attachment-including export of Beeper
-data to disk. For a lightweight per-chat JSON dump, see [messages
-export](messages.md).
-
-## Command
-
-```sh
-beeper export
- [-o, --out DIR]
- [--account SEL]...
- [--chat SEL]...
- [--limit-chats N]
- [--limit-messages N]
- [--max-participants N]
- [--no-attachments]
- [--force]
- [--quiet]
- [--pick N]
-```
-
-## Notes
-
-- Default `--out` directory is `beeper-export`.
-- Layout: `accounts.json`, `chats.json`, `manifest.json`, plus one directory per chat with `chat.json`, `messages.json`, `messages.markdown`, `messages.html`, attachments, and per-chat checkpoint state.
-- Exports are resumable. Re-running picks up where the last run left off unless `--force` is set.
-- `--max-participants` (default 500) bounds the participant list stored in each `chat.json`.
-- `--no-attachments` skips downloading media; metadata is still recorded.
-- `--limit-chats` / `--limit-messages` are intended for sanity-checking large exports.
-
-## Examples
-
-```sh
-beeper export --out ./beeper-export
-beeper export --chat "Family" --out ./family
-beeper export --account whatsapp --no-attachments --quiet
-beeper export --force --out ./beeper-export
-```
diff --git a/packages/cli/docs/setup.md b/packages/cli/docs/setup.md
deleted file mode 100644
index 9bf22140..00000000
--- a/packages/cli/docs/setup.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# setup
-
-Read when: making a Beeper target ready for the first time, switching to a
-different target, or installing a managed runtime.
-
-`beeper setup` orchestrates the path from "I have nothing" to "the selected
-target is ready". By default it detects a running local Beeper Desktop, offers
-to reuse that session, and falls back to a guided choice between Desktop /
-Server / remote targets.
-
-## Commands
-
-```sh
-beeper setup [--local | --oauth | --remote URL | --desktop | --server] [--install] [--channel stable|nightly]
-beeper install desktop [--channel stable|nightly]
-beeper install server [--channel stable|nightly] [--server-env production|staging]
-```
-
-## Notes
-
-- `setup --local` reuses the local Beeper Desktop session (fastest trusted-device path).
-- `setup --oauth` runs browser-based OAuth/PKCE against the resolved target.
-- `setup --remote URL` configures a remote Beeper Desktop or Server target.
-- `setup --desktop --install` or `setup --server --install` installs the runtime if missing, then sets up.
-- `install desktop|server` installs without changing the selected target.
-- The selected target is persisted in `~/.beeper/config.json` (override with `BEEPER_CLI_CONFIG_DIR`).
-- For non-interactive use, pass a token in the environment: `BEEPER_ACCESS_TOKEN=… beeper …`.
-
-## Examples
-
-```sh
-beeper setup
-beeper setup --local
-beeper setup --oauth
-beeper setup --remote https://desktop.example.com
-beeper setup --desktop --install --channel nightly
-beeper install server --server-env staging
-```
diff --git a/packages/cli/docs/targets.md b/packages/cli/docs/targets.md
deleted file mode 100644
index d77ee831..00000000
--- a/packages/cli/docs/targets.md
+++ /dev/null
@@ -1,50 +0,0 @@
-# targets
-
-Read when: managing local Desktop, managed Server, or remote Beeper API
-targets — adding, switching, starting/stopping a managed runtime, or removing
-a target.
-
-A *target* is a runnable or reachable Beeper endpoint profile: local Server,
-local Desktop, Desktop API, or a profile that combines Desktop/Server runtime
-state. The CLI tracks an optional default; commands use it unless
-`--target ` overrides.
-
-## Commands
-
-```sh
-beeper targets list
-beeper targets add desktop [name] [--port N] [--server-env production|staging] [--default]
-beeper targets add server [name] [--port N] [--server-env production|staging] [--default]
-beeper targets add remote [--default]
-beeper targets use
-beeper targets show [name]
-beeper targets status [name]
-beeper targets start | stop | restart [name]
-beeper targets logs [name]
-beeper targets enable | disable [name] # start at login
-beeper targets remove
-```
-
-## Notes
-
-- `list` prints all configured targets; the one used by default has `default: true`.
-- `show` defaults to the currently-selected target if no name is given.
-- `status` checks endpoint and process reachability. For setup/auth/encryption
- diagnostics use `beeper doctor`.
-- `start`/`stop`/`restart` only apply to managed targets (`type: desktop|server`); they error for `remote`.
-- `enable`/`disable` registers/unregisters the launchd or systemd unit that
- starts the managed target at login.
-- Removing the active default clears the `defaultTarget` config field.
-- `BEEPER_TARGET=` overrides the default for a single shell.
-
-## Examples
-
-```sh
-beeper targets list --json
-beeper targets add desktop work --default
-beeper targets add server prod --server-env production --default
-beeper targets add remote office https://desktop.office.example.com --default
-beeper targets use work
-beeper targets logs work | less
-beeper targets restart work
-```
diff --git a/packages/cli/docs/update.md b/packages/cli/docs/update.md
deleted file mode 100644
index 8403eff0..00000000
--- a/packages/cli/docs/update.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# update
-
-Read when: checking for new versions of the CLI, the CLI-managed Desktop
-install, or the CLI-managed Server install — and choosing whether to install.
-
-## Command
-
-```sh
-beeper update [--check] [--cli] [--desktop] [--server]
-```
-
-## Notes
-
-- With no kind flag, checks all three (CLI, Desktop, Server) that apply.
-- `--check` prints what's available without installing.
-- The CLI itself is never auto-upgraded; `--cli` prints the right command for your install method (Homebrew, npm-global, or in-repo git build).
-- `--desktop` reports on the CLI-owned Desktop install; updating Desktop itself happens inside the Desktop app.
-- `--server` updates the CLI-managed Server install in place, then restarts any running managed Server targets.
-
-## Examples
-
-```sh
-beeper update --check
-beeper update --cli
-beeper update --desktop --json
-beeper update --server
-```
diff --git a/packages/cli/docs/watch.md b/packages/cli/docs/watch.md
deleted file mode 100644
index a8df6c74..00000000
--- a/packages/cli/docs/watch.md
+++ /dev/null
@@ -1,35 +0,0 @@
-# watch
-
-Read when: subscribing to live Desktop API events (new/updated/deleted chats
-and messages), optionally forwarding them to a webhook.
-
-## Commands
-
-```sh
-beeper watch
- [-c, --chat CHAT_ID]...
- [--include-type EVENT_TYPE]...
- [--exclude-type EVENT_TYPE]...
- [--webhook URL [--webhook-secret SECRET] [--webhook-queue N]]
- [--json]
-```
-
-## Notes
-
-- Subscribes to the Desktop API WebSocket at the path returned by `/v1/info` (defaults to `/v1/ws`).
-- Without `--chat`, subscribes to all chats.
-- Event types come from the Desktop API: `chat.upserted`, `chat.deleted`, `message.upserted`, `message.deleted`.
-- `--include-type` and `--exclude-type` are mutually exclusive.
-- `--webhook URL` forwards every event as a POST body (best-effort, fire-and-forget).
-- `--webhook-secret SECRET` signs the body with HMAC-SHA256 and sets `X-Beeper-Signature: sha256=`.
-- `--webhook-queue` (default 64) caps pending deliveries; excess events are dropped with a stderr warning.
-- `--quiet` suppresses the human-mode status line; `--json` prints raw events line-delimited.
-
-## Examples
-
-```sh
-beeper watch
-beeper watch --chat '!abc:beeper.com' --json
-beeper watch --include-type message.upserted --include-type message.deleted
-beeper watch --webhook https://example.com/hook --webhook-secret "$BEEPER_WEBHOOK_SECRET"
-```
diff --git a/packages/cli/package.json b/packages/cli/package.json
index f79ec98f..b35a43b9 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -100,6 +100,9 @@
"messages": {
"description": "List, search, show, edit, delete, and export messages"
},
+ "resolve": {
+ "description": "Resolve selectors into concrete candidates"
+ },
"send": {
"description": "Send text, files, and reactions"
},
diff --git a/packages/cli/scripts/generate-command-map.ts b/packages/cli/scripts/generate-command-map.ts
index 3e68239d..e809e4e9 100644
--- a/packages/cli/scripts/generate-command-map.ts
+++ b/packages/cli/scripts/generate-command-map.ts
@@ -10,8 +10,10 @@ const outPath = join(root, 'src', 'commands.generated.ts')
const listAliases: Record = {
'accounts:list': ['accounts'],
'bridges:list': ['bridges'],
- 'chats:list': ['chats', 'accounts:chats'],
+ 'chats:list': ['chats', 'accounts:chats', 'ls'],
'contacts:list': ['contacts'],
+ 'messages:search': ['search'],
+ 'send:text': ['send'],
'targets:list': ['targets'],
}
diff --git a/packages/cli/scripts/generate-readme.ts b/packages/cli/scripts/generate-readme.ts
index f9529adc..cfc17595 100644
--- a/packages/cli/scripts/generate-readme.ts
+++ b/packages/cli/scripts/generate-readme.ts
@@ -24,7 +24,7 @@ const commands = commandManifest.map(item => {
};
});
-const globalFlags = new Set(['base-url', 'debug', 'events', 'full', 'json', 'quiet', 'read-only', 'target', 'timeout', 'yes']);
+const globalFlags = new Set(['base-url', 'debug', 'dry-run', 'events', 'force', 'format', 'full', 'json', 'no-input', 'quiet', 'read-only', 'results-only', 'select', 'target', 'timeout', 'yes']);
const commandList = commands.map(command => {
const id = displayID(command.id);
return `| \`${id}\` | ${escapeTable(text(command.summary || command.description || ''))} |`;
@@ -33,9 +33,23 @@ const commandList = commands.map(command => {
const examplesByID = new Map(commandManifest.map(item => [item.command, item.examples ?? []]));
const commandSections = commands.map(command => commandSection(command)).join('\n\n');
-const intro = `# beeper — One CLI for all your chats
+// Public URL where the Astro docs site (in `docs/`) is published. Keep this in
+// sync with `site` + `base` in `docs/astro.config.mjs`.
+const docsUrl = 'https://beeper.github.io/cli';
+const repoUrl = 'https://github.com/beeper/cli';
-> Built for you and your agent. Batteries included.
+const intro = `
+
+# beeper
+
+**One CLI for all your chats.** Built for you and your agent — batteries included.
+
+[](https://www.npmjs.com/package/beeper-cli)
+[](${repoUrl}/blob/main/packages/cli/LICENSE)
+[](${docsUrl})
+[](https://bun.sh)
+
+
Talks to Beeper Desktop on this machine, to a Beeper Server you self-host, or
to either one running somewhere else. Send and receive across the chat
@@ -48,7 +62,7 @@ Facebook Messenger · X (Twitter) DMs · LinkedIn · Slack ·
Google Messages (RCS/SMS) · Google Chat · Matrix · IRC · Bluesky.
Run \`beeper bridges list\` for the live list on your target.
-Command manual: \`beeper man\` · CLI docs: \`beeper docs\`
+📖 **[Read the docs](${docsUrl})** · command manual: \`beeper man\` · open docs: \`beeper docs\`
## Features
@@ -228,19 +242,22 @@ WhatsApp, Telegram, Discord, iMessage, and the rest show up under \`accounts lis
## Documentation
+Full documentation lives at **[${docsUrl.replace(/^https?:\/\//, '')}](${docsUrl})**
+(built from [\`docs/\`](docs/) with Astro Starlight — a fully static site).
+
| Topic | Page | Commands |
| --- | --- | --- |
-| **Setup + install** | [setup](docs/setup.md) · [auth](docs/auth.md) | \`setup\` · \`install desktop\` · \`install server\` · \`verify\` · \`status\` · \`doctor\` · \`auth status\` |
-| **Targets** | [targets](docs/targets.md) | \`targets list\` · \`targets add desktop\` · \`targets add server\` · \`targets add remote\` · \`targets use\` · \`targets status\` · \`targets logs\` |
-| **Bridges + accounts** | [accounts](docs/accounts.md) | \`bridges list\` · \`bridges show\` · \`accounts list\` · \`accounts add\` · \`accounts show\` · \`accounts use\` · \`accounts remove\` |
-| **Chats** | [chats](docs/chats.md) | \`chats list\` · \`chats search\` · \`chats show\` · \`chats start\` · \`chats archive\` · \`chats pin\` · \`chats mute\` · \`chats priority\` · \`chats remind\` · \`chats rename\` · \`chats draft\` · \`chats focus\` |
-| **Messages** | [messages](docs/messages.md) · [send](docs/send.md) · [presence](docs/presence.md) | \`messages list\` · \`messages search\` · \`messages export\` · \`send text\` · \`send file\` · \`send sticker\` · \`send voice\` · \`send react\` · \`presence\` |
-| **Contacts + media** | [contacts](docs/contacts.md) · [media](docs/media.md) · [export](docs/export.md) | \`contacts list\` · \`contacts search\` · \`media download\` · \`export\` |
-| **Automation** | [watch](docs/watch.md) · [rpc](docs/rpc.md) · [api](docs/api.md) | \`watch\` · \`watch --webhook\` · \`rpc\` · \`man\` · \`api get\` · \`api post\` · \`api request\` |
-| **Maintenance** | [config](docs/config.md) · [update](docs/update.md) | \`update\` · \`config\` · \`completion\` · \`docs\` · \`version\` |
+| **Setup + install** | [connect](${docsUrl}/connect/) · [install](${docsUrl}/install/) · [auth](${docsUrl}/auth/) | \`setup\` · \`install desktop\` · \`install server\` · \`verify\` · \`status\` · \`doctor\` · \`auth status\` |
+| **Targets** | [targets](${docsUrl}/targets/) | \`targets list\` · \`targets add desktop\` · \`targets add server\` · \`targets add remote\` · \`targets use\` · \`targets status\` · \`targets logs\` |
+| **Bridges + accounts** | [accounts](${docsUrl}/accounts/) | \`bridges list\` · \`bridges show\` · \`accounts list\` · \`accounts add\` · \`accounts show\` · \`accounts use\` · \`accounts remove\` |
+| **Chats** | [chats](${docsUrl}/chats/) | \`chats list\` · \`chats search\` · \`chats show\` · \`chats start\` · \`chats archive\` · \`chats pin\` · \`chats mute\` · \`chats priority\` · \`chats remind\` · \`chats rename\` · \`chats draft\` · \`chats focus\` |
+| **Messages** | [messages](${docsUrl}/messages/) · [send](${docsUrl}/send/) · [presence](${docsUrl}/presence/) | \`messages list\` · \`messages search\` · \`messages export\` · \`send text\` · \`send file\` · \`send sticker\` · \`send voice\` · \`send react\` · \`presence\` |
+| **Contacts + media** | [contacts](${docsUrl}/contacts/) · [media](${docsUrl}/media/) · [export](${docsUrl}/export/) | \`contacts list\` · \`contacts search\` · \`media download\` · \`export\` |
+| **Automation** | [scripting](${docsUrl}/scripting/) · [watch](${docsUrl}/watch/) · [rpc](${docsUrl}/rpc/) · [api](${docsUrl}/api/) | \`watch\` · \`watch --webhook\` · \`rpc\` · \`man\` · \`api get\` · \`api post\` · \`api request\` |
+| **Maintenance** | [config](${docsUrl}/config/) · [update](${docsUrl}/update/) | \`update\` · \`config\` · \`completion\` · \`docs\` · \`version\` |
Use \`beeper docs\` to open the CLI docs and \`beeper man\` to print the local
-command manual.
+command manual. To work on the docs site locally: \`cd docs && bun install && bun run dev\`.
## Configuration
diff --git a/packages/cli/src/commands.generated.ts b/packages/cli/src/commands.generated.ts
index ed34e12a..f8170748 100644
--- a/packages/cli/src/commands.generated.ts
+++ b/packages/cli/src/commands.generated.ts
@@ -60,45 +60,51 @@ import Command58 from './commands/messages/show.js'
import Command59 from './commands/plugins.js'
import Command60 from './commands/plugins/available.js'
import Command61 from './commands/presence.js'
-import Command62 from './commands/rpc.js'
-import Command63 from './commands/send/file.js'
-import Command64 from './commands/send/react.js'
-import Command65 from './commands/send/sticker.js'
-import Command66 from './commands/send/text.js'
-import Command67 from './commands/send/unreact.js'
-import Command68 from './commands/send/voice.js'
-import Command69 from './commands/setup.js'
-import Command70 from './commands/status.js'
-import Command71 from './commands/targets/add/desktop.js'
-import Command72 from './commands/targets/add/remote.js'
-import Command73 from './commands/targets/add/server.js'
-import Command74 from './commands/targets/disable.js'
-import Command75 from './commands/targets/enable.js'
-import Command76 from './commands/targets/list.js'
-import Command77 from './commands/targets/logs.js'
-import Command78 from './commands/targets/remove.js'
-import Command79 from './commands/targets/restart.js'
-import Command80 from './commands/targets/show.js'
-import Command81 from './commands/targets/start.js'
-import Command82 from './commands/targets/status.js'
-import Command83 from './commands/targets/stop.js'
-import Command84 from './commands/targets/use.js'
-import Command85 from './commands/update.js'
-import Command86 from './commands/verify.js'
-import Command87 from './commands/verify/approve.js'
-import Command88 from './commands/verify/cancel.js'
-import Command89 from './commands/verify/list.js'
-import Command90 from './commands/verify/qr-confirm.js'
-import Command91 from './commands/verify/qr-scan.js'
-import Command92 from './commands/verify/recovery-key.js'
-import Command93 from './commands/verify/reset-recovery-key.js'
-import Command94 from './commands/verify/sas.js'
-import Command95 from './commands/verify/sas-confirm.js'
-import Command96 from './commands/verify/show.js'
-import Command97 from './commands/verify/start.js'
-import Command98 from './commands/verify/status.js'
-import Command99 from './commands/version.js'
-import Command100 from './commands/watch.js'
+import Command62 from './commands/resolve/account.js'
+import Command63 from './commands/resolve/bridge.js'
+import Command64 from './commands/resolve/chat.js'
+import Command65 from './commands/resolve/contact.js'
+import Command66 from './commands/resolve/target.js'
+import Command67 from './commands/rpc.js'
+import Command68 from './commands/schema.js'
+import Command69 from './commands/send/file.js'
+import Command70 from './commands/send/react.js'
+import Command71 from './commands/send/sticker.js'
+import Command72 from './commands/send/text.js'
+import Command73 from './commands/send/unreact.js'
+import Command74 from './commands/send/voice.js'
+import Command75 from './commands/setup.js'
+import Command76 from './commands/status.js'
+import Command77 from './commands/targets/add/desktop.js'
+import Command78 from './commands/targets/add/remote.js'
+import Command79 from './commands/targets/add/server.js'
+import Command80 from './commands/targets/disable.js'
+import Command81 from './commands/targets/enable.js'
+import Command82 from './commands/targets/list.js'
+import Command83 from './commands/targets/logs.js'
+import Command84 from './commands/targets/remove.js'
+import Command85 from './commands/targets/restart.js'
+import Command86 from './commands/targets/show.js'
+import Command87 from './commands/targets/start.js'
+import Command88 from './commands/targets/status.js'
+import Command89 from './commands/targets/stop.js'
+import Command90 from './commands/targets/use.js'
+import Command91 from './commands/update.js'
+import Command92 from './commands/verify.js'
+import Command93 from './commands/verify/approve.js'
+import Command94 from './commands/verify/cancel.js'
+import Command95 from './commands/verify/list.js'
+import Command96 from './commands/verify/qr-confirm.js'
+import Command97 from './commands/verify/qr-scan.js'
+import Command98 from './commands/verify/recovery-key.js'
+import Command99 from './commands/verify/reset-recovery-key.js'
+import Command100 from './commands/verify/sas.js'
+import Command101 from './commands/verify/sas-confirm.js'
+import Command102 from './commands/verify/show.js'
+import Command103 from './commands/verify/start.js'
+import Command104 from './commands/verify/status.js'
+import Command105 from './commands/version.js'
+import Command106 from './commands/watch.js'
export const commands = {
'accounts': Command1,
@@ -156,6 +162,7 @@ export const commands = {
'export': Command47,
'install:desktop': Command48,
'install:server': Command49,
+ 'ls': Command21,
'man': Command50,
'media:download': Command51,
'messages:context': Command52,
@@ -168,44 +175,52 @@ export const commands = {
'plugins': Command59,
'plugins:available': Command60,
'presence': Command61,
- 'rpc': Command62,
- 'send:file': Command63,
- 'send:react': Command64,
- 'send:sticker': Command65,
- 'send:text': Command66,
- 'send:unreact': Command67,
- 'send:voice': Command68,
- 'setup': Command69,
- 'status': Command70,
- 'targets': Command76,
- 'targets:add:desktop': Command71,
- 'targets:add:remote': Command72,
- 'targets:add:server': Command73,
- 'targets:disable': Command74,
- 'targets:enable': Command75,
- 'targets:list': Command76,
- 'targets:logs': Command77,
- 'targets:remove': Command78,
- 'targets:restart': Command79,
- 'targets:show': Command80,
- 'targets:start': Command81,
- 'targets:status': Command82,
- 'targets:stop': Command83,
- 'targets:use': Command84,
- 'update': Command85,
- 'verify': Command86,
- 'verify:approve': Command87,
- 'verify:cancel': Command88,
- 'verify:list': Command89,
- 'verify:qr-confirm': Command90,
- 'verify:qr-scan': Command91,
- 'verify:recovery-key': Command92,
- 'verify:reset-recovery-key': Command93,
- 'verify:sas': Command94,
- 'verify:sas-confirm': Command95,
- 'verify:show': Command96,
- 'verify:start': Command97,
- 'verify:status': Command98,
- 'version': Command99,
- 'watch': Command100,
+ 'resolve:account': Command62,
+ 'resolve:bridge': Command63,
+ 'resolve:chat': Command64,
+ 'resolve:contact': Command65,
+ 'resolve:target': Command66,
+ 'rpc': Command67,
+ 'schema': Command68,
+ 'search': Command57,
+ 'send': Command72,
+ 'send:file': Command69,
+ 'send:react': Command70,
+ 'send:sticker': Command71,
+ 'send:text': Command72,
+ 'send:unreact': Command73,
+ 'send:voice': Command74,
+ 'setup': Command75,
+ 'status': Command76,
+ 'targets': Command82,
+ 'targets:add:desktop': Command77,
+ 'targets:add:remote': Command78,
+ 'targets:add:server': Command79,
+ 'targets:disable': Command80,
+ 'targets:enable': Command81,
+ 'targets:list': Command82,
+ 'targets:logs': Command83,
+ 'targets:remove': Command84,
+ 'targets:restart': Command85,
+ 'targets:show': Command86,
+ 'targets:start': Command87,
+ 'targets:status': Command88,
+ 'targets:stop': Command89,
+ 'targets:use': Command90,
+ 'update': Command91,
+ 'verify': Command92,
+ 'verify:approve': Command93,
+ 'verify:cancel': Command94,
+ 'verify:list': Command95,
+ 'verify:qr-confirm': Command96,
+ 'verify:qr-scan': Command97,
+ 'verify:recovery-key': Command98,
+ 'verify:reset-recovery-key': Command99,
+ 'verify:sas': Command100,
+ 'verify:sas-confirm': Command101,
+ 'verify:show': Command102,
+ 'verify:start': Command103,
+ 'verify:status': Command104,
+ 'version': Command105,
+ 'watch': Command106,
}
diff --git a/packages/cli/src/commands/accounts/add.ts b/packages/cli/src/commands/accounts/add.ts
index 1db3c81b..d28138e0 100644
--- a/packages/cli/src/commands/accounts/add.ts
+++ b/packages/cli/src/commands/accounts/add.ts
@@ -5,7 +5,7 @@ import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import type { Bridge, LoginFlow } from '@beeper/desktop-api/resources/bridges.js'
import { createClient } from '../../lib/client.js'
import { printAccountLoginStep, runGuidedAccountLogin } from '../../lib/account-login.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
type AccountType = Bridge
@@ -29,7 +29,6 @@ export default class AccountsAdd extends BeeperCommand {
async run(): Promise {
const { args, flags } = await this.parse(AccountsAdd)
- ensureWritable(flags)
const client = await createClient(flags)
if (!args.bridge) {
@@ -67,6 +66,23 @@ export default class AccountsAdd extends BeeperCommand {
if (!flags.json && loginFlows.length > 1) this.log(`Using flow ${flowID}`)
}
+ if (flags['dry-run']) {
+ await printDryRun('accounts.add', {
+ bridgeID: accountType.id,
+ bridgeName: accountType.displayName,
+ flowID,
+ loginID: flags['login-id'],
+ guided: flags.guided,
+ nonInteractive: flags['non-interactive'],
+ cookieKeys: Object.keys(parseKeyValueFlags(flags.cookie, '--cookie')),
+ fieldKeys: Object.keys(parseKeyValueFlags(flags.field, '--field')),
+ webview: flags.webview,
+ webviewBackend: flags['webview-backend'],
+ }, flags.json ? 'json' : 'human')
+ return
+ }
+
+ ensureWritable(flags)
const step = await client.bridges.loginSessions.create(accountType.id, {
flowID,
loginID: flags['login-id'],
diff --git a/packages/cli/src/commands/accounts/remove.ts b/packages/cli/src/commands/accounts/remove.ts
index 6bf7180f..93e20aa5 100644
--- a/packages/cli/src/commands/accounts/remove.ts
+++ b/packages/cli/src/commands/accounts/remove.ts
@@ -1,7 +1,7 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
import { resolveAccountID } from '../../lib/resolve.js'
export default class AccountsRemove extends BeeperCommand {
@@ -14,6 +14,10 @@ export default class AccountsRemove extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const accountID = await resolveAccountID(client, args.account)
+ if (flags['dry-run']) {
+ await printDryRun('accounts.remove', { accountID }, flags.json ? 'json' : 'human')
+ return
+ }
const accounts = client.accounts as any
if (accounts.delete) await accounts.delete(accountID)
else if (accounts.remove) await accounts.remove(accountID)
diff --git a/packages/cli/src/commands/accounts/use.ts b/packages/cli/src/commands/accounts/use.ts
index cbb88f4f..441f560c 100644
--- a/packages/cli/src/commands/accounts/use.ts
+++ b/packages/cli/src/commands/accounts/use.ts
@@ -1,7 +1,7 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
import { resolveAccountID } from '../../lib/resolve.js'
import { updateConfig } from '../../lib/targets.js'
@@ -15,12 +15,20 @@ export default class AccountsUse extends BeeperCommand {
const { args, flags } = await this.parse(AccountsUse)
ensureWritable(flags)
if (args.account === '') {
+ if (flags['dry-run']) {
+ await printDryRun('accounts.use', { defaultAccount: undefined }, flags.json ? 'json' : 'human')
+ return
+ }
await updateConfig(config => ({ ...config, defaultAccount: undefined }))
await printSuccess({ message: 'Cleared default account' }, flags.json ? 'json' : 'human')
return
}
const client = await createClient(flags)
const accountID = await resolveAccountID(client, args.account)
+ if (flags['dry-run']) {
+ await printDryRun('accounts.use', { defaultAccount: accountID }, flags.json ? 'json' : 'human')
+ return
+ }
await updateConfig(config => ({ ...config, defaultAccount: accountID }))
await printSuccess({ message: `Default account: ${accountID}`, data: { accountID } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/api/get.ts b/packages/cli/src/commands/api/get.ts
index a36515d4..850156d4 100644
--- a/packages/cli/src/commands/api/get.ts
+++ b/packages/cli/src/commands/api/get.ts
@@ -16,11 +16,12 @@ export default class ApiGet extends BeeperCommand {
async run(): Promise {
const { args, flags } = await this.parse(ApiGet)
+ const format = flags.json ? 'json' : 'human'
if (flags['no-auth']) {
- await printData(await appRequest('GET', args.path, { baseURL: flags['base-url'], target: flags.target, token: false }), flags.json ? 'json' : 'human')
+ await printData(await appRequest('GET', args.path, { baseURL: flags['base-url'], target: flags.target, token: false }), format)
return
}
const client = await createClient(flags)
- await printData(await client.get(args.path), flags.json ? 'json' : 'human')
+ await printData(await client.get(args.path), format)
}
}
diff --git a/packages/cli/src/commands/api/post.ts b/packages/cli/src/commands/api/post.ts
index 95c573ce..c381a3bf 100644
--- a/packages/cli/src/commands/api/post.ts
+++ b/packages/cli/src/commands/api/post.ts
@@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { appRequest } from '../../lib/app-api.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class ApiPost extends BeeperCommand {
static override summary = 'Call a raw Desktop API POST path with a JSON body'
@@ -18,17 +18,22 @@ export default class ApiPost extends BeeperCommand {
async run(): Promise {
const { args, flags } = await this.parse(ApiPost)
ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
let body: Record
try {
body = JSON.parse(flags.body) as Record
} catch {
throw new Error(`--body is not valid JSON: ${flags.body}`)
}
+ if (flags['dry-run']) {
+ await printDryRun('api.post', { method: 'POST', path: args.path, body, noAuth: flags['no-auth'], target: flags.target, baseURL: flags['base-url'] }, format)
+ return
+ }
if (flags['no-auth']) {
- await printData(await appRequest('POST', args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), flags.json ? 'json' : 'human')
+ await printData(await appRequest('POST', args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), format)
return
}
const client = await createClient(flags)
- await printData(await client.post(args.path, { body }), flags.json ? 'json' : 'human')
+ await printData(await client.post(args.path, { body }), format)
}
}
diff --git a/packages/cli/src/commands/api/request.ts b/packages/cli/src/commands/api/request.ts
index db3c297b..c551e450 100644
--- a/packages/cli/src/commands/api/request.ts
+++ b/packages/cli/src/commands/api/request.ts
@@ -1,7 +1,7 @@
import { Args, Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { appRequest, type AppRequestMethod } from '../../lib/app-api.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class ApiRequest extends BeeperCommand {
static override summary = 'Call a raw Desktop API path with any supported HTTP method'
@@ -19,11 +19,16 @@ export default class ApiRequest extends BeeperCommand {
const { args, flags } = await this.parse(ApiRequest)
const method = args.method as AppRequestMethod
if (method !== 'GET') ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
const body = flags.body ? JSON.parse(flags.body) as Record : undefined
+ if (flags['dry-run'] && method !== 'GET') {
+ await printDryRun('api.request', { method, path: args.path, body, noAuth: flags['no-auth'], target: flags.target, baseURL: flags['base-url'] }, format)
+ return
+ }
if (flags['no-auth']) {
- await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), flags.json ? 'json' : 'human')
+ await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target, token: false }), format)
return
}
- await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target }), flags.json ? 'json' : 'human')
+ await printData(await appRequest(method, args.path, { baseURL: flags['base-url'], body, target: flags.target }), format)
}
}
diff --git a/packages/cli/src/commands/auth/email/response.ts b/packages/cli/src/commands/auth/email/response.ts
index 1a517ecb..3268af76 100644
--- a/packages/cli/src/commands/auth/email/response.ts
+++ b/packages/cli/src/commands/auth/email/response.ts
@@ -2,7 +2,7 @@ import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../../lib/command.js'
import { resolveTarget } from '../../../lib/targets.js'
import { finishEmailSetup } from '../../../lib/setup-login.js'
-import { printData } from '../../../lib/output.js'
+import { printData, printDryRun } from '../../../lib/output.js'
export default class AuthEmailResponse extends BeeperCommand {
static override summary = 'Finish email sign-in with a verification code'
@@ -16,7 +16,12 @@ export default class AuthEmailResponse extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(AuthEmailResponse)
ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] })
+ if (flags['dry-run']) {
+ await printDryRun('auth.email.response', { target: target.id, baseURL: target.baseURL, setupRequestID: flags['setup-request-id'], username: flags.username, yes: flags.yes }, format)
+ return
+ }
const data = await finishEmailSetup(target, {
code: flags.code,
json: flags.json,
@@ -24,6 +29,6 @@ export default class AuthEmailResponse extends BeeperCommand {
username: flags.username,
yes: flags.yes,
})
- await printData(data, flags.json ? 'json' : 'human')
+ await printData(data, format)
}
}
diff --git a/packages/cli/src/commands/auth/logout.ts b/packages/cli/src/commands/auth/logout.ts
index a100569f..fc8756d5 100644
--- a/packages/cli/src/commands/auth/logout.ts
+++ b/packages/cli/src/commands/auth/logout.ts
@@ -1,18 +1,23 @@
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { clearTargetAuth, resolveTarget } from '../../lib/targets.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class AuthLogout extends BeeperCommand {
static override summary = 'Clear stored authentication'
async run(): Promise {
const { flags } = await this.parse(AuthLogout)
- ensureWritable(flags)
+ if (!flags['dry-run']) ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] })
+ const token = target.auth?.accessToken
+ if (flags['dry-run']) {
+ await printDryRun('auth.logout', { target: target.id, baseURL: target.baseURL, hadToken: Boolean(token), revokeToken: Boolean(token) }, format)
+ return
+ }
if (process.env.BEEPER_ACCESS_TOKEN && !target.auth?.accessToken) {
throw new Error('auth logout cannot clear BEEPER_ACCESS_TOKEN from the environment; unset it in the calling process.')
}
- const token = target.auth?.accessToken
let revoked = false
if (token) {
const response = await fetch(new URL('/oauth/revoke', target.baseURL), {
@@ -24,6 +29,6 @@ export default class AuthLogout extends BeeperCommand {
revoked = Boolean(response?.ok)
await clearTargetAuth(target)
}
- await printSuccess({ message: 'Logged out', detail: token ? 'local token cleared' : 'no token was stored', data: { revoked, hadToken: Boolean(token) } }, flags.json ? 'json' : 'human')
+ await printSuccess({ message: 'Logged out', detail: token ? 'local token cleared' : 'no token was stored', data: { revoked, hadToken: Boolean(token) } }, format)
}
}
diff --git a/packages/cli/src/commands/bridges/show.ts b/packages/cli/src/commands/bridges/show.ts
index 76840af4..592a32eb 100644
--- a/packages/cli/src/commands/bridges/show.ts
+++ b/packages/cli/src/commands/bridges/show.ts
@@ -30,21 +30,13 @@ export default class BridgesShow extends BeeperCommand {
function resolveBridge(items: Array>, input: string): Record {
const normalizedInput = normalize(input)
- const exact = items.filter(item => [
- item.id,
- item.displayName,
- item.network,
- item.type,
- ].some(value => normalize(value) === normalizedInput))
+ const fields = (item: Record): unknown[] => [item.id, item.displayName, item.network, item.type]
+
+ const exact = items.filter(item => fields(item).some(value => normalize(value) === normalizedInput))
if (exact.length === 1) return exact[0]!
if (exact.length > 1) throw ambiguousBridge(input, exact)
- const partial = items.filter(item => [
- item.id,
- item.displayName,
- item.network,
- item.type,
- ].some(value => normalize(value).includes(normalizedInput)))
+ const partial = items.filter(item => fields(item).some(value => normalize(value).includes(normalizedInput)))
if (partial.length === 1) return partial[0]!
if (partial.length > 1) throw ambiguousBridge(input, partial)
diff --git a/packages/cli/src/commands/chats/archive.ts b/packages/cli/src/commands/chats/archive.ts
index 17b27e61..da099632 100644
--- a/packages/cli/src/commands/chats/archive.ts
+++ b/packages/cli/src/commands/chats/archive.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsArchive extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsArchive extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.archive', { chatID, isArchived: true }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isArchived: true }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/avatar.ts b/packages/cli/src/commands/chats/avatar.ts
index cee1f6b1..7c97c2bc 100644
--- a/packages/cli/src/commands/chats/avatar.ts
+++ b/packages/cli/src/commands/chats/avatar.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsAvatar extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsAvatar extends BeeperCommand {
if (!flags.clear && !flags.file) throw new Error('Provide --file or --clear')
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.avatar', { chatID, imgURL: flags.clear ? null : flags.file }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { imgURL: flags.clear ? null : flags.file }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/description.ts b/packages/cli/src/commands/chats/description.ts
index 429c7ac6..0cb2022a 100644
--- a/packages/cli/src/commands/chats/description.ts
+++ b/packages/cli/src/commands/chats/description.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsDescription extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsDescription extends BeeperCommand {
if (!flags.clear && !flags.description) throw new Error('Provide --description or --clear')
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.description', { chatID, description: flags.clear ? null : flags.description }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { description: flags.clear ? null : flags.description }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/disappear.ts b/packages/cli/src/commands/chats/disappear.ts
index 16eace97..6e9237c7 100644
--- a/packages/cli/src/commands/chats/disappear.ts
+++ b/packages/cli/src/commands/chats/disappear.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsDisappear extends BeeperCommand {
@@ -17,11 +17,15 @@ export default class ChatsDisappear extends BeeperCommand {
}
async run(): Promise {
const { flags } = await this.parse(ChatsDisappear)
+ const expiry = flags.seconds.toLowerCase() === 'off' ? null : Number(flags.seconds)
+ if (expiry !== null && (!Number.isInteger(expiry) || expiry < 0)) throw new Error('--seconds must be a positive integer or "off"')
+ if (flags['dry-run']) {
+ await printDryRun('chats.disappear', { chat: flags.chat, pick: flags.pick, messageExpirySeconds: expiry }, flags.json ? 'json' : 'human')
+ return
+ }
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
- const expiry = flags.seconds.toLowerCase() === 'off' ? null : Number(flags.seconds)
- if (expiry !== null && (!Number.isInteger(expiry) || expiry < 0)) throw new Error('--seconds must be a positive integer or "off"')
await printData(await client.chats.update(chatID, { messageExpirySeconds: expiry }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/draft.ts b/packages/cli/src/commands/chats/draft.ts
index 15c8df79..ec4f22ed 100644
--- a/packages/cli/src/commands/chats/draft.ts
+++ b/packages/cli/src/commands/chats/draft.ts
@@ -2,7 +2,7 @@ import { Flags } from '@oclif/core'
import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsDraft extends BeeperCommand {
@@ -24,9 +24,17 @@ export default class ChatsDraft extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
if (flags.clear) {
+ if (flags['dry-run']) {
+ await printDryRun('chats.draft', { chatID, draft: null }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { draft: null }), flags.json ? 'json' : 'human')
return
}
+ if (flags['dry-run']) {
+ await printDryRun('chats.draft', { chatID, draft: { text: flags.text!, file: flags.file, fileName: flags.filename, mimeType: flags.mime } }, flags.json ? 'json' : 'human')
+ return
+ }
const upload = flags.file ? await client.assets.upload({ file: createReadStream(flags.file), fileName: flags.filename, mimeType: flags.mime }) : undefined
await printData(await client.chats.update(chatID, { draft: { text: flags.text!, attachments: upload?.uploadID ? { [upload.uploadID]: upload as any } : undefined } }), flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/chats/focus.ts b/packages/cli/src/commands/chats/focus.ts
index ef30b469..c9e811b8 100644
--- a/packages/cli/src/commands/chats/focus.ts
+++ b/packages/cli/src/commands/chats/focus.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsFocus extends BeeperCommand {
@@ -20,6 +19,10 @@ export default class ChatsFocus extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.focus', { chatID, messageID: flags.message, draftText: flags.draft, draftAttachmentPath: flags.attachment }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.focus({ chatID, messageID: flags.message, draftText: flags.draft, draftAttachmentPath: flags.attachment }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/mark-read.ts b/packages/cli/src/commands/chats/mark-read.ts
index 4f942867..82dceed9 100644
--- a/packages/cli/src/commands/chats/mark-read.ts
+++ b/packages/cli/src/commands/chats/mark-read.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsMarkRead extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsMarkRead extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.mark-read', { chatID, messageID: flags.message }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.markRead(chatID, { messageID: flags.message }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/mark-unread.ts b/packages/cli/src/commands/chats/mark-unread.ts
index 26bd3df7..e27c9839 100644
--- a/packages/cli/src/commands/chats/mark-unread.ts
+++ b/packages/cli/src/commands/chats/mark-unread.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsMarkUnread extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsMarkUnread extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.mark-unread', { chatID, messageID: flags.message }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.markUnread(chatID, { messageID: flags.message }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/mute.ts b/packages/cli/src/commands/chats/mute.ts
index 082fbcbb..39d6e338 100644
--- a/packages/cli/src/commands/chats/mute.ts
+++ b/packages/cli/src/commands/chats/mute.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsMute extends BeeperCommand {
@@ -16,6 +16,10 @@ export default class ChatsMute extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.mute', { chatID, isMuted: true }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isMuted: true }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/notify-anyway.ts b/packages/cli/src/commands/chats/notify-anyway.ts
index 27f20517..4174098d 100644
--- a/packages/cli/src/commands/chats/notify-anyway.ts
+++ b/packages/cli/src/commands/chats/notify-anyway.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsNotifyAnyway extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsNotifyAnyway extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.notify-anyway', { chatID }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.notifyAnyway(chatID), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/pin.ts b/packages/cli/src/commands/chats/pin.ts
index a5e0b024..7ee34d16 100644
--- a/packages/cli/src/commands/chats/pin.ts
+++ b/packages/cli/src/commands/chats/pin.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsPin extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsPin extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.pin', { chatID, isPinned: true }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isPinned: true }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/priority.ts b/packages/cli/src/commands/chats/priority.ts
index 68d75ec5..1ae932f9 100644
--- a/packages/cli/src/commands/chats/priority.ts
+++ b/packages/cli/src/commands/chats/priority.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsPriority extends BeeperCommand {
@@ -19,6 +19,10 @@ export default class ChatsPriority extends BeeperCommand {
const update = flags.level === 'inbox'
? { isArchived: false, isLowPriority: false }
: { isLowPriority: true }
+ if (flags['dry-run']) {
+ await printDryRun('chats.priority', { chatID, level: flags.level, update }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, update), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/remind.ts b/packages/cli/src/commands/chats/remind.ts
index 7edd7d6e..f2599030 100644
--- a/packages/cli/src/commands/chats/remind.ts
+++ b/packages/cli/src/commands/chats/remind.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsRemind extends BeeperCommand {
@@ -18,6 +17,10 @@ export default class ChatsRemind extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.remind', { chatID, reminder: { remindAt: flags.when, dismissOnIncomingMessage: flags['dismiss-on-message'] || undefined } }, flags.json ? 'json' : 'human')
+ return
+ }
await client.chats.reminders.create(chatID, { reminder: { remindAt: flags.when, dismissOnIncomingMessage: flags['dismiss-on-message'] || undefined } })
await printSuccess({ message: 'Reminder set', detail: flags.when, data: { chatID, remindAt: flags.when } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/chats/rename.ts b/packages/cli/src/commands/chats/rename.ts
index 07139fe1..d965bff8 100644
--- a/packages/cli/src/commands/chats/rename.ts
+++ b/packages/cli/src/commands/chats/rename.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsRename extends BeeperCommand {
@@ -16,6 +16,10 @@ export default class ChatsRename extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.rename', { chatID, title: flags.title }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { title: flags.title }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/start.ts b/packages/cli/src/commands/chats/start.ts
index 59f5c6f7..170bd82b 100644
--- a/packages/cli/src/commands/chats/start.ts
+++ b/packages/cli/src/commands/chats/start.ts
@@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'
import type { ChatStartParams } from '@beeper/desktop-api/resources/chats'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { listAccountIDs, resolveAccountID, userQueryFromInput } from '../../lib/resolve.js'
export default class ChatsStart extends BeeperCommand {
@@ -20,6 +20,10 @@ export default class ChatsStart extends BeeperCommand {
const accountID = flags.account ? await resolveAccountID(client, flags.account) : await defaultAccountID(client)
const user = userQueryFromInput(args.user)
const payload: ChatStartParams & { title?: string } = { accountID, user, title: flags.title }
+ if (flags['dry-run']) {
+ await printDryRun('chats.start', payload as unknown as Record, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.start(payload), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/unarchive.ts b/packages/cli/src/commands/chats/unarchive.ts
index 0da9088a..17f55f9e 100644
--- a/packages/cli/src/commands/chats/unarchive.ts
+++ b/packages/cli/src/commands/chats/unarchive.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsUnarchive extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsUnarchive extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.unarchive', { chatID, isArchived: false }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isArchived: false }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/unmute.ts b/packages/cli/src/commands/chats/unmute.ts
index 022ae568..6466e892 100644
--- a/packages/cli/src/commands/chats/unmute.ts
+++ b/packages/cli/src/commands/chats/unmute.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsUnmute extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsUnmute extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.unmute', { chatID, isMuted: false }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isMuted: false }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/unpin.ts b/packages/cli/src/commands/chats/unpin.ts
index cb4ada38..1053c681 100644
--- a/packages/cli/src/commands/chats/unpin.ts
+++ b/packages/cli/src/commands/chats/unpin.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsUnpin extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsUnpin extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.unpin', { chatID, isPinned: false }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.chats.update(chatID, { isPinned: false }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/chats/unremind.ts b/packages/cli/src/commands/chats/unremind.ts
index 437ceca5..5fa5e9ef 100644
--- a/packages/cli/src/commands/chats/unremind.ts
+++ b/packages/cli/src/commands/chats/unremind.ts
@@ -1,8 +1,7 @@
import { Flags } from '@oclif/core'
-import { createReadStream } from 'node:fs'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class ChatsUnremind extends BeeperCommand {
@@ -14,6 +13,10 @@ export default class ChatsUnremind extends BeeperCommand {
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('chats.unremind', { chatID }, flags.json ? 'json' : 'human')
+ return
+ }
await client.chats.reminders.delete(chatID)
await printSuccess({ message: 'Reminder cleared', data: { chatID } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/config/reset.ts b/packages/cli/src/commands/config/reset.ts
index e5ee7a1a..8d3b9636 100644
--- a/packages/cli/src/commands/config/reset.ts
+++ b/packages/cli/src/commands/config/reset.ts
@@ -1,6 +1,6 @@
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { resetConfig } from '../../lib/targets.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class ConfigReset extends BeeperCommand {
static override summary = 'Reset CLI configuration'
@@ -8,7 +8,12 @@ export default class ConfigReset extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(ConfigReset)
ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
+ if (flags['dry-run']) {
+ await printDryRun('config.reset', {}, format)
+ return
+ }
await resetConfig()
- await printSuccess({ message: 'Config reset' }, flags.json ? 'json' : 'human')
+ await printSuccess({ message: 'Config reset' }, format)
}
}
diff --git a/packages/cli/src/commands/config/set.ts b/packages/cli/src/commands/config/set.ts
index 2bdc2a05..2cd95099 100644
--- a/packages/cli/src/commands/config/set.ts
+++ b/packages/cli/src/commands/config/set.ts
@@ -1,7 +1,7 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { updateConfig } from '../../lib/targets.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class ConfigSet extends BeeperCommand {
static override summary = 'Set a CLI configuration value'
@@ -13,12 +13,17 @@ export default class ConfigSet extends BeeperCommand {
async run(): Promise {
const { args, flags } = await this.parse(ConfigSet)
ensureWritable(flags)
+ const format = flags.json ? 'json' : 'human'
const nextValue = args.value === '' ? undefined : args.value
+ if (flags['dry-run']) {
+ await printDryRun('config.set', { [args.key]: nextValue }, format)
+ return
+ }
await updateConfig(config => ({ ...config, [args.key]: nextValue }))
await printSuccess({
message: nextValue === undefined ? `Cleared ${args.key}` : `Set ${args.key}`,
detail: nextValue,
data: { [args.key]: nextValue },
- }, flags.json ? 'json' : 'human')
+ }, format)
}
}
diff --git a/packages/cli/src/commands/contacts/search.ts b/packages/cli/src/commands/contacts/search.ts
index 7a809f6d..2c9d1d08 100644
--- a/packages/cli/src/commands/contacts/search.ts
+++ b/packages/cli/src/commands/contacts/search.ts
@@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'
import { BeeperCommand } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
import { apiCopy, cliCopy } from '../../lib/copy.js'
-import { printList } from '../../lib/output.js'
+import { isMachineReadableOutput, printList } from '../../lib/output.js'
import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js'
import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js'
@@ -32,7 +32,7 @@ export default class ContactsSearch extends BeeperCommand {
}
return collected
}
- const useSpinner = !flags.json
+ const useSpinner = !isMachineReadableOutput(flags.json ? 'json' : 'human')
const results = useSpinner
? await withSpinner(`Searching contacts for "${args.query}"…`, load, {
done: value => `${value.length} match${value.length === 1 ? '' : 'es'} across ${accountIDs.length} account${accountIDs.length === 1 ? '' : 's'}`,
diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts
index e0b428d1..a07a5581 100644
--- a/packages/cli/src/commands/doctor.ts
+++ b/packages/cli/src/commands/doctor.ts
@@ -10,7 +10,11 @@ export default class Doctor extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(Doctor)
const target = await resolveTarget({ target: flags.target, baseURL: flags['base-url'] })
- const checks = { target: await targetLiveStatus(target), readiness: await evaluateReadiness({ baseURL: target.baseURL, target: target.id }) }
+ const [targetStatus, readiness] = await Promise.all([
+ targetLiveStatus(target),
+ evaluateReadiness({ baseURL: target.baseURL, target: target.id }),
+ ])
+ const checks = { target: targetStatus, readiness }
await printData({ ok: checks.readiness.state === 'ready', checks }, flags.json ? 'json' : 'human')
if (checks.readiness.state !== 'ready') this.exit(ExitCodes.NotReady)
}
diff --git a/packages/cli/src/commands/export.ts b/packages/cli/src/commands/export.ts
index 525bc66a..ef7af0ae 100644
--- a/packages/cli/src/commands/export.ts
+++ b/packages/cli/src/commands/export.ts
@@ -1,7 +1,8 @@
import { Flags } from '@oclif/core'
-import { BeeperCommand } from '../lib/command.js'
+import { BeeperCommand, ensureWritable } from '../lib/command.js'
import { createClient } from '../lib/client.js'
import { exportBeeperData } from '../lib/export/index.js'
+import { printDryRun } from '../lib/output.js'
import { resolveAccountIDs, resolveChatID } from '../lib/resolve.js'
export default class Export extends BeeperCommand {
@@ -26,11 +27,25 @@ export default class Export extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(Export)
+ ensureWritable(flags)
const client = await createClient(flags)
const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true })
const chatIDs = flags.chat?.length
? await Promise.all(flags.chat.map(chat => resolveChatID(client, chat, { accountIDs, pick: flags.pick })))
: undefined
+ if (flags['dry-run']) {
+ await printDryRun('export', {
+ accountIDs,
+ chatIDs,
+ downloadAttachments: !flags['no-attachments'],
+ force: flags.force,
+ limitChats: flags['limit-chats'],
+ limitMessages: flags['limit-messages'],
+ maxParticipants: flags['max-participants'],
+ outDir: flags.out,
+ }, flags.json ? 'json' : 'human')
+ return
+ }
const manifest = await exportBeeperData(client, {
accountIDs,
diff --git a/packages/cli/src/commands/install/desktop.ts b/packages/cli/src/commands/install/desktop.ts
index f617e172..37901fb8 100644
--- a/packages/cli/src/commands/install/desktop.ts
+++ b/packages/cli/src/commands/install/desktop.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { installDesktop, type InstallChannel } from '../../lib/installations.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class SetupInstallDesktop extends BeeperCommand {
static override summary = 'Install Beeper Desktop locally'
@@ -12,6 +12,10 @@ export default class SetupInstallDesktop extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(SetupInstallDesktop)
ensureWritable(flags)
+ if (flags['dry-run']) {
+ await printDryRun('install.desktop', { channel: flags.channel }, flags.json ? 'json' : 'human')
+ return
+ }
const installation = await installDesktop({ channel: flags.channel as InstallChannel })
await printSuccess({
message: `Installed Beeper Desktop ${installation.version ?? ''}`.trim(),
diff --git a/packages/cli/src/commands/install/server.ts b/packages/cli/src/commands/install/server.ts
index c612a131..78c7bc65 100644
--- a/packages/cli/src/commands/install/server.ts
+++ b/packages/cli/src/commands/install/server.ts
@@ -2,18 +2,23 @@ import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { installServer, type InstallChannel } from '../../lib/installations.js'
import { pathSetupHint } from '../../lib/env.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
+import { SERVER_ENVIRONMENTS, normalizeServerEnv } from '../../lib/server-env.js'
export default class SetupInstallServer extends BeeperCommand {
static override summary = 'Install Beeper Server locally'
static override flags = {
channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Server release channel' }),
- 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
+ 'server-env': Flags.string({ default: 'prod', description: 'Server environment: local, dev, staging, or prod', options: [...SERVER_ENVIRONMENTS], parse: async value => normalizeServerEnv(value) }),
}
async run(): Promise {
const { flags } = await this.parse(SetupInstallServer)
ensureWritable(flags)
+ if (flags['dry-run']) {
+ await printDryRun('install.server', { channel: flags.channel, serverEnv: flags['server-env'] }, flags.json ? 'json' : 'human')
+ return
+ }
const installation = await installServer({ channel: flags.channel as InstallChannel, serverEnv: flags['server-env'] })
await printSuccess({
message: `Installed Beeper Server ${installation.version ?? ''}`.trim(),
diff --git a/packages/cli/src/commands/man.ts b/packages/cli/src/commands/man.ts
index 275fa1ee..c8fd9508 100644
--- a/packages/cli/src/commands/man.ts
+++ b/packages/cli/src/commands/man.ts
@@ -1,4 +1,5 @@
import { BeeperCommand } from '../lib/command.js'
+import { metadataForCommand } from '../lib/command-metadata.js'
import { commandManifest } from '../lib/manifest.js'
import { printCommands } from '../lib/output.js'
export default class Man extends BeeperCommand {
@@ -17,53 +18,3 @@ export default class Man extends BeeperCommand {
await printCommands(commands, flags.json ? 'json' : 'human', { title: 'Beeper CLI' })
}
}
-
-function metadataForCommand(command: string): {
- mutates: boolean
- requiresAuth: boolean
- selectors: string[]
- output: 'data' | 'list' | 'stream' | 'success' | 'send-result' | 'manual'
- related: string[]
-} {
- const parts = command.split(' ')
- const root = parts[0] ?? ''
- const mutatingRoots = new Set(['setup', 'install', 'send', 'update'])
- const mutatingVerbs = new Set([
- 'add', 'archive', 'unarchive', 'pin', 'unpin', 'mute', 'unmute', 'mark-read', 'mark-unread',
- 'priority', 'notify-anyway', 'rename', 'description', 'avatar', 'draft', 'disappear', 'remind',
- 'unremind', 'focus', 'edit', 'delete', 'remove', 'use', 'set', 'reset', 'logout', 'start', 'stop',
- 'restart', 'enable', 'disable', 'approve', 'recovery-key', 'reset-recovery-key', 'cancel', 'sas',
- 'sas-confirm', 'qr-scan', 'qr-confirm',
- ])
- const mutates = mutatingRoots.has(root) || parts.some(part => mutatingVerbs.has(part ?? ''))
- const localOnly = root === 'config' || root === 'completion' || root === 'docs' || root === 'version' || root === 'man'
- const requiresAuth = !localOnly && command !== 'targets list' && !command.startsWith('targets add') && !command.startsWith('install ')
- const selectors = [
- command.includes('chats ') || command.includes('messages ') || command.startsWith('send ') || command === 'presence' ? 'chat' : undefined,
- command.includes('accounts ') || command.includes('contacts ') || command === 'chats start' ? 'account' : undefined,
- command.includes('targets ') || command === 'status' || command === 'doctor' || command.startsWith('auth ') || command.startsWith('verify') ? 'target' : undefined,
- command.startsWith('bridges ') || command === 'accounts add' ? 'bridge' : undefined,
- command.includes('messages ') || command.startsWith('send react') || command.startsWith('send unreact') ? 'message' : undefined,
- ].filter((value): value is string => Boolean(value))
- const output = command.startsWith('send ') ? 'send-result'
- : command === 'watch' || command === 'rpc' ? 'stream'
- : command === 'man' ? 'manual'
- : command.endsWith('list') || command.includes('search') || command === 'bridges list' ? 'list'
- : mutates ? 'success'
- : 'data'
- const related = relatedForCommand(command)
- return { mutates, requiresAuth, selectors, output, related }
-}
-
-function relatedForCommand(command: string): string[] {
- if (command.startsWith('send ')) return ['messages list', 'watch']
- if (command.startsWith('messages ')) return ['chats list', 'send text']
- if (command.startsWith('chats ')) return ['messages list', 'send text']
- if (command.startsWith('bridges ')) return ['accounts add', 'accounts list']
- if (command.startsWith('accounts ')) return ['bridges list', 'chats list']
- if (command.startsWith('targets ')) return ['status', 'doctor']
- if (command === 'status') return ['doctor', 'setup']
- if (command === 'doctor') return ['status', 'setup']
- if (command.startsWith('verify')) return ['setup', 'status']
- return []
-}
diff --git a/packages/cli/src/commands/media/download.ts b/packages/cli/src/commands/media/download.ts
index 9636d4d7..3c86c8d7 100644
--- a/packages/cli/src/commands/media/download.ts
+++ b/packages/cli/src/commands/media/download.ts
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class MediaDownload extends BeeperCommand {
static override summary = 'Download message media'
static override args = { url: Args.string({ required: true, description: 'mxc:// or localmxc:// URL' }) }
@@ -12,6 +12,12 @@ export default class MediaDownload extends BeeperCommand {
}
async run(): Promise {
const { args, flags } = await this.parse(MediaDownload)
+ const format = flags.json ? 'json' : 'human'
+ if (flags['dry-run'] && flags.out !== '-') {
+ ensureWritable(flags)
+ await printDryRun('media.download', { url: args.url, out: flags.out }, format)
+ return
+ }
const client = await createClient(flags)
const response = await client.assets.serve({ url: args.url })
const buffer = Buffer.from(await response.arrayBuffer())
@@ -23,6 +29,6 @@ export default class MediaDownload extends BeeperCommand {
await mkdir(flags.out, { recursive: true })
const path = join(flags.out, basename(new URL(args.url).pathname) || 'media')
await writeFile(path, buffer)
- await printSuccess({ message: 'Downloaded media', detail: path, data: { path, bytes: buffer.length } }, flags.json ? 'json' : 'human')
+ await printSuccess({ message: 'Downloaded media', detail: path, data: { path, bytes: buffer.length } }, format)
}
}
diff --git a/packages/cli/src/commands/messages/delete.ts b/packages/cli/src/commands/messages/delete.ts
index 4488cdac..ac011a6f 100644
--- a/packages/cli/src/commands/messages/delete.ts
+++ b/packages/cli/src/commands/messages/delete.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class MessagesDelete extends BeeperCommand {
@@ -17,6 +17,10 @@ export default class MessagesDelete extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('messages.delete', { chatID, messageID: flags.id, forEveryone: flags['for-everyone'] }, flags.json ? 'json' : 'human')
+ return
+ }
await client.messages.delete(flags.id, { chatID, forEveryone: flags['for-everyone'] || undefined })
await printSuccess({ message: flags['for-everyone'] ? 'Deleted for everyone' : 'Deleted', data: { chatID, messageID: flags.id, forEveryone: flags['for-everyone'] } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/messages/edit.ts b/packages/cli/src/commands/messages/edit.ts
index bc5a2cc4..a473017b 100644
--- a/packages/cli/src/commands/messages/edit.ts
+++ b/packages/cli/src/commands/messages/edit.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class MessagesEdit extends BeeperCommand {
@@ -17,6 +17,10 @@ export default class MessagesEdit extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
+ if (flags['dry-run']) {
+ await printDryRun('messages.edit', { chatID, messageID: flags.id, text: flags.message }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.messages.update(flags.id, { chatID, text: flags.message }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/messages/export.ts b/packages/cli/src/commands/messages/export.ts
index 74080e8f..03d8ac38 100644
--- a/packages/cli/src/commands/messages/export.ts
+++ b/packages/cli/src/commands/messages/export.ts
@@ -1,7 +1,8 @@
import { writeFile } from 'node:fs/promises'
import { Flags } from '@oclif/core'
-import { BeeperCommand } from '../../lib/command.js'
+import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
+import { printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class MessagesExport extends BeeperCommand {
@@ -22,6 +23,21 @@ export default class MessagesExport extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(MessagesExport)
if (flags['before-cursor'] && flags['after-cursor']) throw new Error('Use only one of --before-cursor or --after-cursor')
+ if (flags['dry-run']) {
+ await printDryRun('messages.export', {
+ chat: flags.chat,
+ pick: flags.pick,
+ output: flags.output,
+ beforeCursor: flags['before-cursor'],
+ afterCursor: flags['after-cursor'],
+ after: flags.after,
+ before: flags.before,
+ limit: flags.limit,
+ asc: flags.asc,
+ }, flags.json ? 'json' : 'human')
+ return
+ }
+ if (flags.output !== '-') ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
const cursor = flags['before-cursor'] ?? flags['after-cursor']
diff --git a/packages/cli/src/commands/messages/search.ts b/packages/cli/src/commands/messages/search.ts
index d88cd8f6..2515087b 100644
--- a/packages/cli/src/commands/messages/search.ts
+++ b/packages/cli/src/commands/messages/search.ts
@@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'
import { BeeperCommand } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
import { usageError } from '../../lib/errors.js'
-import { collectPage, printIDs, printList } from '../../lib/output.js'
+import { collectPage, isMachineReadableOutput, printIDs, printList } from '../../lib/output.js'
import { resolveAccountIDs, resolveChatID } from '../../lib/resolve.js'
import { withInkSpinner as withSpinner } from '../../lib/ink/spinner.js'
@@ -58,13 +58,14 @@ export default class MessagesSearch extends BeeperCommand {
query: args.query,
sender: flags.sender as 'me' | 'others' | (string & {}) | undefined,
}
- const useSpinner = !flags.json && !flags.ids
+ const useSpinner = !isMachineReadableOutput(flags.ids ? 'ids' : flags.json ? 'json' : 'human')
const label = args.query ? `Searching messages for "${args.query}"…` : 'Searching messages…'
+ const collect = () => collectPage(client.messages.search(params), flags.limit)
const items = useSpinner
- ? await withSpinner(label, () => collectPage(client.messages.search(params), flags.limit), {
+ ? await withSpinner(label, collect, {
done: value => `${value.length} match${value.length === 1 ? '' : 'es'}`,
})
- : await collectPage(client.messages.search(params), flags.limit)
+ : await collect()
if (flags.ids) {
printIDs(items)
return
diff --git a/packages/cli/src/commands/plugins/available.ts b/packages/cli/src/commands/plugins/available.ts
index 4147b30b..e2f16dd6 100644
--- a/packages/cli/src/commands/plugins/available.ts
+++ b/packages/cli/src/commands/plugins/available.ts
@@ -9,12 +9,15 @@ export default class PluginsAvailable extends BeeperCommand {
const { flags } = await this.parse(PluginsAvailable)
const installed = new Set(this.config.plugins.keys())
const corePlugins = new Set((this.config.pjson.oclif.plugins ?? []) as string[])
- const plugins = recommendedPlugins.map(plugin => ({
- ...plugin,
- installed: installed.has(plugin.name),
- status: installed.has(plugin.name) ? 'installed' : 'not installed',
- core: corePlugins.has(plugin.name),
- }))
+ const plugins = recommendedPlugins.map(plugin => {
+ const isInstalled = installed.has(plugin.name)
+ return {
+ ...plugin,
+ installed: isInstalled,
+ status: isInstalled ? 'installed' : 'not installed',
+ core: corePlugins.has(plugin.name),
+ }
+ })
await printData(plugins, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/presence.ts b/packages/cli/src/commands/presence.ts
index ffcc3df5..fe85d783 100644
--- a/packages/cli/src/commands/presence.ts
+++ b/packages/cli/src/commands/presence.ts
@@ -2,7 +2,7 @@ import { setTimeout as delay } from 'node:timers/promises'
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../lib/command.js'
import { createClient } from '../lib/client.js'
-import { printSuccess } from '../lib/output.js'
+import { printDryRun, printSuccess } from '../lib/output.js'
import { resolveChatID } from '../lib/resolve.js'
export default class Presence extends BeeperCommand {
@@ -17,9 +17,14 @@ export default class Presence extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(Presence)
- ensureWritable(flags)
if (flags.duration !== undefined && flags.duration <= 0) throw new Error('--duration must be a positive integer (seconds)')
if (flags.duration !== undefined && flags.state !== 'typing') throw new Error('--duration only applies when --state is typing')
+ if (flags['dry-run']) {
+ await printDryRun('presence', { chat: flags.chat, pick: flags.pick, state: flags.state, durationSeconds: flags.duration }, flags.json ? 'json' : 'human')
+ return
+ }
+
+ ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.chat, { pick: flags.pick })
const post = (state: 'typing' | 'paused') =>
@@ -32,6 +37,7 @@ export default class Presence extends BeeperCommand {
await printSuccess({ message: `Sent typing then paused after ${flags.duration}s`, data: { chatID, state: 'paused', durationSeconds: flags.duration } }, flags.json ? 'json' : 'human')
return
}
+
await printSuccess({ message: `Sent ${flags.state} indicator`, data: { chatID, state: flags.state } }, flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/resolve/account.ts b/packages/cli/src/commands/resolve/account.ts
new file mode 100644
index 00000000..ade03dc9
--- /dev/null
+++ b/packages/cli/src/commands/resolve/account.ts
@@ -0,0 +1,47 @@
+import { Args, Flags } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { createClient } from '../../lib/client.js'
+import { notFound } from '../../lib/errors.js'
+import { printData } from '../../lib/output.js'
+import { resolveAccountIDs } from '../../lib/resolve.js'
+
+export default class ResolveAccount extends BeeperCommand {
+ static override summary = 'Resolve an account selector'
+ static override args = {
+ selector: Args.string({ required: true, description: 'Account ID, network, bridge, or account user selector' }),
+ }
+ static override flags = {
+ pick: Flags.integer({ description: 'Select the Nth candidate (1-indexed)' }),
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(ResolveAccount)
+ const client = await createClient(flags)
+ const response = await client.accounts.list()
+ const rows = Array.isArray(response) ? response : ((response as any).items ?? [])
+ const ids = await resolveAccountIDs(client, [args.selector], { allowMultiplePerInput: true, applyDefault: false })
+ const candidates = rows.filter((row: any) => ids?.includes(String(row.accountID ?? row.id)))
+ if (!candidates.length) throw notFound(`No account matches "${args.selector}"`, { selector: args.selector, kind: 'account' })
+ const pick = flags.pick
+ const selected = pick !== undefined ? candidates[pick - 1] : candidates.length === 1 ? candidates[0] : undefined
+ if (pick !== undefined && !selected) throw notFound(`--pick ${pick} is outside the ${candidates.length} matching accounts`, { selector: args.selector, pick, count: candidates.length })
+ await printData({
+ selector: args.selector,
+ kind: 'account',
+ selected: selected ? accountCandidate(selected, candidates.indexOf(selected) + 1) : null,
+ candidates: candidates.map((account: any, index: number) => accountCandidate(account, index + 1)),
+ }, flags.json ? 'json' : 'human')
+ }
+}
+
+function accountCandidate(account: any, pick: number): Record {
+ return {
+ pick,
+ id: account.accountID ?? account.id,
+ accountID: account.accountID,
+ network: account.network,
+ bridge: account.bridge,
+ user: account.user,
+ raw: account,
+ }
+}
diff --git a/packages/cli/src/commands/resolve/bridge.ts b/packages/cli/src/commands/resolve/bridge.ts
new file mode 100644
index 00000000..44781dfe
--- /dev/null
+++ b/packages/cli/src/commands/resolve/bridge.ts
@@ -0,0 +1,50 @@
+import { Args, Flags } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { createClient } from '../../lib/client.js'
+import { notFound } from '../../lib/errors.js'
+import { printData } from '../../lib/output.js'
+
+export default class ResolveBridge extends BeeperCommand {
+ static override summary = 'Resolve a bridge selector'
+ static override args = {
+ selector: Args.string({ required: true, description: 'Bridge ID, type, provider, or display name' }),
+ }
+ static override flags = {
+ pick: Flags.integer({ description: 'Select the Nth candidate (1-indexed)' }),
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(ResolveBridge)
+ const client = await createClient(flags)
+ const response = await client.bridges.list()
+ const rows = ((response as unknown as { items?: Array> }).items ?? [])
+ const normalized = normalize(args.selector)
+ const candidates = rows.filter(bridge =>
+ normalize(bridge.id) === normalized ||
+ normalize(bridge.type) === normalized ||
+ normalize(bridge.provider) === normalized ||
+ normalize(bridge.name) === normalized ||
+ normalize(bridge.displayName) === normalized ||
+ normalize(bridge.id).includes(normalized) ||
+ normalize(bridge.displayName).includes(normalized)
+ )
+ if (!candidates.length) throw notFound(`No bridge matches "${args.selector}"`, { selector: args.selector, kind: 'bridge' })
+ const pick = flags.pick
+ const selected = pick !== undefined ? candidates[pick - 1] : candidates.length === 1 ? candidates[0] : undefined
+ if (pick !== undefined && !selected) throw notFound(`--pick ${pick} is outside the ${candidates.length} matching bridges`, { selector: args.selector, pick, count: candidates.length })
+ await printData({
+ selector: args.selector,
+ kind: 'bridge',
+ selected: selected ? bridgeCandidate(selected, candidates.indexOf(selected) + 1) : null,
+ candidates: candidates.map((bridge, index) => bridgeCandidate(bridge, index + 1)),
+ }, flags.json ? 'json' : 'human')
+ }
+}
+
+function bridgeCandidate(bridge: Record, pick: number): Record {
+ return { pick, id: bridge.id, type: bridge.type, provider: bridge.provider, displayName: bridge.displayName ?? bridge.name, status: bridge.status, raw: bridge }
+}
+
+function normalize(value: unknown): string {
+ return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '')
+}
diff --git a/packages/cli/src/commands/resolve/chat.ts b/packages/cli/src/commands/resolve/chat.ts
new file mode 100644
index 00000000..4586e190
--- /dev/null
+++ b/packages/cli/src/commands/resolve/chat.ts
@@ -0,0 +1,59 @@
+import { Args, Flags } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { createClient } from '../../lib/client.js'
+import { notFound } from '../../lib/errors.js'
+import { collectPage, printData } from '../../lib/output.js'
+import { resolveAccountIDs } from '../../lib/resolve.js'
+
+export default class ResolveChat extends BeeperCommand {
+ static override summary = 'Resolve a chat selector to concrete chat candidates'
+ static override args = {
+ selector: Args.string({ required: true, description: 'Chat ID, local ID, exact title, or search text' }),
+ }
+ static override flags = {
+ account: Flags.string({ multiple: true, description: 'Limit to account selector. Repeat for multiple.' }),
+ pick: Flags.integer({ description: 'Select the Nth candidate (1-indexed)' }),
+ limit: Flags.integer({ default: 10, description: 'Maximum candidates to return' }),
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(ResolveChat)
+ const client = await createClient(flags)
+ const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true })
+ const candidates = await collectPage(client.chats.search({ accountIDs, query: args.selector, scope: 'titles' }), flags.limit)
+ const normalized = normalize(args.selector)
+ const exact = candidates.filter(chat =>
+ normalize(chat.id) === normalized ||
+ normalize(chat.localChatID) === normalized ||
+ normalize(chat.title) === normalized
+ )
+ const matches = exact.length ? exact : candidates
+ if (!matches.length) throw notFound(`No chat matches "${args.selector}"`, { selector: args.selector, kind: 'chat' })
+ const selected = flags.pick ? matches[flags.pick - 1] : matches.length === 1 ? matches[0] : undefined
+ if (flags.pick && !selected) throw notFound(`--pick ${flags.pick} is outside the ${matches.length} matching chats`, { selector: args.selector, pick: flags.pick, count: matches.length })
+ await printData({
+ selector: args.selector,
+ kind: 'chat',
+ selected: selected ? chatCandidate(selected, matches.indexOf(selected) + 1) : null,
+ candidates: matches.map((chat, index) => chatCandidate(chat, index + 1)),
+ }, flags.json ? 'json' : 'human')
+ }
+}
+
+type Chat = Record
+
+function chatCandidate(chat: Chat, pick: number): Record {
+ return {
+ pick,
+ id: chat.id,
+ localChatID: chat.localChatID,
+ title: chat.title,
+ network: chat.network,
+ accountID: chat.accountID,
+ raw: chat,
+ }
+}
+
+function normalize(value: unknown): string {
+ return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '')
+}
diff --git a/packages/cli/src/commands/resolve/contact.ts b/packages/cli/src/commands/resolve/contact.ts
new file mode 100644
index 00000000..8cfd2491
--- /dev/null
+++ b/packages/cli/src/commands/resolve/contact.ts
@@ -0,0 +1,63 @@
+import { Args, Flags } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { createClient } from '../../lib/client.js'
+import { notFound } from '../../lib/errors.js'
+import { printData } from '../../lib/output.js'
+import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js'
+
+export default class ResolveContact extends BeeperCommand {
+ static override summary = 'Resolve a contact selector'
+ static override args = {
+ selector: Args.string({ required: true, description: 'Contact name, username, phone, email, or ID' }),
+ }
+ static override flags = {
+ account: Flags.string({ multiple: true, description: 'Limit to account selector. Repeat for multiple.' }),
+ pick: Flags.integer({ description: 'Select the Nth candidate (1-indexed)' }),
+ limit: Flags.integer({ default: 10, description: 'Maximum candidates to return per account' }),
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(ResolveContact)
+ const client = await createClient(flags)
+ const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client)
+ const candidates: Array> = []
+ for (const accountID of accountIDs) {
+ try {
+ const result = await client.accounts.contacts.search(accountID, { query: args.selector })
+ candidates.push(...result.items.slice(0, flags.limit).map((item: unknown) => ({ ...(item as Record), accountID })))
+ } catch (error) {
+ if (shouldIgnoreLookupError(error)) continue
+ throw error
+ }
+ }
+ if (!candidates.length) throw notFound(`No contact matches "${args.selector}"`, { selector: args.selector, kind: 'contact' })
+ const pick = flags.pick
+ const selected = pick !== undefined ? candidates[pick - 1] : candidates.length === 1 ? candidates[0] : undefined
+ if (pick !== undefined && !selected) throw notFound(`--pick ${pick} is outside the ${candidates.length} matching contacts`, { selector: args.selector, pick, count: candidates.length })
+ await printData({
+ selector: args.selector,
+ kind: 'contact',
+ selected: selected ? contactCandidate(selected, candidates.indexOf(selected) + 1) : null,
+ candidates: candidates.map((contact, index) => contactCandidate(contact, index + 1)),
+ }, flags.json ? 'json' : 'human')
+ }
+}
+
+function contactCandidate(contact: Record, pick: number): Record {
+ return {
+ pick,
+ id: contact.id,
+ accountID: contact.accountID,
+ displayName: contact.displayName ?? contact.fullName ?? contact.name,
+ username: contact.username,
+ phoneNumber: contact.phoneNumber,
+ email: contact.email,
+ }
+}
+
+function shouldIgnoreLookupError(error: unknown): boolean {
+ if (!(error instanceof Error)) return false
+ const status = (error as Error & { status?: number; statusCode?: number }).status ?? (error as Error & { status?: number; statusCode?: number }).statusCode
+ if (status === 400 || status === 404) return true
+ return /\b(400|404)\b|not supported|not found/i.test(error.message)
+}
diff --git a/packages/cli/src/commands/resolve/target.ts b/packages/cli/src/commands/resolve/target.ts
new file mode 100644
index 00000000..a6b8b325
--- /dev/null
+++ b/packages/cli/src/commands/resolve/target.ts
@@ -0,0 +1,52 @@
+import { Args, Flags } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { notFound } from '../../lib/errors.js'
+import { printData } from '../../lib/output.js'
+import { builtInDesktopTargetID, listTargets, readConfig, type Target } from '../../lib/targets.js'
+
+export default class ResolveTarget extends BeeperCommand {
+ static override summary = 'Resolve a target selector'
+ static override args = {
+ selector: Args.string({ required: true, description: 'Target name, ID, type, or base URL' }),
+ }
+ static override flags = {
+ pick: Flags.integer({ description: 'Select the Nth candidate (1-indexed)' }),
+ }
+
+ async run(): Promise {
+ const { args, flags } = await this.parse(ResolveTarget)
+ const config = await readConfig()
+ const builtIn: Target = {
+ id: builtInDesktopTargetID,
+ type: 'desktop',
+ name: 'Beeper Desktop',
+ baseURL: process.env.BEEPER_DESKTOP_BASE_URL || config.baseURL || 'http://127.0.0.1:23373',
+ auth: config.auth,
+ }
+ const targets = [builtIn, ...await listTargets()]
+ const normalized = normalize(args.selector)
+ const candidates = targets.filter(target =>
+ normalize(target.id) === normalized ||
+ normalize(target.name) === normalized ||
+ normalize(target.type) === normalized ||
+ normalize(target.baseURL).includes(normalized)
+ )
+ if (!candidates.length) throw notFound(`No target matches "${args.selector}"`, { selector: args.selector, kind: 'target' })
+ const selected = flags.pick ? candidates[flags.pick - 1] : candidates.length === 1 ? candidates[0] : undefined
+ if (flags.pick && !selected) throw notFound(`--pick ${flags.pick} is outside the ${candidates.length} matching targets`, { selector: args.selector, pick: flags.pick, count: candidates.length })
+ await printData({
+ selector: args.selector,
+ kind: 'target',
+ selected: selected ? targetCandidate(selected, candidates.indexOf(selected) + 1) : null,
+ candidates: candidates.map((target, index) => targetCandidate(target, index + 1)),
+ }, flags.json ? 'json' : 'human')
+ }
+}
+
+function targetCandidate(target: Target, pick: number): Record {
+ return { pick, id: target.id, name: target.name, type: target.type, baseURL: target.baseURL, managed: target.managed, raw: target }
+}
+
+function normalize(value: unknown): string {
+ return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '')
+}
diff --git a/packages/cli/src/commands/schema.ts b/packages/cli/src/commands/schema.ts
new file mode 100644
index 00000000..f990d858
--- /dev/null
+++ b/packages/cli/src/commands/schema.ts
@@ -0,0 +1,141 @@
+import { Args } from '@oclif/core'
+import { BeeperCommand } from '../lib/command.js'
+import { metadataForCommand } from '../lib/command-metadata.js'
+import { commandManifest } from '../lib/manifest.js'
+import { printData } from '../lib/output.js'
+
+type RawCommand = {
+ id: string
+ aliases?: string[]
+ args?: Record
+ description?: string
+ flags?: Record
+ hidden?: boolean
+ pluginName?: string
+ summary?: string
+}
+
+export default class Schema extends BeeperCommand {
+ static override strict = false
+ static override summary = 'Print machine-readable command/flag schema'
+ static override description = 'Agent-first schema for commands, flags, args, examples, mutation metadata, selectors, output shapes, and related commands.'
+ static override args = {
+ command: Args.string({ required: false, description: 'Optional command path, such as "messages search"' }),
+ }
+
+ async run(): Promise {
+ const { argv } = await this.parse(Schema)
+ const pathArgs = argv as string[]
+ const requested = pathArgs.length > 0 ? pathArgs.join(' ') : undefined
+ const manifestByCommand = new Map(commandManifest.map(item => [item.command, item]))
+ const commands = (this.config.commands as RawCommand[])
+ .filter(command => !command.hidden)
+ .map(command => {
+ const path = command.id.replaceAll(':', ' ')
+ const manifest = manifestByCommand.get(path)
+ const metadata = metadataForCommand(path)
+ return {
+ path,
+ id: command.id,
+ aliases: (command.aliases ?? []).map(alias => alias.replaceAll(':', ' ')),
+ summary: command.summary ?? manifest?.description ?? command.description ?? '',
+ description: command.description ?? manifest?.description ?? command.summary ?? '',
+ examples: manifest?.examples ?? [],
+ args: normalizeFields(command.args),
+ flags: normalizeFields(command.flags),
+ ...metadata,
+ supports: {
+ dryRun: metadata.mutates,
+ force: metadata.mutates,
+ format: true,
+ noInput: true,
+ readOnly: true,
+ select: true,
+ },
+ outputShape: outputShape(metadata.output),
+ }
+ })
+ .sort((a, b) => a.path.localeCompare(b.path))
+
+ const filtered = requested
+ ? commands.filter(command => command.path === requested || command.path.startsWith(`${requested} `))
+ : commands
+
+ await printData({
+ schemaVersion: 1,
+ bin: this.config.bin,
+ version: this.config.version,
+ defaults: {
+ stdout: 'primary command output only',
+ stderr: 'diagnostics, progress, events, and structured errors',
+ nonTTYFormat: 'json',
+ ttyFormat: 'table',
+ },
+ formats: ['json', 'jsonl', 'table', 'text', 'ids'],
+ exitCodes: {
+ 0: 'success',
+ 1: 'generic runtime error',
+ 2: 'usage error',
+ 3: 'auth required',
+ 4: 'target/account not ready',
+ 5: 'selector matched nothing',
+ 6: 'ambiguous selector',
+ 127: 'declined did-you-mean suggestion',
+ },
+ commands: filtered,
+ }, 'json')
+ }
+}
+
+function normalizeFields(fields: Record | undefined): Array> {
+ if (!fields) return []
+ return Object.entries(fields).map(([name, raw]) => normalizeField(name, raw))
+}
+
+function normalizeField(name: string, raw: unknown): Record {
+ const record = raw && typeof raw === 'object' ? raw as Record : {}
+ return {
+ name,
+ description: record.description ?? record.summary ?? '',
+ required: Boolean(record.required),
+ multiple: Boolean(record.multiple),
+ default: record.default,
+ options: record.options,
+ char: record.char,
+ type: typeName(record),
+ }
+}
+
+function typeName(record: Record): string {
+ if (Array.isArray(record.options)) return 'enum'
+ if (record.type === 'boolean' || record.type === 'option') return String(record.type)
+ if (typeof record.parse === 'function') return 'string'
+ if (typeof record.default === 'boolean') return 'boolean'
+ if (typeof record.default === 'number') return 'integer'
+ return 'string'
+}
+
+function outputShape(kind: string): Record {
+ const envelope = { ok: true, data: '', error: null, meta: '' }
+ switch (kind) {
+ case 'list': {
+ return { kind, envelope, data: 'array' }
+ }
+
+ case 'send-result': {
+ return { kind, envelope, data: { chatID: 'string', pendingMessageID: 'string?', state: 'string?' } }
+ }
+
+ case 'stream': {
+ return { kind, data: 'jsonl events or RPC lines' }
+ }
+
+ case 'success': {
+ return { kind, envelope, data: { message: 'string', detail: 'string?', data: 'object?' } }
+ }
+
+ default: {
+ return { kind, envelope, data: 'object' }
+ }
+ }
+}
diff --git a/packages/cli/src/commands/send/file.ts b/packages/cli/src/commands/send/file.ts
index 67ed8ae6..2e5442fc 100644
--- a/packages/cli/src/commands/send/file.ts
+++ b/packages/cli/src/commands/send/file.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
import { sendMessage } from '../../lib/send-message.js'
@@ -29,6 +29,12 @@ export default class SendFile extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
- await printData(await sendMessage(client, { chatID, file: flags.file, fileName: flags.filename, mimeType: flags.mime, replyTo: flags['reply-to'], text: flags.caption || '', wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human')
+ const request = { chatID, file: flags.file, fileName: flags.filename, mimeType: flags.mime, replyTo: flags['reply-to'], text: flags.caption || '', wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }
+ if (flags['dry-run']) {
+ await printDryRun('send.file', request, flags.json ? 'json' : 'human')
+ return
+ }
+
+ await printData(await sendMessage(client, request), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/send/react.ts b/packages/cli/src/commands/send/react.ts
index af3fde14..c4d5cea5 100644
--- a/packages/cli/src/commands/send/react.ts
+++ b/packages/cli/src/commands/send/react.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class SendReact extends BeeperCommand {
@@ -18,6 +18,11 @@ export default class SendReact extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
+ const request = { chatID, messageID: flags.id, reactionKey: flags.reaction, transactionID: flags.transaction }
+ if (flags['dry-run']) {
+ await printDryRun('send.react', request, flags.json ? 'json' : 'human')
+ return
+ }
await printData(
await client.chats.messages.reactions.add(flags.id, { chatID, reactionKey: flags.reaction, transactionID: flags.transaction }),
flags.json ? 'json' : 'human',
diff --git a/packages/cli/src/commands/send/sticker.ts b/packages/cli/src/commands/send/sticker.ts
index 3fb53bb2..284c8de0 100644
--- a/packages/cli/src/commands/send/sticker.ts
+++ b/packages/cli/src/commands/send/sticker.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
import { sendMessage } from '../../lib/send-message.js'
@@ -23,18 +23,23 @@ export default class SendSticker extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
+ const request = {
+ chatID,
+ file: flags.file,
+ fileName: flags.filename,
+ mimeType: flags.mime,
+ replyTo: flags['reply-to'],
+ text: '',
+ attachmentType: 'sticker' as const,
+ wait: flags.wait,
+ waitTimeoutMs: flags['wait-timeout'],
+ }
+ if (flags['dry-run']) {
+ await printDryRun('send.sticker', request, flags.json ? 'json' : 'human')
+ return
+ }
await printData(
- await sendMessage(client, {
- chatID,
- file: flags.file,
- fileName: flags.filename,
- mimeType: flags.mime,
- replyTo: flags['reply-to'],
- text: '',
- attachmentType: 'sticker',
- wait: flags.wait,
- waitTimeoutMs: flags['wait-timeout'],
- }),
+ await sendMessage(client, request),
flags.json ? 'json' : 'human',
)
}
diff --git a/packages/cli/src/commands/send/text.ts b/packages/cli/src/commands/send/text.ts
index 42fdacfe..3363907a 100644
--- a/packages/cli/src/commands/send/text.ts
+++ b/packages/cli/src/commands/send/text.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
import { sendMessage } from '../../lib/send-message.js'
@@ -28,6 +28,12 @@ export default class SendText extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
- await printData(await sendMessage(client, { chatID, text: flags.message, replyTo: flags['reply-to'], mentions: flags.mention, noPreview: flags['no-preview'], wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }), flags.json ? 'json' : 'human')
+ const request = { chatID, text: flags.message, replyTo: flags['reply-to'], mentions: flags.mention, noPreview: flags['no-preview'], wait: flags.wait, waitTimeoutMs: flags['wait-timeout'] }
+ if (flags['dry-run']) {
+ await printDryRun('send.text', request, flags.json ? 'json' : 'human')
+ return
+ }
+
+ await printData(await sendMessage(client, request), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/send/unreact.ts b/packages/cli/src/commands/send/unreact.ts
index b49bf7c9..c1494ea6 100644
--- a/packages/cli/src/commands/send/unreact.ts
+++ b/packages/cli/src/commands/send/unreact.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
export default class SendUnreact extends BeeperCommand {
@@ -19,6 +19,11 @@ export default class SendUnreact extends BeeperCommand {
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
+ const request = { chatID, messageID: flags.id, reactionKey: flags.reaction }
+ if (flags['dry-run']) {
+ await printDryRun('send.unreact', request, flags.json ? 'json' : 'human')
+ return
+ }
await printData(
await client.chats.messages.reactions.delete(flags.reaction, { chatID, messageID: flags.id }),
flags.json ? 'json' : 'human',
diff --git a/packages/cli/src/commands/send/voice.ts b/packages/cli/src/commands/send/voice.ts
index 5c831b72..e89f8aa9 100644
--- a/packages/cli/src/commands/send/voice.ts
+++ b/packages/cli/src/commands/send/voice.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { resolveChatID } from '../../lib/resolve.js'
import { sendMessage } from '../../lib/send-message.js'
@@ -21,22 +21,29 @@ export default class SendVoice extends BeeperCommand {
}
async run(): Promise {
const { flags } = await this.parse(SendVoice)
+ const dryRunRequest = {
+ chat: flags.to,
+ pick: flags.pick,
+ file: flags.file,
+ fileName: flags.filename,
+ mimeType: flags.mime,
+ replyTo: flags['reply-to'],
+ text: '',
+ attachmentType: 'voice-note' as const,
+ duration: flags.duration,
+ wait: flags.wait,
+ waitTimeoutMs: flags['wait-timeout'],
+ }
+ if (flags['dry-run']) {
+ await printDryRun('send.voice', dryRunRequest, flags.json ? 'json' : 'human')
+ return
+ }
+
ensureWritable(flags)
const client = await createClient(flags)
const chatID = await resolveChatID(client, flags.to, { pick: flags.pick })
await printData(
- await sendMessage(client, {
- chatID,
- file: flags.file,
- fileName: flags.filename,
- mimeType: flags.mime,
- replyTo: flags['reply-to'],
- text: '',
- attachmentType: 'voice-note',
- duration: flags.duration,
- wait: flags.wait,
- waitTimeoutMs: flags['wait-timeout'],
- }),
+ await sendMessage(client, { ...dryRunRequest, chatID }),
flags.json ? 'json' : 'human',
)
}
diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts
index 5b45031b..f1ab07a3 100644
--- a/packages/cli/src/commands/setup.ts
+++ b/packages/cli/src/commands/setup.ts
@@ -9,6 +9,7 @@ import { loginWithPKCE } from '../lib/oauth.js'
import { findDesktopAppPath, launchDesktopApp, startProfile } from '../lib/profiles.js'
import { interactiveEmailSetup } from '../lib/setup-login.js'
import { renderStartupLogo } from '../lib/logo.js'
+import { SERVER_ENVIRONMENTS, SERVER_ENV_API_BASE_URLS, normalizeServerEnv } from '../lib/server-env.js'
import {
builtInDesktopTargetID,
createProfileTarget,
@@ -16,13 +17,14 @@ import {
readConfig,
readTarget,
listTargets,
+ pathExists,
saveTargetAuth,
updateConfig,
writeTarget,
type AuthSource,
type Target,
} from '../lib/targets.js'
-import { printData, printSuccess } from '../lib/output.js'
+import { printData, printDryRun, printSuccess } from '../lib/output.js'
export default class Setup extends BeeperCommand {
static override summary = 'Make the selected target ready for messaging'
@@ -34,7 +36,7 @@ export default class Setup extends BeeperCommand {
desktop: Flags.boolean({ default: false, description: 'Set up a local Beeper Desktop target' }),
install: Flags.boolean({ default: false, description: 'Allow installing missing managed runtime' }),
channel: Flags.string({ options: ['stable', 'nightly'], default: 'stable', description: 'Install release channel' }),
- 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
+ 'server-env': Flags.string({ default: 'prod', description: 'Server environment: local, dev, staging, or prod', options: [...SERVER_ENVIRONMENTS], parse: async value => normalizeServerEnv(value) }),
email: Flags.string({ description: 'Sign in with an email address' }),
username: Flags.string({ description: 'Username to use if setup creates a new account' }),
}
@@ -57,6 +59,22 @@ export default class Setup extends BeeperCommand {
if ((flags.local || flags.oauth) && (flags.remote || flags.server || flags.desktop)) {
throw new Error('Use --local or --oauth with an existing target, not with --remote, --server, or --desktop.')
}
+ if (flags['dry-run']) {
+ await printDryRun('setup', {
+ target: flags.target,
+ baseURL: flags['base-url'],
+ targetMode: flags.remote ? 'remote' : flags.server ? 'server' : flags.desktop ? 'desktop' : 'selected',
+ authMode: flags.local ? 'local' : flags.oauth ? 'oauth' : flags.email ? 'email' : 'auto',
+ remote: flags.remote,
+ install: flags.install,
+ channel: flags.channel,
+ serverEnv: flags['server-env'],
+ email: flags.email,
+ username: flags.username,
+ yes: flags.yes,
+ }, flags.json ? 'json' : 'human')
+ return
+ }
if (flags.events) writeEvent('setup_step', { step: 'start', target: flags.target })
if (flags.remote) {
@@ -102,7 +120,7 @@ export default class Setup extends BeeperCommand {
return
}
if (flags.json || !process.stdin.isTTY) {
- await printData(setupSessionFoundOutput(local, setupCmd), flags.json ? 'json' : 'human')
+ await printData(setupSessionFoundOutput(local, setupCmd, detected.serverInstalled), flags.json ? 'json' : 'human')
return
}
printLocalDesktopPreview(local)
@@ -119,21 +137,21 @@ export default class Setup extends BeeperCommand {
} else if (flags.json || !process.stdin.isTTY) {
await printData(setupStateOutput(detected, target), flags.json ? 'json' : 'human')
return
- } else if (detected.kind === 'installed-not-running' && !flags.json && process.stdin.isTTY) {
+ } else if (detected.kind === 'installed-not-running') {
printStatus('Found Beeper Desktop on this device.', 'installed, not running')
const shouldLaunch = flags.yes || await promptYesNoDefaultYes('Launch Beeper Desktop now?')
if (shouldLaunch) {
await launchAndPoll(target, setupCmd, flags)
return
}
- } else if (detected.kind === 'running-signed-out' && !flags.json && process.stdin.isTTY) {
+ } else if (detected.kind === 'running-signed-out') {
printStatus('Found Beeper Desktop on this device.', 'running, signed out')
const shouldOpen = flags.yes || await promptYesNoDefaultYes('Open Beeper Desktop so you can sign in?')
if (shouldOpen) {
await launchAndPoll(target, setupCmd, flags)
return
}
- } else if (detected.kind === 'session-unreadable' && !flags.json && process.stdin.isTTY) {
+ } else if (detected.kind === 'session-unreadable') {
printStatus('Found Beeper Desktop on this device.', 'signed in, but CLI could not read the local session')
process.stdout.write('You can still connect through Beeper Desktop.\n')
if (flags.debug) process.stdout.write(`\n${detected.reason}\n`)
@@ -143,7 +161,7 @@ export default class Setup extends BeeperCommand {
await this.setupOAuth(target, flags)
return
}
- } else if (detected.kind === 'not-installed' && !flags.json && process.stdin.isTTY) {
+ } else if (detected.kind === 'not-installed') {
await this.setupFromChoice(flags)
return
}
@@ -152,7 +170,8 @@ export default class Setup extends BeeperCommand {
const readiness = await evaluateReadiness({ baseURL: target.baseURL, target: target.id })
if (readiness.state === 'target-unreachable' && target.type !== 'desktop') {
if (flags.json || !process.stdin.isTTY) {
- await printData(currentTargetBrokenOutput(target, readiness), flags.json ? 'json' : 'human')
+ const serverInstalled = await isServerInstalled()
+ await printData(currentTargetBrokenOutput(target, readiness, serverInstalled), flags.json ? 'json' : 'human')
return
}
if (await this.handleBrokenCurrentTarget(target, readiness, flags)) return
@@ -215,8 +234,7 @@ export default class Setup extends BeeperCommand {
private async setupManaged(type: 'desktop' | 'server', flags: SetupFlags): Promise {
if (flags.install) {
if ((flags.json || !process.stdin.isTTY) && !flags.yes) throw new Error('Install requires --install --yes in non-interactive mode.')
- if (type === 'desktop') await installWithCopy('desktop', flags)
- else await installWithCopy('server', flags)
+ await installWithCopy(type, flags)
}
const id = flags.target ?? type
const target = await readTarget(id) ?? await createProfileTarget(type, id, { serverEnv: flags['server-env'], port: undefined })
@@ -250,12 +268,14 @@ export default class Setup extends BeeperCommand {
}
private async setupFromChoice(flags: SetupFlags): Promise {
+ const serverInstalled = await isServerInstalled()
process.stdout.write('No usable Beeper Desktop session was found on this device.\n\n')
process.stdout.write('How do you want to connect Beeper CLI?\n\n')
process.stdout.write(' 1. Install Beeper Desktop\n')
- process.stdout.write(' 2. Install local Beeper Server\n')
+ process.stdout.write(` 2. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`)
process.stdout.write(' 3. Connect with Desktop API on another device\n\n')
- const choice = await promptChoice('Choose [1]: ', ['1', '2', '3'], '1')
+ const defaultChoice = serverInstalled ? '2' : '1'
+ const choice = await promptChoice(`Choose [${defaultChoice}]: `, ['1', '2', '3'], defaultChoice)
if (choice === '1') {
if (!await promptYesNoDefaultYes('Install Beeper Desktop stable from beeper.com?')) return
await installWithCopy('desktop', { ...flags, channel: 'stable' })
@@ -264,8 +284,10 @@ export default class Setup extends BeeperCommand {
return
}
if (choice === '2') {
- if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return
- await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
+ if (!serverInstalled) {
+ if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return
+ await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'prod' })
+ }
await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' })
return
}
@@ -275,12 +297,13 @@ export default class Setup extends BeeperCommand {
}
private async handleBrokenCurrentTarget(target: Target, readiness: Readiness, flags: SetupFlags): Promise {
+ const serverInstalled = await isServerInstalled()
process.stdout.write(`Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.\n\n`)
if (readiness.message) process.stdout.write(`${readiness.message}\n\n`)
process.stdout.write('What do you want to do?\n\n')
process.stdout.write(` 1. Retry ${target.name ?? target.id}\n`)
process.stdout.write(' 2. Use Beeper Desktop on this device\n')
- process.stdout.write(' 3. Install local Beeper Server\n')
+ process.stdout.write(` 3. ${serverInstalled ? 'Use installed local Beeper Server' : 'Install local Beeper Server'}\n`)
process.stdout.write(' 4. Connect with Desktop API on another device\n\n')
const choice = await promptChoice('Choose [1]: ', ['1', '2', '3', '4'], '1')
if (choice === '1') return false
@@ -290,8 +313,10 @@ export default class Setup extends BeeperCommand {
return true
}
if (choice === '3') {
- if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return true
- await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'production' })
+ if (!serverInstalled) {
+ if (!await promptYesNoDefaultYes('Install local Beeper Server stable from beeper.com?')) return true
+ await installWithCopy('server', { ...flags, channel: 'stable', 'server-env': 'prod' })
+ }
await this.setupManaged('server', { ...flags, install: false, server: true, channel: 'stable' })
return true
}
@@ -336,11 +361,11 @@ type PreparedLocalDesktopSetup = {
}
type DesktopSetupDetection =
- | { kind: 'session-found'; local: PreparedLocalDesktopSetup }
- | { kind: 'installed-not-running' }
- | { kind: 'running-signed-out'; readiness?: Readiness }
- | { kind: 'session-unreadable'; reason: string; readiness?: Readiness }
- | { kind: 'not-installed' }
+ | { kind: 'session-found'; local: PreparedLocalDesktopSetup; serverInstalled: boolean }
+ | { kind: 'installed-not-running'; serverInstalled: boolean }
+ | { kind: 'running-signed-out'; readiness?: Readiness; serverInstalled: boolean }
+ | { kind: 'session-unreadable'; reason: string; readiness?: Readiness; serverInstalled: boolean }
+ | { kind: 'not-installed'; serverInstalled: boolean }
async function setupTarget(flags: SetupFlags): Promise {
if (flags['base-url']) return { id: customTargetID, type: 'desktop', baseURL: flags['base-url'] }
@@ -397,29 +422,33 @@ async function prepareLocalDesktopSetup(target: Target, flags: SetupFlags): Prom
async function detectDesktopSetup(target: Target, flags: SetupFlags): Promise {
printProgress(flags, 'Checking Beeper Desktop')
- const appInstalled = await isDesktopAppInstalled()
+ const installations = await readInstallations().catch((): Awaited> => ({}))
+ const serverInstalled = await isServerInstalled(installations)
+ const appInstalled = Boolean(installations.desktop?.path || await findDesktopAppPath())
printProgress(flags, 'Reading local Desktop session')
const local = await prepareLocalDesktopSetup(target, flags).catch(error => ({ error }))
- if (!('error' in local)) return { kind: 'session-found', local }
+ if (!('error' in local)) return { kind: 'session-found', local, serverInstalled }
printProgress(flags, 'Checking Desktop readiness')
const desktop = await findLocalDesktop({ baseURL: target.baseURL, scan: target.id === builtInDesktopTargetID, timeoutMs: 500 }).catch(() => undefined)
if (desktop) {
const readiness = await evaluateReadiness({ baseURL: desktop.baseURL, target: target.id, token: false })
- if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness }
+ if (readiness.state === 'needs-login') return { kind: 'running-signed-out', readiness, serverInstalled }
return {
kind: 'session-unreadable',
reason: local.error instanceof Error ? local.error.message : String(local.error),
readiness,
+ serverInstalled,
}
}
- return appInstalled ? { kind: 'installed-not-running' } : { kind: 'not-installed' }
+ return appInstalled ? { kind: 'installed-not-running', serverInstalled } : { kind: 'not-installed', serverInstalled }
}
-async function isDesktopAppInstalled(): Promise {
- const installations = await readInstallations().catch((): Awaited> => ({}))
- return Boolean(installations.desktop?.path || await findDesktopAppPath())
+async function isServerInstalled(installations?: Awaited>): Promise {
+ if (process.env.BEEPER_SERVER_BIN) return true
+ const installation = installations ?? await readInstallations().catch((): Awaited> => ({}))
+ return Boolean(installation.server?.path && await pathExists(installation.server.path))
}
async function commitLocalDesktopSetup(prepared: PreparedLocalDesktopSetup): Promise {
@@ -498,7 +527,13 @@ function printLocalDesktopPreview(prepared: PreparedLocalDesktopSetup): void {
process.stdout.write('\n')
}
-function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string): Record {
+function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: string, serverInstalled: boolean): Record {
+ const availableActions = [
+ action('use-desktop-session', `${setupCmd} --local`),
+ action('desktop-oauth', `${setupCmd} --oauth`),
+ action('connect-remote', 'beeper setup --remote '),
+ ]
+ if (serverInstalled) availableActions.push(installedServerAction(true))
return {
state: local.readiness.state === 'ready' ? 'desktop-ready' : 'desktop-session-found',
message: local.readiness.state === 'ready'
@@ -508,11 +543,7 @@ function setupSessionFoundOutput(local: PreparedLocalDesktopSetup, setupCmd: str
readiness: local.readiness,
localDesktop: localDesktopPreview(local),
recommendedAction: action('use-desktop-session', `${setupCmd} --local`),
- availableActions: [
- action('use-desktop-session', `${setupCmd} --local`),
- action('desktop-oauth', `${setupCmd} --oauth`),
- action('connect-remote', 'beeper setup --remote '),
- ],
+ availableActions,
}
}
@@ -589,8 +620,9 @@ async function maybeDriveOnboarding(result: SetupResult, flags: SetupFlags): Pro
async function installWithCopy(type: 'desktop' | 'server', flags: SetupFlags): Promise {
const label = type === 'desktop' ? 'Beeper Desktop' : 'local Beeper Server'
const channel = flags.channel === 'nightly' ? 'nightly' : 'stable'
- const serverEnv = flags['server-env'] === 'staging' ? 'staging' : 'production'
- if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} ${channel} from beeper.com...\n`)
+ const serverEnv = normalizeServerEnv(flags['server-env'])
+ const source = type === 'server' ? new URL(SERVER_ENV_API_BASE_URLS[serverEnv]).host : 'beeper.com'
+ if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installing ${label} ${channel} from ${source}...\n`)
if (type === 'desktop') await installDesktop({ channel, serverEnv })
else await installServer({ channel, serverEnv })
if (!flags.json && process.stdin.isTTY) process.stdout.write(`Installed ${label} ${channel}.\n\n`)
@@ -611,6 +643,7 @@ function printNextSteps(): void {
function setupStateOutput(detected: Exclude, target: Target): Record {
if (detected.kind === 'installed-not-running') {
+ const serverAction = installedServerAction(detected.serverInstalled)
return setupActionEnvelope({
state: 'desktop-installed-not-running',
message: 'Beeper Desktop is installed but not running.',
@@ -619,24 +652,31 @@ function setupStateOutput(detected: Exclude'),
- action('install-server', 'beeper setup --server --install --yes'),
+ serverAction,
],
})
}
if (detected.kind === 'running-signed-out') {
+ const availableActions = [
+ action('open-desktop', 'beeper setup --desktop --yes'),
+ action('connect-remote', 'beeper setup --remote '),
+ ]
+ if (detected.serverInstalled) availableActions.push(installedServerAction(true))
return setupActionEnvelope({
state: 'desktop-running-signed-out',
message: 'Beeper Desktop is running but not signed in.',
target,
readiness: detected.readiness,
recommendedAction: action('open-desktop', 'beeper setup --desktop --yes'),
- availableActions: [
- action('open-desktop', 'beeper setup --desktop --yes'),
- action('connect-remote', 'beeper setup --remote '),
- ],
+ availableActions,
})
}
if (detected.kind === 'session-unreadable') {
+ const availableActions = [
+ action('desktop-oauth', 'beeper setup --oauth --yes'),
+ action('connect-remote', 'beeper setup --remote '),
+ ]
+ if (detected.serverInstalled) availableActions.push(installedServerAction(true))
return setupActionEnvelope({
state: 'desktop-running-session-unreadable',
message: 'Beeper Desktop is running, but CLI could not read the local session.',
@@ -644,26 +684,30 @@ function setupStateOutput(detected: Exclude'),
- ],
+ availableActions,
})
}
+ const serverAction = installedServerAction(detected.serverInstalled)
return setupActionEnvelope({
state: 'desktop-not-installed',
message: 'No Beeper Desktop installation was found on this device.',
target,
- recommendedAction: action('install-desktop', 'beeper setup --desktop --install --yes'),
+ recommendedAction: detected.serverInstalled ? serverAction : action('install-desktop', 'beeper setup --desktop --install --yes'),
availableActions: [
action('install-desktop', 'beeper setup --desktop --install --yes'),
- action('install-server', 'beeper setup --server --install --yes'),
+ serverAction,
action('connect-remote', 'beeper setup --remote '),
],
})
}
-function currentTargetBrokenOutput(target: Target, readiness: Readiness): Record {
+function installedServerAction(installed: boolean): { id: string; command: string } {
+ return installed
+ ? action('use-installed-server', 'beeper setup --server --yes')
+ : action('install-server', 'beeper setup --server --install --yes')
+}
+
+function currentTargetBrokenOutput(target: Target, readiness: Readiness, serverInstalled: boolean): Record {
return {
state: 'current-target-unreachable',
message: `Beeper CLI is set up for ${target.name ?? target.id}, but it is not reachable.`,
@@ -673,7 +717,7 @@ function currentTargetBrokenOutput(target: Target, readiness: Readiness): Record
availableActions: [
action('retry-current', `beeper setup -t ${target.id}`),
action('use-desktop', 'beeper setup --desktop'),
- action('install-server', 'beeper setup --server --install --yes'),
+ installedServerAction(serverInstalled),
action('connect-remote', 'beeper setup --remote '),
],
}
diff --git a/packages/cli/src/commands/targets/add/desktop.ts b/packages/cli/src/commands/targets/add/desktop.ts
index 34af8af4..2c1732d5 100644
--- a/packages/cli/src/commands/targets/add/desktop.ts
+++ b/packages/cli/src/commands/targets/add/desktop.ts
@@ -1,7 +1,8 @@
import { Args, Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../../lib/command.js'
import { createProfileTarget, readTarget, updateConfig } from '../../../lib/targets.js'
-import { printSuccess } from '../../../lib/output.js'
+import { printDryRun, printSuccess } from '../../../lib/output.js'
+import { SERVER_ENVIRONMENTS, normalizeServerEnv } from '../../../lib/server-env.js'
export default class TargetsAddDesktop extends BeeperCommand {
static override summary = 'Add a managed Beeper Desktop target'
@@ -9,13 +10,17 @@ export default class TargetsAddDesktop extends BeeperCommand {
static override flags = {
port: Flags.integer({ description: 'TCP port the managed Desktop will expose its API on' }),
default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }),
- 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
+ 'server-env': Flags.string({ default: 'prod', description: 'Server environment: local, dev, staging, or prod', options: [...SERVER_ENVIRONMENTS], parse: async value => normalizeServerEnv(value) }),
}
async run(): Promise {
const { args, flags } = await this.parse(TargetsAddDesktop)
ensureWritable(flags)
const id = args.name ?? 'desktop'
if (await readTarget(id)) throw new Error(`Target "${id}" already exists.`)
+ if (flags['dry-run']) {
+ await printDryRun('targets.add.desktop', { id, type: 'desktop', serverEnv: flags['server-env'], port: flags.port, default: flags.default }, flags.json ? 'json' : 'human')
+ return
+ }
const target = await createProfileTarget('desktop', id, { serverEnv: flags['server-env'], port: flags.port })
if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id }))
await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human')
diff --git a/packages/cli/src/commands/targets/add/remote.ts b/packages/cli/src/commands/targets/add/remote.ts
index 1036872f..6c21ddcb 100644
--- a/packages/cli/src/commands/targets/add/remote.ts
+++ b/packages/cli/src/commands/targets/add/remote.ts
@@ -1,7 +1,7 @@
import { Args, Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../../lib/command.js'
import { readTarget, updateConfig, writeTarget, type Target } from '../../../lib/targets.js'
-import { printSuccess } from '../../../lib/output.js'
+import { printDryRun, printSuccess } from '../../../lib/output.js'
export default class TargetsAddRemote extends BeeperCommand {
static override summary = 'Add a remote Beeper Desktop or Server target'
@@ -17,6 +17,10 @@ export default class TargetsAddRemote extends BeeperCommand {
ensureWritable(flags)
if (await readTarget(args.name)) throw new Error(`Target "${args.name}" already exists.`)
const target: Target = { id: args.name, name: args.name, type: 'remote', baseURL: args.url, managed: false }
+ if (flags['dry-run']) {
+ await printDryRun('targets.add.remote', { target, default: flags.default }, flags.json ? 'json' : 'human')
+ return
+ }
await writeTarget(target)
if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id }))
await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human')
diff --git a/packages/cli/src/commands/targets/add/server.ts b/packages/cli/src/commands/targets/add/server.ts
index b5b10aa2..ae3f4a3e 100644
--- a/packages/cli/src/commands/targets/add/server.ts
+++ b/packages/cli/src/commands/targets/add/server.ts
@@ -1,7 +1,8 @@
import { Args, Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../../lib/command.js'
import { createProfileTarget, readTarget, updateConfig } from '../../../lib/targets.js'
-import { printSuccess } from '../../../lib/output.js'
+import { printDryRun, printSuccess } from '../../../lib/output.js'
+import { SERVER_ENVIRONMENTS, normalizeServerEnv } from '../../../lib/server-env.js'
export default class TargetsAddServer extends BeeperCommand {
static override summary = 'Add a managed Beeper Server target'
@@ -9,13 +10,17 @@ export default class TargetsAddServer extends BeeperCommand {
static override flags = {
port: Flags.integer({ description: 'TCP port the managed Server will expose its API on' }),
default: Flags.boolean({ default: false, description: 'Set this target as the default after creation' }),
- 'server-env': Flags.string({ options: ['production', 'staging'], default: 'production', description: 'Server environment. Staging forces nightly.' }),
+ 'server-env': Flags.string({ default: 'prod', description: 'Server environment: local, dev, staging, or prod', options: [...SERVER_ENVIRONMENTS], parse: async value => normalizeServerEnv(value) }),
}
async run(): Promise {
const { args, flags } = await this.parse(TargetsAddServer)
ensureWritable(flags)
const id = args.name ?? 'server'
if (await readTarget(id)) throw new Error(`Target "${id}" already exists.`)
+ if (flags['dry-run']) {
+ await printDryRun('targets.add.server', { id, type: 'server', serverEnv: flags['server-env'], port: flags.port, default: flags.default }, flags.json ? 'json' : 'human')
+ return
+ }
const target = await createProfileTarget('server', id, { serverEnv: flags['server-env'], port: flags.port })
if (flags.default) await updateConfig(config => ({ ...config, defaultTarget: target.id }))
await printSuccess({ message: `Added target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human')
diff --git a/packages/cli/src/commands/targets/disable.ts b/packages/cli/src/commands/targets/disable.ts
index e0aa652e..b2afabc0 100644
--- a/packages/cli/src/commands/targets/disable.ts
+++ b/packages/cli/src/commands/targets/disable.ts
@@ -1,8 +1,8 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { readTarget, resolveTarget } from '../../lib/targets.js'
+import { resolveTarget } from '../../lib/targets.js'
import { assertServerProfile, disableProfile } from '../../lib/profiles.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsDisable extends BeeperCommand {
static override summary = 'Disable a local Beeper Server target at login'
@@ -13,6 +13,10 @@ export default class TargetsDisable extends BeeperCommand {
const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] })
if (!target) throw new Error(`Unknown Beeper target "${args.name}".`)
assertServerProfile(target)
+ if (flags['dry-run']) {
+ await printDryRun('targets.disable', { target }, flags.json ? 'json' : 'human')
+ return
+ }
const path = await disableProfile(target)
await printSuccess({ message: `Disabled target at login: ${target.id}`, detail: path, data: { target, path } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/targets/enable.ts b/packages/cli/src/commands/targets/enable.ts
index a709806d..89ea1e68 100644
--- a/packages/cli/src/commands/targets/enable.ts
+++ b/packages/cli/src/commands/targets/enable.ts
@@ -1,8 +1,8 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { readTarget, resolveTarget } from '../../lib/targets.js'
+import { resolveTarget } from '../../lib/targets.js'
import { assertServerProfile, enableProfile } from '../../lib/profiles.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsEnable extends BeeperCommand {
static override summary = 'Enable a local Beeper Server target at login'
@@ -13,6 +13,10 @@ export default class TargetsEnable extends BeeperCommand {
const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] })
if (!target) throw new Error(`Unknown Beeper target "${args.name}".`)
assertServerProfile(target)
+ if (flags['dry-run']) {
+ await printDryRun('targets.enable', { target }, flags.json ? 'json' : 'human')
+ return
+ }
const path = await enableProfile(target)
await printSuccess({ message: `Enabled target at login: ${target.id}`, detail: path, data: { target, path } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/targets/list.ts b/packages/cli/src/commands/targets/list.ts
index e8721e76..9b05baa2 100644
--- a/packages/cli/src/commands/targets/list.ts
+++ b/packages/cli/src/commands/targets/list.ts
@@ -1,10 +1,7 @@
-import { Args, Flags } from '@oclif/core'
-import { readFile } from 'node:fs/promises'
-import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { builtInDesktopTargetID, createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js'
-import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js'
+import { BeeperCommand } from '../../lib/command.js'
+import { builtInDesktopTargetID, listTargets, readConfig, type Target } from '../../lib/targets.js'
import { targetLiveStatus } from '../../lib/target-status.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData } from '../../lib/output.js'
export default class TargetsList extends BeeperCommand {
static override summary = 'List configured Beeper targets'
diff --git a/packages/cli/src/commands/targets/logs.ts b/packages/cli/src/commands/targets/logs.ts
index 21c23dc2..2844eb8b 100644
--- a/packages/cli/src/commands/targets/logs.ts
+++ b/packages/cli/src/commands/targets/logs.ts
@@ -2,7 +2,7 @@ import { Args, Flags } from '@oclif/core'
import { readdir, readFile, stat } from 'node:fs/promises'
import { join } from 'node:path'
import { BeeperCommand } from '../../lib/command.js'
-import { customTargetID, readTarget, resolveTarget } from '../../lib/targets.js'
+import { customTargetID, resolveTarget } from '../../lib/targets.js'
import { desktopLogDir, profileErrorLogPath, profileLogPath } from '../../lib/profiles.js'
export default class TargetsLogs extends BeeperCommand {
@@ -26,8 +26,7 @@ export default class TargetsLogs extends BeeperCommand {
}
const files = await listLogFiles(desktopLogDir(target.managed ? target : undefined))
const selected = flags.all ? files : files.slice(0, flags.files)
- for (const file of files) {
- if (!selected.includes(file)) continue
+ for (const file of selected) {
await printLogFile(file, flags.lines)
}
}
diff --git a/packages/cli/src/commands/targets/remove.ts b/packages/cli/src/commands/targets/remove.ts
index 099ae16f..988e25ad 100644
--- a/packages/cli/src/commands/targets/remove.ts
+++ b/packages/cli/src/commands/targets/remove.ts
@@ -1,10 +1,7 @@
-import { Args, Flags } from '@oclif/core'
-import { readFile } from 'node:fs/promises'
+import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js'
-import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js'
-import { targetLiveStatus } from '../../lib/target-status.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { removeTarget } from '../../lib/targets.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsRemove extends BeeperCommand {
static override summary = 'Remove a target'
@@ -12,6 +9,10 @@ export default class TargetsRemove extends BeeperCommand {
async run(): Promise {
const { args, flags } = await this.parse(TargetsRemove)
ensureWritable(flags)
+ if (flags['dry-run']) {
+ await printDryRun('targets.remove', { id: args.name }, flags.json ? 'json' : 'human')
+ return
+ }
await removeTarget(args.name)
await printSuccess({ message: `Removed target: ${args.name}`, data: { id: args.name } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/targets/restart.ts b/packages/cli/src/commands/targets/restart.ts
index 414e004b..30966768 100644
--- a/packages/cli/src/commands/targets/restart.ts
+++ b/packages/cli/src/commands/targets/restart.ts
@@ -1,8 +1,8 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { readTarget, resolveTarget } from '../../lib/targets.js'
+import { resolveTarget } from '../../lib/targets.js'
import { assertServerProfile, startProfile, stopProfile } from '../../lib/profiles.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsRestart extends BeeperCommand {
static override summary = 'Restart a local Beeper Server target'
@@ -13,6 +13,10 @@ export default class TargetsRestart extends BeeperCommand {
const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] })
if (!target) throw new Error(`Unknown Beeper target "${args.name}".`)
assertServerProfile(target)
+ if (flags['dry-run']) {
+ await printDryRun('targets.restart', { target }, flags.json ? 'json' : 'human')
+ return
+ }
await stopProfile(target).catch(() => undefined)
const result = await startProfile(target)
await printSuccess({ message: `Restarted target: ${target.id}`, detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human')
diff --git a/packages/cli/src/commands/targets/show.ts b/packages/cli/src/commands/targets/show.ts
index 2fc64d07..892672c4 100644
--- a/packages/cli/src/commands/targets/show.ts
+++ b/packages/cli/src/commands/targets/show.ts
@@ -1,10 +1,7 @@
-import { Args, Flags } from '@oclif/core'
-import { readFile } from 'node:fs/promises'
-import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js'
-import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js'
-import { targetLiveStatus } from '../../lib/target-status.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { Args } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { resolveTarget } from '../../lib/targets.js'
+import { printData } from '../../lib/output.js'
export default class TargetsShow extends BeeperCommand {
static override summary = 'Show target details'
diff --git a/packages/cli/src/commands/targets/start.ts b/packages/cli/src/commands/targets/start.ts
index e51ca9b8..b659aaaf 100644
--- a/packages/cli/src/commands/targets/start.ts
+++ b/packages/cli/src/commands/targets/start.ts
@@ -1,8 +1,8 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { customTargetID, readTarget, resolveTarget } from '../../lib/targets.js'
+import { customTargetID, resolveTarget } from '../../lib/targets.js'
import { launchDesktopApp, startProfile } from '../../lib/profiles.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsStart extends BeeperCommand {
static override summary = 'Start a local Server target or open Beeper Desktop'
@@ -13,13 +13,23 @@ export default class TargetsStart extends BeeperCommand {
const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] })
if (!target) throw new Error(`Unknown Beeper target "${args.name}".`)
if (target.type === 'desktop' && target.id !== customTargetID) {
+ if (flags['dry-run']) {
+ await printDryRun('targets.start', { target, launchDesktop: true }, flags.json ? 'json' : 'human')
+ return
+ }
const result = await launchDesktopApp(target.managed ? target : undefined)
await printSuccess({ message: 'Opened Beeper Desktop', detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human')
return
}
+
if (!target.managed || target.type !== 'server') {
throw new Error(`Target "${target.id}" is not a local Beeper Server install.`)
}
+
+ if (flags['dry-run']) {
+ await printDryRun('targets.start', { target, startProfile: true }, flags.json ? 'json' : 'human')
+ return
+ }
const result = await startProfile(target)
await printSuccess({ message: `Started target: ${target.id}`, detail: target.baseURL, data: { target, result } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/targets/status.ts b/packages/cli/src/commands/targets/status.ts
index 531ce441..a8cf970d 100644
--- a/packages/cli/src/commands/targets/status.ts
+++ b/packages/cli/src/commands/targets/status.ts
@@ -1,10 +1,8 @@
-import { Args, Flags } from '@oclif/core'
-import { readFile } from 'node:fs/promises'
-import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js'
-import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js'
+import { Args } from '@oclif/core'
+import { BeeperCommand } from '../../lib/command.js'
+import { resolveTarget } from '../../lib/targets.js'
import { targetLiveStatus } from '../../lib/target-status.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { printData } from '../../lib/output.js'
export default class TargetsStatus extends BeeperCommand {
static override summary = 'Check endpoint and process reachability for a target'
diff --git a/packages/cli/src/commands/targets/stop.ts b/packages/cli/src/commands/targets/stop.ts
index badd49cc..31d96a95 100644
--- a/packages/cli/src/commands/targets/stop.ts
+++ b/packages/cli/src/commands/targets/stop.ts
@@ -1,8 +1,8 @@
import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { readTarget, resolveTarget } from '../../lib/targets.js'
+import { resolveTarget } from '../../lib/targets.js'
import { assertServerProfile, stopProfile } from '../../lib/profiles.js'
-import { printSuccess } from '../../lib/output.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsStop extends BeeperCommand {
static override summary = 'Stop a local Beeper Server target'
@@ -13,6 +13,10 @@ export default class TargetsStop extends BeeperCommand {
const target = await resolveTarget({ target: args.name ?? flags.target, baseURL: flags['base-url'] })
if (!target) throw new Error(`Unknown Beeper target "${args.name}".`)
assertServerProfile(target)
+ if (flags['dry-run']) {
+ await printDryRun('targets.stop', { target }, flags.json ? 'json' : 'human')
+ return
+ }
await stopProfile(target)
await printSuccess({ message: `Stopped target: ${target.id}`, data: { target } }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/targets/use.ts b/packages/cli/src/commands/targets/use.ts
index cf46e5a3..3dbeca6b 100644
--- a/packages/cli/src/commands/targets/use.ts
+++ b/packages/cli/src/commands/targets/use.ts
@@ -1,10 +1,7 @@
-import { Args, Flags } from '@oclif/core'
-import { readFile } from 'node:fs/promises'
+import { Args } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
-import { createProfileTarget, listTargets, readConfig, readTarget, removeTarget, resolveTarget, updateConfig, writeTarget, type Target } from '../../lib/targets.js'
-import { disableProfile, enableProfile, profileErrorLogPath, profileLogPath, profileStatus, startProfile, stopProfile } from '../../lib/profiles.js'
-import { targetLiveStatus } from '../../lib/target-status.js'
-import { printData, printSuccess } from '../../lib/output.js'
+import { readTarget, updateConfig } from '../../lib/targets.js'
+import { printDryRun, printSuccess } from '../../lib/output.js'
export default class TargetsUse extends BeeperCommand {
static override summary = 'Set the default target'
@@ -14,6 +11,10 @@ export default class TargetsUse extends BeeperCommand {
ensureWritable(flags)
const target = await readTarget(args.name)
if (!target) throw new Error(`Unknown Beeper target "${args.name}". Run \`beeper targets list\`.`)
+ if (flags['dry-run']) {
+ await printDryRun('targets.use', { defaultTarget: target.id, target }, flags.json ? 'json' : 'human')
+ return
+ }
await updateConfig(config => ({ ...config, defaultTarget: target.id }))
await printSuccess({ message: `Using target: ${target.id}`, detail: target.baseURL, data: target }, flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts
index 587fda05..f14f9f61 100644
--- a/packages/cli/src/commands/update.ts
+++ b/packages/cli/src/commands/update.ts
@@ -9,7 +9,7 @@ import {
import { profileStatus, startProfile, stopProfile } from '../lib/profiles.js'
import { listTargets } from '../lib/targets.js'
import { pathSetupHint } from '../lib/env.js'
-import { printData } from '../lib/output.js'
+import { printData, printDryRun } from '../lib/output.js'
import pkg from '../../package.json' with { type: 'json' }
export default class Update extends BeeperCommand {
@@ -23,8 +23,12 @@ export default class Update extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(Update)
- if (!flags.check) ensureWritable(flags)
+ if (!flags.check && !flags['dry-run']) ensureWritable(flags)
const selected = flags.cli || flags.desktop || flags.server
+ if (flags['dry-run'] && !flags.check) {
+ await printDryRun('update', { cli: !selected || flags.cli, desktop: !selected || flags.desktop, server: !selected || flags.server }, flags.json ? 'json' : 'human')
+ return
+ }
const installations = await readInstallations()
const results: Array> = []
diff --git a/packages/cli/src/commands/verify.ts b/packages/cli/src/commands/verify.ts
index a95b8ab1..a8e3dd78 100644
--- a/packages/cli/src/commands/verify.ts
+++ b/packages/cli/src/commands/verify.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../lib/command.js'
import { driveVerification } from '../lib/app-state.js'
-import { printData } from '../lib/output.js'
+import { printData, printDryRun } from '../lib/output.js'
export default class AuthVerify extends BeeperCommand {
static override summary = 'Finish setup verification or verify another device'
static override flags = {
@@ -10,6 +10,10 @@ export default class AuthVerify extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(AuthVerify)
ensureWritable(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify', { baseURL: flags['base-url'], target: flags.target, userID: flags.user, yes: flags.yes }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await driveVerification({ baseURL: flags['base-url'], target: flags.target, userID: flags.user, yes: flags.yes }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/approve.ts b/packages/cli/src/commands/verify/approve.ts
index 489f8bc4..115937fa 100644
--- a/packages/cli/src/commands/verify/approve.ts
+++ b/packages/cli/src/commands/verify/approve.ts
@@ -1,14 +1,21 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
+
export default class AuthVerifyApprove extends BeeperCommand {
static override summary = 'Approve a pending device verification request'
static override flags = {
id: Flags.string({ description: 'Verification request ID. Defaults to the active request.' }),
}
+
async run(): Promise {
const { flags } = await this.parse(AuthVerifyApprove)
+ if (flags['dry-run']) {
+ await printDryRun('verify.approve', { id: flags.id ?? 'active' }, flags.json ? 'json' : 'human')
+ return
+ }
+
ensureWritable(flags)
const client = await createClient(flags)
await printData(await client.app.verifications.accept(flags.id ?? 'active'), flags.json ? 'json' : 'human')
diff --git a/packages/cli/src/commands/verify/cancel.ts b/packages/cli/src/commands/verify/cancel.ts
index f30ddd33..a2c50f02 100644
--- a/packages/cli/src/commands/verify/cancel.ts
+++ b/packages/cli/src/commands/verify/cancel.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifyCancel extends BeeperCommand {
static override summary = 'Cancel an in-progress device verification'
static override flags = {
@@ -10,6 +10,10 @@ export default class AuthVerifyCancel extends BeeperCommand {
async run(): Promise {
const { flags } = await this.parse(AuthVerifyCancel)
ensureWritable(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.cancel', { id: flags.id ?? 'active' }, flags.json ? 'json' : 'human')
+ return
+ }
const client = await createClient(flags)
await printData(await client.app.verifications.cancel(flags.id ?? 'active', {}), flags.json ? 'json' : 'human')
}
diff --git a/packages/cli/src/commands/verify/qr-confirm.ts b/packages/cli/src/commands/verify/qr-confirm.ts
index 0cb190e0..662f4e63 100644
--- a/packages/cli/src/commands/verify/qr-confirm.ts
+++ b/packages/cli/src/commands/verify/qr-confirm.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifyQrConfirm extends BeeperCommand {
static override summary = 'Confirm that the other device scanned your QR code'
static override flags = {
@@ -11,6 +11,10 @@ export default class AuthVerifyQrConfirm extends BeeperCommand {
const { flags } = await this.parse(AuthVerifyQrConfirm)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.qr-confirm', { id: flags.id ?? 'active' }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.verifications.qr.confirmScanned(flags.id ?? 'active'), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/qr-scan.ts b/packages/cli/src/commands/verify/qr-scan.ts
index baf39624..553b57c8 100644
--- a/packages/cli/src/commands/verify/qr-scan.ts
+++ b/packages/cli/src/commands/verify/qr-scan.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifyQrScan extends BeeperCommand {
static override summary = 'Submit a scanned QR-code verification payload'
static override flags = {
@@ -12,6 +12,10 @@ export default class AuthVerifyQrScan extends BeeperCommand {
const { flags } = await this.parse(AuthVerifyQrScan)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.qr-scan', { id: flags.id ?? 'active', payload: flags.payload }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.verifications.qr.scan({ data: flags.payload }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/recovery-key.ts b/packages/cli/src/commands/verify/recovery-key.ts
index 1c13b696..b2ef5e3d 100644
--- a/packages/cli/src/commands/verify/recovery-key.ts
+++ b/packages/cli/src/commands/verify/recovery-key.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifyRecoveryKey extends BeeperCommand {
static override summary = 'Unlock encrypted messages with a recovery key'
static override flags = {
@@ -11,6 +11,10 @@ export default class AuthVerifyRecoveryKey extends BeeperCommand {
const { flags } = await this.parse(AuthVerifyRecoveryKey)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.recovery-key', { keyProvided: true }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.login.verification.recoveryKey.verify({ recoveryKey: flags.key }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/reset-recovery-key.ts b/packages/cli/src/commands/verify/reset-recovery-key.ts
index f2676d98..bc891026 100644
--- a/packages/cli/src/commands/verify/reset-recovery-key.ts
+++ b/packages/cli/src/commands/verify/reset-recovery-key.ts
@@ -1,6 +1,6 @@
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
import { promptYesNoDefaultYes } from '../../lib/app-api.js'
export default class AuthVerifyResetRecoveryKey extends BeeperCommand {
@@ -10,6 +10,10 @@ export default class AuthVerifyResetRecoveryKey extends BeeperCommand {
const { flags } = await this.parse(AuthVerifyResetRecoveryKey)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.reset-recovery-key', { confirmWithYes: flags.yes }, flags.json ? 'json' : 'human')
+ return
+ }
const reset = await client.app.login.verification.recoveryKey.reset.create({})
if ((flags.json || !process.stdin.isTTY) && !flags.yes) {
diff --git a/packages/cli/src/commands/verify/sas-confirm.ts b/packages/cli/src/commands/verify/sas-confirm.ts
index dbd618b0..472b1298 100644
--- a/packages/cli/src/commands/verify/sas-confirm.ts
+++ b/packages/cli/src/commands/verify/sas-confirm.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifySasConfirm extends BeeperCommand {
static override summary = 'Confirm matching emoji verification'
static override flags = {
@@ -11,6 +11,10 @@ export default class AuthVerifySasConfirm extends BeeperCommand {
const { flags } = await this.parse(AuthVerifySasConfirm)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.sas-confirm', { id: flags.id ?? 'active' }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.verifications.sas.confirm(flags.id ?? 'active'), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/sas.ts b/packages/cli/src/commands/verify/sas.ts
index d184102e..f116f6c6 100644
--- a/packages/cli/src/commands/verify/sas.ts
+++ b/packages/cli/src/commands/verify/sas.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifySas extends BeeperCommand {
static override summary = 'Start emoji verification'
static override flags = {
@@ -11,6 +11,10 @@ export default class AuthVerifySas extends BeeperCommand {
const { flags } = await this.parse(AuthVerifySas)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.sas', { id: flags.id ?? 'active' }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.verifications.sas.start(flags.id ?? 'active'), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/verify/start.ts b/packages/cli/src/commands/verify/start.ts
index 58f58871..66594609 100644
--- a/packages/cli/src/commands/verify/start.ts
+++ b/packages/cli/src/commands/verify/start.ts
@@ -1,7 +1,7 @@
import { Flags } from '@oclif/core'
import { BeeperCommand, ensureWritable } from '../../lib/command.js'
import { createClient } from '../../lib/client.js'
-import { printData } from '../../lib/output.js'
+import { printData, printDryRun } from '../../lib/output.js'
export default class AuthVerifyStart extends BeeperCommand {
static override summary = 'Start a device verification request'
static override flags = {
@@ -11,6 +11,10 @@ export default class AuthVerifyStart extends BeeperCommand {
const { flags } = await this.parse(AuthVerifyStart)
ensureWritable(flags)
const client = await createClient(flags)
+ if (flags['dry-run']) {
+ await printDryRun('verify.start', { userID: flags.user }, flags.json ? 'json' : 'human')
+ return
+ }
await printData(await client.app.verifications.create({ userID: flags.user }), flags.json ? 'json' : 'human')
}
}
diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts
index 4a4e7e5f..e585a010 100644
--- a/packages/cli/src/commands/watch.ts
+++ b/packages/cli/src/commands/watch.ts
@@ -4,7 +4,7 @@ import WebSocket from 'ws'
import { BeeperCommand, writeEvent } from '../lib/command.js'
import { requireToken } from '../lib/client.js'
import { getBaseURL } from '../lib/targets.js'
-import { startStream } from '../lib/output.js'
+import { isMachineReadableOutput, startStream } from '../lib/output.js'
type WebhookConfig = { url: string; secret?: string; queue: Array<{ body: string; signature?: string }>; inflight: number; max: number }
export type EventFilter = { include?: Set; exclude?: Set }
@@ -14,8 +14,8 @@ export default class Watch extends BeeperCommand {
static override flags = {
chat: Flags.string({ char: 'c', multiple: true, description: 'Chat ID to subscribe to. Defaults to all chats.' }),
json: Flags.boolean({ default: false, description: 'Print raw JSON, one event per line' }),
- 'include-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Only forward events of these types. Repeat for multiple.' }),
- 'exclude-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted'], description: 'Drop events of these types. Repeat for multiple.' }),
+ 'include-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Only forward events of these types. Repeat for multiple.' }),
+ 'exclude-type': Flags.string({ multiple: true, options: ['chat.upserted', 'chat.deleted', 'message.upserted', 'message.deleted', 'message.stream'], description: 'Drop events of these types. Repeat for multiple.' }),
webhook: Flags.string({ description: 'Forward each event to this URL as a POST request (best-effort, fire-and-forget)' }),
'webhook-secret': Flags.string({ description: 'HMAC-SHA256 secret. Signs payloads with X-Beeper-Signature: sha256=' }),
'webhook-queue': Flags.integer({ default: 64, description: 'Maximum pending webhook deliveries before dropping events' }),
@@ -44,7 +44,7 @@ export default class Watch extends BeeperCommand {
? { url: flags.webhook, secret: flags['webhook-secret'], queue: [], inflight: 0, max: flags['webhook-queue'] }
: undefined
- if (flags.json) {
+ if (flags.json || isMachineReadableOutput('human')) {
await this.runJSON(ws, subscribed, flags.events, webhook, filter)
return
}
diff --git a/packages/cli/src/lib/command-metadata.ts b/packages/cli/src/lib/command-metadata.ts
new file mode 100644
index 00000000..214abf19
--- /dev/null
+++ b/packages/cli/src/lib/command-metadata.ts
@@ -0,0 +1,55 @@
+export type CommandMetadata = {
+ mutates: boolean
+ requiresAuth: boolean
+ selectors: string[]
+ output: 'data' | 'list' | 'stream' | 'success' | 'send-result' | 'manual' | 'schema'
+ related: string[]
+}
+
+const mutatingRoots = new Set(['export', 'install', 'presence', 'send', 'setup', 'update'])
+const mutatingVerbs = new Set([
+ 'add', 'approve', 'archive', 'avatar', 'cancel', 'delete', 'description', 'disable', 'disappear', 'download',
+ 'draft', 'edit', 'enable', 'export', 'focus', 'logout', 'mark-read', 'mark-unread', 'mute', 'notify-anyway',
+ 'pin', 'post', 'priority', 'qr-confirm', 'qr-scan', 'recovery-key', 'remind', 'remove', 'rename', 'reset',
+ 'reset-recovery-key', 'response', 'restart', 'sas', 'sas-confirm', 'set', 'start', 'stop', 'unarchive',
+ 'unmute', 'unpin', 'unremind', 'use',
+])
+const localOnly = new Set(['completion', 'config', 'docs', 'man', 'schema', 'version'])
+
+export function metadataForCommand(command: string): CommandMetadata {
+ const parts = command.split(' ')
+ const root = parts[0] ?? ''
+ const mutates = command === 'verify' || command === 'api request' || mutatingRoots.has(root) || parts.some(part => mutatingVerbs.has(part ?? ''))
+ const requiresAuth = !localOnly.has(root) && command !== 'targets list' && !command.startsWith('targets add') && !command.startsWith('install ')
+ const selectors = [
+ command.includes('chats ') || command.includes('messages ') || command.startsWith('send ') || command === 'presence' || command.startsWith('resolve chat') ? 'chat' : undefined,
+ command.includes('accounts ') || command.includes('contacts ') || command === 'chats start' || command.startsWith('resolve account') || command.startsWith('resolve contact') ? 'account' : undefined,
+ command.includes('targets ') || command === 'status' || command === 'doctor' || command.startsWith('auth ') || command.startsWith('verify') || command.startsWith('resolve target') ? 'target' : undefined,
+ command.startsWith('bridges ') || command === 'accounts add' || command.startsWith('resolve bridge') ? 'bridge' : undefined,
+ command.includes('messages ') || command.startsWith('send react') || command.startsWith('send unreact') ? 'message' : undefined,
+ ].filter(Boolean) as string[]
+ const output = command === 'schema' ? 'schema'
+ : command.startsWith('send ') ? 'send-result'
+ : command === 'watch' || command === 'rpc' ? 'stream'
+ : command === 'man' ? 'manual'
+ : command.endsWith('list') || command.includes('search') || command === 'bridges list' || command.startsWith('resolve ') ? 'list'
+ : mutates ? 'success'
+ : 'data'
+ const related = relatedForCommand(command)
+ return { mutates, requiresAuth, selectors, output, related }
+}
+
+function relatedForCommand(command: string): string[] {
+ if (command.startsWith('send ')) return ['messages list', 'watch']
+ if (command.startsWith('messages ')) return ['chats list', 'send text']
+ if (command.startsWith('chats ')) return ['messages list', 'send text']
+ if (command.startsWith('bridges ')) return ['accounts add', 'accounts list']
+ if (command.startsWith('accounts ')) return ['bridges list', 'chats list']
+ if (command.startsWith('targets ')) return ['status', 'doctor']
+ if (command.startsWith('resolve ')) return ['chats search', 'accounts list', 'targets list', 'bridges list']
+ if (command === 'status') return ['doctor', 'setup']
+ if (command === 'doctor') return ['status', 'setup']
+ if (command === 'schema') return ['man']
+ if (command.startsWith('verify')) return ['setup', 'status']
+ return []
+}
diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts
index a5cecd4b..ac7faa13 100644
--- a/packages/cli/src/lib/command.ts
+++ b/packages/cli/src/lib/command.ts
@@ -6,13 +6,19 @@ export abstract class BeeperCommand extends Command {
'base-url': Flags.string({ description: 'Beeper Desktop API base URL (overrides --target)' }),
target: Flags.string({ char: 't', description: 'Named Beeper target to use for this command' }),
debug: Flags.boolean({ default: false, description: 'Print SDK debug logging on stderr' }),
+ 'dry-run': Flags.boolean({ default: false, description: 'Do not make changes; print intended actions when supported' }),
events: Flags.boolean({ default: false, description: 'Emit NDJSON lifecycle events on stderr (long-running commands)' }),
+ force: Flags.boolean({ char: 'f', default: false, description: 'Skip confirmations for destructive commands' }),
+ format: Flags.string({ options: ['json', 'jsonl', 'table', 'text', 'ids'], description: 'Output format. Defaults to json for agents/non-TTY, table for TTY.' }),
full: Flags.boolean({ default: false, description: 'Disable text-output truncation; print full IDs and bodies' }),
- json: Flags.boolean({ default: false, description: 'Print machine-readable JSON envelope on stdout' }),
+ json: Flags.boolean({ default: false, description: 'Alias for --format json' }),
+ 'no-input': Flags.boolean({ default: false, description: 'Never prompt; fail instead (useful for agents and CI)' }),
quiet: Flags.boolean({ char: 'q', default: false, description: 'Suppress spinners and success lines (errors still print). Honored with or without --json.' }),
'read-only': Flags.boolean({ default: false, description: 'Reject commands that would modify Beeper or local CLI state (or set BEEPER_READONLY=1)' }),
+ 'results-only': Flags.boolean({ default: false, description: 'In JSON mode, emit only the primary result instead of the envelope' }),
+ select: Flags.string({ description: 'In JSON/JSONL mode, project comma-separated fields; dot paths supported' }),
timeout: Flags.string({ description: 'Maximum time to wait, such as 30s, 2m, or 1h' }),
- yes: Flags.boolean({ char: 'y', default: false, description: 'Skip interactive confirmation prompts' }),
+ yes: Flags.boolean({ char: 'y', default: false, description: 'Alias for --force' }),
}
public override async init(): Promise {
@@ -20,22 +26,40 @@ export abstract class BeeperCommand extends Command {
if (this.argv.includes('--quiet') || this.argv.includes('-q')) {
process.env.BEEPER_QUIET = '1'
}
+
+ const format = outputFormatFromArgv(this.argv)
+ if (format) {
+ process.env.BEEPER_OUTPUT_FORMAT = format
+ } else if (this.argv.includes('--json')) {
+ process.env.BEEPER_OUTPUT_FORMAT = 'json'
+ } else if (process.env.BEEPER_AGENT === '1' || !process.stdout.isTTY) {
+ process.env.BEEPER_OUTPUT_FORMAT = 'json'
+ }
+
+ const select = stringFlagFromArgv(this.argv, '--select')
+ if (select) process.env.BEEPER_OUTPUT_SELECT = select
+ if (this.argv.includes('--results-only')) process.env.BEEPER_OUTPUT_RESULTS_ONLY = '1'
+ if (this.argv.includes('--no-input') || process.env.BEEPER_AGENT === '1') process.env.BEEPER_NO_INPUT = '1'
+ if (this.argv.includes('--force') || this.argv.includes('-f') || this.argv.includes('--yes') || this.argv.includes('-y')) process.env.BEEPER_FORCE = '1'
}
protected override async catch(error: Error & { exitCode?: number }): Promise {
- const code = error instanceof CLIError ? error.exitCode : error.exitCode ?? ExitCodes.Generic
- process.exitCode = process.exitCode ?? code
const message = error.message || String(error)
+ const inferredCode = error instanceof CLIError ? error.exitCode : inferExitCode(message)
+ const code = inferredCode ?? error.exitCode ?? ExitCodes.Generic
+ process.exitCode = process.exitCode ?? code
const tryMessage = error instanceof CLIError ? error.tryMessage : undefined
- const isBug = !(error instanceof CLIError) || error instanceof BugError
+ const isBug = error instanceof BugError || (!(error instanceof CLIError) && inferredCode === undefined)
if (this.argv.includes('--events')) {
writeEvent('error', { message, exitCode: code, kind: isBug ? 'bug' : 'abort', tryMessage })
return
}
- if (this.argv.includes('--json')) {
- process.stderr.write(`${JSON.stringify({ success: false, data: null, error: message, exitCode: code, kind: isBug ? 'bug' : 'abort', tryMessage })}\n`)
+ if (isMachineOutput(this.argv)) {
+ const errorCodeValue = error instanceof CLIError && error.code ? error.code : errorCode(code, isBug)
+ const data = error instanceof CLIError ? error.data : undefined
+ process.stderr.write(`${JSON.stringify({ ok: false, data: data ?? null, error: { code: errorCodeValue, message, exitCode: code, kind: isBug ? 'bug' : 'abort', hint: tryMessage } })}\n`)
return
}
@@ -49,6 +73,14 @@ export abstract class BeeperCommand extends Command {
}
}
+function inferExitCode(message: string): number | undefined {
+ if (/\b401\b|unauthorized|invalid token|auth(?:entication)? required/i.test(message)) return ExitCodes.AuthRequired
+ if (/\b404\b|not\s+found|unknown .*target|no .*matches/i.test(message)) return ExitCodes.NotFound
+ if (/ECONNREFUSED|ENOTFOUND|ETIMEDOUT|fetch failed|not reachable|not ready/i.test(message)) return ExitCodes.NotReady
+ if (/\busage\b|\binvalid (?:argument|option|flag|value|input)\b|\bmust provide\b|\brequired (?:flag|argument|option|value)\b|\bunknown flag\b|\bparse error\b/i.test(message)) return ExitCodes.Usage
+ return undefined
+}
+
function formatBugPanel(error: Error, version: string): string {
const bar = '─'.repeat(60)
const stack = error.stack?.split('\n').slice(0, 8).join('\n') ?? error.message
@@ -68,7 +100,7 @@ function formatBugPanel(error: Error, version: string): string {
export function ensureWritable(flags: { 'read-only'?: boolean }): void {
const env = process.env.BEEPER_READONLY
- const readOnly = flags['read-only'] || ['1', 'true', 'yes', 'on'].includes(String(env ?? '').toLowerCase())
+ const readOnly = flags['read-only'] || ['1', 'on', 'true', 'yes'].includes(String(env ?? '').toLowerCase())
if (readOnly) throw new CLIError('read-only mode: command would modify Beeper or local CLI state', ExitCodes.Usage)
}
@@ -79,3 +111,63 @@ export function writeEvent(event: string, data: Record = {}): v
export function isQuiet(): boolean {
return process.env.BEEPER_QUIET === '1'
}
+
+export function isNoInput(): boolean {
+ return process.env.BEEPER_NO_INPUT === '1'
+}
+
+export function isForce(flags?: { force?: boolean; yes?: boolean }): boolean {
+ return Boolean(flags?.force || flags?.yes || process.env.BEEPER_FORCE === '1')
+}
+
+function outputFormatFromArgv(argv: string[]): string | undefined {
+ return stringFlagFromArgv(argv, '--format')
+}
+
+function stringFlagFromArgv(argv: string[], name: string): string | undefined {
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i]
+ if (arg === name) return argv[i + 1]
+ if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1)
+ }
+
+ return undefined
+}
+
+function isMachineOutput(argv: string[]): boolean {
+ const format = outputFormatFromArgv(argv) ?? process.env.BEEPER_OUTPUT_FORMAT
+ return argv.includes('--json') || format === 'json' || format === 'jsonl'
+}
+
+function errorCode(code: number, isBug: boolean): string {
+ if (isBug) return 'internal_error'
+ switch (code) {
+ case ExitCodes.Ambiguous: {
+ return 'ambiguous_selector'
+ }
+
+ case ExitCodes.AuthRequired: {
+ return 'auth_required'
+ }
+
+ case ExitCodes.CommandNotFound: {
+ return 'command_not_found'
+ }
+
+ case ExitCodes.NotFound: {
+ return 'not_found'
+ }
+
+ case ExitCodes.NotReady: {
+ return 'not_ready'
+ }
+
+ case ExitCodes.Usage: {
+ return 'usage_error'
+ }
+
+ default: {
+ return 'runtime_error'
+ }
+ }
+}
diff --git a/packages/cli/src/lib/errors.ts b/packages/cli/src/lib/errors.ts
index ce198333..894c93a4 100644
--- a/packages/cli/src/lib/errors.ts
+++ b/packages/cli/src/lib/errors.ts
@@ -25,10 +25,14 @@ export type ExitCode = typeof ExitCodes[keyof typeof ExitCodes]
export class CLIError extends Error {
readonly exitCode: ExitCode
readonly tryMessage?: string
- constructor(message: string, exitCode: ExitCode, tryMessage?: string) {
+ readonly code?: string
+ readonly data?: Record
+ constructor(message: string, exitCode: ExitCode, tryMessage?: string, options: { code?: string; data?: Record } = {}) {
super(message)
this.exitCode = exitCode
this.tryMessage = tryMessage
+ this.code = options.code
+ this.data = options.data
this.name = 'CLIError'
}
}
@@ -38,8 +42,8 @@ export class CLIError extends Error {
* Renders as a single-line red message. Do not include a stack trace.
*/
export class AbortError extends CLIError {
- constructor(message: string, exitCode: ExitCode = ExitCodes.Generic, tryMessage?: string) {
- super(message, exitCode, tryMessage)
+ constructor(message: string, exitCode: ExitCode = ExitCodes.Generic, tryMessage?: string, options: { code?: string; data?: Record } = {}) {
+ super(message, exitCode, tryMessage, options)
this.name = 'AbortError'
}
}
@@ -50,7 +54,7 @@ export class AbortError extends CLIError {
*/
export class BugError extends CLIError {
constructor(message: string, tryMessage?: string) {
- super(message, ExitCodes.Generic, tryMessage)
+ super(message, ExitCodes.Generic, tryMessage, { code: 'internal_error' })
this.name = 'BugError'
}
}
@@ -58,5 +62,5 @@ export class BugError extends CLIError {
export const usageError = (message: string) => new AbortError(message, ExitCodes.Usage)
export const authRequired = (message: string) => new AbortError(message, ExitCodes.AuthRequired)
export const notReady = (message: string) => new AbortError(message, ExitCodes.NotReady)
-export const notFound = (message: string) => new AbortError(message, ExitCodes.NotFound)
-export const ambiguous = (message: string) => new AbortError(message, ExitCodes.Ambiguous)
+export const notFound = (message: string, data?: Record) => new AbortError(message, ExitCodes.NotFound, undefined, { code: 'not_found', data })
+export const ambiguous = (message: string, data?: Record) => new AbortError(message, ExitCodes.Ambiguous, 'Pass an exact ID or --pick N.', { code: 'ambiguous_selector', data })
diff --git a/packages/cli/src/lib/export/index.ts b/packages/cli/src/lib/export/index.ts
index 5422f73d..dca90d82 100644
--- a/packages/cli/src/lib/export/index.ts
+++ b/packages/cli/src/lib/export/index.ts
@@ -6,8 +6,6 @@ import { fileURLToPath } from 'node:url'
import type { Chat } from '@beeper/desktop-api/resources/chats/chats'
import type { Attachment, Message } from '@beeper/desktop-api/resources/shared'
-type AnyRecord = Record
-
export type ExportOptions = {
accountIDs?: string[]
chatIDs?: string[]
diff --git a/packages/cli/src/lib/ink/render.tsx b/packages/cli/src/lib/ink/render.tsx
index 19110b9b..007c629f 100644
--- a/packages/cli/src/lib/ink/render.tsx
+++ b/packages/cli/src/lib/ink/render.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react'
+import React, { useEffect, useRef, useState } from 'react'
import { Box, render as inkRender, Static, Text, useApp, useInput } from 'ink'
import Spinner from 'ink-spinner'
import {
@@ -28,7 +28,7 @@ import {
UserRow,
} from './components.js'
import type { RecordValue } from './format.js'
-import { glyphs, theme } from './theme.js'
+import { theme } from './theme.js'
const App: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { exit } = useApp()
@@ -127,7 +127,7 @@ export async function renderValue(value: unknown): Promise {
await renderOnce( )
return
case 'doctor': {
- const rawChecks = Array.isArray(record.checks)
+ const checks = Array.isArray(record.checks)
? record.checks as Array<{ ok: boolean; name: string; detail?: string }>
: record.checks && typeof record.checks === 'object'
? Object.entries(record.checks as Record).map(([name, value]) => {
@@ -140,7 +140,6 @@ export async function renderValue(value: unknown): Promise {
return { ok, name, detail }
})
: []
- const checks = rawChecks
await renderOnce( )
return
}
diff --git a/packages/cli/src/lib/ink/theme.ts b/packages/cli/src/lib/ink/theme.ts
index 32986f4d..31bed95c 100644
--- a/packages/cli/src/lib/ink/theme.ts
+++ b/packages/cli/src/lib/ink/theme.ts
@@ -76,8 +76,7 @@ export function senderColor(id: string | undefined | null): string {
if (!id) return theme.text
let hash = 0
for (let i = 0; i < id.length; i++) hash = ((hash << 5) - hash + id.charCodeAt(i)) | 0
- const palette = groupSenderPalette
- return palette[Math.abs(hash) % palette.length]!
+ return groupSenderPalette[Math.abs(hash) % groupSenderPalette.length]!
}
// Glyphs — every visual cue we use sits in this map so a single audit covers them.
diff --git a/packages/cli/src/lib/installations.ts b/packages/cli/src/lib/installations.ts
index 19abe049..6f3a2b58 100644
--- a/packages/cli/src/lib/installations.ts
+++ b/packages/cli/src/lib/installations.ts
@@ -1,5 +1,5 @@
import { createWriteStream } from 'node:fs'
-import { chmod, cp, mkdir, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'
+import { chmod, cp, mkdir, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, extname, join } from 'node:path'
import { Readable } from 'node:stream'
@@ -8,12 +8,14 @@ import type { ReadableStream } from 'node:stream/web'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { beeperDir } from './targets.js'
+import { SERVER_ENV_API_BASE_URLS, normalizeServerEnv, type ServerEnv } from './server-env.js'
+
+export type { ServerEnv } from './server-env.js'
const execFileAsync = promisify(execFile)
export type InstallKind = 'desktop' | 'server'
export type InstallChannel = 'stable' | 'nightly'
-export type ServerEnv = 'production' | 'staging'
export type Installation = {
kind: InstallKind
@@ -87,11 +89,8 @@ export function normalizeInstallRequest(options: {
bundleID: string
apiBaseURL: string
} {
- // TODO: switch Server installs back to production once the production download
- // endpoint returns a beeper-server artifact instead of the Desktop app bundle.
- const serverEnv = options.kind === 'server' ? 'staging' : normalizeServerEnv(options.serverEnv)
- let channel = options.channel ?? 'stable'
- if (serverEnv === 'staging') channel = 'nightly'
+ const serverEnv = normalizeServerEnv(options.serverEnv)
+ const channel = options.channel ?? 'stable'
const platform = normalizeDownloadPlatform(options.platform ?? process.platform)
const feedPlatform = normalizeFeedPlatform(options.platform ?? process.platform)
const arch = normalizeArch(options.arch ?? process.arch)
@@ -104,7 +103,7 @@ export function normalizeInstallRequest(options: {
feedPlatform,
arch,
bundleID,
- apiBaseURL: options.kind === 'server' || serverEnv === 'staging' ? 'https://api.beeper-staging.com' : 'https://api.beeper.com',
+ apiBaseURL: SERVER_ENV_API_BASE_URLS[serverEnv],
}
}
@@ -118,8 +117,7 @@ export function feedURLFor(options: ReturnType):
}
export function downloadURLFor(options: ReturnType): string {
- const channelSegment = options.serverEnv === 'staging' && options.kind === 'server' ? 'stable' : options.channel
- return `${options.apiBaseURL}/desktop/download/${options.platform}/${options.arch}/${channelSegment}/${options.bundleID}`
+ return `${options.apiBaseURL}/desktop/download/${options.platform}/${options.arch}/${options.channel}/${options.bundleID}`
}
export async function fetchFeed(feedURL: string): Promise {
@@ -150,8 +148,13 @@ export async function checkInstallationUpdate(installation: Installation): Promi
export async function installDesktop(options: { channel?: InstallChannel; serverEnv?: string } = {}): Promise {
const request = normalizeInstallRequest({ kind: 'desktop', channel: options.channel, serverEnv: options.serverEnv })
- if (request.serverEnv === 'staging') throw new Error('Desktop staging installs are not supported by the CLI.')
- const feedURL = feedURLFor(request)
+ const feedRequest = request.serverEnv === 'prod'
+ ? request
+ : { ...request, serverEnv: 'prod' as const, apiBaseURL: SERVER_ENV_API_BASE_URLS.prod }
+ if (request.serverEnv !== feedRequest.serverEnv) {
+ process.stderr.write(`Desktop ${request.serverEnv} installs use the production update feed; the app will still launch against ${request.serverEnv}.\n`)
+ }
+ const feedURL = feedURLFor(feedRequest)
const feed = await fetchFeed(feedURL)
const downloadURL = feed.url
if (!downloadURL) throw new Error('Desktop update feed did not include a download URL.')
@@ -296,7 +299,6 @@ async function copyPath(source: string, destination: string): Promise {
}
async function findAppBundle(dir: string): Promise {
- const { readdir, stat } = await import('node:fs/promises')
const entries = await readdir(dir)
for (const entry of entries) {
const path = join(dir, entry)
@@ -311,7 +313,6 @@ async function findAppBundle(dir: string): Promise {
}
async function findServerExecutable(dir: string): Promise {
- const { readdir, stat } = await import('node:fs/promises')
const entries = await readdir(dir)
for (const entry of entries) {
const path = join(dir, entry)
@@ -326,12 +327,6 @@ async function findServerExecutable(dir: string): Promise {
throw new Error('Downloaded Beeper Server artifact did not contain a beeper-server executable.')
}
-function normalizeServerEnv(value?: string): ServerEnv {
- if (!value || value === 'production' || value === 'prod') return 'production'
- if (value === 'staging') return 'staging'
- throw new Error(`Unsupported server env "${value}". Expected production or staging.`)
-}
-
function normalizeDownloadPlatform(platform: NodeJS.Platform): 'macos' | 'windows' | 'linux' {
if (platform === 'darwin') return 'macos'
if (platform === 'win32') return 'windows'
diff --git a/packages/cli/src/lib/manifest.ts b/packages/cli/src/lib/manifest.ts
index 5b7dfe63..8ae53c47 100644
--- a/packages/cli/src/lib/manifest.ts
+++ b/packages/cli/src/lib/manifest.ts
@@ -49,7 +49,7 @@ export const commandManifest: ManifestCommand[] = [
{
command: 'targets add server',
description: 'Add a managed Beeper Server target',
- examples: ['beeper targets add server prod --server-env production --default'],
+ examples: ['beeper targets add server prod --server-env prod --default'],
},
{
command: 'targets add remote',
@@ -236,7 +236,8 @@ export const commandManifest: ManifestCommand[] = [
examples: [
'beeper chats list',
'beeper chats list --pinned --limit 50',
- 'beeper chats list --unread --no-muted --json',
+ 'beeper chats list --unread --no-muted --format json',
+ 'beeper ls --format ids',
],
},
{
@@ -370,6 +371,7 @@ export const commandManifest: ManifestCommand[] = [
description: 'Search messages across chats',
examples: [
'beeper messages search invoice',
+ 'beeper search invoice --format jsonl --select id,chatID,text',
'beeper messages search --chat 10313 --sender me --media image',
'beeper messages search "flight" --after 2026-01-01 --before 2026-02-01',
],
@@ -407,6 +409,7 @@ export const commandManifest: ManifestCommand[] = [
command: 'send text',
description: 'Send a text message',
examples: [
+ 'beeper send --to 10313 --message "on my way" --dry-run --format json',
'beeper send text --to 10313 --message "on my way"',
'beeper send text --to 8951 --message "hi"',
'beeper send text --to "Family" --message "hi" --pick 1',
@@ -464,6 +467,31 @@ export const commandManifest: ManifestCommand[] = [
description: 'Show contact details',
examples: ['beeper contacts show "Alice" --account whatsapp'],
},
+ {
+ command: 'resolve chat',
+ description: 'Resolve a chat selector to concrete chat candidates',
+ examples: ['beeper resolve chat Family --format json', 'beeper resolve chat Family --pick 1 --results-only'],
+ },
+ {
+ command: 'resolve account',
+ description: 'Resolve an account selector',
+ examples: ['beeper resolve account whatsapp --format json'],
+ },
+ {
+ command: 'resolve contact',
+ description: 'Resolve a contact selector',
+ examples: ['beeper resolve contact Alice --account whatsapp --format json'],
+ },
+ {
+ command: 'resolve target',
+ description: 'Resolve a target selector',
+ examples: ['beeper resolve target desktop --format json'],
+ },
+ {
+ command: 'resolve bridge',
+ description: 'Resolve a bridge selector',
+ examples: ['beeper resolve bridge whatsapp --format json'],
+ },
{
command: 'media download',
description: 'Download message media',
@@ -495,7 +523,16 @@ export const commandManifest: ManifestCommand[] = [
{
command: 'man',
description: 'Print the command manual',
- examples: ['beeper man', 'beeper man --json'],
+ examples: ['beeper man', 'beeper man --format json', 'beeper man --format ids'],
+ },
+ {
+ command: 'schema',
+ description: 'Print machine-readable command/flag schema',
+ examples: [
+ 'beeper schema',
+ 'beeper schema send --results-only',
+ 'beeper schema --select commands.path,commands.flags.name --results-only',
+ ],
},
{
command: 'doctor',
diff --git a/packages/cli/src/lib/output.ts b/packages/cli/src/lib/output.ts
index f697d2d9..2f836aaf 100644
--- a/packages/cli/src/lib/output.ts
+++ b/packages/cli/src/lib/output.ts
@@ -1,22 +1,28 @@
import type { StreamController, Suggestion } from './ink/render.js'
-export type OutputFormat = 'human' | 'json' | 'jsonl'
+export type OutputFormat = 'human' | 'json' | 'jsonl' | 'table' | 'text' | 'ids'
type RecordValue = Record
const writeJSON = (value: unknown, format: 'json' | 'jsonl'): void => {
process.stdout.write(`${JSON.stringify(value, null, format === 'json' ? 2 : 0)}\n`)
}
-const envelope = (data: unknown) => ({ success: true, data, error: null })
+const envelope = (data: unknown, meta: Record = {}) => ({ ok: true, data, error: null, meta })
const loadInk = () => import('./ink/render.js')
export async function printData(value: unknown, format: OutputFormat): Promise {
+ format = effectiveFormat(format)
+ if (format === 'ids') {
+ printIDs(Array.isArray(value) ? value : [value])
+ return
+ }
if (format === 'json') {
- writeJSON(envelope(value), 'json')
+ writeJSON(jsonPayload(value), 'json')
return
}
if (format === 'jsonl') {
+ value = projectJSON(value)
if (Array.isArray(value)) {
for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`)
return
@@ -24,21 +30,39 @@ export async function printData(value: unknown, format: OutputFormat): Promise, format: OutputFormat): Promise {
+ await printData({ dryRun: true, action, request }, format)
+}
+
export async function printList(
value: unknown[],
format: OutputFormat,
empty: { title: string; subtitle?: string; suggestions?: Suggestion[] },
): Promise {
+ format = effectiveFormat(format)
+ if (format === 'ids') {
+ printIDs(value)
+ return
+ }
if (format === 'json') {
- writeJSON(envelope(value), 'json')
+ writeJSON(jsonPayload(value), 'json')
return
}
if (format === 'jsonl') {
- for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`)
+ const projected = projectJSON(value)
+ for (const item of Array.isArray(projected) ? projected : [projected]) process.stdout.write(`${JSON.stringify(item)}\n`)
+ return
+ }
+ if (format === 'text') {
+ printText(value)
return
}
const { renderList } = await loadInk()
@@ -73,8 +97,17 @@ export async function printSuccess(
opts: { message: string; detail?: string; entity?: unknown; data?: Record },
format: OutputFormat,
): Promise {
+ format = effectiveFormat(format)
if (format === 'json' || format === 'jsonl') {
- writeJSON(envelope({ message: opts.message, detail: opts.detail, entity: opts.entity, ...(opts.data ?? {}) }), format)
+ writeJSON(jsonPayload({ message: opts.message, detail: opts.detail, entity: opts.entity, ...opts.data }), format)
+ return
+ }
+ if (format === 'ids') {
+ printIDs([opts.entity ?? opts.data ?? {}])
+ return
+ }
+ if (format === 'text') {
+ process.stdout.write(`${opts.message}${opts.detail ? `\t${opts.detail}` : ''}\n`)
return
}
if (process.env.BEEPER_QUIET === '1') return
@@ -86,8 +119,17 @@ export async function printFailure(
opts: { message: string; detail?: string; data?: Record },
format: OutputFormat,
): Promise {
+ format = effectiveFormat(format)
if (format === 'json' || format === 'jsonl') {
- writeJSON({ success: false, data: opts.data ?? null, error: opts.message }, format)
+ writeJSON({ ok: false, data: opts.data ?? null, error: { message: opts.message, detail: opts.detail } }, format)
+ return
+ }
+ if (format === 'text') {
+ process.stdout.write(`${opts.message}${opts.detail ? `\t${opts.detail}` : ''}\n`)
+ return
+ }
+ if (format === 'ids') {
+ if (opts.data) printIDs([opts.data])
return
}
const { renderFailure } = await loadInk()
@@ -95,8 +137,17 @@ export async function printFailure(
}
export async function printConfig(data: Record, format: OutputFormat): Promise {
+ format = effectiveFormat(format)
if (format === 'json' || format === 'jsonl') {
- writeJSON(envelope(data), format)
+ writeJSON(jsonPayload(data), format)
+ return
+ }
+ if (format === 'ids') {
+ printIDs([data])
+ return
+ }
+ if (format === 'text') {
+ printText(data)
return
}
const { renderConfig } = await loadInk()
@@ -108,8 +159,17 @@ export async function printCommands(
format: OutputFormat,
opts?: { title?: string; intro?: string[] },
): Promise {
+ format = effectiveFormat(format)
if (format === 'json' || format === 'jsonl') {
- writeJSON(envelope(items), format)
+ writeJSON(jsonPayload(items, opts ? { title: opts.title } : {}), format)
+ return
+ }
+ if (format === 'ids') {
+ for (const item of items) process.stdout.write(`${item.command}\n`)
+ return
+ }
+ if (format === 'text') {
+ for (const item of items) process.stdout.write(`${item.command}\t${item.description}\n`)
return
}
const { renderCommands } = await loadInk()
@@ -122,3 +182,111 @@ export async function startStream(opts: { baseURL: string; subscribed: string[]
}
export type { Suggestion } from './ink/render.js'
+
+export function isMachineReadableOutput(format?: OutputFormat): boolean {
+ const effective = effectiveFormat(format ?? 'human')
+ return effective === 'json' || effective === 'jsonl' || effective === 'ids' || effective === 'text'
+}
+
+function effectiveFormat(format: OutputFormat): OutputFormat {
+ const env = process.env.BEEPER_OUTPUT_FORMAT as OutputFormat | undefined
+ if (env && ['json', 'jsonl', 'table', 'text', 'ids'].includes(env)) return env === 'table' ? 'human' : env
+ return format === 'table' ? 'human' : format
+}
+
+function jsonPayload(value: unknown, meta: Record = {}): unknown {
+ const projected = projectJSON(value)
+ if (process.env.BEEPER_OUTPUT_RESULTS_ONLY === '1') return unwrapPrimary(projected)
+ return envelope(projected, meta)
+}
+
+function projectJSON(value: unknown): unknown {
+ const fields = (process.env.BEEPER_OUTPUT_SELECT ?? '')
+ .split(',')
+ .map(item => item.trim())
+ .filter(Boolean)
+ let output = value
+ if (process.env.BEEPER_OUTPUT_RESULTS_ONLY === '1') output = unwrapPrimary(output)
+ if (!fields.length) return output
+ return selectFields(output, fields)
+}
+
+function unwrapPrimary(value: unknown): unknown {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return value
+ const record = value as Record
+ if ('items' in record) return record.items
+ if ('results' in record) return record.results
+ if ('data' in record) return record.data
+ const metaKeys = new Set(['nextCursor', 'nextPageToken', 'cursor', 'hasMore', 'count', 'query'])
+ const keys = Object.keys(record).filter(key => !metaKeys.has(key))
+ if (keys.length === 1) return record[keys[0]!]
+ return value
+}
+
+function selectFields(value: unknown, fields: string[]): unknown {
+ if (Array.isArray(value)) return value.map(item => selectFields(item, fields))
+ if (!value || typeof value !== 'object') return value
+ const out: Record = {}
+ for (const field of fields) {
+ const selected = selectPath(value, field.split('.'))
+ if (selected !== undefined) mergeSelected(out, selected)
+ }
+ return out
+}
+
+function selectPath(value: unknown, parts: string[]): unknown {
+ if (!parts.length) return value
+ if (Array.isArray(value)) {
+ const items = value.map(item => selectPath(item, parts)).filter(item => item !== undefined)
+ return items.length ? items : undefined
+ }
+ if (!value || typeof value !== 'object') return undefined
+ const [part, ...rest] = parts
+ if (!part) return undefined
+ const child = (value as Record)[part]
+ if (child === undefined) return undefined
+ const selected = selectPath(child, rest)
+ return selected === undefined ? undefined : { [part]: selected }
+}
+
+function mergeSelected(target: Record, selected: unknown): void {
+ if (!selected || typeof selected !== 'object' || Array.isArray(selected)) return
+ for (const [key, value] of Object.entries(selected as Record)) {
+ const current = target[key]
+ if (Array.isArray(value)) {
+ const currentItems = Array.isArray(current) ? current : []
+ target[key] = value.map((item, index) => {
+ const base = currentItems[index]
+ if (item && typeof item === 'object' && !Array.isArray(item) && base && typeof base === 'object' && !Array.isArray(base)) {
+ return mergeObjects(base as Record, item as Record)
+ }
+ return item
+ })
+ } else if (value && typeof value === 'object' && !Array.isArray(value) && current && typeof current === 'object' && !Array.isArray(current)) {
+ target[key] = mergeObjects(current as Record, value as Record)
+ } else {
+ target[key] = value
+ }
+ }
+}
+
+function mergeObjects(left: Record, right: Record): Record {
+ const out = { ...left }
+ mergeSelected(out, right)
+ return out
+}
+
+function printText(value: unknown): void {
+ if (Array.isArray(value)) {
+ for (const item of value) printText(item)
+ return
+ }
+ if (!value || typeof value !== 'object') {
+ if (value !== undefined) process.stdout.write(`${String(value)}\n`)
+ return
+ }
+ for (const [key, item] of Object.entries(value as Record)) {
+ if (item === null || item === undefined) continue
+ process.stdout.write(`${key}\t${typeof item === 'object' ? JSON.stringify(item) : String(item)}\n`)
+ }
+}
diff --git a/packages/cli/src/lib/profiles.ts b/packages/cli/src/lib/profiles.ts
index 6599bcad..38437c03 100644
--- a/packages/cli/src/lib/profiles.ts
+++ b/packages/cli/src/lib/profiles.ts
@@ -1,11 +1,11 @@
import { spawn } from 'node:child_process'
import { execFile } from 'node:child_process'
import { closeSync, openSync } from 'node:fs'
-import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
-import { beeperDir, type Target } from './targets.js'
+import { beeperDir, pathExists, type Target } from './targets.js'
import { readInstallations } from './installations.js'
import { usageError } from './errors.js'
@@ -148,9 +148,7 @@ export async function stopProfile(target: Target): Promise {
export async function profileStatus(target: Target): Promise> {
assertProfile(target)
const run = await readRun(target.id)
- const reachable = await fetch(new URL('/v1/info', target.baseURL), { signal: AbortSignal.timeout(1000) })
- .then(response => response.ok)
- .catch(() => false)
+ const reachable = await isReachable(target)
return {
id: target.id,
type: target.type,
@@ -381,12 +379,3 @@ async function waitForExit(pid: number, timeoutMs: number): Promise {
async function sleep(ms: number): Promise {
await new Promise(resolve => setTimeout(resolve, ms))
}
-
-async function pathExists(path: string): Promise {
- try {
- await access(path)
- return true
- } catch {
- return false
- }
-}
diff --git a/packages/cli/src/lib/resolve.ts b/packages/cli/src/lib/resolve.ts
index d83eb3b7..4414df5b 100644
--- a/packages/cli/src/lib/resolve.ts
+++ b/packages/cli/src/lib/resolve.ts
@@ -1,6 +1,7 @@
import { readConfig } from './targets.js'
import { ambiguous, notFound } from './errors.js'
import { confirmSuggestion, declineWithExit127, rankSuggestions } from './did-you-mean.js'
+import { collectPage } from './output.js'
type AnyRecord = Record
@@ -31,9 +32,13 @@ export async function resolveAccountIDs(
const resolved: string[] = []
for (const input of effectiveInputs) {
const matches = matchAccounts(accounts, input)
- if (matches.length === 0) throw notFound(`No account matches "${input}"`)
+ if (matches.length === 0) throw notFound(`No account matches "${input}"`, { selector: input, kind: 'account' })
if (matches.length > 1 && !options.allowMultiplePerInput) {
- throw ambiguous(formatAmbiguous(`account "${input}"`, matches.map(formatAccount)))
+ throw ambiguous(formatAmbiguous(`account "${input}"`, matches.map(formatAccount)), {
+ selector: input,
+ kind: 'account',
+ candidates: matches.map((account, index) => ({ pick: index + 1, id: String(account.accountID ?? account.id), label: formatAccount(account) })),
+ })
}
resolved.push(...matches.map(account => String(account.accountID)))
}
@@ -43,7 +48,7 @@ export async function resolveAccountIDs(
export async function resolveAccountID(client: any, input: string): Promise {
const [accountID] = await resolveAccountIDs(client, [input]) ?? []
- if (!accountID) throw notFound(`No account matches "${input}"`)
+ if (!accountID) throw notFound(`No account matches "${input}"`, { selector: input, kind: 'account' })
return accountID
}
@@ -57,7 +62,7 @@ export async function resolveChatID(client: any, input: string, options: ChatRes
const exact = await retrieveChat(client, input)
if (exact) return chatInputID(exact)
- const candidates = await collect(client.chats.search({
+ const candidates = await collectPage(client.chats.search({
accountIDs: options.accountIDs,
query: input,
scope: 'titles',
@@ -73,23 +78,35 @@ export async function resolveChatID(client: any, input: string, options: ChatRes
if (matches.length === 0) {
const suggestion = await suggestChat(client, input, options)
if (suggestion) return suggestion
- return input
+ throw notFound(`No chat matches "${input}"`, { selector: input, kind: 'chat' })
}
if (matches.length === 1) return chatInputID(matches[0]!)
if (options.pick) {
const selected = matches[options.pick - 1]
- if (!selected) throw notFound(`--pick ${options.pick} is outside the ${matches.length} matching chats`)
+ if (!selected) throw notFound(`--pick ${options.pick} is outside the ${matches.length} matching chats`, { selector: input, kind: 'chat', pick: options.pick, count: matches.length })
return chatInputID(selected)
}
- throw ambiguous(formatAmbiguous(`chat "${input}"`, matches.map(formatChat)))
+ throw ambiguous(formatAmbiguous(`chat "${input}"`, matches.map(formatChat)), {
+ selector: input,
+ kind: 'chat',
+ candidates: matches.map((chat, index) => ({
+ pick: index + 1,
+ id: String(chat.id),
+ localChatID: chat.localChatID ? String(chat.localChatID) : undefined,
+ title: chat.title ? String(chat.title) : undefined,
+ network: chat.network ? String(chat.network) : undefined,
+ label: formatChat(chat),
+ })),
+ })
}
async function suggestChat(client: any, input: string, options: ChatResolutionOptions): Promise {
+ if (process.env.BEEPER_NO_INPUT === '1') return undefined
let pool: AnyRecord[]
try {
- pool = await collect(client.chats.list({ accountIDs: options.accountIDs, limit: 100 }), 100)
+ pool = await collectPage(client.chats.list({ accountIDs: options.accountIDs, limit: 100 }), 100)
} catch {
return undefined
}
@@ -147,15 +164,6 @@ async function retrieveChat(client: any, input: string): Promise(iterable: AsyncIterable, limit: number): Promise {
- const items: T[] = []
- for await (const item of iterable) {
- items.push(item)
- if (items.length >= limit) break
- }
- return items
-}
-
function normalize(value: unknown): string {
return String(value ?? '').trim().toLowerCase().replace(/[\s._-]+/g, '')
}
diff --git a/packages/cli/src/lib/server-env.ts b/packages/cli/src/lib/server-env.ts
new file mode 100644
index 00000000..d4a3bc0c
--- /dev/null
+++ b/packages/cli/src/lib/server-env.ts
@@ -0,0 +1,16 @@
+export const SERVER_ENVIRONMENTS = ['local', 'dev', 'staging', 'prod'] as const
+
+export type ServerEnv = typeof SERVER_ENVIRONMENTS[number]
+
+export const SERVER_ENV_API_BASE_URLS: Record = {
+ local: 'https://api.beeper.localtest.me',
+ dev: 'https://api.beeper-dev.com',
+ staging: 'https://api.beeper-staging.com',
+ prod: 'https://api.beeper.com',
+}
+
+export function normalizeServerEnv(value?: string): ServerEnv {
+ if (!value || value === 'prod' || value === 'production') return 'prod'
+ if (value === 'local' || value === 'dev' || value === 'staging') return value
+ throw new Error(`Unsupported server env "${value}". Expected local, dev, staging, or prod.`)
+}
diff --git a/packages/cli/src/lib/setup-login.ts b/packages/cli/src/lib/setup-login.ts
index 41fab57f..1768c885 100644
--- a/packages/cli/src/lib/setup-login.ts
+++ b/packages/cli/src/lib/setup-login.ts
@@ -29,8 +29,9 @@ export async function finishEmailSetup(target: Target, options: {
const client = setupClient(target)
let output = await client.app.login.response({ setupRequestID: options.setupRequestID, response: options.code })
if (isRegistrationRequired(output)) {
- if ((options.json || !process.stdin.isTTY) && !options.yes) throw new Error('Registration requires --yes to accept the Beeper terms in non-interactive setup.')
- const username = options.username ?? (options.json || !process.stdin.isTTY ? undefined : await promptUsername(output.usernameSuggestions))
+ const nonInteractive = options.json || !process.stdin.isTTY
+ if (nonInteractive && !options.yes) throw new Error('Registration requires --yes to accept the Beeper terms in non-interactive setup.')
+ const username = options.username ?? (nonInteractive ? undefined : await promptUsername(output.usernameSuggestions))
if (!username) throw new Error('Registration requires --username.')
if (!options.yes && !await promptYesNoDefaultYes('Accept the Beeper terms and create this account?')) throw new Error('Registration cancelled.')
output = await client.app.login.register({
diff --git a/packages/cli/src/lib/targets.ts b/packages/cli/src/lib/targets.ts
index e16730c5..8c1623ad 100644
--- a/packages/cli/src/lib/targets.ts
+++ b/packages/cli/src/lib/targets.ts
@@ -3,6 +3,7 @@ import { access, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promise
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { notFound } from './errors.js'
+import { normalizeServerEnv } from './server-env.js'
export type AuthSource = 'desktop-db' | 'desktop-cache' | 'desktop-oauth' | 'remote-oauth' | 'manual'
@@ -201,7 +202,7 @@ function normalizeLocalTarget(target: Target): Target {
}
export async function createProfileTarget(type: ManagedTargetType, id: string, options: { serverEnv?: string; port?: number } = {}): Promise {
- const serverEnv = options.serverEnv ?? 'production'
+ const serverEnv = normalizeServerEnv(options.serverEnv)
const port = options.port ?? await nextPort()
const target: Target = {
id,
diff --git a/packages/cli/test/cli-smoke.ts b/packages/cli/test/cli-smoke.ts
index f10fb246..43a736f4 100644
--- a/packages/cli/test/cli-smoke.ts
+++ b/packages/cli/test/cli-smoke.ts
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import { spawnSync } from 'node:child_process'
-import { existsSync, readdirSync, rmSync } from 'node:fs'
+import { existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { commandManifest } from '../dist/lib/manifest.js'
@@ -106,11 +106,17 @@ const expectedCommands = [
'contacts list',
'contacts search',
'contacts show',
+ 'resolve chat',
+ 'resolve account',
+ 'resolve contact',
+ 'resolve target',
+ 'resolve bridge',
'media download',
'export',
'watch',
'rpc',
'man',
+ 'schema',
'doctor',
'status',
'docs',
@@ -176,12 +182,12 @@ assert.match(setupHelp, /--email/, 'setup should expose email setup start')
assert.doesNotMatch(setupHelp, /--code|--accept-terms/, 'setup must not accept OTP or terms flags in the first command')
const man = JSON.parse(ok('man', '--json'))
-assert.equal(man.success, true)
+assert.equal(man.ok, true)
assert.equal(man.error, null)
assert.deepEqual(man.data.map(item => item.command), expectedCommands)
const availablePlugins = JSON.parse(ok('plugins', 'available', '--json'))
-assert.equal(availablePlugins.success, true)
+assert.equal(availablePlugins.ok, true)
assert.equal(availablePlugins.data[0].name, '@beeper/cli-plugin-cloudflare')
assert.equal(availablePlugins.data[0].status, 'not installed')
assert.deepEqual(availablePlugins.data[0].commands, ['targets tunnel'])
@@ -199,41 +205,66 @@ rmSync(configDir, { recursive: true, force: true })
let result = run('targets', 'add', 'remote', 'work', 'http://127.0.0.1:23373', '--default', '--json')
assert.equal(result.status, 0, result.stderr)
let envelope = JSON.parse(result.stdout)
-assert.equal(envelope.success, true)
+assert.equal(envelope.ok, true)
assert.equal(envelope.data.id, 'work')
assert.equal(envelope.data.type, 'remote')
result = run('targets', 'list', '--json')
assert.equal(result.status, 0, result.stderr)
envelope = JSON.parse(result.stdout)
-assert.equal(envelope.success, true)
+assert.equal(envelope.ok, true)
assert(envelope.data.some(item => item.id === 'work' && item.default))
result = run('auth', 'status', '--json')
assert.equal(result.status, 0, result.stderr)
envelope = JSON.parse(result.stdout)
-assert.equal(envelope.success, true)
+assert.equal(envelope.ok, true)
assert.equal(envelope.data.authenticated, false)
assert.equal(envelope.data.target, 'work')
result = run('send', 'text', '--to', 'family', '--message', 'on my way', '--read-only', '--json')
assert.notEqual(result.status, 0)
envelope = JSON.parse(result.stderr)
-assert.equal(envelope.success, false)
-assert.match(envelope.error, /read-only mode/)
+assert.equal(envelope.ok, false)
+assert.match(envelope.error.message, /read-only mode/)
result = run('setup', '--remote', 'http://127.0.0.1:9', '--target', 'email-remote', '--email', 'staging-user-123456@example.invalid', '--json')
assert.notEqual(result.status, 0)
envelope = JSON.parse(result.stderr)
-assert.equal(envelope.success, false)
-assert.match(envelope.error, /auth email start/)
-assert.doesNotMatch(envelope.error, /--code|OTP/i, 'setup must direct automation to the two-step email commands without accepting OTP itself')
+assert.equal(envelope.ok, false)
+assert.match(envelope.error.message, /auth email start/)
+assert.doesNotMatch(envelope.error.message, /--code|OTP/i, 'setup must direct automation to the two-step email commands without accepting OTP itself')
result = run('targets', 'show', 'email-remote', '--json')
assert.notEqual(result.status, 0)
envelope = JSON.parse(result.stderr)
-assert.equal(envelope.success, false)
-assert.match(envelope.error, /Unknown Beeper target/)
+assert.equal(envelope.ok, false)
+assert.match(envelope.error.message, /Unknown Beeper target/)
+
+rmSync(configDir, { recursive: true, force: true })
+const fakeServerPath = join(configDir, 'bin', 'beeper-server')
+mkdirSync(join(configDir, 'bin'), { recursive: true })
+writeFileSync(fakeServerPath, '#!/bin/sh\n', { mode: 0o755 })
+writeFileSync(join(configDir, 'installations.json'), `${JSON.stringify({
+ server: {
+ kind: 'server',
+ channel: 'stable',
+ serverEnv: 'prod',
+ bundleID: 'com.automattic.beeper.server',
+ version: 'test',
+ path: fakeServerPath,
+ feedURL: 'https://example.invalid/feed',
+ downloadURL: 'https://example.invalid/download',
+ installedAt: '2026-05-18T00:00:00.000Z',
+ updatedAt: '2026-05-18T00:00:00.000Z',
+ },
+}, null, 2)}\n`)
+result = run('setup', '--json')
+assert.equal(result.status, 0, result.stderr)
+envelope = JSON.parse(result.stdout)
+assert.equal(envelope.ok, true)
+assert(envelope.data.availableActions.some(action => action.id === 'use-installed-server' && action.command === 'beeper setup --server --yes'))
+assert(!envelope.data.availableActions.some(action => action.id === 'install-server'), 'setup must not offer to reinstall an already installed Server')
const rpcResult = spawnSync(process.execPath, ['./bin/dev.js', 'rpc'], {
cwd: root,
@@ -248,13 +279,36 @@ assert.equal(rpcResult.status, 0, rpcResult.stderr)
const rpcLine = JSON.parse(rpcResult.stdout)
assert.equal(rpcLine.id, 1)
assert.equal(rpcLine.ok, true)
-assert.match(rpcLine.stdout, /"success": true/)
+assert.match(rpcLine.stdout, /"ok": true/)
const stagingServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'staging', channel: 'stable', platform: 'darwin', arch: 'arm64' })
-assert.equal(stagingServerRequest.channel, 'nightly')
-assert.equal(stagingServerRequest.bundleID, 'com.automattic.beeper.server.nightly')
-assert.equal(feedURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=arm64')
-assert.equal(downloadURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server.nightly')
+assert.equal(stagingServerRequest.channel, 'stable')
+assert.equal(stagingServerRequest.bundleID, 'com.automattic.beeper.server')
+assert.equal(feedURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=arm64')
+assert.equal(downloadURLFor(stagingServerRequest), 'https://api.beeper-staging.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server')
+
+const prodServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'prod', channel: 'stable', platform: 'darwin', arch: 'arm64' })
+assert.equal(prodServerRequest.channel, 'stable')
+assert.equal(prodServerRequest.bundleID, 'com.automattic.beeper.server')
+assert.equal(feedURLFor(prodServerRequest), 'https://api.beeper.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=arm64')
+assert.equal(downloadURLFor(prodServerRequest), 'https://api.beeper.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server')
+
+const productionAliasServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'production', channel: 'stable', platform: 'darwin', arch: 'arm64' })
+assert.equal(productionAliasServerRequest.serverEnv, 'prod')
+
+const localServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'local', channel: 'stable', platform: 'darwin', arch: 'arm64' })
+assert.equal(feedURLFor(localServerRequest), 'https://api.beeper.localtest.me/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=arm64')
+assert.equal(downloadURLFor(localServerRequest), 'https://api.beeper.localtest.me/desktop/download/macos/arm64/stable/com.automattic.beeper.server')
+
+const devServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'dev', channel: 'stable', platform: 'darwin', arch: 'arm64' })
+assert.equal(feedURLFor(devServerRequest), 'https://api.beeper-dev.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server&platform=darwin&channel=stable&arch=arm64')
+assert.equal(downloadURLFor(devServerRequest), 'https://api.beeper-dev.com/desktop/download/macos/arm64/stable/com.automattic.beeper.server')
+
+const stagingNightlyServerRequest = normalizeInstallRequest({ kind: 'server', serverEnv: 'staging', channel: 'nightly', platform: 'darwin', arch: 'arm64' })
+assert.equal(stagingNightlyServerRequest.channel, 'nightly')
+assert.equal(stagingNightlyServerRequest.bundleID, 'com.automattic.beeper.server.nightly')
+assert.equal(feedURLFor(stagingNightlyServerRequest), 'https://api.beeper-staging.com/desktop/update-feed.json?bundleID=com.automattic.beeper.server.nightly&platform=darwin&channel=nightly&arch=arm64')
+assert.equal(downloadURLFor(stagingNightlyServerRequest), 'https://api.beeper-staging.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.server.nightly')
const desktopNightlyRequest = normalizeInstallRequest({ kind: 'desktop', channel: 'nightly', platform: 'darwin', arch: 'arm64' })
assert.equal(downloadURLFor(desktopNightlyRequest), 'https://api.beeper.com/desktop/download/macos/arm64/nightly/com.automattic.beeper.desktop.nightly')
@@ -289,6 +343,7 @@ assert.equal(await resolveChatID(fakeClient, '10313'), '10313')
assert.equal(await resolveChatID(fakeClient, 'Family Work'), '8951')
assert.equal(await resolveChatID(fakeClient, 'fam', { pick: 2 }), '8951')
await assert.rejects(() => resolveChatID(fakeClient, 'fam'), /Ambiguous chat/)
+await assert.rejects(() => resolveChatID(fakeClient, 'missing'), /No chat matches/)
function listCommandFiles(dir) {
const output = []
diff --git a/packages/cli/test/messages-search-validation.test.ts b/packages/cli/test/messages-search-validation.test.ts
index ad053898..bb4c2117 100644
--- a/packages/cli/test/messages-search-validation.test.ts
+++ b/packages/cli/test/messages-search-validation.test.ts
@@ -17,9 +17,9 @@ describe('messages search query-or-filter requirement', () => {
const result = run('messages', 'search', '--json')
expect(result.status).toBe(2)
const envelope = JSON.parse(result.stderr)
- expect(envelope.success).toBe(false)
- expect(envelope.exitCode).toBe(2)
- expect(envelope.error).toMatch(/Provide a search query or at least one filter flag/)
+ expect(envelope.ok).toBe(false)
+ expect(envelope.error.exitCode).toBe(2)
+ expect(envelope.error.message).toMatch(/Provide a search query or at least one filter flag/)
})
it('accepts a bare query', () => {
diff --git a/packages/npm/scripts/build.ts b/packages/npm/scripts/build.ts
index 95b406b9..fa7be4c8 100644
--- a/packages/npm/scripts/build.ts
+++ b/packages/npm/scripts/build.ts
@@ -1,4 +1,5 @@
#!/usr/bin/env bun
+/* eslint-disable no-template-curly-in-string */
import { chmod, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
@@ -29,114 +30,6 @@ await writeFile(join(root, 'bin', 'beeper.js'), launcher())
await chmod(join(root, 'bin', 'beeper.js'), 0o755)
function launcher() {
- return `#!/usr/bin/env node
-import { createHash } from 'node:crypto'
-import { createWriteStream, existsSync } from 'node:fs'
-import { chmod, mkdir, readFile, rename, rm } from 'node:fs/promises'
-import { get } from 'node:https'
-import { homedir, platform as osPlatform, arch as osArch, tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
-import { fileURLToPath } from 'node:url'
-import { spawn } from 'node:child_process'
-
-const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
-const manifest = JSON.parse(await readFile(join(packageRoot, 'binaries.json'), 'utf8'))
-const platform = targetPlatform()
-const artifact = manifest.artifacts.find(item => item.platform === platform)
-
-if (!artifact) {
- console.error(\`beeper-cli does not ship a binary for \${process.platform}/\${process.arch}.\`)
- process.exit(1)
-}
-
-const cacheDir = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli', manifest.version)
-const binPath = join(cacheDir, 'bin', manifest.command || 'beeper')
-
-const expectedBinarySha256 = artifact.binarySha256 || artifact.sha256
-
-if (!existsSync(binPath) || await sha256(binPath).catch(() => '') !== expectedBinarySha256) {
- const tempDir = join(tmpdir(), \`beeper-cli-\${manifest.version}-\${process.pid}\`)
- const archivePath = join(tempDir, artifact.file)
- await rm(tempDir, { recursive: true, force: true })
- await mkdir(tempDir, { recursive: true })
- await download(\`https://github.com/beeper/cli/releases/download/v\${manifest.version}/\${artifact.file}\`, archivePath)
- const actual = await sha256(archivePath)
- if (actual !== artifact.sha256) {
- await rm(tempDir, { recursive: true, force: true })
- console.error(\`beeper-cli binary checksum mismatch for \${artifact.file}.\`)
- process.exit(1)
- }
- await extract(archivePath, tempDir)
- const extractedBin = join(tempDir, 'bin', manifest.command || 'beeper')
- await chmod(extractedBin, 0o755)
- await rm(cacheDir, { recursive: true, force: true })
- await mkdir(dirname(binPath), { recursive: true })
- await rename(extractedBin, binPath)
- await rm(tempDir, { recursive: true, force: true })
-}
-
-const child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit', env: process.env })
-child.on('exit', (code, signal) => {
- if (signal) process.kill(process.pid, signal)
- process.exit(code ?? 1)
-})
-
-function targetPlatform() {
- const os = osPlatform()
- const cpu = osArch()
- const normalizedOS = os === 'darwin' || os === 'linux' ? os : os === 'win32' ? 'windows' : os
- const normalizedArch = cpu === 'x64' || cpu === 'arm64' ? cpu : cpu
- return \`\${normalizedOS}-\${normalizedArch}\`
-}
-
-async function sha256(path) {
- const hash = createHash('sha256')
- hash.update(await readFile(path))
- return hash.digest('hex')
-}
-
-async function download(url, destination) {
- await new Promise((resolve, reject) => {
- get(url, response => {
- if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0) && response.headers.location) {
- response.resume()
- download(response.headers.location, destination).then(resolve, reject)
- return
- }
- if (response.statusCode !== 200) {
- response.resume()
- reject(new Error(\`Download failed with HTTP \${response.statusCode}: \${url}\`))
- return
- }
- const file = createWriteStream(destination, { mode: 0o755 })
- response.pipe(file)
- file.on('finish', () => file.close(resolve))
- file.on('error', reject)
- }).on('error', reject)
- })
+ return "#!/usr/bin/env node\nimport { createHash } from 'node:crypto'\nimport { createWriteStream, existsSync } from 'node:fs'\nimport { chmod, mkdir, readFile, rename, rm } from 'node:fs/promises'\nimport { get } from 'node:https'\nimport { homedir, platform as osPlatform, arch as osArch, tmpdir } from 'node:os'\nimport { dirname, join } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { spawn } from 'node:child_process'\n\nconst packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..')\nconst manifest = JSON.parse(await readFile(join(packageRoot, 'binaries.json'), 'utf8'))\nconst platform = targetPlatform()\nconst artifact = manifest.artifacts.find(item => item.platform === platform)\n\nif (!artifact) {\n console.error(`beeper-cli does not ship a binary for ${process.platform}/${process.arch}.`)\n process.exit(1)\n}\n\nconst cacheDir = process.env.BEEPER_CLI_BINARY_CACHE_DIR || join(homedir(), '.cache', 'beeper-cli', manifest.version)\nconst binPath = join(cacheDir, 'bin', manifest.command || 'beeper')\n\nconst expectedBinarySha256 = artifact.binarySha256 || artifact.sha256\n\nif (!existsSync(binPath) || await sha256(binPath).catch(() => '') !== expectedBinarySha256) {\n const tempDir = join(tmpdir(), `beeper-cli-${manifest.version}-${process.pid}`)\n const archivePath = join(tempDir, artifact.file)\n const downloadURL = `https://github.com/beeper/cli/releases/download/v${manifest.version}/${artifact.file}`\n logStep(`installing beeper-cli ${manifest.version} for ${platform}`)\n await rm(tempDir, { recursive: true, force: true })\n await mkdir(tempDir, { recursive: true })\n await download(downloadURL, archivePath)\n logStep('verifying download')\n const actual = await sha256(archivePath)\n if (actual !== artifact.sha256) {\n await rm(tempDir, { recursive: true, force: true })\n console.error(`beeper-cli binary checksum mismatch for ${artifact.file}.`)\n process.exit(1)\n }\n logStep('extracting binary')\n await extract(archivePath, tempDir)\n const extractedBin = join(tempDir, 'bin', manifest.command || 'beeper')\n await chmod(extractedBin, 0o755)\n logStep(`caching binary in ${cacheDir}`)\n await rm(cacheDir, { recursive: true, force: true })\n await mkdir(dirname(binPath), { recursive: true })\n await rename(extractedBin, binPath)\n await rm(tempDir, { recursive: true, force: true })\n logStep('ready')\n}\n\nif (process.env.BEEPER_CLI_LAUNCHER_DEBUG === '1') logStep(`starting ${binPath}`)\nconst child = spawn(binPath, process.argv.slice(2), { stdio: 'inherit', env: process.env })\nchild.on('exit', (code, signal) => {\n if (signal) process.kill(process.pid, signal)\n process.exit(code ?? 1)\n})\n\nfunction logStep(message) {\n console.error(`beeper-cli: ${message}`)\n}\n\nfunction targetPlatform() {\n const os = osPlatform()\n const cpu = osArch()\n const normalizedOS = os === 'darwin' || os === 'linux' ? os : os === 'win32' ? 'windows' : os\n const normalizedArch = cpu === 'x64' || cpu === 'arm64' ? cpu : cpu\n return `${normalizedOS}-${normalizedArch}`\n}\n\nasync function sha256(path) {\n const hash = createHash('sha256')\n hash.update(await readFile(path))\n return hash.digest('hex')\n}\n\nasync function download(url, destination, redirects = 0) {\n if (redirects > 10) throw new Error(`Too many redirects while downloading ${artifact.file}`)\n\n logStep(`downloading ${artifact.file}`)\n await new Promise((resolve, reject) => {\n get(url, response => {\n if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0) && response.headers.location) {\n response.resume()\n const nextURL = new URL(response.headers.location, url).toString()\n logStep(`redirecting to ${new URL(nextURL).host}`)\n download(nextURL, destination, redirects + 1).then(resolve, reject)\n return\n }\n if (response.statusCode !== 200) {\n response.resume()\n reject(new Error(`Download failed with HTTP ${response.statusCode}: ${url}`))\n return\n }\n const total = Number(response.headers['content-length'] ?? 0)\n let downloaded = 0\n let nextLoggedPercent = 25\n const file = createWriteStream(destination, { mode: 0o755 })\n response.on('data', chunk => {\n downloaded += chunk.length\n if (!total) return\n const percent = Math.floor(downloaded / total * 100)\n while (percent >= nextLoggedPercent && nextLoggedPercent <= 100) {\n logStep(`downloaded ${nextLoggedPercent}%`)\n nextLoggedPercent += 25\n }\n })\n response.pipe(file)\n file.on('finish', () => file.close(resolve))\n file.on('error', reject)\n }).on('error', reject)\n })\n}\n\nasync function extract(archivePath, destination) {\n if (artifact.file.endsWith('.zip')) {\n await run('/usr/bin/ditto', ['-x', '-k', archivePath, destination])\n return\n }\n if (artifact.file.endsWith('.tar.gz')) {\n await run('tar', ['-xzf', archivePath, '-C', destination])\n return\n }\n throw new Error(`Unsupported beeper-cli archive: ${artifact.file}`)\n}\n\nasync function run(command, args) {\n await new Promise((resolve, reject) => {\n const child = spawn(command, args, { stdio: 'ignore' })\n child.on('error', reject)\n child.on('exit', code => {\n if (code === 0) resolve()\n else reject(new Error(`${command} ${args.join(' ')} exited with ${code}`))\n })\n })\n}"
}
-async function extract(archivePath, destination) {
- if (artifact.file.endsWith('.zip')) {
- await run('/usr/bin/ditto', ['-x', '-k', archivePath, destination])
- return
- }
- if (artifact.file.endsWith('.tar.gz')) {
- await run('tar', ['-xzf', archivePath, '-C', destination])
- return
- }
- throw new Error(\`Unsupported beeper-cli archive: \${artifact.file}\`)
-}
-
-async function run(command, args) {
- await new Promise((resolve, reject) => {
- const child = spawn(command, args, { stdio: 'ignore' })
- child.on('error', reject)
- child.on('exit', code => {
- if (code === 0) resolve()
- else reject(new Error(\`\${command} \${args.join(' ')} exited with \${code}\`))
- })
- })
-}
-`
-}