From 60b410e1f99416df67fe9c3d4fb7f0199c485ff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 13 May 2026 19:23:49 +0200 Subject: [PATCH 01/19] Redesign CLI with TypeScript oclif --- .github/workflows/ci.yml | 11 + .gitignore | 1 + README.md | 196 ++-- TYPESCRIPT_CLI.md | 97 ++ bin/dev.js | 4 + bin/run.js | 4 + package-lock.json | 1585 +++++++++++++++++++++++++++++++ package.json | 85 ++ src/commands/accounts.ts | 18 + src/commands/api/get.ts | 21 + src/commands/api/post.ts | 23 + src/commands/archive.ts | 23 + src/commands/assets/download.ts | 22 + src/commands/assets/upload.ts | 29 + src/commands/auth/login.ts | 26 + src/commands/auth/logout.ts | 20 + src/commands/auth/status.ts | 25 + src/commands/chat.ts | 28 + src/commands/chat/open.ts | 1 + src/commands/chats/index.ts | 30 + src/commands/chats/search.ts | 42 + src/commands/clear-draft.ts | 25 + src/commands/commands.ts | 15 + src/commands/config/get.ts | 20 + src/commands/config/path.ts | 10 + src/commands/config/reset.ts | 11 + src/commands/config/set.ts | 16 + src/commands/contacts/search.ts | 33 + src/commands/create-chat.ts | 32 + src/commands/current-user.ts | 24 + src/commands/delete-message.ts | 28 + src/commands/doctor.ts | 46 + src/commands/draft.ts | 52 + src/commands/edit.ts | 30 + src/commands/focus.ts | 33 + src/commands/llm.ts | 14 + src/commands/mark-read.ts | 1 + src/commands/mark-unread.ts | 1 + src/commands/message.ts | 26 + src/commands/messages/index.ts | 38 + src/commands/messages/search.ts | 41 + src/commands/mute.ts | 25 + src/commands/notify-anyway.ts | 25 + src/commands/react.ts | 32 + src/commands/read.ts | 26 + src/commands/remind.ts | 30 + src/commands/reply-file.ts | 60 ++ src/commands/reply.ts | 62 ++ src/commands/rpc.ts | 52 + src/commands/search.ts | 22 + src/commands/send-file.ts | 60 ++ src/commands/send.ts | 62 ++ src/commands/shell.ts | 40 + src/commands/start-chat.ts | 55 ++ src/commands/status.ts | 22 + src/commands/tail.ts | 1 + src/commands/thread.ts | 1 + src/commands/threads.ts | 1 + src/commands/unarchive.ts | 23 + src/commands/unmute.ts | 25 + src/commands/unreact.ts | 30 + src/commands/unread.ts | 26 + src/commands/unremind.ts | 23 + src/commands/watch.ts | 65 ++ src/commands/whoami.ts | 1 + src/lib/argv.ts | 39 + src/lib/client.ts | 20 + src/lib/config.ts | 58 ++ src/lib/manifest.ts | 59 ++ src/lib/oauth.ts | 163 ++++ src/lib/output.ts | 58 ++ src/lib/pkce.ts | 16 + src/lib/resolve.ts | 144 +++ src/lib/runner.ts | 38 + src/lib/wait.ts | 24 + test/cli-smoke.mjs | 177 ++++ tsconfig.json | 15 + 77 files changed, 4296 insertions(+), 71 deletions(-) create mode 100644 TYPESCRIPT_CLI.md create mode 100755 bin/dev.js create mode 100755 bin/run.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/commands/accounts.ts create mode 100644 src/commands/api/get.ts create mode 100644 src/commands/api/post.ts create mode 100644 src/commands/archive.ts create mode 100644 src/commands/assets/download.ts create mode 100644 src/commands/assets/upload.ts create mode 100644 src/commands/auth/login.ts create mode 100644 src/commands/auth/logout.ts create mode 100644 src/commands/auth/status.ts create mode 100644 src/commands/chat.ts create mode 100644 src/commands/chat/open.ts create mode 100644 src/commands/chats/index.ts create mode 100644 src/commands/chats/search.ts create mode 100644 src/commands/clear-draft.ts create mode 100644 src/commands/commands.ts create mode 100644 src/commands/config/get.ts create mode 100644 src/commands/config/path.ts create mode 100644 src/commands/config/reset.ts create mode 100644 src/commands/config/set.ts create mode 100644 src/commands/contacts/search.ts create mode 100644 src/commands/create-chat.ts create mode 100644 src/commands/current-user.ts create mode 100644 src/commands/delete-message.ts create mode 100644 src/commands/doctor.ts create mode 100644 src/commands/draft.ts create mode 100644 src/commands/edit.ts create mode 100644 src/commands/focus.ts create mode 100644 src/commands/llm.ts create mode 100644 src/commands/mark-read.ts create mode 100644 src/commands/mark-unread.ts create mode 100644 src/commands/message.ts create mode 100644 src/commands/messages/index.ts create mode 100644 src/commands/messages/search.ts create mode 100644 src/commands/mute.ts create mode 100644 src/commands/notify-anyway.ts create mode 100644 src/commands/react.ts create mode 100644 src/commands/read.ts create mode 100644 src/commands/remind.ts create mode 100644 src/commands/reply-file.ts create mode 100644 src/commands/reply.ts create mode 100644 src/commands/rpc.ts create mode 100644 src/commands/search.ts create mode 100644 src/commands/send-file.ts create mode 100644 src/commands/send.ts create mode 100644 src/commands/shell.ts create mode 100644 src/commands/start-chat.ts create mode 100644 src/commands/status.ts create mode 100644 src/commands/tail.ts create mode 100644 src/commands/thread.ts create mode 100644 src/commands/threads.ts create mode 100644 src/commands/unarchive.ts create mode 100644 src/commands/unmute.ts create mode 100644 src/commands/unreact.ts create mode 100644 src/commands/unread.ts create mode 100644 src/commands/unremind.ts create mode 100644 src/commands/watch.ts create mode 100644 src/commands/whoami.ts create mode 100644 src/lib/argv.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/manifest.ts create mode 100644 src/lib/oauth.ts create mode 100644 src/lib/output.ts create mode 100644 src/lib/pkce.ts create mode 100644 src/lib/resolve.ts create mode 100644 src/lib/runner.ts create mode 100644 src/lib/wait.ts create mode 100644 test/cli-smoke.mjs create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2742613..26d3c04 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,3 +114,14 @@ jobs: - name: Run tests run: ./scripts/test + + - uses: actions/setup-node@v6 + with: + node-version: 25 + cache: npm + + - name: Install TypeScript CLI dependencies + run: npm ci + + - name: Run TypeScript CLI tests + run: npm test diff --git a/.gitignore b/.gitignore index 268ede0..cadd9eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .prism.log .stdy.log dist/ +node_modules/ /beeper-desktop-cli *.exe diff --git a/README.md b/README.md index 09e97fb..eda4c23 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,175 @@ # Beeper Desktop CLI -The official CLI for the [Beeper Desktop REST API](https://developers.beeper.com/desktop-api/). +Command-line access to the [Beeper Desktop API](https://developers.beeper.com/desktop-api/). -It is generated with [Stainless](https://www.stainless.com/). +The CLI is built with TypeScript, oclif, and the official `@beeper/desktop-api` +SDK. It supports OAuth login, chat and message workflows, live event streaming, +asset transfer, machine-readable output, and raw API access for advanced use. - +## Install -## Installation +```sh +npm install +npm run build +node ./bin/run.js --help +``` -### Installing with Homebrew +During development, run commands directly from TypeScript: ```sh -brew install beeper/tap/beeper-desktop-cli +npm run dev -- --help ``` -### Installing with Go +The package exposes both `beeper` and `beeper-desktop-cli`. -To test or install the CLI locally, you need [Go](https://go.dev/doc/install) version 1.22 or later installed. +## Authenticate ```sh -go install 'github.com/beeper/desktop-api-cli/cmd/beeper-desktop-cli@latest' +beeper auth login +beeper auth status ``` -Once you have run `go install`, the binary is placed in your Go bin directory: +`auth login` uses OAuth2 Authorization Code with PKCE. It registers a local +client, opens the authorization URL, listens on a loopback callback, exchanges +the authorization code, and stores the bearer token in +`~/.config/beeper/config.json`. -- **Default location**: `$HOME/go/bin` (or `$GOPATH/bin` if GOPATH is set) -- **Check your path**: Run `go env GOPATH` to see the base directory - -If commands aren't found after installation, add the Go bin directory to your PATH: +For non-interactive use, pass a token through the environment: ```sh -# Add to your shell profile (.zshrc, .bashrc, etc.) -export PATH="$PATH:$(go env GOPATH)/bin" +BEEPER_ACCESS_TOKEN=... beeper chats --json ``` - +## Common Workflows -### Running Locally +```sh +beeper doctor +beeper status +beeper accounts +beeper whoami +``` -After cloning the git repository for this project, you can use the -`scripts/run` script to run the tool locally: +```sh +beeper chats +beeper chats --ids +beeper chats search dinner +beeper chat "Family" +beeper chat open "Family" +``` ```sh -./scripts/run args... +beeper messages "Family" +beeper messages "Family" --ids +beeper messages search deploy --chat "Team" +beeper search pizza ``` -## Usage +```sh +beeper send "Family" "on my way" +beeper send "Family" "on my way" --wait +beeper send "Family" "see attached" --file ./photo.jpg +beeper reply "Family" MESSAGE_ID "yes" +beeper edit "Family" MESSAGE_ID "updated text" +beeper delete-message "Family" MESSAGE_ID +``` -The CLI follows a resource-based command structure: +```sh +beeper contacts search jane +beeper contacts search jane --account imessage +beeper start-chat +15551234567 +beeper start-chat jane@example.com --account imessage +beeper create-chat --account imessage --participant USER_ID +``` ```sh -beeper-desktop-cli [resource] [flags...] +beeper read "Family" +beeper unread "Family" +beeper archive "Family" +beeper unarchive "Family" +beeper mute "Family" +beeper unmute "Family" +beeper remind "Family" 2026-05-13T12:00:00Z +beeper unremind "Family" ``` ```sh -beeper-desktop-cli chats search \ - --include-muted \ - --limit 3 \ - --type single +beeper assets upload ./photo.jpg +beeper assets download mxc://example.org/media ``` -For details about specific commands, use the `--help` flag. +```sh +beeper watch --json +beeper tail --json +beeper shell +printf '%s\n' '{"id":1,"command":"status --json"}' | beeper rpc +``` -### Environment variables +```sh +beeper config get --json +beeper config set baseURL http://localhost:23373 +beeper config reset +``` -| Environment variable | Description | Required | -| --------------------- | ----------------------------------------------------------------------------------------------------- | -------- | -| `BEEPER_ACCESS_TOKEN` | Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. | yes | +```sh +beeper api get /v1/info +beeper api post /v1/chats/CHAT/archive --body '{"archived":true}' +``` -### Global flags +## Input Resolution -- `--access-token` - Bearer access token obtained via OAuth2 PKCE flow or created in-app. Required for all API operations. (can also be set with `BEEPER_ACCESS_TOKEN` env var) -- `--help` - Show command line usage -- `--debug` - Enable debug logging (includes HTTP request/response details) -- `--version`, `-v` - Show the CLI version -- `--base-url` - Use a custom API backend URL -- `--format` - Change the output format (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--format-error` - Change the output format for errors (`auto`, `explore`, `json`, `jsonl`, `pretty`, `raw`, `yaml`) -- `--transform` - Transform the data output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) -- `--transform-error` - Transform the error output using [GJSON syntax](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) +Commands accept practical identifiers instead of requiring exact IDs everywhere: -### Passing files as arguments +- Chat arguments accept Beeper chat IDs, local chat IDs, exact titles, or search text. +- Ambiguous chat matches return numbered choices; pass `--pick N` to select one. +- Account arguments accept account IDs, network names, bridge type/id, or account user identity. +- Account filters can expand a network name to multiple matching accounts. +- `contacts search` and `start-chat` can search across all accounts when `--account` is omitted. -To pass files to your API, you can use the `@myfile.ext` syntax: +Send commands can wait for Desktop to resolve a pending message: -```bash -beeper-desktop-cli --arg @abe.jpg +```sh +beeper send "Family" "on my way" --wait ``` -Files can also be passed inside JSON or YAML blobs: +Use `--wait-timeout` and `--wait-interval` to tune the polling window. -```bash -beeper-desktop-cli --arg '{image: "@abe.jpg"}' -# Equivalent: -beeper-desktop-cli < --username '\@abe' -``` +- `--json` for structured output +- `--debug` for SDK debug logging +- `--base-url` to point at a different local Desktop API server -#### Explicit encoding +`commands --json` prints a compact command manifest for tools and agents. +`llm` prints a concise human-readable command guide. -For JSON endpoints, the CLI tool does filetype sniffing to determine whether the -file contents should be sent as a string literal (for plain text files) or as a -base64-encoded string literal (for binary files). If you need to explicitly send -the file as either plain text or base64-encoded data, you can use -`@file://myfile.txt` (for string encoding) or `@data://myfile.dat` (for -base64-encoding). Note that absolute paths will begin with `@file://` or -`@data://`, followed by a third `/` (for example, `@file:///tmp/file.txt`). +## Compatibility Aliases -```bash -beeper-desktop-cli --arg @data://file.txt +These aliases are supported for convenience: + +| Alias | Canonical command | +| --- | --- | +| `beeper threads` | `beeper chats` | +| `beeper thread CHAT` | `beeper chat CHAT` | +| `beeper chat open CHAT [MESSAGE]` | `beeper focus CHAT [MESSAGE]` | +| `beeper mark-read CHAT` | `beeper read CHAT` | +| `beeper mark-unread CHAT` | `beeper unread CHAT` | +| `beeper tail` | `beeper watch` | +| `beeper whoami` | `beeper current-user` | + +File-oriented helpers are also available: + +```sh +beeper send-file CHAT FILE [TEXT] +beeper reply-file CHAT MESSAGE FILE [TEXT] ``` + +## Environment + +| Environment variable | Description | +| --- | --- | +| `BEEPER_ACCESS_TOKEN` | Bearer token. Overrides stored OAuth login. | +| `BEEPER_DESKTOP_BASE_URL` | Beeper Desktop API base URL. Defaults to `http://localhost:23373`. | +| `BEEPER_BASE_URL` | SDK-compatible base URL fallback. | +| `BEEPER_CLI_CONFIG_DIR` | Override config directory for testing or isolated profiles. | diff --git a/TYPESCRIPT_CLI.md b/TYPESCRIPT_CLI.md new file mode 100644 index 0000000..303be5e --- /dev/null +++ b/TYPESCRIPT_CLI.md @@ -0,0 +1,97 @@ +# TypeScript oclif CLI + +This implementation uses oclif and the official `@beeper/desktop-api` +TypeScript SDK. + +## Command Surface + +```sh +beeper auth login +beeper auth logout +beeper auth status +beeper doctor +beeper status +beeper accounts +beeper contacts search QUERY +beeper contacts search QUERY --account imessage +beeper create-chat --account imessage --participant USER_ID +beeper start-chat +15551234567 +beeper start-chat jane@example.com --account imessage +beeper chats +beeper chats --ids +beeper chats search QUERY +beeper chat CHAT +beeper chat open CHAT [MESSAGE] +beeper message CHAT MESSAGE +beeper messages CHAT +beeper messages CHAT --ids +beeper messages search [QUERY] +beeper search QUERY +beeper send CHAT TEXT +beeper send CHAT TEXT --wait +beeper send CHAT TEXT --file ./image.png +beeper send-file CHAT ./image.png [TEXT] +beeper reply CHAT MESSAGE TEXT +beeper reply-file CHAT MESSAGE ./image.png [TEXT] +beeper edit CHAT MESSAGE TEXT +beeper delete-message CHAT MESSAGE +beeper react CHAT MESSAGE REACTION +beeper unreact CHAT MESSAGE REACTION +beeper focus [CHAT] [MESSAGE] +beeper draft CHAT TEXT +beeper clear-draft CHAT +beeper archive CHAT +beeper unarchive CHAT +beeper read CHAT +beeper unread CHAT +beeper mute CHAT +beeper unmute CHAT +beeper notify-anyway CHAT +beeper remind CHAT 2026-05-13T12:00:00Z +beeper unremind CHAT +beeper assets upload ./file.png +beeper assets download mxc://example.org/media +beeper current-user +beeper commands --json +beeper llm +beeper shell +beeper rpc +beeper watch --json +beeper tail --json +beeper whoami +beeper config get --json +beeper config set baseURL http://localhost:23373 +beeper config reset +beeper threads +beeper thread CHAT +beeper mark-read CHAT +beeper mark-unread CHAT +beeper api get PATH +beeper api post PATH --body JSON +``` + +## Resolution + +- Chat arguments accept chat IDs, local chat IDs, exact titles, or search text. +- Ambiguous chat matches return numbered choices and accept `--pick N`. +- Account arguments accept account IDs, network names, bridge type/id, or account user identity. +- Account filters can expand one network to multiple accounts. +- Contact and start-chat commands search across accounts when no account is specified. + +## Send Confirmation + +Send and reply commands accept `--wait`, `--wait-timeout`, and +`--wait-interval`. `--wait` polls the pending message ID returned by Desktop +until it resolves to a message. + +## OAuth + +`auth login` uses OAuth2 Authorization Code with PKCE: + +1. Start a loopback callback server on `127.0.0.1`. +2. Register a public OAuth client with `POST /oauth/register`. +3. Generate an S256 PKCE verifier/challenge pair and random state. +4. Open `GET /oauth/authorize`. +5. Validate the callback `state`. +6. Exchange the code with `POST /oauth/token`. +7. Store the bearer token under `~/.config/beeper/config.json` with mode `0600`. diff --git a/bin/dev.js b/bin/dev.js new file mode 100755 index 0000000..e91fe4c --- /dev/null +++ b/bin/dev.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core' + +await execute({ development: true, dir: import.meta.url }) diff --git a/bin/run.js b/bin/run.js new file mode 100755 index 0000000..3492285 --- /dev/null +++ b/bin/run.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core' + +await execute({ dir: import.meta.url }) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7a08ded --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1585 @@ +{ + "name": "@beeper/desktop-api-cli", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@beeper/desktop-api-cli", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@beeper/desktop-api": "^5.0.0", + "@oclif/core": "^4.11.2", + "@oclif/plugin-autocomplete": "^3.2.49", + "@oclif/plugin-help": "^6.2.48", + "@oclif/plugin-not-found": "^3.2.85" + }, + "bin": { + "beeper": "bin/run.js", + "beeper-desktop-cli": "bin/run.js" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@beeper/desktop-api": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@beeper/desktop-api/-/desktop-api-5.0.0.tgz", + "integrity": "sha512-f9GhxQw6E0VMiVJGcdkUPNc6qCypaQlgMgxG6yU2lLy9gzx8xEObYnJbuhmT+HBg2SaQzNB33fC5ZT4OL3GnQw==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@oclif/core": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.11.2.tgz", + "integrity": "sha512-LWDalCgy+hYyAkLa9sMIXMXk6ws5RzQhVnkmfXtVIIyEEYigbXQ/9/x+s76p53MiXxNc6SJB7lfwkPF+SdzfMQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.0", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.14", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-autocomplete": { + "version": "3.2.49", + "resolved": "https://registry.npmjs.org/@oclif/plugin-autocomplete/-/plugin-autocomplete-3.2.49.tgz", + "integrity": "sha512-+rrAZ468bW/B9uVrn6sEnFYepy3M1N/BWht8mHzhFIFCIduPSoE+8MweROxZLOGBZrXGWt0iavuPQmy0eaXRfQ==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4", + "ansis": "^3.16.0", + "debug": "^4.4.1", + "ejs": "^3.1.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-help": { + "version": "6.2.48", + "resolved": "https://registry.npmjs.org/@oclif/plugin-help/-/plugin-help-6.2.48.tgz", + "integrity": "sha512-nvGLBtUZUWrHfoAEDRsRZUHKVwptyZ6F+MErdVRLQBo3dja0GCZH8DE33dA7mBux2KOmbxGqop15gyud9HZYhQ==", + "license": "MIT", + "dependencies": { + "@oclif/core": "^4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/plugin-not-found": { + "version": "3.2.85", + "resolved": "https://registry.npmjs.org/@oclif/plugin-not-found/-/plugin-not-found-3.2.85.tgz", + "integrity": "sha512-Si18rRKWknlvQ5anmFbQz9oKBae5/l/Npreuf05xdoNWfOV1J97Z7cpzqBlHbldmxCIiDRgmDKuCBBi4XN6ACA==", + "license": "MIT", + "dependencies": { + "@inquirer/prompts": "^7.10.1", + "@oclif/core": "^4.11.2", + "ansis": "^3.17.0", + "fast-levenshtein": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@types/node": { + "version": "25.7.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", + "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.21.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "license": "MIT" + }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-levenshtein": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz", + "integrity": "sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ==", + "license": "MIT", + "dependencies": { + "fastest-levenshtein": "^1.0.7" + } + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", + "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b3b51db --- /dev/null +++ b/package.json @@ -0,0 +1,85 @@ +{ + "name": "@beeper/desktop-api-cli", + "version": "0.0.0", + "description": "Beeper Desktop API command-line interface", + "license": "MIT", + "type": "module", + "bin": { + "beeper": "./bin/run.js", + "beeper-desktop-cli": "./bin/run.js" + }, + "files": [ + "bin", + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", + "dev": "node --import tsx ./bin/dev.js", + "test": "npm run build && node ./test/cli-smoke.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "oclif": { + "additionalHelpFlags": [ + "-h" + ], + "commands": { + "strategy": "pattern", + "target": "./dist/commands", + "globPatterns": [ + "**/*.js" + ] + }, + "bin": "beeper", + "dirname": "beeper", + "flexibleTaxonomy": true, + "helpOptions": { + "maxWidth": 100 + }, + "plugins": [ + "@oclif/plugin-help", + "@oclif/plugin-autocomplete", + "@oclif/plugin-not-found" + ], + "topicSeparator": " ", + "topics": { + "api": { + "description": "Call raw Desktop API endpoints." + }, + "assets": { + "description": "Upload and download message assets." + }, + "auth": { + "description": "Authenticate with local Beeper Desktop." + }, + "chat": { + "description": "Open and inspect one chat." + }, + "chats": { + "description": "List and search chats." + }, + "config": { + "description": "Manage local CLI configuration." + }, + "contacts": { + "description": "Search contacts across accounts." + }, + "messages": { + "description": "List and search chat messages." + } + } + }, + "dependencies": { + "@beeper/desktop-api": "^5.0.0", + "@oclif/core": "^4.11.2", + "@oclif/plugin-autocomplete": "^3.2.49", + "@oclif/plugin-help": "^6.2.48", + "@oclif/plugin-not-found": "^3.2.85" + }, + "devDependencies": { + "@types/node": "^25.7.0", + "tsx": "^4.21.0", + "typescript": "^6.0.3" + } +} diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts new file mode 100644 index 0000000..721e5e0 --- /dev/null +++ b/src/commands/accounts.ts @@ -0,0 +1,18 @@ +import { Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' + +export default class Accounts extends Command { + static override summary = 'List connected chat accounts' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(Accounts) + const client = await createClient(flags) + printData(await client.accounts.list(), flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/api/get.ts b/src/commands/api/get.ts new file mode 100644 index 0000000..d51ce8b --- /dev/null +++ b/src/commands/api/get.ts @@ -0,0 +1,21 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class ApiGet extends Command { + static override summary = 'Call a raw Desktop API GET path' + static override args = { + path: Args.string({ description: 'API path, for example /v1/info', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ApiGet) + const client = await createClient(flags) + printData(await client.get(args.path), flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/api/post.ts b/src/commands/api/post.ts new file mode 100644 index 0000000..c93a8dd --- /dev/null +++ b/src/commands/api/post.ts @@ -0,0 +1,23 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class ApiPost extends Command { + static override summary = 'Call a raw Desktop API POST path with a JSON body' + static override args = { + path: Args.string({ description: 'API path, for example /v1/messages/{chatID}/send', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + body: Flags.string({ default: '{}', description: 'JSON request body' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: true, allowNo: true, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ApiPost) + const client = await createClient(flags) + const body = JSON.parse(flags.body) as Record + printData(await client.post(args.path, { body }), flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/archive.ts b/src/commands/archive.ts new file mode 100644 index 0000000..1623025 --- /dev/null +++ b/src/commands/archive.ts @@ -0,0 +1,23 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Archive extends Command { + static override summary = 'Archive a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Archive) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.archive(chatID, { archived: true }) + this.log('Archived') + } +} diff --git a/src/commands/assets/download.ts b/src/commands/assets/download.ts new file mode 100644 index 0000000..efbbc24 --- /dev/null +++ b/src/commands/assets/download.ts @@ -0,0 +1,22 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class AssetsDownload extends Command { + static override summary = 'Download an mxc:// or localmxc:// asset' + static override args = { + url: Args.string({ description: 'Asset URL', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(AssetsDownload) + const client = await createClient(flags) + const result = await client.assets.download({ url: args.url }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/assets/upload.ts b/src/commands/assets/upload.ts new file mode 100644 index 0000000..72f4f7d --- /dev/null +++ b/src/commands/assets/upload.ts @@ -0,0 +1,29 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' + +export default class AssetsUpload extends Command { + static override summary = 'Upload a file and return an upload ID' + static override args = { + file: Args.string({ description: 'File to upload', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + 'file-name': Flags.string({ description: 'Display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'MIME type' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(AssetsUpload) + const client = await createClient(flags) + const result = await client.assets.upload({ + file: createReadStream(args.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts new file mode 100644 index 0000000..e4411b6 --- /dev/null +++ b/src/commands/auth/login.ts @@ -0,0 +1,26 @@ +import { Command, Flags } from '@oclif/core' +import { loginWithPKCE } from '../../lib/oauth.js' +import { readConfig } from '../../lib/config.js' + +export default class AuthLogin extends Command { + static override summary = 'Authenticate with Beeper Desktop using OAuth2 PKCE' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'client-name': Flags.string({ default: 'Beeper CLI', description: 'OAuth client name shown in Beeper Desktop' }), + 'no-open': Flags.boolean({ default: false, description: 'Print the authorization URL instead of opening a browser' }), + scope: Flags.string({ default: 'read write', description: 'Space-separated OAuth scopes' }), + } + + async run(): Promise { + const { flags } = await this.parse(AuthLogin) + const config = await readConfig() + const token = await loginWithPKCE({ + baseURL: flags['base-url'] ?? config.baseURL, + clientName: flags['client-name'], + openBrowser: !flags['no-open'], + scope: flags.scope, + }) + this.log(`Authenticated as OAuth client ${token.clientID}`) + if (token.expires_in) this.log(`Token expires in ${token.expires_in}s`) + } +} diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts new file mode 100644 index 0000000..53cc254 --- /dev/null +++ b/src/commands/auth/logout.ts @@ -0,0 +1,20 @@ +import { Command } from '@oclif/core' +import { readConfig, updateConfig } from '../../lib/config.js' + +export default class AuthLogout extends Command { + static override summary = 'Remove the locally stored Beeper Desktop token' + + async run(): Promise { + const config = await readConfig() + const token = config.auth?.accessToken + if (token) { + await fetch(new URL('/oauth/revoke', config.baseURL), { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ token, token_type_hint: 'access_token' }), + }).catch(() => undefined) + } + await updateConfig(current => ({ ...current, auth: undefined })) + this.log('Logged out') + } +} diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts new file mode 100644 index 0000000..10faa54 --- /dev/null +++ b/src/commands/auth/status.ts @@ -0,0 +1,25 @@ +import { Command, Flags } from '@oclif/core' +import { readConfig } from '../../lib/config.js' +import { printData } from '../../lib/output.js' + +export default class AuthStatus extends Command { + static override summary = 'Show local auth status and token metadata' + static override flags = { + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(AuthStatus) + const config = await readConfig() + const authenticated = Boolean(process.env.BEEPER_ACCESS_TOKEN || config.auth?.accessToken) + const data = { + authenticated, + baseURL: config.baseURL, + source: process.env.BEEPER_ACCESS_TOKEN ? 'env' : config.auth?.accessToken ? 'config' : 'none', + clientID: config.auth?.clientID, + expiresAt: config.auth?.expiresAt, + scope: config.auth?.scope, + } + printData(data, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/chat.ts b/src/commands/chat.ts new file mode 100644 index 0000000..e9c833b --- /dev/null +++ b/src/commands/chat.ts @@ -0,0 +1,28 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Chat extends Command { + static override summary = 'Show one chat by ID' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'max-participants': Flags.integer({ description: 'Maximum participants to return' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Chat) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const chat = await client.chats.retrieve(chatID, { + maxParticipantCount: flags['max-participants'], + }) + printData(chat, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/chat/open.ts b/src/commands/chat/open.ts new file mode 100644 index 0000000..8fff7af --- /dev/null +++ b/src/commands/chat/open.ts @@ -0,0 +1 @@ +export { default } from '../focus.js' diff --git a/src/commands/chats/index.ts b/src/commands/chats/index.ts new file mode 100644 index 0000000..192b28f --- /dev/null +++ b/src/commands/chats/index.ts @@ -0,0 +1,30 @@ +import { Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { collectPage, printData, printIDs } from '../../lib/output.js' +import { resolveAccountIDs } from '../../lib/resolve.js' + +export default class ChatsIndex extends Command { + static override summary = 'List recent chats' + static override flags = { + account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), + } + + async run(): Promise { + const { flags } = await this.parse(ChatsIndex) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const items = await collectPage(client.chats.list({ + accountIDs, + }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + printData(items, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/chats/search.ts b/src/commands/chats/search.ts new file mode 100644 index 0000000..f860159 --- /dev/null +++ b/src/commands/chats/search.ts @@ -0,0 +1,42 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { collectPage, printData, printIDs } from '../../lib/output.js' +import { resolveAccountIDs } from '../../lib/resolve.js' + +export default class ChatsSearch extends Command { + static override summary = 'Search chats by title, network, or participants' + static override args = { + query: Args.string({ description: 'Literal chat search query', required: true }), + } + static override flags = { + account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), + inbox: Flags.string({ options: ['primary', 'low-priority', 'archive'] }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), + scope: Flags.string({ options: ['titles', 'participants'] }), + type: Flags.string({ options: ['single', 'group', 'any'] }), + unread: Flags.boolean({ default: false, description: 'Only unread chats' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ChatsSearch) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) + const items = await collectPage(client.chats.search({ + accountIDs, + inbox: flags.inbox as 'primary' | 'low-priority' | 'archive' | undefined, + query: args.query, + scope: flags.scope as 'titles' | 'participants' | undefined, + type: flags.type as 'single' | 'group' | 'any' | undefined, + unreadOnly: flags.unread || undefined, + }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + printData(items, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/clear-draft.ts b/src/commands/clear-draft.ts new file mode 100644 index 0000000..9b17fff --- /dev/null +++ b/src/commands/clear-draft.ts @@ -0,0 +1,25 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class ClearDraft extends Command { + static override summary = 'Clear a chat draft' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ClearDraft) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { draft: null }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/commands.ts b/src/commands/commands.ts new file mode 100644 index 0000000..e3aa0e6 --- /dev/null +++ b/src/commands/commands.ts @@ -0,0 +1,15 @@ +import { Command, Flags } from '@oclif/core' +import { commandManifest } from '../lib/manifest.js' +import { printData } from '../lib/output.js' + +export default class Commands extends Command { + static override summary = 'Print the Beeper CLI command manifest' + static override flags = { + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(Commands) + printData(commandManifest, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/config/get.ts b/src/commands/config/get.ts new file mode 100644 index 0000000..3854332 --- /dev/null +++ b/src/commands/config/get.ts @@ -0,0 +1,20 @@ +import { Args, Command, Flags } from '@oclif/core' +import { readConfig } from '../../lib/config.js' +import { printData } from '../../lib/output.js' + +export default class ConfigGet extends Command { + static override summary = 'Print CLI configuration' + static override args = { + key: Args.string({ description: 'Optional config key to print', options: ['baseURL', 'auth'], required: false }), + } + static override flags = { + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ConfigGet) + const config = await readConfig() + const value = args.key ? config[args.key as 'baseURL' | 'auth'] : config + printData(value, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/config/path.ts b/src/commands/config/path.ts new file mode 100644 index 0000000..e6ce8fd --- /dev/null +++ b/src/commands/config/path.ts @@ -0,0 +1,10 @@ +import { Command } from '@oclif/core' +import { configPath } from '../../lib/config.js' + +export default class ConfigPath extends Command { + static override summary = 'Print the CLI config path' + + async run(): Promise { + this.log(configPath()) + } +} diff --git a/src/commands/config/reset.ts b/src/commands/config/reset.ts new file mode 100644 index 0000000..c795d04 --- /dev/null +++ b/src/commands/config/reset.ts @@ -0,0 +1,11 @@ +import { Command } from '@oclif/core' +import { resetConfig } from '../../lib/config.js' + +export default class ConfigReset extends Command { + static override summary = 'Reset CLI configuration' + + async run(): Promise { + await resetConfig() + this.log('Config reset') + } +} diff --git a/src/commands/config/set.ts b/src/commands/config/set.ts new file mode 100644 index 0000000..f592cd8 --- /dev/null +++ b/src/commands/config/set.ts @@ -0,0 +1,16 @@ +import { Args, Command } from '@oclif/core' +import { updateConfig } from '../../lib/config.js' + +export default class ConfigSet extends Command { + static override summary = 'Set a CLI configuration value' + static override args = { + key: Args.string({ description: 'Config key to set', options: ['baseURL'], required: true }), + value: Args.string({ description: 'Config value', required: true }), + } + + async run(): Promise { + const { args } = await this.parse(ConfigSet) + await updateConfig(config => ({ ...config, [args.key]: args.value })) + this.log(`${args.key}=${args.value}`) + } +} diff --git a/src/commands/contacts/search.ts b/src/commands/contacts/search.ts new file mode 100644 index 0000000..421705a --- /dev/null +++ b/src/commands/contacts/search.ts @@ -0,0 +1,33 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { printData } from '../../lib/output.js' +import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' + +export default class ContactsSearch extends Command { + static override summary = 'Search account contacts' + static override args = { + query: Args.string({ description: 'Contact search query', required: true }), + } + static override flags = { + account: Flags.string({ multiple: true, description: 'Account ID, network, bridge, or account user. Omit to search every account.' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ContactsSearch) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const results = [] + for (const accountID of accountIDs) { + try { + const result = await client.accounts.contacts.search(accountID, { query: args.query }) + results.push(...result.items.map((item: unknown) => ({ ...(item as Record), accountID }))) + } catch { + // Some networks reject exact lookups for some identifiers; keep trying the rest. + } + } + printData(flags.json ? { items: results } : results, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/create-chat.ts b/src/commands/create-chat.ts new file mode 100644 index 0000000..cb5788d --- /dev/null +++ b/src/commands/create-chat.ts @@ -0,0 +1,32 @@ +import { Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveAccountID } from '../lib/resolve.js' + +export default class CreateChat extends Command { + static override summary = 'Create a direct or group chat from participant IDs' + static override flags = { + account: Flags.string({ description: 'Account ID, network, bridge, or account user', required: true }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + message: Flags.string({ description: 'Optional first message' }), + participant: Flags.string({ multiple: true, required: true, description: 'Participant user ID' }), + title: Flags.string({ description: 'Group title' }), + type: Flags.string({ default: 'single', options: ['single', 'group'], description: 'Chat type' }), + } + + async run(): Promise { + const { flags } = await this.parse(CreateChat) + const client = await createClient(flags) + const accountID = await resolveAccountID(client, flags.account) + const result = await client.chats.create({ + accountID, + messageText: flags.message, + participantIDs: flags.participant, + title: flags.title, + type: flags.type as 'single' | 'group', + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/current-user.ts b/src/commands/current-user.ts new file mode 100644 index 0000000..fdf29e8 --- /dev/null +++ b/src/commands/current-user.ts @@ -0,0 +1,24 @@ +import { Command, Flags } from '@oclif/core' +import { getBaseURL } from '../lib/config.js' +import { printData } from '../lib/output.js' +import { requireToken } from '../lib/client.js' + +export default class CurrentUser extends Command { + static override summary = 'Show the authenticated Desktop API user' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(CurrentUser) + const token = await requireToken() + const baseURL = await getBaseURL(flags['base-url']) + const response = await fetch(new URL('/oauth/userinfo', baseURL), { + headers: { Authorization: `Bearer ${token}` }, + }) + const text = await response.text() + if (!response.ok) throw new Error(text || `HTTP ${response.status}`) + printData(JSON.parse(text) as unknown, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/delete-message.ts b/src/commands/delete-message.ts new file mode 100644 index 0000000..2886bf0 --- /dev/null +++ b/src/commands/delete-message.ts @@ -0,0 +1,28 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class DeleteMessage extends Command { + static override summary = 'Delete a message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + 'for-everyone': Flags.boolean({ default: false, description: 'Request deletion for everyone when supported' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(DeleteMessage) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.messages.delete(args.message, { + chatID, + forEveryone: flags['for-everyone'] || undefined, + }) + this.log('Deleted') + } +} diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..457af21 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,46 @@ +import { Command, Flags } from '@oclif/core' +import { createClient, requireToken } from '../lib/client.js' +import { readConfig } from '../lib/config.js' +import { printData } from '../lib/output.js' + +export default class Doctor extends Command { + static override summary = 'Verify Desktop API reachability and authentication' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(Doctor) + const config = await readConfig() + const baseURL = flags['base-url'] ?? config.baseURL + const checks: Array<{ ok: boolean; name: string; detail?: string }> = [] + + try { + const response = await fetch(new URL('/v1/info', baseURL)) + checks.push({ ok: response.ok, name: 'server', detail: `${response.status} ${response.statusText}` }) + } catch (error) { + checks.push({ ok: false, name: 'server', detail: String(error) }) + } + + try { + await requireToken() + checks.push({ ok: true, name: 'token', detail: process.env.BEEPER_ACCESS_TOKEN ? 'env' : 'config' }) + } catch (error) { + checks.push({ ok: false, name: 'token', detail: error instanceof Error ? error.message : String(error) }) + } + + try { + const client = await createClient({ ...flags, baseURL }) + await client.accounts.list() + checks.push({ ok: true, name: 'authenticated-request' }) + } catch (error) { + checks.push({ ok: false, name: 'authenticated-request', detail: error instanceof Error ? error.message : String(error) }) + } + + const result = { ok: checks.every(check => check.ok), checks } + printData(result, flags.json ? 'json' : 'human') + if (!result.ok) this.exit(1) + } +} diff --git a/src/commands/draft.ts b/src/commands/draft.ts new file mode 100644 index 0000000..e8043d1 --- /dev/null +++ b/src/commands/draft.ts @@ -0,0 +1,52 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Draft extends Command { + static override summary = 'Set a chat draft' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + text: Args.string({ description: 'Draft text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + file: Flags.string({ description: 'Draft attachment file' }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Draft) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const upload = flags.file + ? await client.assets.upload({ + file: createReadStream(flags.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + : undefined + const result = await client.chats.update(chatID, { + draft: { + text: args.text, + attachments: upload?.uploadID + ? { + [upload.uploadID]: { + uploadID: upload.uploadID, + duration: upload.duration, + fileName: upload.fileName, + mimeType: upload.mimeType, + size: upload.width && upload.height ? { height: upload.height, width: upload.width } : undefined, + }, + } + : undefined, + }, + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/edit.ts b/src/commands/edit.ts new file mode 100644 index 0000000..6a492ac --- /dev/null +++ b/src/commands/edit.ts @@ -0,0 +1,30 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Edit extends Command { + static override summary = 'Edit a text message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID', required: true }), + text: Args.string({ description: 'New message text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Edit) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.messages.update(args.message, { + chatID, + text: args.text, + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/focus.ts b/src/commands/focus.ts new file mode 100644 index 0000000..23766b2 --- /dev/null +++ b/src/commands/focus.ts @@ -0,0 +1,33 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Focus extends Command { + static override summary = 'Focus Beeper Desktop, optionally opening a chat or message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: false }), + message: Args.string({ description: 'Message ID', required: false }), + } + static override flags = { + attachment: Flags.string({ description: 'Draft attachment path' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + draft: Flags.string({ description: 'Draft text' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Focus) + const client = await createClient(flags) + const chatID = args.chat ? await resolveChatID(client, args.chat, { pick: flags.pick }) : undefined + const result = await client.focus({ + chatID, + draftAttachmentPath: flags.attachment, + draftText: flags.draft, + messageID: args.message, + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/llm.ts b/src/commands/llm.ts new file mode 100644 index 0000000..40caef3 --- /dev/null +++ b/src/commands/llm.ts @@ -0,0 +1,14 @@ +import { Command } from '@oclif/core' +import { commandManifest } from '../lib/manifest.js' + +export default class LLM extends Command { + static override summary = 'Print compact CLI help for agents' + + async run(): Promise { + this.log('Beeper Desktop CLI') + this.log('Auth: beeper auth login') + this.log('Output: most commands accept --json; list commands accept --limit where useful.') + this.log('Common:') + for (const item of commandManifest) this.log(`- beeper ${item.command}: ${item.description}`) + } +} diff --git a/src/commands/mark-read.ts b/src/commands/mark-read.ts new file mode 100644 index 0000000..d41eeb6 --- /dev/null +++ b/src/commands/mark-read.ts @@ -0,0 +1 @@ +export { default } from './read.js' diff --git a/src/commands/mark-unread.ts b/src/commands/mark-unread.ts new file mode 100644 index 0000000..759dba1 --- /dev/null +++ b/src/commands/mark-unread.ts @@ -0,0 +1 @@ +export { default } from './unread.js' diff --git a/src/commands/message.ts b/src/commands/message.ts new file mode 100644 index 0000000..a6579f7 --- /dev/null +++ b/src/commands/message.ts @@ -0,0 +1,26 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Message extends Command { + static override summary = 'Show one message by ID' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Message) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.messages.retrieve(args.message, { chatID }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/messages/index.ts b/src/commands/messages/index.ts new file mode 100644 index 0000000..d851323 --- /dev/null +++ b/src/commands/messages/index.ts @@ -0,0 +1,38 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { collectPage, printData, printIDs } from '../../lib/output.js' +import { resolveChatID } from '../../lib/resolve.js' + +export default class MessagesIndex extends Command { + static override summary = 'List messages in a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + before: Flags.string({ description: 'Fetch messages before cursor' }), + after: Flags.string({ description: 'Fetch messages after cursor' }), + debug: Flags.boolean({ default: false }), + ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(MessagesIndex) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const cursor = flags.before ?? flags.after + const direction = flags.after ? 'after' : flags.before ? 'before' : undefined + const items = await collectPage(client.messages.list(chatID, { + cursor, + direction, + }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + printData(items, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/messages/search.ts b/src/commands/messages/search.ts new file mode 100644 index 0000000..4f49f9d --- /dev/null +++ b/src/commands/messages/search.ts @@ -0,0 +1,41 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../../lib/client.js' +import { collectPage, printData, printIDs } from '../../lib/output.js' +import { resolveAccountIDs, resolveChatID } from '../../lib/resolve.js' + +export default class MessagesSearch extends Command { + static override summary = 'Search messages' + static override args = { + query: Args.string({ description: 'Literal message search query', required: false }), + } + static override flags = { + account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + chat: Flags.string({ multiple: true, description: 'Limit to chat ID' }), + debug: Flags.boolean({ default: false }), + ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), + sender: Flags.string({ description: 'me, others, or a user ID' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(MessagesSearch) + 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 }))) + : undefined + const items = await collectPage(client.messages.search({ + accountIDs, + chatIDs, + query: args.query, + sender: flags.sender as 'me' | 'others' | (string & {}) | undefined, + }), flags.limit) + if (flags.ids) { + printIDs(items) + return + } + printData(items, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/mute.ts b/src/commands/mute.ts new file mode 100644 index 0000000..cfc94bc --- /dev/null +++ b/src/commands/mute.ts @@ -0,0 +1,25 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Mute extends Command { + static override summary = 'Mute a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Mute) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { isMuted: true }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/notify-anyway.ts b/src/commands/notify-anyway.ts new file mode 100644 index 0000000..88d95af --- /dev/null +++ b/src/commands/notify-anyway.ts @@ -0,0 +1,25 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class NotifyAnyway extends Command { + static override summary = 'Trigger notify-anyway for a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(NotifyAnyway) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.notifyAnyway(chatID) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/react.ts b/src/commands/react.ts new file mode 100644 index 0000000..4f2b465 --- /dev/null +++ b/src/commands/react.ts @@ -0,0 +1,32 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class React extends Command { + static override summary = 'Add a reaction to a message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID', required: true }), + reaction: Args.string({ description: 'Reaction key, emoji, or shortcode', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + transaction: Flags.string({ description: 'Optional transaction ID' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(React) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.messages.reactions.add(args.message, { + chatID, + reactionKey: args.reaction, + transactionID: flags.transaction, + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/read.ts b/src/commands/read.ts new file mode 100644 index 0000000..d40fcac --- /dev/null +++ b/src/commands/read.ts @@ -0,0 +1,26 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Read extends Command { + static override summary = 'Mark a chat as read' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + message: Flags.string({ description: 'Mark read through this message ID' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Read) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.markRead(chatID, { messageID: flags.message }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/remind.ts b/src/commands/remind.ts new file mode 100644 index 0000000..46708ca --- /dev/null +++ b/src/commands/remind.ts @@ -0,0 +1,30 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Remind extends Command { + static override summary = 'Set a chat reminder' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + when: Args.string({ description: 'ISO timestamp for the reminder', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + 'dismiss-on-message': Flags.boolean({ default: false, description: 'Cancel if someone messages in the chat' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Remind) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.reminders.create(chatID, { + reminder: { + dismissOnIncomingMessage: flags['dismiss-on-message'] || undefined, + remindAt: args.when, + }, + }) + this.log('Reminder set') + } +} diff --git a/src/commands/reply-file.ts b/src/commands/reply-file.ts new file mode 100644 index 0000000..b781eb3 --- /dev/null +++ b/src/commands/reply-file.ts @@ -0,0 +1,60 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' +import { waitForMessage } from '../lib/wait.js' + +export default class ReplyFile extends Command { + static override summary = 'Reply to a message with a file attachment' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID to reply to', required: true }), + file: Args.string({ description: 'File attachment to upload and send', required: true }), + text: Args.string({ description: 'Optional reply text', required: false }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(ReplyFile) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const attachment = await client.assets.upload({ + file: createReadStream(args.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + if (!attachment.uploadID) throw new Error('Upload did not return an uploadID') + const uploadID = attachment.uploadID + const result = await client.messages.send(chatID, { + attachment: { + uploadID, + duration: attachment.duration, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + size: attachment.width && attachment.height ? { height: attachment.height, width: attachment.width } : undefined, + }, + replyToMessageID: args.message, + text: args.text || '', + }) + if (flags.wait) { + const resolved = await waitForMessage(client, chatID, result.pendingMessageID, { + intervalMs: flags['wait-interval'], + timeoutMs: flags['wait-timeout'], + }) + printData(resolved, flags.json ? 'json' : 'human') + return + } + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/reply.ts b/src/commands/reply.ts new file mode 100644 index 0000000..b721545 --- /dev/null +++ b/src/commands/reply.ts @@ -0,0 +1,62 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' +import { waitForMessage } from '../lib/wait.js' + +export default class Reply extends Command { + static override summary = 'Reply to a message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID to reply to', required: true }), + text: Args.string({ description: 'Reply text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + file: Flags.string({ description: 'File attachment to upload and send' }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Reply) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const attachment = flags.file + ? await client.assets.upload({ + file: createReadStream(flags.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + : undefined + const result = await client.messages.send(chatID, { + attachment: attachment?.uploadID + ? { + uploadID: attachment.uploadID, + duration: attachment.duration, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + size: attachment.width && attachment.height ? { height: attachment.height, width: attachment.width } : undefined, + } + : undefined, + replyToMessageID: args.message, + text: args.text, + }) + if (flags.wait) { + const resolved = await waitForMessage(client, chatID, result.pendingMessageID, { + intervalMs: flags['wait-interval'], + timeoutMs: flags['wait-timeout'], + }) + printData(resolved, flags.json ? 'json' : 'human') + return + } + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/rpc.ts b/src/commands/rpc.ts new file mode 100644 index 0000000..be899a5 --- /dev/null +++ b/src/commands/rpc.ts @@ -0,0 +1,52 @@ +import { Command } from '@oclif/core' +import { createInterface } from 'node:readline/promises' +import { stdin as input } from 'node:process' +import { splitCommandLine } from '../lib/argv.js' +import { runCli } from '../lib/runner.js' + +type RPCRequest = { + args?: string[] + argv?: string[] + command?: string + id?: string | number | null +} + +export default class RPC extends Command { + static override summary = 'Run newline-delimited JSON command RPC' + static override description = 'Reads JSON lines like {"id":1,"command":"send CHAT hello"} or {"id":1,"args":["status","--json"]}.' + + async run(): Promise { + const rl = createInterface({ input }) + + for await (const line of rl) { + if (!line.trim()) continue + + try { + const request = JSON.parse(line) as RPCRequest + const args = normalizeArgs(request) + if (args[0] === 'rpc' || args[0] === 'shell') throw new Error(`Unsupported nested command: ${args[0]}`) + const result = await runCli(args) + process.stdout.write(`${JSON.stringify({ + id: request.id ?? null, + ok: result.code === 0, + code: result.code, + signal: result.signal, + stdout: result.stdout, + stderr: result.stderr, + })}\n`) + } catch (error) { + process.stdout.write(`${JSON.stringify({ + id: null, + ok: false, + error: error instanceof Error ? error.message : String(error), + })}\n`) + } + } + } +} + +function normalizeArgs(request: RPCRequest): string[] { + const args = request.args ?? request.argv ?? (request.command ? splitCommandLine(request.command) : undefined) + if (!args || args.length === 0) throw new Error('Expected args, argv, or command') + return args +} diff --git a/src/commands/search.ts b/src/commands/search.ts new file mode 100644 index 0000000..4d80369 --- /dev/null +++ b/src/commands/search.ts @@ -0,0 +1,22 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' + +export default class Search extends Command { + static override summary = 'Search chats and messages' + static override args = { + query: Args.string({ description: 'Literal search query', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Search) + const client = await createClient(flags) + const result = await client.search({ query: args.query }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/send-file.ts b/src/commands/send-file.ts new file mode 100644 index 0000000..fba73eb --- /dev/null +++ b/src/commands/send-file.ts @@ -0,0 +1,60 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' +import { waitForMessage } from '../lib/wait.js' + +export default class SendFile extends Command { + static override summary = 'Send a file attachment to a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + file: Args.string({ description: 'File attachment to upload and send', required: true }), + text: Args.string({ description: 'Optional message text', required: false }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + 'reply-to': Flags.string({ description: 'Reply to message ID' }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(SendFile) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const attachment = await client.assets.upload({ + file: createReadStream(args.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + if (!attachment.uploadID) throw new Error('Upload did not return an uploadID') + const uploadID = attachment.uploadID + const result = await client.messages.send(chatID, { + attachment: { + uploadID, + duration: attachment.duration, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + size: attachment.width && attachment.height ? { height: attachment.height, width: attachment.width } : undefined, + }, + replyToMessageID: flags['reply-to'], + text: args.text || '', + }) + if (flags.wait) { + const resolved = await waitForMessage(client, chatID, result.pendingMessageID, { + intervalMs: flags['wait-interval'], + timeoutMs: flags['wait-timeout'], + }) + printData(resolved, flags.json ? 'json' : 'human') + return + } + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/send.ts b/src/commands/send.ts new file mode 100644 index 0000000..cfb9305 --- /dev/null +++ b/src/commands/send.ts @@ -0,0 +1,62 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createReadStream } from 'node:fs' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' +import { waitForMessage } from '../lib/wait.js' + +export default class Send extends Command { + static override summary = 'Send a text message to a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + text: Args.string({ description: 'Message text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + file: Flags.string({ description: 'File attachment to upload and send' }), + 'file-name': Flags.string({ description: 'Attachment display filename' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + 'mime-type': Flags.string({ description: 'Attachment MIME type' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + 'reply-to': Flags.string({ description: 'Reply to message ID' }), + wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), + 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), + 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Send) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const attachment = flags.file + ? await client.assets.upload({ + file: createReadStream(flags.file), + fileName: flags['file-name'], + mimeType: flags['mime-type'], + }) + : undefined + const result = await client.messages.send(chatID, { + attachment: attachment?.uploadID + ? { + uploadID: attachment.uploadID, + duration: attachment.duration, + fileName: attachment.fileName, + mimeType: attachment.mimeType, + size: attachment.width && attachment.height ? { height: attachment.height, width: attachment.width } : undefined, + } + : undefined, + replyToMessageID: flags['reply-to'], + text: args.text, + }) + if (flags.wait) { + const resolved = await waitForMessage(client, chatID, result.pendingMessageID, { + intervalMs: flags['wait-interval'], + timeoutMs: flags['wait-timeout'], + }) + printData(resolved, flags.json ? 'json' : 'human') + return + } + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/shell.ts b/src/commands/shell.ts new file mode 100644 index 0000000..7238092 --- /dev/null +++ b/src/commands/shell.ts @@ -0,0 +1,40 @@ +import { Command } from '@oclif/core' +import { createInterface } from 'node:readline/promises' +import { stdin as input, stdout as output } from 'node:process' +import { splitCommandLine } from '../lib/argv.js' +import { runCli } from '../lib/runner.js' + +export default class Shell extends Command { + static override summary = 'Run an interactive Beeper CLI shell' + + async run(): Promise { + const rl = createInterface({ input, output, prompt: 'beeper> ' }) + let closed = false + rl.on('close', () => { + closed = true + }) + const interactive = Boolean(input.isTTY && output.isTTY) + if (interactive) rl.prompt() + + for await (const line of rl) { + const trimmed = line.trim() + if (!trimmed) { + if (interactive && !closed) rl.prompt() + continue + } + if (trimmed === 'exit' || trimmed === 'quit') break + + try { + const args = splitCommandLine(trimmed) + if (args[0] === 'shell') throw new Error('Nested shell is not supported') + await runCli(args, { inherit: true }) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), { exit: false }) + } + + if (interactive && !closed) rl.prompt() + } + + if (!closed) rl.close() + } +} diff --git a/src/commands/start-chat.ts b/src/commands/start-chat.ts new file mode 100644 index 0000000..fe21828 --- /dev/null +++ b/src/commands/start-chat.ts @@ -0,0 +1,55 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { listAccountIDs, resolveAccountIDs, userQueryFromInput } from '../lib/resolve.js' + +export default class StartChat extends Command { + static override summary = 'Resolve a contact and open or create a direct chat' + static override args = { + query: Args.string({ description: 'Phone, email, username, user ID, or name', required: false }), + } + static override flags = { + account: Flags.string({ multiple: true, description: 'Account ID, network, bridge, or account user. Omit to try every account.' }), + 'allow-invite': Flags.boolean({ default: false, description: 'Allow invite-based DM creation when required' }), + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + email: Flags.string({ description: 'Email address' }), + id: Flags.string({ description: 'Known user ID' }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + message: Flags.string({ description: 'Optional first message' }), + name: Flags.string({ description: 'Display name hint' }), + phone: Flags.string({ description: 'Phone number' }), + username: Flags.string({ description: 'Username' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(StartChat) + const client = await createClient(flags) + const accountIDs = await resolveAccountIDs(client, flags.account, { allowMultiplePerInput: true }) ?? await listAccountIDs(client) + const user: Record = args.query ? userQueryFromInput(args.query) : {} + if (flags.email) user.email = flags.email + if (flags.name) user.fullName = flags.name + if (flags.id) user.id = flags.id + if (flags.phone) user.phoneNumber = flags.phone + if (flags.username) user.username = flags.username + const result = await tryStartChat(client, accountIDs, { + allowInvite: flags['allow-invite'] || undefined, + messageText: flags.message, + user, + }) + printData(result, flags.json ? 'json' : 'human') + } +} + +async function tryStartChat(client: any, accountIDs: string[], body: Record) { + const failures: string[] = [] + for (const accountID of accountIDs) { + try { + return await client.chats.start({ ...body, accountID }) + } catch (error) { + failures.push(`${accountID}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + throw new Error(`No account could start this chat:\n${failures.map(failure => ` - ${failure}`).join('\n')}`) +} diff --git a/src/commands/status.ts b/src/commands/status.ts new file mode 100644 index 0000000..4f55685 --- /dev/null +++ b/src/commands/status.ts @@ -0,0 +1,22 @@ +import { Command, Flags } from '@oclif/core' +import { readConfig } from '../lib/config.js' +import { printData } from '../lib/output.js' + +export default class Status extends Command { + static override summary = 'Check Beeper Desktop API status' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + } + + async run(): Promise { + const { flags } = await this.parse(Status) + const config = await readConfig() + const baseURL = flags['base-url'] ?? config.baseURL + const response = await fetch(new URL('/v1/info', baseURL)) + if (!response.ok) throw new Error(`Beeper Desktop API returned ${response.status} ${response.statusText}`) + const info = await response.json() + printData(info, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/tail.ts b/src/commands/tail.ts new file mode 100644 index 0000000..7218830 --- /dev/null +++ b/src/commands/tail.ts @@ -0,0 +1 @@ +export { default } from './watch.js' diff --git a/src/commands/thread.ts b/src/commands/thread.ts new file mode 100644 index 0000000..691c701 --- /dev/null +++ b/src/commands/thread.ts @@ -0,0 +1 @@ +export { default } from './chat.js' diff --git a/src/commands/threads.ts b/src/commands/threads.ts new file mode 100644 index 0000000..0d76408 --- /dev/null +++ b/src/commands/threads.ts @@ -0,0 +1 @@ +export { default } from './chats/index.js' diff --git a/src/commands/unarchive.ts b/src/commands/unarchive.ts new file mode 100644 index 0000000..955c47c --- /dev/null +++ b/src/commands/unarchive.ts @@ -0,0 +1,23 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unarchive extends Command { + static override summary = 'Unarchive a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Unarchive) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.archive(chatID, { archived: false }) + this.log('Unarchived') + } +} diff --git a/src/commands/unmute.ts b/src/commands/unmute.ts new file mode 100644 index 0000000..534b854 --- /dev/null +++ b/src/commands/unmute.ts @@ -0,0 +1,25 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unmute extends Command { + static override summary = 'Unmute a chat' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Unmute) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.update(chatID, { isMuted: false }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/unreact.ts b/src/commands/unreact.ts new file mode 100644 index 0000000..56aae35 --- /dev/null +++ b/src/commands/unreact.ts @@ -0,0 +1,30 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unreact extends Command { + static override summary = 'Remove your reaction from a message' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + message: Args.string({ description: 'Message ID', required: true }), + reaction: Args.string({ description: 'Reaction key, emoji, or shortcode', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Unreact) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.messages.reactions.delete(args.reaction, { + chatID, + messageID: args.message, + }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/unread.ts b/src/commands/unread.ts new file mode 100644 index 0000000..0be96c8 --- /dev/null +++ b/src/commands/unread.ts @@ -0,0 +1,26 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { printData } from '../lib/output.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unread extends Command { + static override summary = 'Mark a chat as unread' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + json: Flags.boolean({ default: false, description: 'Print JSON' }), + message: Flags.string({ description: 'Mark unread from this message ID' }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Unread) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + const result = await client.chats.markUnread(chatID, { messageID: flags.message }) + printData(result, flags.json ? 'json' : 'human') + } +} diff --git a/src/commands/unremind.ts b/src/commands/unremind.ts new file mode 100644 index 0000000..256dc04 --- /dev/null +++ b/src/commands/unremind.ts @@ -0,0 +1,23 @@ +import { Args, Command, Flags } from '@oclif/core' +import { createClient } from '../lib/client.js' +import { resolveChatID } from '../lib/resolve.js' + +export default class Unremind extends Command { + static override summary = 'Clear a chat reminder' + static override args = { + chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + } + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + debug: Flags.boolean({ default: false }), + pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + } + + async run(): Promise { + const { args, flags } = await this.parse(Unremind) + const client = await createClient(flags) + const chatID = await resolveChatID(client, args.chat, { pick: flags.pick }) + await client.chats.reminders.delete(chatID) + this.log('Reminder cleared') + } +} diff --git a/src/commands/watch.ts b/src/commands/watch.ts new file mode 100644 index 0000000..f38134e --- /dev/null +++ b/src/commands/watch.ts @@ -0,0 +1,65 @@ +import { Command, Flags } from '@oclif/core' +import { requireToken } from '../lib/client.js' +import { getBaseURL } from '../lib/config.js' + +export default class Watch extends Command { + static override summary = 'Stream Desktop API WebSocket events' + static override flags = { + 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 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' }), + } + + async run(): Promise { + const { flags } = await this.parse(Watch) + const token = await requireToken() + const baseURL = await getBaseURL(flags['base-url']) + const info = await fetch(new URL('/v1/info', baseURL)) + if (!info.ok) throw new Error(`Failed to fetch /v1/info: HTTP ${info.status}`) + const metadata = await info.json() as { endpoints?: { ws_events?: string } } + const endpoint = metadata.endpoints?.ws_events || '/v1/ws' + const url = new URL(endpoint, baseURL) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + + const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } } as unknown as string[]) + const chatIDs = flags.chat?.length ? flags.chat : ['*'] + + ws.addEventListener('open', () => { + ws.send(JSON.stringify({ type: 'subscriptions.set', chatIDs })) + }) + + ws.addEventListener('message', event => { + const data = typeof event.data === 'string' ? event.data : event.data.toString() + if (flags.json) { + this.log(data) + return + } + + try { + const parsed = JSON.parse(data) as Record + const type = parsed.type ? String(parsed.type) : 'event' + const chatID = parsed.chatID ? ` ${String(parsed.chatID)}` : '' + const messageID = parsed.messageID ? ` ${String(parsed.messageID)}` : '' + this.log(`${type}${chatID}${messageID}`) + } catch { + this.log(data) + } + }) + + ws.addEventListener('error', () => { + this.error('WebSocket connection failed', { exit: 1 }) + }) + + ws.addEventListener('close', event => { + if (event.code !== 1000) this.error(`WebSocket closed: ${event.code} ${event.reason}`, { exit: 1 }) + }) + + await new Promise(resolve => { + process.once('SIGINT', () => { + ws.close(1000) + resolve() + }) + ws.addEventListener('close', () => resolve()) + }) + } +} diff --git a/src/commands/whoami.ts b/src/commands/whoami.ts new file mode 100644 index 0000000..f7962f8 --- /dev/null +++ b/src/commands/whoami.ts @@ -0,0 +1 @@ +export { default } from './current-user.js' diff --git a/src/lib/argv.ts b/src/lib/argv.ts new file mode 100644 index 0000000..aeac915 --- /dev/null +++ b/src/lib/argv.ts @@ -0,0 +1,39 @@ +export function splitCommandLine(input: string): string[] { + const tokens: string[] = [] + let current = '' + let quote: '"' | "'" | undefined + let escaped = false + + for (const char of input) { + if (escaped) { + current += char + escaped = false + continue + } + + if (char === '\\' && quote !== "'") { + escaped = true + continue + } + + if ((char === '"' || char === "'") && (!quote || quote === char)) { + quote = quote ? undefined : char + continue + } + + if (!quote && /\s/.test(char)) { + if (current) { + tokens.push(current) + current = '' + } + continue + } + + current += char + } + + if (escaped) current += '\\' + if (quote) throw new Error(`Unclosed ${quote} quote`) + if (current) tokens.push(current) + return tokens +} diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..b103db0 --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,20 @@ +import BeeperDesktop from '@beeper/desktop-api' +import { getAccessToken, readConfig } from './config.js' + +export async function createClient(flags: { baseURL?: string; 'base-url'?: string; debug?: boolean } = {}) { + const config = await readConfig() + const accessToken = await requireToken() + return new BeeperDesktop({ + accessToken, + baseURL: flags.baseURL || flags['base-url'] || config.baseURL, + logLevel: flags.debug ? 'debug' : 'warn', + }) +} + +export async function requireToken(): Promise { + const token = await getAccessToken() + if (!token) { + throw new Error('Not authenticated. Run `beeper auth login` or set BEEPER_ACCESS_TOKEN.') + } + return token +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..0ec4127 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,58 @@ +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' + +export type StoredAuth = { + accessToken: string + clientID?: string + expiresAt?: string + scope?: string + tokenType: 'Bearer' +} + +export type Config = { + auth?: StoredAuth + baseURL: string +} + +const defaultBaseURL = 'http://localhost:23373' + +export const configPath = () => + join(process.env.BEEPER_CLI_CONFIG_DIR ?? join(homedir(), '.config', 'beeper'), 'config.json') + +export async function readConfig(): Promise { + try { + const raw = await readFile(configPath(), 'utf8') + const parsed = JSON.parse(raw) as Partial + return { + baseURL: parsed.baseURL || process.env.BEEPER_DESKTOP_BASE_URL || process.env.BEEPER_BASE_URL || defaultBaseURL, + auth: parsed.auth, + } + } catch { + return { baseURL: process.env.BEEPER_DESKTOP_BASE_URL || process.env.BEEPER_BASE_URL || defaultBaseURL } + } +} + +export async function writeConfig(config: Config): Promise { + const file = configPath() + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }) +} + +export async function updateConfig(update: (config: Config) => Config | Promise): Promise { + const next = await update(await readConfig()) + await writeConfig(next) + return next +} + +export async function resetConfig(): Promise { + await rm(configPath(), { force: true }) +} + +export async function getAccessToken(): Promise { + return process.env.BEEPER_ACCESS_TOKEN || (await readConfig()).auth?.accessToken +} + +export async function getBaseURL(override?: string): Promise { + return override || (await readConfig()).baseURL +} diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts new file mode 100644 index 0000000..b7424e1 --- /dev/null +++ b/src/lib/manifest.ts @@ -0,0 +1,59 @@ +export const commandManifest = [ + { command: 'accounts', description: 'List connected accounts' }, + { command: 'api get', description: 'Make an authenticated raw GET request' }, + { command: 'api post', description: 'Make an authenticated raw POST request' }, + { command: 'archive', description: 'Archive a chat' }, + { command: 'assets download', description: 'Download an asset' }, + { command: 'assets upload', description: 'Upload an asset' }, + { command: 'auth login', description: 'Authenticate with local Beeper Desktop' }, + { command: 'auth logout', description: 'Remove local credentials' }, + { command: 'auth status', description: 'Show local auth state' }, + { command: 'chat', description: 'Show one chat' }, + { command: 'chat open', description: 'Alias for focus' }, + { command: 'chats', description: 'List chats' }, + { command: 'chats search', description: 'Search chats' }, + { command: 'clear-draft', description: 'Clear a chat draft' }, + { command: 'commands', description: 'Print the command manifest' }, + { command: 'config get', description: 'Print CLI configuration' }, + { command: 'config path', description: 'Print the config file path' }, + { command: 'config reset', description: 'Reset CLI configuration' }, + { command: 'config set', description: 'Set CLI configuration' }, + { command: 'contacts search', description: 'Search contacts' }, + { command: 'create-chat', description: 'Create a chat' }, + { command: 'current-user', description: 'Show the OAuth userinfo response' }, + { command: 'delete-message', description: 'Delete a message' }, + { command: 'doctor', description: 'Check Desktop API readiness' }, + { command: 'draft', description: 'Set a chat draft' }, + { command: 'edit', description: 'Edit a message' }, + { command: 'focus', description: 'Focus Beeper Desktop or one chat' }, + { command: 'llm', description: 'Print compact CLI help for agents' }, + { command: 'message', description: 'Show one message' }, + { command: 'mark-read', description: 'Alias for read' }, + { command: 'mark-unread', description: 'Alias for unread' }, + { command: 'messages', description: 'List messages in a chat' }, + { command: 'messages search', description: 'Search messages' }, + { command: 'mute', description: 'Mute a chat' }, + { command: 'notify-anyway', description: 'Notify anyway for a chat' }, + { command: 'react', description: 'Add a reaction' }, + { command: 'read', description: 'Mark a chat read' }, + { command: 'remind', description: 'Set a chat reminder' }, + { command: 'reply', description: 'Reply to a message' }, + { command: 'reply-file', description: 'Reply to a message with a file attachment' }, + { command: 'rpc', description: 'Run JSONL command RPC over stdin/stdout' }, + { command: 'search', description: 'Search chats and messages' }, + { command: 'send', description: 'Send a message' }, + { command: 'send-file', description: 'Send a file attachment' }, + { command: 'shell', description: 'Run an interactive Beeper CLI shell' }, + { command: 'start-chat', description: 'Start a chat with participants' }, + { command: 'status', description: 'Show Desktop API server info' }, + { command: 'tail', description: 'Alias for watch' }, + { command: 'thread', description: 'Alias for chat' }, + { command: 'threads', description: 'Alias for chats' }, + { command: 'unarchive', description: 'Unarchive a chat' }, + { command: 'unmute', description: 'Unmute a chat' }, + { command: 'unreact', description: 'Remove a reaction' }, + { command: 'unread', description: 'Mark a chat unread' }, + { command: 'unremind', description: 'Clear a chat reminder' }, + { command: 'watch', description: 'Stream Desktop API WebSocket events' }, + { command: 'whoami', description: 'Alias for current-user' }, +] diff --git a/src/lib/oauth.ts b/src/lib/oauth.ts new file mode 100644 index 0000000..8cdd17e --- /dev/null +++ b/src/lib/oauth.ts @@ -0,0 +1,163 @@ +import { createServer } from 'node:http' +import { AddressInfo } from 'node:net' +import { spawn } from 'node:child_process' +import { createPKCEPair, createState } from './pkce.js' +import { updateConfig } from './config.js' + +export type OAuthLoginOptions = { + baseURL: string + clientName: string + openBrowser: boolean + scope: string + timeoutMs?: number +} + +type RegisterResponse = { + authorization_endpoint?: string + client_id: string + token_endpoint?: string +} + +type TokenResponse = { + access_token: string + expires_in?: number + scope?: string + token_type: 'Bearer' +} + +export async function loginWithPKCE(options: OAuthLoginOptions): Promise { + const callback = await createCallbackServer(options.timeoutMs ?? 120_000) + try { + const redirectURI = `http://127.0.0.1:${callback.port}/callback` + const registered = await registerClient(options.baseURL, options.clientName, redirectURI, options.scope) + const pkce = createPKCEPair() + const state = createState() + const authorizeURL = new URL(registered.authorization_endpoint ?? '/oauth/authorize', options.baseURL) + authorizeURL.searchParams.set('client_id', registered.client_id) + authorizeURL.searchParams.set('redirect_uri', redirectURI) + authorizeURL.searchParams.set('response_type', 'code') + authorizeURL.searchParams.set('scope', options.scope) + authorizeURL.searchParams.set('state', state) + authorizeURL.searchParams.set('code_challenge', pkce.codeChallenge) + authorizeURL.searchParams.set('code_challenge_method', 'S256') + + if (options.openBrowser) openExternal(authorizeURL.toString()) + else process.stderr.write(`Open this URL to authenticate:\n${authorizeURL.toString()}\n`) + + const result = await callback.waitForCode + if (result.state !== state) throw new Error('OAuth state mismatch.') + if (result.error) throw new Error(`OAuth authorization failed: ${result.error}`) + if (!result.code) throw new Error('OAuth callback did not include an authorization code.') + + const token = await exchangeToken( + registered.token_endpoint ?? new URL('/oauth/token', options.baseURL).toString(), + registered.client_id, + result.code, + pkce.codeVerifier, + ) + + await updateConfig(config => ({ + ...config, + baseURL: options.baseURL, + auth: { + accessToken: token.access_token, + clientID: registered.client_id, + expiresAt: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : undefined, + scope: token.scope, + tokenType: token.token_type, + }, + })) + + return { ...token, clientID: registered.client_id } + } finally { + await callback.close() + } +} + +async function registerClient(baseURL: string, clientName: string, redirectURI: string, scope: string): Promise { + const response = await fetch(new URL('/oauth/register', baseURL), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: clientName, + grant_types: ['authorization_code'], + response_types: ['code'], + redirect_uris: [redirectURI], + scope, + token_endpoint_auth_method: 'none', + }), + }) + if (!response.ok) throw new Error(`OAuth client registration failed: ${response.status} ${await response.text()}`) + return response.json() as Promise +} + +async function exchangeToken(tokenEndpoint: string, clientID: string, code: string, codeVerifier: string): Promise { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: clientID, + code, + code_verifier: codeVerifier, + }) + const response = await fetch(tokenEndpoint, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body, + }) + if (!response.ok) throw new Error(`OAuth token exchange failed: ${response.status} ${await response.text()}`) + return response.json() as Promise +} + +function openExternal(url: string): void { + const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open' + const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url] + const child = spawn(command, args, { detached: true, stdio: 'ignore' }) + child.unref() +} + +function createCallbackServer(timeoutMs: number): Promise<{ + close: () => Promise + port: number + waitForCode: Promise<{ code?: string; error?: string; state?: string }> +}> { + return new Promise((resolve, reject) => { + let settle!: (value: { code?: string; error?: string; state?: string }) => void + let fail!: (error: Error) => void + const waitForCode = new Promise<{ code?: string; error?: string; state?: string }>((res, rej) => { + settle = res + fail = rej + }) + + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? '/', 'http://127.0.0.1') + if (url.pathname !== '/callback') { + res.writeHead(404).end('Not found') + return + } + const code = url.searchParams.get('code') ?? undefined + const error = url.searchParams.get('error') ?? undefined + const state = url.searchParams.get('state') ?? undefined + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('Beeper CLI

You can close this tab and return to the terminal.

') + settle({ code, error, state }) + } catch (error) { + fail(error instanceof Error ? error : new Error(String(error))) + } + }) + + const timeout = setTimeout(() => fail(new Error('Timed out waiting for OAuth callback.')), timeoutMs) + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() as AddressInfo + resolve({ + close: () => + new Promise(closeResolve => { + clearTimeout(timeout) + server.close(() => closeResolve()) + }), + port: address.port, + waitForCode, + }) + }) + }) +} diff --git a/src/lib/output.ts b/src/lib/output.ts new file mode 100644 index 0000000..f7fb8c4 --- /dev/null +++ b/src/lib/output.ts @@ -0,0 +1,58 @@ +export type OutputFormat = 'human' | 'json' | 'jsonl' + +export function printData(value: unknown, format: OutputFormat): void { + if (format === 'json') { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`) + return + } + + if (format === 'jsonl') { + if (Array.isArray(value)) { + for (const item of value) process.stdout.write(`${JSON.stringify(item)}\n`) + return + } + process.stdout.write(`${JSON.stringify(value)}\n`) + return + } + + if (Array.isArray(value)) { + for (const item of value) printHuman(item) + return + } + + printHuman(value) +} + +function printHuman(value: unknown): void { + if (!value || typeof value !== 'object') { + process.stdout.write(`${String(value)}\n`) + return + } + + const record = value as Record + const title = record.title ?? record.displayName ?? record.name ?? record.id ?? record.messageID + if (title) process.stdout.write(`${String(title)}\n`) + for (const [key, item] of Object.entries(record)) { + if (item == null || key === 'title' || key === 'displayName' || key === 'name') continue + if (typeof item === 'object') continue + process.stdout.write(` ${key}: ${String(item)}\n`) + } +} + +export async function collectPage(iterable: AsyncIterable, limit?: number): Promise { + const items: T[] = [] + for await (const item of iterable) { + items.push(item) + if (limit && items.length >= limit) break + } + return items +} + +export function printIDs(values: unknown[]): void { + for (const value of values) { + if (!value || typeof value !== 'object') continue + const record = value as Record + const id = record.id ?? record.chatID ?? record.messageID + if (id) process.stdout.write(`${String(id)}\n`) + } +} diff --git a/src/lib/pkce.ts b/src/lib/pkce.ts new file mode 100644 index 0000000..34cac24 --- /dev/null +++ b/src/lib/pkce.ts @@ -0,0 +1,16 @@ +import { createHash, randomBytes } from 'node:crypto' + +export type PKCEPair = { + codeChallenge: string + codeVerifier: string +} + +export function createPKCEPair(): PKCEPair { + const codeVerifier = randomBytes(64).toString('base64url') + const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url') + return { codeChallenge, codeVerifier } +} + +export function createState(): string { + return randomBytes(24).toString('base64url') +} diff --git a/src/lib/resolve.ts b/src/lib/resolve.ts new file mode 100644 index 0000000..a53102a --- /dev/null +++ b/src/lib/resolve.ts @@ -0,0 +1,144 @@ +type AnyRecord = Record + +export type AccountResolutionOptions = { + allowMultiplePerInput?: boolean +} + +export type ChatResolutionOptions = { + accountIDs?: string[] + pick?: number +} + +export async function resolveAccountIDs( + client: any, + inputs?: string[], + options: AccountResolutionOptions = {}, +): Promise { + if (!inputs?.length) return undefined + + const accounts = await client.accounts.list() as AnyRecord[] + const resolved: string[] = [] + for (const input of inputs) { + const matches = matchAccounts(accounts, input) + if (matches.length === 0) throw new Error(`No account matches "${input}"`) + if (matches.length > 1 && !options.allowMultiplePerInput) { + throw new Error(formatAmbiguous(`account "${input}"`, matches.map(formatAccount))) + } + resolved.push(...matches.map(account => String(account.accountID))) + } + + return Array.from(new Set(resolved)) +} + +export async function resolveAccountID(client: any, input: string): Promise { + const [accountID] = await resolveAccountIDs(client, [input]) ?? [] + if (!accountID) throw new Error(`No account matches "${input}"`) + return accountID +} + +export async function listAccountIDs(client: any): Promise { + const accounts = await client.accounts.list() as AnyRecord[] + return accounts.map(account => String(account.accountID)).filter(Boolean) +} + +export async function resolveChatID(client: any, input: string, options: ChatResolutionOptions = {}): Promise { + const exact = await retrieveChat(client, input) + if (exact) return exact.id + + const candidates = await collect(client.chats.search({ + accountIDs: options.accountIDs, + query: input, + scope: 'titles', + }), 10) + + const normalizedInput = normalize(input) + const exactMatches = candidates.filter(chat => + normalize(chat.id) === normalizedInput || + normalize(chat.localChatID) === normalizedInput || + normalize(chat.title) === normalizedInput + ) + const matches = exactMatches.length ? exactMatches : candidates + if (matches.length === 0) return input + if (matches.length === 1) return String(matches[0]!.id) + + if (options.pick) { + const selected = matches[options.pick - 1] + if (!selected) throw new Error(`--pick ${options.pick} is outside the ${matches.length} matching chats`) + return String(selected.id) + } + + throw new Error(formatAmbiguous(`chat "${input}"`, matches.map(formatChat))) +} + +function matchAccounts(accounts: AnyRecord[], input: string): AnyRecord[] { + const normalizedInput = normalize(input) + const exact = accounts.filter(account => + normalize(account.accountID) === normalizedInput || + normalize(account.network) === normalizedInput || + normalize(account.bridge?.type) === normalizedInput || + normalize(account.bridge?.id) === normalizedInput || + normalize(account.user?.id) === normalizedInput || + normalize(account.user?.username) === normalizedInput || + normalize(account.user?.displayName) === normalizedInput || + normalize(account.user?.name) === normalizedInput || + normalize(account.user?.email) === normalizedInput + ) + if (exact.length) return exact + + return accounts.filter(account => + includesNormalized(account.accountID, normalizedInput) || + includesNormalized(account.network, normalizedInput) || + includesNormalized(account.bridge?.type, normalizedInput) || + includesNormalized(account.bridge?.id, normalizedInput) || + includesNormalized(account.user?.displayName, normalizedInput) || + includesNormalized(account.user?.name, normalizedInput) + ) +} + +async function retrieveChat(client: any, input: string): Promise { + try { + return await client.chats.retrieve(input, { maxParticipants: 0 }) + } catch { + return undefined + } +} + +async function collect(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, '') +} + +function includesNormalized(value: unknown, normalizedInput: string): boolean { + return normalize(value).includes(normalizedInput) +} + +function formatAmbiguous(label: string, choices: string[]): string { + return `Ambiguous ${label}. Use an exact ID or --pick N:\n${choices.map((choice, index) => ` ${index + 1}. ${choice}`).join('\n')}` +} + +function formatAccount(account: AnyRecord): string { + const network = account.network ? ` ${account.network}` : '' + const bridge = account.bridge?.type ? ` ${account.bridge.type}` : '' + const user = account.user?.displayName || account.user?.name || account.user?.username || account.user?.id || '' + return `${account.accountID}${network}${bridge}${user ? ` ${user}` : ''}` +} + +function formatChat(chat: AnyRecord): string { + const network = chat.network ? ` ${chat.network}` : '' + return `${chat.id}${network} ${chat.title ?? ''}`.trim() +} + +export function userQueryFromInput(input: string): AnyRecord { + const trimmed = input.trim() + if (trimmed.includes('@')) return { email: trimmed, username: trimmed } + if (/^\+?[\d\s().-]{5,}$/.test(trimmed)) return { phoneNumber: trimmed } + return { fullName: trimmed, username: trimmed, id: trimmed } +} diff --git a/src/lib/runner.ts b/src/lib/runner.ts new file mode 100644 index 0000000..f6d2341 --- /dev/null +++ b/src/lib/runner.ts @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process' + +export type RunResult = { + code: number | null + signal: NodeJS.Signals | null + stdout: string + stderr: string +} + +export async function runCli(args: string[], options: { inherit?: boolean } = {}): Promise { + const child = spawn(process.execPath, [process.argv[1]!, ...args], { + env: process.env, + stdio: options.inherit ? 'inherit' : ['ignore', 'pipe', 'pipe'], + }) + + if (options.inherit) { + return new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, stdout: '', stderr: '' })) + }) + } + + let stdout = '' + let stderr = '' + child.stdout?.setEncoding('utf8') + child.stderr?.setEncoding('utf8') + child.stdout?.on('data', chunk => { + stdout += chunk + }) + child.stderr?.on('data', chunk => { + stderr += chunk + }) + + return new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (code, signal) => resolve({ code, signal, stdout, stderr })) + }) +} diff --git a/src/lib/wait.ts b/src/lib/wait.ts new file mode 100644 index 0000000..12d0ad5 --- /dev/null +++ b/src/lib/wait.ts @@ -0,0 +1,24 @@ +import { setTimeout as sleep } from 'node:timers/promises' + +export type WaitOptions = { + intervalMs?: number + timeoutMs?: number +} + +export async function waitForMessage(client: any, chatID: string, pendingMessageID: string, options: WaitOptions = {}) { + const started = Date.now() + const timeoutMs = options.timeoutMs ?? 30_000 + const intervalMs = options.intervalMs ?? 750 + let lastError: unknown + + while (Date.now() - started < timeoutMs) { + try { + return await client.messages.retrieve(pendingMessageID, { chatID }) + } catch (error) { + lastError = error + await sleep(intervalMs) + } + } + + throw new Error(`Timed out waiting for ${pendingMessageID}${lastError instanceof Error ? `: ${lastError.message}` : ''}`) +} diff --git a/test/cli-smoke.mjs b/test/cli-smoke.mjs new file mode 100644 index 0000000..e137844 --- /dev/null +++ b/test/cli-smoke.mjs @@ -0,0 +1,177 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' +import { commandManifest } from '../dist/lib/manifest.js' +import { resolveAccountID, resolveAccountIDs, resolveChatID } from '../dist/lib/resolve.js' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const run = (...args) => spawnSync(process.execPath, ['./bin/run.js', ...args], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, +}) + +const ok = (...args) => { + const result = run(...args) + assert.equal(result.status, 0, `${args.join(' ')} failed\nSTDOUT:\n${result.stdout}\nSTDERR:\n${result.stderr}`) + return result.stdout +} + +const commandFiles = listCommandFiles(join(root, 'src/commands')) +const commandNames = commandFiles.map(file => fileToCommand(file)).sort() +const manifestNames = commandManifest.map(item => item.command).sort() + +assert.deepEqual(manifestNames, commandNames, 'command manifest must match src/commands') +assert.equal(new Set(manifestNames).size, manifestNames.length, 'command manifest must not contain duplicates') + +const help = ok('--help') +assert.match(help, /\bchat\b/, 'help should expose canonical chat command') +assert.match(help, /\bchats\b/, 'help should expose canonical chats command') +assert.match(help, /\bthread\b/, 'help should expose compatibility thread alias') +assert.doesNotMatch(help, /\bserve\b/, 'help must not expose assets serve') +assert.doesNotMatch(help, /\bfailed-sends\b|\bscheduled\b|\blocal\s+stats\b/, 'help must not expose stale local DB commands') + +for (const command of [ + ['chat', '--help'], + ['chat', 'open', '--help'], + ['chats', '--help'], + ['thread', '--help'], + ['threads', '--help'], + ['tail', '--help'], + ['send-file', '--help'], + ['reply-file', '--help'], + ['watch', '--help'], + ['current-user', '--help'], + ['whoami', '--help'], + ['config', 'get', '--help'], + ['config', 'set', '--help'], + ['config', 'reset', '--help'], + ['llm'], +]) { + ok(...command) +} + +assert.match(ok('send', '--help'), /--pick/, 'send should expose --pick for ambiguous chat names') +assert.match(ok('send', '--help'), /--wait/, 'send should expose --wait') +assert.match(ok('messages', '--help'), /--pick/, 'messages should expose --pick for ambiguous chat names') +assert.match(ok('chats', '--help'), /--account=\.\.\./, 'chats should accept account selectors') + +const commandsJSON = JSON.parse(ok('commands', '--json')) +assert.equal(commandsJSON.length, commandManifest.length, 'commands --json should expose the full manifest') +assert(commandsJSON.some(item => item.command === 'threads'), 'commands --json should include alias commands') +assert(commandsJSON.some(item => item.command === 'chat'), 'commands --json should include canonical chat command') +assert(commandsJSON.some(item => item.command === 'chat open'), 'commands --json should include chat open alias') +assert(commandsJSON.some(item => item.command === 'tail'), 'commands --json should include tail alias') +assert(commandsJSON.some(item => item.command === 'whoami'), 'commands --json should include whoami alias') +assert(!commandsJSON.some(item => item.command.includes('serve')), 'commands --json must not include serve') + +const configDir = '/tmp/beeper-cli-test-config' +const configEnv = { ...process.env, BEEPER_CLI_CONFIG_DIR: configDir } +let config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'set', 'baseURL', 'http://127.0.0.1:23373'], { + cwd: root, + encoding: 'utf8', + env: configEnv, +}) +assert.equal(config.status, 0, config.stderr) +config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'get', 'baseURL'], { + cwd: root, + encoding: 'utf8', + env: configEnv, +}) +assert.equal(config.status, 0, config.stderr) +assert.match(config.stdout, /127\.0\.0\.1:23373/) +config = spawnSync(process.execPath, ['./bin/run.js', 'config', 'reset'], { + cwd: root, + encoding: 'utf8', + env: configEnv, +}) +assert.equal(config.status, 0, config.stderr) + +const rpc = spawnSync('printf', ['%s\n', '{"id":1,"command":"auth status --json"}'], { + encoding: 'utf8', + cwd: root, +}) +assert.equal(rpc.status, 0, rpc.stderr) +const rpcResult = spawnSync(process.execPath, ['./bin/run.js', 'rpc'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, + input: rpc.stdout, +}) +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, /"authenticated": false/) + +const shell = spawnSync(process.execPath, ['./bin/run.js', 'shell'], { + cwd: root, + encoding: 'utf8', + env: { + ...process.env, + BEEPER_CLI_CONFIG_DIR: '/tmp/beeper-cli-test', + }, + input: 'auth status --json\nquit\n', +}) +assert.equal(shell.status, 0, shell.stderr) +assert.match(shell.stdout, /"authenticated": false/) + +const fakeClient = { + accounts: { + list: async () => [ + { accountID: 'imessage-main', bridge: { id: 'local-imessage', type: 'imessage' }, network: 'iMessage', user: { displayName: 'Main' } }, + { accountID: 'telegram-main', bridge: { id: 'telegramgo', type: 'telegram' }, network: 'Telegram', user: { displayName: 'Main' } }, + ], + }, + chats: { + retrieve: async id => { + if (id === '!exact:beeper.com' || id === 'local-family') return { id: '!family:beeper.com', localChatID: 'local-family', title: 'Family', network: 'iMessage' } + throw new Error('not found') + }, + search: async function* ({ query }) { + const rows = [ + { id: '!family:beeper.com', localChatID: 'local-family', title: 'Family', network: 'iMessage' }, + { id: '!family-work:beeper.com', localChatID: 'local-family-work', title: 'Family Work', network: 'Telegram' }, + ].filter(chat => chat.title.toLowerCase().includes(String(query).toLowerCase())) + for (const row of rows) yield row + }, + }, +} + +assert.equal(await resolveAccountID(fakeClient, 'imessage'), 'imessage-main') +assert.deepEqual(await resolveAccountIDs(fakeClient, ['main'], { allowMultiplePerInput: true }), ['imessage-main', 'telegram-main']) +await assert.rejects(() => resolveAccountID(fakeClient, 'main'), /Ambiguous account/) +assert.equal(await resolveChatID(fakeClient, 'local-family'), '!family:beeper.com') +assert.equal(await resolveChatID(fakeClient, 'Family Work'), '!family-work:beeper.com') +assert.equal(await resolveChatID(fakeClient, 'fam', { pick: 2 }), '!family-work:beeper.com') +await assert.rejects(() => resolveChatID(fakeClient, 'fam'), /Ambiguous chat/) + +console.log(`cli-smoke: ${commandManifest.length} commands verified`) + +function listCommandFiles(dir) { + const files = [] + for (const entry of readdirSync(dir)) { + const path = join(dir, entry) + if (statSync(path).isDirectory()) { + files.push(...listCommandFiles(path)) + } else if (path.endsWith('.ts')) { + files.push(path) + } + } + return files +} + +function fileToCommand(file) { + const rel = relative(join(root, 'src/commands'), file).replace(/\.ts$/, '') + return rel.endsWith('/index') + ? rel.slice(0, -'/index'.length).replaceAll('/', ' ') + : rel.replaceAll('/', ' ') +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1c4e7a6 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "esModuleInterop": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noUncheckedIndexedAccess": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "target": "ES2022" + }, + "include": ["src/**/*.ts"] +} From 50b48ab497f09506c36d3ef54009fa58a98963e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 13 May 2026 20:28:55 +0200 Subject: [PATCH 02/19] Add npm and Homebrew publishing --- .github/workflows/ci.yml | 125 ++++---------------------- .github/workflows/publish-release.yml | 52 +++++++---- .goreleaser.yml | 110 ----------------------- README.md | 30 +++++++ bin/check-release-environment | 13 +++ package.json | 1 + release-please-config.json | 5 +- scripts/build-homebrew-archive.mjs | 112 +++++++++++++++++++++++ scripts/publish-homebrew-formula.mjs | 121 +++++++++++++++++++++++++ 9 files changed, 336 insertions(+), 233 deletions(-) delete mode 100644 .goreleaser.yml create mode 100644 scripts/build-homebrew-archive.mjs create mode 100644 scripts/publish-homebrew-formula.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26d3c04..582bbfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,126 +2,39 @@ name: CI on: push: branches: - - '**' - - '!integrated/**' - - '!stl-preview-head/**' - - '!stl-preview-base/**' - - '!generated' - - '!codegen/**' - - 'codegen/stl/**' + - "**" + - "!integrated/**" + - "!stl-preview-head/**" + - "!stl-preview-base/**" + - "!generated" + - "!codegen/**" + - "codegen/stl/**" pull_request: branches-ignore: - - 'stl-preview-head/**' - - 'stl-preview-base/**' - -env: - GOPRIVATE: github.com/beeper/desktop-api-go,github.com/stainless-sdks/beeper-desktop-api-go + - "stl-preview-head/**" + - "stl-preview-base/**" jobs: - lint: - timeout-minutes: 10 - name: lint - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run lints - run: ./scripts/lint - - build: - timeout-minutes: 10 - name: build - permissions: - contents: read - id-token: write - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork - steps: - - uses: actions/checkout@v6 - - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run goreleaser - uses: goreleaser/goreleaser-action@v6.1.0 - with: - version: latest - args: release --snapshot --clean --skip=publish - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get GitHub OIDC Token - if: |- - github.repository == 'stainless-sdks/beeper-desktop-api-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - id: github-oidc - uses: actions/github-script@v8 - with: - script: core.setOutput('github_token', await core.getIDToken()); - - - name: Upload tarball - if: |- - github.repository == 'stainless-sdks/beeper-desktop-api-cli' && - !startsWith(github.ref, 'refs/heads/stl/') - env: - URL: https://pkg.stainless.com/s - AUTH: ${{ steps.github-oidc.outputs.github_token }} - SHA: ${{ github.sha }} - run: ./scripts/utils/upload-artifact.sh - test: timeout-minutes: 10 name: test - runs-on: ${{ github.repository == 'stainless-sdks/beeper-desktop-api-cli' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} - if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: ./.github/actions/setup-go - with: - stainless-api-key: ${{ secrets.STAINLESS_API_KEY }} - - - name: Link staging branch - if: github.repository == 'stainless-sdks/beeper-desktop-api-cli' - run: | - ./scripts/link 'github.com/stainless-sdks/beeper-desktop-api-go@${{ github.ref_name }}' || true - - - name: Bootstrap - run: ./scripts/bootstrap - - - name: Run tests - run: ./scripts/test - - uses: actions/setup-node@v6 with: - node-version: 25 + node-version: 24 cache: npm - - name: Install TypeScript CLI dependencies + - name: Install dependencies run: npm ci - - name: Run TypeScript CLI tests + - name: Typecheck + run: npm run typecheck + + - name: Test run: npm test + + - name: Package Homebrew archive + run: npm run pack:homebrew diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 4e556fb..aa8f51e 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -2,6 +2,7 @@ name: Publish Release permissions: contents: write + id-token: write concurrency: group: publish @@ -11,27 +12,48 @@ on: tags: - "v*" jobs: - goreleaser: + publish: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v5 + - name: Set up Node + uses: actions/setup-node@v6 with: - go-version-file: "go.mod" - - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v6.1.0 - with: - version: latest - args: release --clean + node-version: 24 + registry-url: https://registry.npmjs.org + cache: npm + - name: Install dependencies + run: npm ci + - name: Test + run: npm test + - name: Publish npm package + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + set -euo pipefail + package_name="$(node -p "require('./package.json').name")" + package_version="$(node -p "require('./package.json').version")" + if npm view "${package_name}@${package_version}" version >/dev/null 2>&1; then + echo "${package_name}@${package_version} is already published; skipping npm publish." + else + npm publish --provenance --access public + fi + - name: Build Homebrew archive + run: npm run pack:homebrew + - name: Publish GitHub release assets + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + if ! gh release view "${tag}" >/dev/null 2>&1; then + gh release create "${tag}" --title "${tag}" --generate-notes --verify-tag + fi + gh release upload "${tag}" dist/release/*.tar.gz dist/release/homebrew.json --clobber + - name: Publish Homebrew formula env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} - MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} - MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} - MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} - MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} - MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} \ No newline at end of file + run: node scripts/publish-homebrew-formula.mjs diff --git a/.goreleaser.yml b/.goreleaser.yml deleted file mode 100644 index b418408..0000000 --- a/.goreleaser.yml +++ /dev/null @@ -1,110 +0,0 @@ -project_name: beeper-desktop-cli -version: 2 - -before: - hooks: - - mkdir -p completions - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion bash > completions/beeper-desktop-cli.bash" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion zsh > completions/beeper-desktop-cli.zsh" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @completion fish > completions/beeper-desktop-cli.fish" - - sh -c "go run ./cmd/beeper-desktop-cli/main.go @manpages -o man" - -builds: - - id: macos - goos: [darwin] - goarch: [amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: linux - goos: [linux] - goarch: ['386', arm, amd64, arm64] - env: - - CGO_ENABLED=0 - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - - - id: windows - goos: [windows] - goarch: ['386', amd64, arm64] - binary: '{{ .ProjectName }}' - main: ./cmd/beeper-desktop-cli/main.go - mod_timestamp: '{{ .CommitTimestamp }}' - ldflags: - - '-s -w -X main.version={{.Version}} -X main.commit={{.Commit}}' - -archives: - - id: linux-archive - ids: [linux] - name_template: '{{ .ProjectName }}_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [tar.gz] - files: - - completions/* - - man/*/* - - id: macos-archive - ids: [macos] - name_template: '{{ .ProjectName }}_{{ .Version }}_macos_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - - id: windows-archive - ids: [windows] - name_template: '{{ .ProjectName }}_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' - formats: [zip] - files: - - completions/* - - man/*/* - -snapshot: - version_template: '{{ .Tag }}-next' - -nfpms: - - license: MIT - maintainer: help@beeper.com - bindir: /usr - formats: - - apk - - deb - - rpm - - termux.deb - - archlinux - contents: - - src: man/man1/*.1.gz - dst: /usr/share/man/man1/ -homebrew_casks: - - name: beeper-desktop-cli - repository: - owner: beeper - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" - homepage: https://developers.beeper.com/desktop-api/ - description: CLI for Beeper Desktop API - license: MIT - binary: "beeper-desktop-cli" - completions: - bash: "completions/beeper-desktop-cli.bash" - zsh: "completions/beeper-desktop-cli.zsh" - fish: "completions/beeper-desktop-cli.fish" - manpages: - - man/man1/beeper-desktop-cli.1.gz - -notarize: - macos: - - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' - ids: [macos] - - sign: - certificate: "{{.Env.MACOS_SIGN_P12}}" - password: "{{.Env.MACOS_SIGN_PASSWORD}}" - - notarize: - issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" - key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" - key: "{{.Env.MACOS_NOTARY_KEY}}" diff --git a/README.md b/README.md index eda4c23..e572872 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,18 @@ asset transfer, machine-readable output, and raw API access for advanced use. ## Install +```sh +npm install -g @beeper/desktop-api-cli +``` + +Or with Homebrew: + +```sh +brew install beeper/tap/beeper-desktop-cli +``` + +For local development: + ```sh npm install npm run build @@ -173,3 +185,21 @@ beeper reply-file CHAT MESSAGE FILE [TEXT] | `BEEPER_DESKTOP_BASE_URL` | Beeper Desktop API base URL. Defaults to `http://localhost:23373`. | | `BEEPER_BASE_URL` | SDK-compatible base URL fallback. | | `BEEPER_CLI_CONFIG_DIR` | Override config directory for testing or isolated profiles. | + +## Publishing + +Tagged releases publish the same CLI to npm and Homebrew. Push a `v*` tag to run +`.github/workflows/publish-release.yml`. + +The release workflow: + +- runs the TypeScript test suite +- publishes `@beeper/desktop-api-cli` to npm with provenance +- builds a Homebrew archive containing the compiled CLI and production dependencies +- uploads the archive to the GitHub release +- updates `beeper/homebrew-tap` with the pinned archive SHA + +Required repository secrets: + +- `NPM_TOKEN` +- `HOMEBREW_TAP_GITHUB_TOKEN` diff --git a/bin/check-release-environment b/bin/check-release-environment index 1e951e9..0a2b720 100644 --- a/bin/check-release-environment +++ b/bin/check-release-environment @@ -1,7 +1,20 @@ #!/usr/bin/env bash +set -euo pipefail errors=() +for path in package.json package-lock.json scripts/build-homebrew-archive.mjs scripts/publish-homebrew-formula.mjs .github/workflows/publish-release.yml; do + if [[ ! -f "${path}" ]]; then + errors+=("Missing required release file: ${path}") + fi +done + +for command in node npm git gh tar; do + if ! command -v "${command}" >/dev/null 2>&1; then + errors+=("Missing required release command: ${command}") + fi +done + lenErrors=${#errors[@]} if [[ lenErrors -gt 0 ]]; then diff --git a/package.json b/package.json index b3b51db..d570ada 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "scripts": { "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", "dev": "node --import tsx ./bin/dev.js", + "pack:homebrew": "npm run build && node scripts/build-homebrew-archive.mjs", "test": "npm run build && node ./test/cli-smoke.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, diff --git a/release-please-config.json b/release-please-config.json index 53619de..9785282 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -61,7 +61,8 @@ ], "release-type": "simple", "extra-files": [ - "pkg/cmd/version.go", + "package.json", + "package-lock.json", "README.md" ] -} \ No newline at end of file +} diff --git a/scripts/build-homebrew-archive.mjs b/scripts/build-homebrew-archive.mjs new file mode 100644 index 0000000..1005c5b --- /dev/null +++ b/scripts/build-homebrew-archive.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node +import {createHash} from 'node:crypto'; +import {existsSync} from 'node:fs'; +import {cp, mkdir, mkdtemp, readFile, rm, stat, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {basename, join, resolve} from 'node:path'; +import {spawn} from 'node:child_process'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const packageJsonPath = join(root, 'package.json'); +const packageLockPath = join(root, 'package-lock.json'); +const distPath = join(root, 'dist'); +const outDir = join(root, 'dist', 'release'); + +const pkg = JSON.parse(await readFile(packageJsonPath, 'utf8')); +const packageName = 'beeper-desktop-cli'; +const commandName = 'beeper'; +const version = process.env.PACKAGE_VERSION || pkg.version; +const archiveName = `${packageName}_${version}_any.tar.gz`; +const archivePath = join(outDir, archiveName); +const metadataPath = join(outDir, 'homebrew.json'); +const workDir = await mkdtemp(join(tmpdir(), 'desktop-api-cli-homebrew-')); + +await ensureBuilt(); +await mkdir(join(workDir, 'bin'), {recursive: true}); +await mkdir(join(workDir, 'libexec'), {recursive: true}); +await mkdir(outDir, {recursive: true}); + +await cp(packageJsonPath, join(workDir, 'libexec', 'package.json')); +await cp(packageLockPath, join(workDir, 'libexec', 'package-lock.json')); +await cp(join(root, 'bin'), join(workDir, 'libexec', 'bin'), {recursive: true}); +await cp(distPath, join(workDir, 'libexec', 'dist'), { + recursive: true, + filter: source => !source.startsWith(outDir), +}); +await writeFile( + join(workDir, 'bin', commandName), + `#!/bin/sh +set -e +prefix="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +exec node "$prefix/libexec/bin/run.js" "$@" +`, + {mode: 0o755}, +); + +await run('npm', ['ci', '--omit=dev', '--ignore-scripts', '--no-audit', '--no-fund'], { + cwd: join(workDir, 'libexec'), +}); +await rm(archivePath, {force: true}); +await run('tar', ['-czf', archivePath, '-C', workDir, '.'], {cwd: root}); + +const sha256 = await hashFile(archivePath); +await writeFile( + metadataPath, + `${JSON.stringify( + { + archive: basename(archivePath), + command: commandName, + package: packageName, + path: archivePath, + sha256, + version, + }, + null, + 2, + )}\n`, +); + +console.log(`${archivePath}`); +console.log(`sha256 ${sha256}`); +await rm(workDir, {recursive: true, force: true}); + +async function ensureBuilt() { + if (!existsSync(distPath)) { + throw new Error('dist/ does not exist. Run npm run build before packaging.'); + } + + const distStats = await stat(distPath); + if (!distStats.isDirectory()) { + throw new Error('dist/ exists but is not a directory.'); + } + + if (!existsSync(join(distPath, 'commands'))) { + throw new Error('dist/commands does not exist. Run npm run build before packaging.'); + } +} + +async function hashFile(path) { + const hash = createHash('sha256'); + hash.update(await readFile(path)); + return hash.digest('hex'); +} + +async function run(command, args, options = {}) { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd || root, + env: process.env, + stdio: 'inherit', + }); + + child.on('error', reject); + child.on('exit', code => { + if (code === 0) { + resolvePromise(); + return; + } + + reject(new Error(`${command} ${args.join(' ')} exited with ${code}`)); + }); + }); +} diff --git a/scripts/publish-homebrew-formula.mjs b/scripts/publish-homebrew-formula.mjs new file mode 100644 index 0000000..85969b0 --- /dev/null +++ b/scripts/publish-homebrew-formula.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +import {existsSync} from 'node:fs'; +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {spawn} from 'node:child_process'; + +const root = new URL('..', import.meta.url).pathname; +const packageJson = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')); +const metadata = JSON.parse(await readFile(new URL('../dist/release/homebrew.json', import.meta.url), 'utf8')); + +const token = process.env.HOMEBREW_TAP_GITHUB_TOKEN; +const tapRepository = process.env.HOMEBREW_TAP_REPOSITORY || 'beeper/homebrew-tap'; +const sourceRepository = process.env.GITHUB_REPOSITORY || 'beeper/desktop-api-cli'; +const version = process.env.PACKAGE_VERSION || metadata.version || packageJson.version; +const formulaName = process.env.HOMEBREW_FORMULA_NAME || 'beeper-desktop-cli'; +const commandName = process.env.HOMEBREW_COMMAND_NAME || metadata.command || 'beeper'; +const formulaClass = formulaName + .split(/[-_]/) + .map(part => `${part[0].toUpperCase()}${part.slice(1)}`) + .join(''); +const tag = process.env.GITHUB_REF_NAME || `v${version}`; + +if (!token) { + throw new Error('HOMEBREW_TAP_GITHUB_TOKEN is required to publish the Homebrew formula.'); +} + +const cloneRoot = await mkdtemp(join(tmpdir(), 'desktop-api-cli-homebrew-')); +const tapPath = join(cloneRoot, 'tap'); +const remote = `https://x-access-token:${token}@github.com/${tapRepository}.git`; + +try { + await run('git', ['clone', '--depth', '1', remote, tapPath], {cwd: cloneRoot, scrub: token}); + await run('git', ['config', 'user.name', process.env.GIT_AUTHOR_NAME || 'beeper-release-bot'], {cwd: tapPath}); + await run('git', ['config', 'user.email', process.env.GIT_AUTHOR_EMAIL || 'help@beeper.com'], {cwd: tapPath}); + + const formulaDir = join(tapPath, 'Formula'); + const formulaPath = join(formulaDir, `${formulaName}.rb`); + if (!existsSync(formulaDir)) { + await mkdir(formulaDir, {recursive: true}); + } + + await writeFile( + formulaPath, + formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}), + ); + await run('git', ['add', formulaPath], {cwd: tapPath}); + + const changed = await output('git', ['diff', '--cached', '--quiet'], {cwd: tapPath, allowFailure: true}); + if (changed.code === 0) { + console.log('Homebrew formula is already current.'); + } else { + await run('git', ['commit', '-m', `${formulaName} ${version}`], {cwd: tapPath}); + await run('git', ['push', 'origin', 'HEAD'], {cwd: tapPath, scrub: token}); + } +} finally { + await rm(cloneRoot, {recursive: true, force: true}); +} + +function formula({formulaClass, formulaName, sourceRepository, tag, version, metadata, commandName}) { + return `class ${formulaClass} < Formula + desc "CLI for Beeper Desktop API" + homepage "https://developers.beeper.com/desktop-api/" + url "https://github.com/${sourceRepository}/releases/download/${tag}/${metadata.archive}" + sha256 "${metadata.sha256}" + license "MIT" + version "${version}" + + depends_on "node" + + def install + libexec.install Dir["libexec/*"] + bin.install "bin/${commandName}" + bin.install_symlink bin/"${commandName}" => "${formulaName}" + end + + test do + assert_match version.to_s, shell_output("#{bin}/${commandName} --version") + end +end +`; +} + +async function run(command, args, options = {}) { + const result = await output(command, args, options); + if (result.code !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${result.code}`); + } +} + +async function output(command, args, options = {}) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + cwd: options.cwd || root, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + const text = chunk.toString(); + stdout += text; + process.stdout.write(options.scrub ? text.replaceAll(options.scrub, '[token]') : text); + }); + child.stderr.on('data', chunk => { + const text = chunk.toString(); + stderr += text; + process.stderr.write(options.scrub ? text.replaceAll(options.scrub, '[token]') : text); + }); + child.on('error', reject); + child.on('exit', code => { + if (code !== 0 && !options.allowFailure) { + resolvePromise({code, stdout, stderr}); + return; + } + + resolvePromise({code, stdout, stderr}); + }); + }); +} From 483c045c518ae2f29f052de1f23ac0717adf62bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 13 May 2026 20:47:32 +0200 Subject: [PATCH 03/19] Align CLI copy with SDK docs --- package.json | 3 +- scripts/check-api-copy.mjs | 76 +++++++++++++++++++++++++++++++++ src/commands/accounts.ts | 7 +-- src/commands/archive.ts | 9 ++-- src/commands/assets/download.ts | 7 +-- src/commands/assets/upload.ts | 13 +++--- src/commands/chat.ts | 11 ++--- src/commands/chats/index.ts | 9 ++-- src/commands/chats/search.ts | 11 ++--- src/commands/contacts/search.ts | 9 ++-- src/commands/create-chat.ts | 9 ++-- src/commands/delete-message.ts | 13 +++--- src/commands/edit.ts | 15 ++++--- src/commands/message.ts | 13 +++--- src/commands/messages/index.ts | 11 ++--- src/commands/messages/search.ts | 13 +++--- src/commands/notify-anyway.ts | 11 ++--- src/commands/react.ts | 15 ++++--- src/commands/read.ts | 13 +++--- src/commands/remind.ts | 11 ++--- src/commands/reply-file.ts | 21 ++++----- src/commands/reply.ts | 21 ++++----- src/commands/send-file.ts | 21 ++++----- src/commands/send.ts | 21 ++++----- src/commands/start-chat.ts | 9 ++-- src/commands/unarchive.ts | 9 ++-- src/commands/unreact.ts | 15 ++++--- src/commands/unread.ts | 13 +++--- src/commands/unremind.ts | 9 ++-- src/lib/copy.ts | 65 ++++++++++++++++++++++++++++ src/lib/manifest.ts | 56 ++++++++++++------------ 31 files changed, 355 insertions(+), 184 deletions(-) create mode 100644 scripts/check-api-copy.mjs create mode 100644 src/lib/copy.ts diff --git a/package.json b/package.json index d570ada..9202a8d 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,10 @@ ], "scripts": { "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json", + "check:api-copy": "npm run build && node scripts/check-api-copy.mjs", "dev": "node --import tsx ./bin/dev.js", "pack:homebrew": "npm run build && node scripts/build-homebrew-archive.mjs", - "test": "npm run build && node ./test/cli-smoke.mjs", + "test": "npm run build && node scripts/check-api-copy.mjs && node ./test/cli-smoke.mjs", "typecheck": "tsc -p tsconfig.json --noEmit" }, "oclif": { diff --git a/scripts/check-api-copy.mjs b/scripts/check-api-copy.mjs new file mode 100644 index 0000000..40bcaee --- /dev/null +++ b/scripts/check-api-copy.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import {readFile} from 'node:fs/promises'; +import {join, resolve} from 'node:path'; + +const root = resolve(new URL('..', import.meta.url).pathname); +const {apiCopy} = await import('../dist/lib/copy.js'); + +const checks = [ + ['accounts.list', 'resources/accounts/accounts.d.ts', 'list'], + ['assets.download', 'resources/assets.d.ts', 'download'], + ['assets.upload', 'resources/assets.d.ts', 'upload'], + ['chats.archive', 'resources/chats/chats.d.ts', 'archive'], + ['chats.create', 'resources/chats/chats.d.ts', 'create'], + ['chats.list', 'resources/chats/chats.d.ts', 'list'], + ['chats.markRead', 'resources/chats/chats.d.ts', 'markRead'], + ['chats.markUnread', 'resources/chats/chats.d.ts', 'markUnread'], + ['chats.notifyAnyway', 'resources/chats/chats.d.ts', 'notifyAnyway'], + ['chats.retrieve', 'resources/chats/chats.d.ts', 'retrieve'], + ['chats.search', 'resources/chats/chats.d.ts', 'search'], + ['chats.start', 'resources/chats/chats.d.ts', 'start'], + ['contacts.search', 'resources/accounts/contacts.d.ts', 'search'], + ['messages.delete', 'resources/messages.d.ts', 'delete'], + ['messages.list', 'resources/messages.d.ts', 'list'], + ['messages.retrieve', 'resources/messages.d.ts', 'retrieve'], + ['messages.search', 'resources/messages.d.ts', 'search'], + ['messages.send', 'resources/messages.d.ts', 'send'], + ['messages.update', 'resources/messages.d.ts', 'update'], + ['reactions.add', 'resources/chats/messages/reactions.d.ts', 'add'], + ['reactions.delete', 'resources/chats/messages/reactions.d.ts', 'delete'], + ['reminders.create', 'resources/chats/reminders.d.ts', 'create'], + ['reminders.delete', 'resources/chats/reminders.d.ts', 'delete'], +]; + +const failures = []; + +for (const [copyPath, sdkPath, method] of checks) { + const expected = getPath(apiCopy, copyPath); + const actual = await sdkMethodDescription(sdkPath, method); + if (expected !== actual) { + failures.push(`${copyPath}\n expected: ${expected}\n actual: ${actual}`); + } +} + +if (failures.length > 0) { + console.error(`API copy drifted from @beeper/desktop-api:\n\n${failures.join('\n\n')}`); + process.exit(1); +} + +console.log(`api-copy: ${checks.length} SDK descriptions verified`); + +function getPath(object, path) { + return path.split('.').reduce((value, key) => value?.[key], object); +} + +async function sdkMethodDescription(relativePath, method) { + const source = await readFile(join(root, 'node_modules', '@beeper', 'desktop-api', relativePath), 'utf8'); + const methodMatch = source.match(new RegExp(String.raw`^\s*${method}\(`, 'm')); + const methodIndex = methodMatch?.index ?? -1; + if (methodIndex === -1) throw new Error(`Could not find SDK method ${relativePath}#${method}`); + + const comments = [...source.slice(0, methodIndex).matchAll(/\/\*\*([\s\S]*?)\*\//g)]; + const match = comments.at(-1); + if (!match) throw new Error(`Could not find SDK docs for ${relativePath}#${method}`); + + const lines = match[1] + .split('\n') + .map(line => line.replace(/^\s*\*\s?/, '').trimEnd()) + + const exampleIndex = lines.findIndex(line => line.startsWith('@example')); + return lines + .slice(0, exampleIndex === -1 ? undefined : exampleIndex) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index 721e5e0..715477a 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -1,13 +1,14 @@ import { Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' export default class Accounts extends Command { - static override summary = 'List connected chat accounts' + static override summary = apiCopy.accounts.list static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), } async run(): Promise { diff --git a/src/commands/archive.ts b/src/commands/archive.ts index 1623025..9332942 100644 --- a/src/commands/archive.ts +++ b/src/commands/archive.ts @@ -1,16 +1,17 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { resolveChatID } from '../lib/resolve.js' export default class Archive extends Command { - static override summary = 'Archive a chat' + static override summary = apiCopy.chats.archive static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/assets/download.ts b/src/commands/assets/download.ts index efbbc24..3079c83 100644 --- a/src/commands/assets/download.ts +++ b/src/commands/assets/download.ts @@ -1,16 +1,17 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' import { printData } from '../../lib/output.js' export default class AssetsDownload extends Command { - static override summary = 'Download an mxc:// or localmxc:// asset' + static override summary = apiCopy.assets.download static override args = { url: Args.string({ description: 'Asset URL', required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), } async run(): Promise { diff --git a/src/commands/assets/upload.ts b/src/commands/assets/upload.ts index 72f4f7d..8eae53e 100644 --- a/src/commands/assets/upload.ts +++ b/src/commands/assets/upload.ts @@ -1,19 +1,20 @@ import { Args, Command, Flags } from '@oclif/core' import { createReadStream } from 'node:fs' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' import { printData } from '../../lib/output.js' export default class AssetsUpload extends Command { - static override summary = 'Upload a file and return an upload ID' + static override summary = apiCopy.assets.upload static override args = { - file: Args.string({ description: 'File to upload', required: true }), + file: Args.string({ description: sdkParamCopy.attachmentFile, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - 'file-name': Flags.string({ description: 'Display filename' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - 'mime-type': Flags.string({ description: 'MIME type' }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), } async run(): Promise { diff --git a/src/commands/chat.ts b/src/commands/chat.ts index e9c833b..7c3a60b 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -1,19 +1,20 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Chat extends Command { - static override summary = 'Show one chat by ID' + static override summary = apiCopy.chats.retrieve static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), 'max-participants': Flags.integer({ description: 'Maximum participants to return' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/chats/index.ts b/src/commands/chats/index.ts index 192b28f..0e64342 100644 --- a/src/commands/chats/index.ts +++ b/src/commands/chats/index.ts @@ -1,16 +1,17 @@ import { Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' import { collectPage, printData, printIDs } from '../../lib/output.js' import { resolveAccountIDs } from '../../lib/resolve.js' export default class ChatsIndex extends Command { - static override summary = 'List recent chats' + static override summary = apiCopy.chats.list static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), } diff --git a/src/commands/chats/search.ts b/src/commands/chats/search.ts index f860159..c504f64 100644 --- a/src/commands/chats/search.ts +++ b/src/commands/chats/search.ts @@ -1,20 +1,21 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' import { collectPage, printData, printIDs } from '../../lib/output.js' import { resolveAccountIDs } from '../../lib/resolve.js' export default class ChatsSearch extends Command { - static override summary = 'Search chats by title, network, or participants' + static override summary = apiCopy.chats.search static override args = { - query: Args.string({ description: 'Literal chat search query', required: true }), + query: Args.string({ description: sdkParamCopy.searchQuery, required: true }), } static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), ids: Flags.boolean({ default: false, description: 'Print only chat IDs' }), inbox: Flags.string({ options: ['primary', 'low-priority', 'archive'] }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), limit: Flags.integer({ default: 20, description: 'Maximum chats to print' }), scope: Flags.string({ options: ['titles', 'participants'] }), type: Flags.string({ options: ['single', 'group', 'any'] }), diff --git a/src/commands/contacts/search.ts b/src/commands/contacts/search.ts index 421705a..083d62b 100644 --- a/src/commands/contacts/search.ts +++ b/src/commands/contacts/search.ts @@ -1,18 +1,19 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' import { printData } from '../../lib/output.js' import { listAccountIDs, resolveAccountIDs } from '../../lib/resolve.js' export default class ContactsSearch extends Command { - static override summary = 'Search account contacts' + static override summary = apiCopy.contacts.search static override args = { query: Args.string({ description: 'Contact search query', required: true }), } static override flags = { - account: Flags.string({ multiple: true, description: 'Account ID, network, bridge, or account user. Omit to search every account.' }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + account: Flags.string({ multiple: true, description: `${cliCopy.args.accountSelector}. Omit to search every account.` }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), } async run(): Promise { diff --git a/src/commands/create-chat.ts b/src/commands/create-chat.ts index cb5788d..6e2a65a 100644 --- a/src/commands/create-chat.ts +++ b/src/commands/create-chat.ts @@ -1,15 +1,16 @@ import { Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveAccountID } from '../lib/resolve.js' export default class CreateChat extends Command { - static override summary = 'Create a direct or group chat from participant IDs' + static override summary = apiCopy.chats.create static override flags = { - account: Flags.string({ description: 'Account ID, network, bridge, or account user', required: true }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + account: Flags.string({ description: cliCopy.args.accountSelector, required: true }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), message: Flags.string({ description: 'Optional first message' }), participant: Flags.string({ multiple: true, required: true, description: 'Participant user ID' }), title: Flags.string({ description: 'Group title' }), diff --git a/src/commands/delete-message.ts b/src/commands/delete-message.ts index 2886bf0..ea09d36 100644 --- a/src/commands/delete-message.ts +++ b/src/commands/delete-message.ts @@ -1,18 +1,19 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { resolveChatID } from '../lib/resolve.js' export default class DeleteMessage extends Command { - static override summary = 'Delete a message' + static override summary = apiCopy.messages.delete static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - 'for-everyone': Flags.boolean({ default: false, description: 'Request deletion for everyone when supported' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + 'for-everyone': Flags.boolean({ default: false, description: sdkParamCopy.forEveryone }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/edit.ts b/src/commands/edit.ts index 6a492ac..fbbf42d 100644 --- a/src/commands/edit.ts +++ b/src/commands/edit.ts @@ -1,20 +1,21 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Edit extends Command { - static override summary = 'Edit a text message' + static override summary = apiCopy.messages.update static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID', required: true }), - text: Args.string({ description: 'New message text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/message.ts b/src/commands/message.ts index a6579f7..7c03685 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -1,19 +1,20 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Message extends Command { - static override summary = 'Show one message by ID' + static override summary = apiCopy.messages.retrieve static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/messages/index.ts b/src/commands/messages/index.ts index d851323..4b2fbf6 100644 --- a/src/commands/messages/index.ts +++ b/src/commands/messages/index.ts @@ -1,22 +1,23 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy } from '../../lib/copy.js' import { collectPage, printData, printIDs } from '../../lib/output.js' import { resolveChatID } from '../../lib/resolve.js' export default class MessagesIndex extends Command { - static override summary = 'List messages in a chat' + static override summary = apiCopy.messages.list static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), before: Flags.string({ description: 'Fetch messages before cursor' }), after: Flags.string({ description: 'Fetch messages after cursor' }), debug: Flags.boolean({ default: false }), ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/messages/search.ts b/src/commands/messages/search.ts index 4f49f9d..c71fc6d 100644 --- a/src/commands/messages/search.ts +++ b/src/commands/messages/search.ts @@ -1,20 +1,21 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../../lib/copy.js' import { collectPage, printData, printIDs } from '../../lib/output.js' import { resolveAccountIDs, resolveChatID } from '../../lib/resolve.js' export default class MessagesSearch extends Command { - static override summary = 'Search messages' + static override summary = apiCopy.messages.search static override args = { - query: Args.string({ description: 'Literal message search query', required: false }), + query: Args.string({ description: sdkParamCopy.searchQuery, required: false }), } static override flags = { - account: Flags.string({ multiple: true, description: 'Limit to account ID, network, bridge, or account user' }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), - chat: Flags.string({ multiple: true, description: 'Limit to chat ID' }), + account: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.accountSelector}` }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), + chat: Flags.string({ multiple: true, description: `Limit to ${cliCopy.args.chatSelector}` }), debug: Flags.boolean({ default: false }), ids: Flags.boolean({ default: false, description: 'Print only message IDs' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), limit: Flags.integer({ default: 50, description: 'Maximum messages to print' }), sender: Flags.string({ description: 'me, others, or a user ID' }), } diff --git a/src/commands/notify-anyway.ts b/src/commands/notify-anyway.ts index 88d95af..60fe42e 100644 --- a/src/commands/notify-anyway.ts +++ b/src/commands/notify-anyway.ts @@ -1,18 +1,19 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class NotifyAnyway extends Command { - static override summary = 'Trigger notify-anyway for a chat' + static override summary = apiCopy.chats.notifyAnyway static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/react.ts b/src/commands/react.ts index 4f2b465..3478955 100644 --- a/src/commands/react.ts +++ b/src/commands/react.ts @@ -1,20 +1,21 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class React extends Command { - static override summary = 'Add a reaction to a message' + static override summary = apiCopy.reactions.add static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID', required: true }), - reaction: Args.string({ description: 'Reaction key, emoji, or shortcode', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + reaction: Args.string({ description: sdkParamCopy.reactionKey, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + pick: Flags.integer({ description: cliCopy.flags.pick }), transaction: Flags.string({ description: 'Optional transaction ID' }), } diff --git a/src/commands/read.ts b/src/commands/read.ts index d40fcac..078525b 100644 --- a/src/commands/read.ts +++ b/src/commands/read.ts @@ -1,19 +1,20 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Read extends Command { - static override summary = 'Mark a chat as read' + static override summary = apiCopy.chats.markRead static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - message: Flags.string({ description: 'Mark read through this message ID' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + message: Flags.string({ description: sdkParamCopy.messageID }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/remind.ts b/src/commands/remind.ts index 46708ca..136788c 100644 --- a/src/commands/remind.ts +++ b/src/commands/remind.ts @@ -1,18 +1,19 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { resolveChatID } from '../lib/resolve.js' export default class Remind extends Command { - static override summary = 'Set a chat reminder' + static override summary = apiCopy.reminders.create static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - when: Args.string({ description: 'ISO timestamp for the reminder', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + when: Args.string({ description: sdkParamCopy.remindAt, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), 'dismiss-on-message': Flags.boolean({ default: false, description: 'Cancel if someone messages in the chat' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/reply-file.ts b/src/commands/reply-file.ts index b781eb3..299141b 100644 --- a/src/commands/reply-file.ts +++ b/src/commands/reply-file.ts @@ -1,25 +1,26 @@ import { Args, Command, Flags } from '@oclif/core' import { createReadStream } from 'node:fs' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' import { waitForMessage } from '../lib/wait.js' export default class ReplyFile extends Command { - static override summary = 'Reply to a message with a file attachment' + static override summary = apiCopy.messages.send static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID to reply to', required: true }), - file: Args.string({ description: 'File attachment to upload and send', required: true }), - text: Args.string({ description: 'Optional reply text', required: false }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.replyToMessageID, required: true }), + file: Args.string({ description: sdkParamCopy.attachmentFile, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: false }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - 'file-name': Flags.string({ description: 'Attachment display filename' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - 'mime-type': Flags.string({ description: 'Attachment MIME type' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), diff --git a/src/commands/reply.ts b/src/commands/reply.ts index b721545..993c445 100644 --- a/src/commands/reply.ts +++ b/src/commands/reply.ts @@ -1,25 +1,26 @@ import { Args, Command, Flags } from '@oclif/core' import { createReadStream } from 'node:fs' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' import { waitForMessage } from '../lib/wait.js' export default class Reply extends Command { - static override summary = 'Reply to a message' + static override summary = apiCopy.messages.send static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID to reply to', required: true }), - text: Args.string({ description: 'Reply text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.replyToMessageID, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - file: Flags.string({ description: 'File attachment to upload and send' }), - 'file-name': Flags.string({ description: 'Attachment display filename' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - 'mime-type': Flags.string({ description: 'Attachment MIME type' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + file: Flags.string({ description: sdkParamCopy.attachmentFile }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), diff --git a/src/commands/send-file.ts b/src/commands/send-file.ts index fba73eb..022a469 100644 --- a/src/commands/send-file.ts +++ b/src/commands/send-file.ts @@ -1,25 +1,26 @@ import { Args, Command, Flags } from '@oclif/core' import { createReadStream } from 'node:fs' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' import { waitForMessage } from '../lib/wait.js' export default class SendFile extends Command { - static override summary = 'Send a file attachment to a chat' + static override summary = apiCopy.messages.send static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - file: Args.string({ description: 'File attachment to upload and send', required: true }), - text: Args.string({ description: 'Optional message text', required: false }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + file: Args.string({ description: sdkParamCopy.attachmentFile, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: false }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - 'file-name': Flags.string({ description: 'Attachment display filename' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - 'mime-type': Flags.string({ description: 'Attachment MIME type' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), - 'reply-to': Flags.string({ description: 'Reply to message ID' }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + 'reply-to': Flags.string({ description: sdkParamCopy.replyToMessageID }), wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), diff --git a/src/commands/send.ts b/src/commands/send.ts index cfb9305..784e212 100644 --- a/src/commands/send.ts +++ b/src/commands/send.ts @@ -1,25 +1,26 @@ import { Args, Command, Flags } from '@oclif/core' import { createReadStream } from 'node:fs' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' import { waitForMessage } from '../lib/wait.js' export default class Send extends Command { - static override summary = 'Send a text message to a chat' + static override summary = apiCopy.messages.send static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - text: Args.string({ description: 'Message text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + text: Args.string({ description: sdkParamCopy.text, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - file: Flags.string({ description: 'File attachment to upload and send' }), - 'file-name': Flags.string({ description: 'Attachment display filename' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - 'mime-type': Flags.string({ description: 'Attachment MIME type' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), - 'reply-to': Flags.string({ description: 'Reply to message ID' }), + file: Flags.string({ description: sdkParamCopy.attachmentFile }), + 'file-name': Flags.string({ description: sdkParamCopy.fileName }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + 'mime-type': Flags.string({ description: sdkParamCopy.mimeType }), + pick: Flags.integer({ description: cliCopy.flags.pick }), + 'reply-to': Flags.string({ description: sdkParamCopy.replyToMessageID }), wait: Flags.boolean({ default: false, description: 'Wait for the pending message to resolve' }), 'wait-interval': Flags.integer({ default: 750, description: 'Milliseconds between message status checks' }), 'wait-timeout': Flags.integer({ default: 30000, description: 'Milliseconds to wait for message resolution' }), diff --git a/src/commands/start-chat.ts b/src/commands/start-chat.ts index fe21828..f69062e 100644 --- a/src/commands/start-chat.ts +++ b/src/commands/start-chat.ts @@ -1,21 +1,22 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { listAccountIDs, resolveAccountIDs, userQueryFromInput } from '../lib/resolve.js' export default class StartChat extends Command { - static override summary = 'Resolve a contact and open or create a direct chat' + static override summary = apiCopy.chats.start static override args = { query: Args.string({ description: 'Phone, email, username, user ID, or name', required: false }), } static override flags = { - account: Flags.string({ multiple: true, description: 'Account ID, network, bridge, or account user. Omit to try every account.' }), + account: Flags.string({ multiple: true, description: `${cliCopy.args.accountSelector}. Omit to try every account.` }), 'allow-invite': Flags.boolean({ default: false, description: 'Allow invite-based DM creation when required' }), - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), email: Flags.string({ description: 'Email address' }), id: Flags.string({ description: 'Known user ID' }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), message: Flags.string({ description: 'Optional first message' }), name: Flags.string({ description: 'Display name hint' }), phone: Flags.string({ description: 'Phone number' }), diff --git a/src/commands/unarchive.ts b/src/commands/unarchive.ts index 955c47c..f3fe6cb 100644 --- a/src/commands/unarchive.ts +++ b/src/commands/unarchive.ts @@ -1,16 +1,17 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { resolveChatID } from '../lib/resolve.js' export default class Unarchive extends Command { - static override summary = 'Unarchive a chat' + static override summary = apiCopy.chats.archive static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/unreact.ts b/src/commands/unreact.ts index 56aae35..2071a2d 100644 --- a/src/commands/unreact.ts +++ b/src/commands/unreact.ts @@ -1,20 +1,21 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Unreact extends Command { - static override summary = 'Remove your reaction from a message' + static override summary = apiCopy.reactions.delete static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), - message: Args.string({ description: 'Message ID', required: true }), - reaction: Args.string({ description: 'Reaction key, emoji, or shortcode', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), + message: Args.string({ description: sdkParamCopy.messageID, required: true }), + reaction: Args.string({ description: sdkParamCopy.reactionKey, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/unread.ts b/src/commands/unread.ts index 0be96c8..68425d4 100644 --- a/src/commands/unread.ts +++ b/src/commands/unread.ts @@ -1,19 +1,20 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy, sdkParamCopy } from '../lib/copy.js' import { printData } from '../lib/output.js' import { resolveChatID } from '../lib/resolve.js' export default class Unread extends Command { - static override summary = 'Mark a chat as unread' + static override summary = apiCopy.chats.markUnread static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - json: Flags.boolean({ default: false, description: 'Print JSON' }), - message: Flags.string({ description: 'Mark unread from this message ID' }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + json: Flags.boolean({ default: false, description: cliCopy.flags.json }), + message: Flags.string({ description: sdkParamCopy.messageID }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/commands/unremind.ts b/src/commands/unremind.ts index 256dc04..f5ce907 100644 --- a/src/commands/unremind.ts +++ b/src/commands/unremind.ts @@ -1,16 +1,17 @@ import { Args, Command, Flags } from '@oclif/core' import { createClient } from '../lib/client.js' +import { apiCopy, cliCopy } from '../lib/copy.js' import { resolveChatID } from '../lib/resolve.js' export default class Unremind extends Command { - static override summary = 'Clear a chat reminder' + static override summary = apiCopy.reminders.delete static override args = { - chat: Args.string({ description: 'Chat ID, local chat ID, title, or search text', required: true }), + chat: Args.string({ description: cliCopy.args.chatSelector, required: true }), } static override flags = { - 'base-url': Flags.string({ description: 'Beeper Desktop API base URL' }), + 'base-url': Flags.string({ description: cliCopy.flags.baseURL }), debug: Flags.boolean({ default: false }), - pick: Flags.integer({ description: 'Pick the Nth chat when the input is ambiguous' }), + pick: Flags.integer({ description: cliCopy.flags.pick }), } async run(): Promise { diff --git a/src/lib/copy.ts b/src/lib/copy.ts new file mode 100644 index 0000000..9787515 --- /dev/null +++ b/src/lib/copy.ts @@ -0,0 +1,65 @@ +export const apiCopy = { + accounts: { + list: 'List Chat Accounts connected to this Beeper Desktop instance, including bridge metadata and network identity.', + }, + assets: { + download: 'Download a Matrix file using its mxc:// or localmxc:// URL to the device running Beeper Desktop and return the local file URL.', + upload: 'Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending a message or materializing a draft attachment.', + }, + chats: { + archive: 'Archive or unarchive a chat. Set archived=true to move to archive, archived=false to move back to inbox', + create: 'Create a direct or group chat from participant IDs. Returns the created chat.', + list: 'List all chats sorted by last activity (most recent first). Combines all accounts into a single paginated list.', + markRead: 'Mark a chat as read, optionally through a specific message ID.', + markUnread: 'Mark a chat as unread, optionally from a specific message ID.', + notifyAnyway: 'Force a delivery notification when supported by the underlying network. Currently intended for iMessage on macOS; unsupported networks return an error.', + retrieve: 'Retrieve chat details including metadata, participants, and latest message', + search: 'Search chats by title, network, or participant names.', + start: 'Resolve a user/contact and open a direct chat. Reuses and returns an existing direct chat when one is found. Available in Beeper Desktop v4.2.808+.', + }, + contacts: { + search: 'Search contacts on a specific account using merged account contacts, network search, and exact identifier lookup.', + }, + messages: { + delete: 'Delete a message by final message ID. Pending message IDs are not accepted because messages cannot be deleted while sending.', + list: 'List all messages in a chat with cursor-based pagination. Sorted by timestamp.', + retrieve: 'Retrieve a message by final message ID, pendingMessageID, or Matrix event ID. Chat ID may be a Beeper chat ID or local chat ID.', + search: 'Search messages across chats.', + send: 'Send a text message to a specific chat. Supports replying to existing messages. Returns a pending message ID.', + update: 'Edit the text content of an existing message. Messages with attachments cannot be edited.', + }, + reactions: { + add: 'Add a reaction to an existing message.', + delete: 'Remove the reaction added by the authenticated user from an existing message.', + }, + reminders: { + create: 'Set a reminder for a chat at a specific time', + delete: 'Clear an existing reminder from a chat', + }, +} as const + +export const sdkParamCopy = { + attachmentFile: 'The file to upload (max 500 MB).', + chatID: 'Chat ID. Input routes also accept the local chat ID from this Beeper Desktop installation when available.', + fileName: 'Original filename. Defaults to the uploaded file name if omitted', + forEveryone: 'True to request deletion for everyone when the network supports it; false to delete only for the authenticated user when supported.', + messageID: 'Message ID.', + mimeType: 'MIME type. Auto-detected from magic bytes if omitted', + reactionKey: 'Reaction key to add (emoji, shortcode, or custom emoji key)', + remindAt: 'Timestamp when the reminder should trigger.', + replyToMessageID: 'Provide a message ID to send this as a reply to an existing message', + searchQuery: 'User-typed search text. Literal word matching (non-semantic).', + text: 'Draft text. Plain text and Markdown are converted to Matrix HTML with the same rules used by send and edit.', +} as const + +export const cliCopy = { + args: { + accountSelector: 'Account ID, network, bridge, or account user', + chatSelector: `${sdkParamCopy.chatID} Also accepts exact chat titles or search text.`, + }, + flags: { + baseURL: 'Beeper Desktop API base URL', + json: 'Print JSON', + pick: 'Pick the Nth chat when the input is ambiguous', + }, +} as const diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index b7424e1..68b00ff 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -1,59 +1,61 @@ +import { apiCopy } from './copy.js' + export const commandManifest = [ - { command: 'accounts', description: 'List connected accounts' }, + { command: 'accounts', description: apiCopy.accounts.list }, { command: 'api get', description: 'Make an authenticated raw GET request' }, { command: 'api post', description: 'Make an authenticated raw POST request' }, - { command: 'archive', description: 'Archive a chat' }, - { command: 'assets download', description: 'Download an asset' }, - { command: 'assets upload', description: 'Upload an asset' }, + { command: 'archive', description: apiCopy.chats.archive }, + { command: 'assets download', description: apiCopy.assets.download }, + { command: 'assets upload', description: apiCopy.assets.upload }, { command: 'auth login', description: 'Authenticate with local Beeper Desktop' }, { command: 'auth logout', description: 'Remove local credentials' }, { command: 'auth status', description: 'Show local auth state' }, - { command: 'chat', description: 'Show one chat' }, + { command: 'chat', description: apiCopy.chats.retrieve }, { command: 'chat open', description: 'Alias for focus' }, - { command: 'chats', description: 'List chats' }, - { command: 'chats search', description: 'Search chats' }, + { command: 'chats', description: apiCopy.chats.list }, + { command: 'chats search', description: apiCopy.chats.search }, { command: 'clear-draft', description: 'Clear a chat draft' }, { command: 'commands', description: 'Print the command manifest' }, { command: 'config get', description: 'Print CLI configuration' }, { command: 'config path', description: 'Print the config file path' }, { command: 'config reset', description: 'Reset CLI configuration' }, { command: 'config set', description: 'Set CLI configuration' }, - { command: 'contacts search', description: 'Search contacts' }, - { command: 'create-chat', description: 'Create a chat' }, + { command: 'contacts search', description: apiCopy.contacts.search }, + { command: 'create-chat', description: apiCopy.chats.create }, { command: 'current-user', description: 'Show the OAuth userinfo response' }, - { command: 'delete-message', description: 'Delete a message' }, + { command: 'delete-message', description: apiCopy.messages.delete }, { command: 'doctor', description: 'Check Desktop API readiness' }, { command: 'draft', description: 'Set a chat draft' }, - { command: 'edit', description: 'Edit a message' }, + { command: 'edit', description: apiCopy.messages.update }, { command: 'focus', description: 'Focus Beeper Desktop or one chat' }, { command: 'llm', description: 'Print compact CLI help for agents' }, - { command: 'message', description: 'Show one message' }, + { command: 'message', description: apiCopy.messages.retrieve }, { command: 'mark-read', description: 'Alias for read' }, { command: 'mark-unread', description: 'Alias for unread' }, - { command: 'messages', description: 'List messages in a chat' }, - { command: 'messages search', description: 'Search messages' }, + { command: 'messages', description: apiCopy.messages.list }, + { command: 'messages search', description: apiCopy.messages.search }, { command: 'mute', description: 'Mute a chat' }, - { command: 'notify-anyway', description: 'Notify anyway for a chat' }, - { command: 'react', description: 'Add a reaction' }, - { command: 'read', description: 'Mark a chat read' }, - { command: 'remind', description: 'Set a chat reminder' }, - { command: 'reply', description: 'Reply to a message' }, - { command: 'reply-file', description: 'Reply to a message with a file attachment' }, + { command: 'notify-anyway', description: apiCopy.chats.notifyAnyway }, + { command: 'react', description: apiCopy.reactions.add }, + { command: 'read', description: apiCopy.chats.markRead }, + { command: 'remind', description: apiCopy.reminders.create }, + { command: 'reply', description: apiCopy.messages.send }, + { command: 'reply-file', description: apiCopy.messages.send }, { command: 'rpc', description: 'Run JSONL command RPC over stdin/stdout' }, { command: 'search', description: 'Search chats and messages' }, - { command: 'send', description: 'Send a message' }, - { command: 'send-file', description: 'Send a file attachment' }, + { command: 'send', description: apiCopy.messages.send }, + { command: 'send-file', description: apiCopy.messages.send }, { command: 'shell', description: 'Run an interactive Beeper CLI shell' }, - { command: 'start-chat', description: 'Start a chat with participants' }, + { command: 'start-chat', description: apiCopy.chats.start }, { command: 'status', description: 'Show Desktop API server info' }, { command: 'tail', description: 'Alias for watch' }, { command: 'thread', description: 'Alias for chat' }, { command: 'threads', description: 'Alias for chats' }, - { command: 'unarchive', description: 'Unarchive a chat' }, + { command: 'unarchive', description: apiCopy.chats.archive }, { command: 'unmute', description: 'Unmute a chat' }, - { command: 'unreact', description: 'Remove a reaction' }, - { command: 'unread', description: 'Mark a chat unread' }, - { command: 'unremind', description: 'Clear a chat reminder' }, + { command: 'unreact', description: apiCopy.reactions.delete }, + { command: 'unread', description: apiCopy.chats.markUnread }, + { command: 'unremind', description: apiCopy.reminders.delete }, { command: 'watch', description: 'Stream Desktop API WebSocket events' }, { command: 'whoami', description: 'Alias for current-user' }, ] From 1469c3e8929eee3af7859b7bb31ea308e5ff267b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?batuhan=20i=C3=A7=C3=B6z?= Date: Wed, 13 May 2026 22:22:25 +0200 Subject: [PATCH 04/19] tui --- .agents/skills/opentui/SKILL.md | 69 +++ .agents/skills/opentui/config.ts | 22 + .../skills/opentui/docs/bindings/react.mdx | 464 ++++++++++++++ .../skills/opentui/docs/bindings/solid.mdx | 500 ++++++++++++++++ .../opentui/docs/components/ascii-font.mdx | 191 ++++++ .../skills/opentui/docs/components/box.mdx | 217 +++++++ .../skills/opentui/docs/components/code.mdx | 280 +++++++++ .../skills/opentui/docs/components/diff.mdx | 90 +++ .../opentui/docs/components/frame-buffer.mdx | 267 +++++++++ .../skills/opentui/docs/components/input.mdx | 198 ++++++ .../opentui/docs/components/line-number.mdx | 119 ++++ .../opentui/docs/components/markdown.mdx | 196 ++++++ .../opentui/docs/components/scrollbar.mdx | 68 +++ .../opentui/docs/components/scrollbox.mdx | 250 ++++++++ .../skills/opentui/docs/components/select.mdx | 205 +++++++ .../skills/opentui/docs/components/slider.mdx | 64 ++ .../opentui/docs/components/tab-select.mdx | 203 +++++++ .../skills/opentui/docs/components/text.mdx | 175 ++++++ .../opentui/docs/components/textarea.mdx | 182 ++++++ .../opentui/docs/core-concepts/audio.mdx | 132 ++++ .../opentui/docs/core-concepts/colors.mdx | 226 +++++++ .../opentui/docs/core-concepts/console.mdx | 117 ++++ .../opentui/docs/core-concepts/constructs.mdx | 226 +++++++ .../opentui/docs/core-concepts/keyboard.mdx | 251 ++++++++ .../opentui/docs/core-concepts/layout.mdx | 255 ++++++++ .../opentui/docs/core-concepts/lifecycle.mdx | 124 ++++ .../docs/core-concepts/notifications.mdx | 35 ++ .../renderables-vs-constructs.mdx | 183 ++++++ .../docs/core-concepts/renderables.mdx | 358 +++++++++++ .../opentui/docs/core-concepts/renderer.mdx | 561 +++++++++++++++++ .../skills/opentui/docs/getting-started.mdx | 90 +++ .agents/skills/opentui/docs/keymap/addons.mdx | 213 +++++++ .agents/skills/opentui/docs/keymap/core.mdx | 566 ++++++++++++++++++ .../opentui/docs/keymap/custom-addons.mdx | 507 ++++++++++++++++ .agents/skills/opentui/docs/keymap/hosts.mdx | 256 ++++++++ .../skills/opentui/docs/keymap/overview.mdx | 322 ++++++++++ .agents/skills/opentui/docs/keymap/react.mdx | 129 ++++ .agents/skills/opentui/docs/keymap/solid.mdx | 122 ++++ .agents/skills/opentui/docs/plugins/core.mdx | 245 ++++++++ .agents/skills/opentui/docs/plugins/react.mdx | 154 +++++ .agents/skills/opentui/docs/plugins/slots.mdx | 235 ++++++++ .agents/skills/opentui/docs/plugins/solid.mdx | 163 +++++ .../opentui/docs/reference/color-matrix.mdx | 160 +++++ .../opentui/docs/reference/env-vars.mdx | 43 ++ .../opentui/docs/reference/tree-sitter.mdx | 196 ++++++ README.md | 14 + bun.lock | 294 +++++++++ package.json | 4 +- skills-lock.json | 11 + src/commands/export.ts | 50 ++ src/lib/export/index.ts | 532 ++++++++++++++++ src/lib/manifest.ts | 1 + test/cli-smoke.mjs | 81 ++- 53 files changed, 10614 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/opentui/SKILL.md create mode 100644 .agents/skills/opentui/config.ts create mode 100644 .agents/skills/opentui/docs/bindings/react.mdx create mode 100644 .agents/skills/opentui/docs/bindings/solid.mdx create mode 100644 .agents/skills/opentui/docs/components/ascii-font.mdx create mode 100644 .agents/skills/opentui/docs/components/box.mdx create mode 100644 .agents/skills/opentui/docs/components/code.mdx create mode 100644 .agents/skills/opentui/docs/components/diff.mdx create mode 100644 .agents/skills/opentui/docs/components/frame-buffer.mdx create mode 100644 .agents/skills/opentui/docs/components/input.mdx create mode 100644 .agents/skills/opentui/docs/components/line-number.mdx create mode 100644 .agents/skills/opentui/docs/components/markdown.mdx create mode 100644 .agents/skills/opentui/docs/components/scrollbar.mdx create mode 100644 .agents/skills/opentui/docs/components/scrollbox.mdx create mode 100644 .agents/skills/opentui/docs/components/select.mdx create mode 100644 .agents/skills/opentui/docs/components/slider.mdx create mode 100644 .agents/skills/opentui/docs/components/tab-select.mdx create mode 100644 .agents/skills/opentui/docs/components/text.mdx create mode 100644 .agents/skills/opentui/docs/components/textarea.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/audio.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/colors.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/console.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/constructs.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/keyboard.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/layout.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/lifecycle.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/notifications.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/renderables-vs-constructs.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/renderables.mdx create mode 100644 .agents/skills/opentui/docs/core-concepts/renderer.mdx create mode 100644 .agents/skills/opentui/docs/getting-started.mdx create mode 100644 .agents/skills/opentui/docs/keymap/addons.mdx create mode 100644 .agents/skills/opentui/docs/keymap/core.mdx create mode 100644 .agents/skills/opentui/docs/keymap/custom-addons.mdx create mode 100644 .agents/skills/opentui/docs/keymap/hosts.mdx create mode 100644 .agents/skills/opentui/docs/keymap/overview.mdx create mode 100644 .agents/skills/opentui/docs/keymap/react.mdx create mode 100644 .agents/skills/opentui/docs/keymap/solid.mdx create mode 100644 .agents/skills/opentui/docs/plugins/core.mdx create mode 100644 .agents/skills/opentui/docs/plugins/react.mdx create mode 100644 .agents/skills/opentui/docs/plugins/slots.mdx create mode 100644 .agents/skills/opentui/docs/plugins/solid.mdx create mode 100644 .agents/skills/opentui/docs/reference/color-matrix.mdx create mode 100644 .agents/skills/opentui/docs/reference/env-vars.mdx create mode 100644 .agents/skills/opentui/docs/reference/tree-sitter.mdx create mode 100644 bun.lock create mode 100644 skills-lock.json create mode 100644 src/commands/export.ts create mode 100644 src/lib/export/index.ts diff --git a/.agents/skills/opentui/SKILL.md b/.agents/skills/opentui/SKILL.md new file mode 100644 index 0000000..93676ad --- /dev/null +++ b/.agents/skills/opentui/SKILL.md @@ -0,0 +1,69 @@ +--- +name: opentui +description: Build terminal UIs with OpenTUI. Covers the core API, native audio, keymaps, React and Solid bindings, components, layout, keyboard input, plugins, and testing. +--- + +# OpenTUI Skill + +Canonical reference docs are authored once in sibling `docs/**/*.mdx` files. + +Inside the OpenTUI repo, this skill root lives at `packages/web/src/content/`, so the same files are also visible at `packages/web/src/content/docs/**/*.mdx`. + +## Path invariant + +- `/docs/` maps to `docs/.mdx` relative to this skill root +- in the repo, that same slug maps to `packages/web/src/content/docs/.mdx` + +## Reading order by area + +- Getting started: `/docs/getting-started` +- Core: `/docs/core-concepts/renderer` +- Audio: `/docs/core-concepts/audio` +- Keymap: `/docs/keymap/overview` +- React: `/docs/bindings/react` +- Solid: `/docs/bindings/solid` +- Components: `/docs/components/text`, `/docs/components/input` +- Layout: `/docs/core-concepts/layout` +- Keyboard: `/docs/core-concepts/keyboard` +- Plugins: `/docs/plugins/slots` +- Reference: `/docs/reference/env-vars` + +## Quick routing by intent + +| Intent(s) | Start here | +| ---------------------------------------------------------- | --------------------------------- | +| `getting-started`, `installation`, `quickstart`, `intro` | `docs/getting-started.mdx` | +| `core`, `renderer`, `terminal`, `scrollback`, `lifecycle` | `docs/core-concepts/renderer.mdx` | +| `audio`, `native-audio`, `sound`, `playback`, `pcm`, `fft` | `docs/core-concepts/audio.mdx` | +| `keymap`, `keybindings`, `shortcuts`, `commands`, `leader` | `docs/keymap/overview.mdx` | +| `layout`, `flexbox`, `yoga`, `positioning` | `docs/core-concepts/layout.mdx` | +| `keyboard`, `input`, `keybindings`, `paste`, `focus` | `docs/core-concepts/keyboard.mdx` | +| `react`, `jsx`, `hooks`, `animation`, `testing` | `docs/bindings/react.mdx` | +| `solid`, `signals`, `jsx`, `hooks`, `animation`, `testing` | `docs/bindings/solid.mdx` | +| `plugins`, `plugin`, `slots`, `registry`, `extensions` | `docs/plugins/slots.mdx` | +| `text`, `styling`, `content`, `selection` | `docs/components/text.mdx` | +| `input`, `form`, `editing`, `focus` | `docs/components/input.mdx` | +| `env`, `environment`, `configuration`, `flags` | `docs/reference/env-vars.mdx` | + +For concrete component requests, jump straight to `docs/components/.mdx` after the relevant entry page. For plugin implementation details, narrow from `docs/plugins/slots.mdx` into `docs/plugins/core.mdx`, `docs/plugins/react.mdx`, or `docs/plugins/solid.mdx`. + +## Current skill entry pages + +- `docs/getting-started.mdx` +- `docs/core-concepts/renderer.mdx` +- `docs/core-concepts/audio.mdx` +- `docs/keymap/overview.mdx` +- `docs/core-concepts/layout.mdx` +- `docs/core-concepts/keyboard.mdx` +- `docs/bindings/react.mdx` +- `docs/bindings/solid.mdx` +- `docs/plugins/slots.mdx` +- `docs/components/text.mdx` +- `docs/components/input.mdx` +- `docs/reference/env-vars.mdx` + +## Working rules + +- Prefer the current entry pages first, then read narrower docs in the same section. +- Read the sibling `docs/**/*.mdx` files directly instead of copying prose into this file. +- Use stable `/docs/...` URLs when cross-referencing docs. diff --git a/.agents/skills/opentui/config.ts b/.agents/skills/opentui/config.ts new file mode 100644 index 0000000..06059fe --- /dev/null +++ b/.agents/skills/opentui/config.ts @@ -0,0 +1,22 @@ +import { defineCollection, z } from "astro:content" + +const docs = defineCollection({ + type: "content", + schema: z.object({ + title: z.string(), + description: z.string().optional(), + order: z.number().int().nonnegative().optional(), + navTitle: z.string().optional(), + skill: z + .object({ + include: z.boolean().default(true), + entry: z.boolean().default(false), + intents: z.array(z.string().trim().min(1)).default([]), + }) + .optional(), + }), +}) + +export const collections = { + docs, +} diff --git a/.agents/skills/opentui/docs/bindings/react.mdx b/.agents/skills/opentui/docs/bindings/react.mdx new file mode 100644 index 0000000..acfa7a4 --- /dev/null +++ b/.agents/skills/opentui/docs/bindings/react.mdx @@ -0,0 +1,464 @@ +--- +title: React +description: Build terminal UIs with React and OpenTUI +order: 2 +skill: + entry: true + intents: [react, jsx, hooks, keyboard, paste, focus, blur, selection, animation, testing] +--- + +# React bindings + +Build terminal user interfaces using React with familiar patterns and components. + +## Installation + +Quick start with [bun](https://bun.sh) and [create-tui](https://github.com/msmps/create-tui): + +```bash +bun create tui --template react +``` + +Manual installation: + +```bash +bun install @opentui/react @opentui/core react +``` + +## Quick start + +```tsx +import { createCliRenderer } from "@opentui/core" +import { createRoot } from "@opentui/react" + +function App() { + return Hello, world! +} + +const renderer = await createCliRenderer() +createRoot(renderer).render() +``` + +## TypeScript configuration + +Configure your `tsconfig.json`: + +```json +{ + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "jsxImportSource": "@opentui/react", + "strict": true, + "skipLibCheck": true + } +} +``` + +## Runtime-loaded plugin support (if needed) + +If your app loads external TS/TSX modules at runtime (for example a file-based plugin system), import this once in your app entry before dynamic imports: + +```ts +import "@opentui/react/runtime-plugin-support" +``` + +Use this for both normal Bun runs and standalone compiled executables. + +## Components + +OpenTUI React provides JSX intrinsic elements that map to core renderables: + +### Layout & display + +- `` - Text display with styling +- `` - Container with borders and layout +- `` - Scrollable container +- `` - ASCII art text + +### Input + +- `` - Single-line text input +- `