| 1 | # π€ eleven β the command-line client |
| 2 | |
| 3 | `eleven` is Eleven Messenger in your terminal: a full E2EE member, not a |
| 4 | gateway or a bot. It signs in with the same passkey backup or invite links |
| 5 | every device uses, holds its private keys in local files, and |
| 6 | encrypts/decrypts everything itself β the servers see exactly what they see |
| 7 | from any other device: ciphertext. |
| 8 | |
| 9 | It's a separate small binary (like `push/` and `home/`): members install the |
| 10 | client, operators install the server. |
| 11 | |
| 12 | ## Installing |
| 13 | |
| 14 | **From the Mac app** (how members get it): the Mac app bundles the CLI at |
| 15 | `Eleven.app/Contents/Helpers/eleven` β universal, signed, and updated by |
| 16 | Sparkle alongside the app (the "Embed eleven CLI" build phase in |
| 17 | `ios/project.yml` compiles it into every build). Settings β General β |
| 18 | **Command-line tool** β Installβ¦ drops a symlink where you choose |
| 19 | (`/usr/local/bin` keeps it on PATH); the app is sandboxed, so the folder pick |
| 20 | runs through the powerbox, and when a folder needs an administrator the pane |
| 21 | hands you the one Terminal command instead: |
| 22 | |
| 23 | ```sh |
| 24 | sudo mkdir -p /usr/local/bin && \ |
| 25 | sudo ln -sf "/Applications/Eleven.app/Contents/Helpers/eleven" /usr/local/bin/eleven |
| 26 | ``` |
| 27 | |
| 28 | **From source** (developers): |
| 29 | |
| 30 | ```sh |
| 31 | go build -o eleven ./eleven |
| 32 | ``` |
| 33 | |
| 34 | ## Sign in with Eleven (`eleven signin`) |
| 35 | |
| 36 | One passkey, every space. `eleven signin` pulls the spaces saved in your |
| 37 | Eleven account (the home server's passkey-protected backup) and signs the |
| 38 | terminal into all of them at once. |
| 39 | |
| 40 | A terminal can't run a passkey ceremony, so the browser does it β the same |
| 41 | handoff the native Mac app uses when its passkey provider can't help: |
| 42 | |
| 43 | 1. `eleven signin` mints an ephemeral keypair + nonce, opens a local loopback |
| 44 | listener, and sends your browser to home's restore page (payload in the |
| 45 | `#fragment` β the home server never sees it). |
| 46 | 2. You approve with Face ID / Touch ID / your passkey. The page decrypts your |
| 47 | backup **in the browser**, seals the home session to the ephemeral key, and |
| 48 | redirects to `http://127.0.0.1:<port>` β hardcoded loopback, ciphertext all |
| 49 | the way. |
| 50 | 3. The CLI unwraps it, pulls the vault, and writes one identity per space |
| 51 | under `~/.eleven/spaces/`. Archived spaces are skipped. |
| 52 | |
| 53 | SSH'd into a box whose browser is elsewhere? `eleven signin --paste` prints |
| 54 | the link and takes the sealed code by paste instead of a redirect. |
| 55 | |
| 56 | The home session lands in `~/.eleven/home.json`, so later `eleven signin` |
| 57 | runs re-sync silently β no browser. Note the trust profile: that file (like |
| 58 | the native apps' Keychain copy) can fetch and open your whole backup, and the |
| 59 | space session files are the space keys themselves. `~/.eleven` *is* your key |
| 60 | ring; deleting it signs the terminal out of everything. |
| 61 | |
| 62 | ## Joining one space by invite (`eleven join`) |
| 63 | |
| 64 | ```sh |
| 65 | eleven join # prompts for an invite link (and your name) |
| 66 | eleven join --name "Paul" 'https://yourspace.11ch.at/join/β¦#β¦' |
| 67 | ``` |
| 68 | |
| 69 | All three link shapes work: |
| 70 | |
| 71 | - **A fresh invite** makes you a new member with a new keypair. |
| 72 | - **A friend's invite link** (the kind that opens a DM with them) joins you |
| 73 | *and* unlocks that chat β the DM key is re-wrapped to your new keypair |
| 74 | locally during the claim. |
| 75 | - **An add-device link** (from your own account) signs the terminal in as |
| 76 | *you*: the link carries your keypair in its `#fragment`, so the terminal |
| 77 | becomes another of your devices and can read everything your others can. |
| 78 | |
| 79 | Your identity lands in `~/.eleven/spaces/<host>.json` (0600) β or pass |
| 80 | `-session file` (`ELEVEN_SESSION`) to pin one identity file and ignore the |
| 81 | store, for scripts. Session files **are** private keys: anyone who reads one |
| 82 | can read those chats, and deleting your only device's key loses it for good β |
| 83 | same as any device. |
| 84 | |
| 85 | Two honest gaps on a *fresh* join, both inherent to how keys travel: your |
| 86 | roster name waits for the instance key, which another member's device grants |
| 87 | once it's online (`eleven` retries the publish on every run until it lands β |
| 88 | your messages carry your name regardless), and chats appear only once someone |
| 89 | DMs you or adds you to a group. Joining a space where Eleven runs, the |
| 90 | welcome DM shows up on its own. |
| 91 | |
| 92 | ## Talking |
| 93 | |
| 94 | ```sh |
| 95 | eleven spaces # what this terminal is signed into |
| 96 | eleven chats # your chats across every space |
| 97 | eleven chat # open your most recently active chat |
| 98 | eleven chat kitchen # β¦or match one by name (case-insensitive) |
| 99 | eleven chat family/kitchen # space/name scopes when names collide |
| 100 | eleven send eleven "what's the weather like for Saturday?" |
| 101 | git log --oneline -5 | eleven send eleven # pipe stdin as the message |
| 102 | ``` |
| 103 | |
| 104 | `eleven chat` is a live loop: incoming messages print as they arrive over the |
| 105 | WebSocket, and anything you type sends on Enter (Ctrl-C or Ctrl-D leaves). |
| 106 | DMing one of the space's bots this way is Claude-in-your-terminal over your |
| 107 | own E2EE infrastructure β the bot can't tell the terminal from the app. |
| 108 | |
| 109 | ## What v1 deliberately leaves out |
| 110 | |
| 111 | Rendered messages are new chat lines (plus `/me`-style action lines and a |
| 112 | "[n button(s)]" note on messages carrying chips). Not yet rendered: edits, |
| 113 | deletions, reactions, typing indicators, attachments, link previews β and |
| 114 | there's no thread creation from the CLI (start chats in the app). Incoming |
| 115 | lines also interleave with whatever you're typing, classic-IRC style; a |
| 116 | proper composer line is a follow-up. |
| 117 | |
| 118 | ## How it's built |
| 119 | |
| 120 | `internal/client` is the E2EE member client layer β join/claim |
| 121 | (`join.go`, mirroring `web/static/join.js` headlessly), the WebSocket |
| 122 | transport with thread-key unwrap, cursor-gated delivery and reconnect |
| 123 | (`client.go`, the transport half of `internal/bot`'s runtime with the bot |
| 124 | policy left out), and the invite-link parser (`invite.go`, mirroring |
| 125 | `web/static/invite.js`). The bot sidecar still carries its own copy of the |
| 126 | transport; moving it onto `internal/client` is the planned follow-up, so the |
| 127 | two can't drift. Session files are byte-compatible across the CLI, the Go bot, |
| 128 | and the Node sidecar. |
| 129 | |
| 130 | E2E coverage lives in `eleven_client_test.go` (root package, against the real |
| 131 | router): all three join paths, name decryption, live delivery, and both-ways |
| 132 | sends. |
| 133 | |