eleven / messenger Public

mac: bundle the eleven CLI + a Settings install action (#503)

The Mac app carries the CLI at Contents/Helpers/eleven — built
universal (CGO off, arm64+x86_64) and signed by the new 'Embed eleven
CLI' phase, so it notarizes and rides Sparkle updates with the app.
Settings → General grows a Command-line tool section: Install… picks a
folder through the powerbox (the app is sandboxed) and symlinks the
bundled binary; a root-owned /usr/local/bin falls back to a copyable
sudo one-liner. Mac-only by nature — no iOS counterpart to keep parity
with.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3ebee4ad5a46d60d35a6d03932c3032c87cf9f87 parent 132b48a
4 files changed, +162 −0
  • docs/eleven-cli.md +18 −0
  • ios/LChatMac/CLIToolSection.swift +108 −0
  • ios/LChatMac/MacSettingsView.swift +1 −0
  • ios/project.yml +35 −0
diff --git a/docs/eleven-cli.md b/docs/eleven-cli.md
index b0e0e43..5c23c6c 100644
--- a/docs/eleven-cli.md
+++ b/docs/eleven-cli.md
@@ -9,6 +9,24 @@ from any other device: ciphertext.
It's a separate small binary (like `push/` and `home/`): members install the
client, operators install the server.
+## Installing
+
+**From the Mac app** (how members get it): the Mac app bundles the CLI at
+`Eleven.app/Contents/Helpers/eleven` — universal, signed, and updated by
+Sparkle alongside the app (the "Embed eleven CLI" build phase in
+`ios/project.yml` compiles it into every build). Settings → General →
+**Command-line tool** → Install… drops a symlink where you choose
+(`/usr/local/bin` keeps it on PATH); the app is sandboxed, so the folder pick
+runs through the powerbox, and when a folder needs an administrator the pane
+hands you the one Terminal command instead:
+
+```sh
+sudo mkdir -p /usr/local/bin && \
+ sudo ln -sf "/Applications/Eleven.app/Contents/Helpers/eleven" /usr/local/bin/eleven
+```
+
+**From source** (developers):
+
```sh
go build -o eleven ./eleven
```
diff --git a/ios/LChatMac/CLIToolSection.swift b/ios/LChatMac/CLIToolSection.swift
new file mode 100644
index 0000000..f1e6de0
--- /dev/null
+++ b/ios/LChatMac/CLIToolSection.swift
@@ -0,0 +1,108 @@
+import AppKit
+import SwiftUI
+
+// 🤖 Settings → General → "Command-line tool": install the bundled eleven CLI
+// (Contents/Helpers/eleven, embedded by the "Embed eleven CLI" build phase —
+// see ios/project.yml and docs/eleven-cli.md) onto the user's PATH.
+//
+// The app is sandboxed, so it can't just write /usr/local/bin: the Install
+// button runs an NSOpenPanel (the powerbox grants write access to whatever
+// folder the user picks) and drops a symlink to the bundled binary there. When
+// even that fails — stock /usr/local/bin is root-owned — we fall back to
+// showing the one Terminal command that does it, with a Copy button. A symlink
+// (not a copy) means Sparkle updates update the CLI too.
+struct CLIToolSection: View {
+ @AppStorage("cliInstalledPath") private var installedPath = ""
+ @State private var note: String?
+ @State private var fallbackCommand: String?
+
+ private var bundled: URL {
+ Bundle.main.bundleURL.appendingPathComponent("Contents/Helpers/eleven")
+ }
+
+ var body: some View {
+ // Older builds (or a build without Go on the machine) carry no helper;
+ // don't offer to install what isn't there.
+ if FileManager.default.fileExists(atPath: bundled.path) {
+ Section("Command-line tool") {
+ Text("Chat from your terminal: `eleven chats`, `eleven chat`, and `eleven send`. It signs in with the same passkey (`eleven signin`).")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ HStack {
+ if installedPath.isEmpty {
+ Text("Not installed").foregroundStyle(.secondary)
+ } else {
+ Label(installedPath, systemImage: installStillValid ? "checkmark.circle" : "exclamationmark.triangle")
+ .foregroundStyle(.secondary)
+ .font(.callout)
+ }
+ Spacer()
+ Button(installedPath.isEmpty ? "Install…" : "Reinstall…") { install() }
+ }
+ if let note {
+ Text(note).font(.footnote).foregroundStyle(.secondary)
+ }
+ if let fallbackCommand {
+ // That folder wasn't writable even via the powerbox (stock
+ // /usr/local/bin is root-owned) — hand over the one-liner.
+ VStack(alignment: .leading, spacing: 6) {
+ Text("That folder needs an administrator — run this in Terminal instead:")
+ .font(.footnote)
+ HStack {
+ Text(fallbackCommand)
+ .font(.system(.footnote, design: .monospaced))
+ .textSelection(.enabled)
+ .lineLimit(2)
+ Button("Copy") {
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(fallbackCommand, forType: .string)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // A best-effort staleness check: the symlink should still resolve to a
+ // binary (the app may have moved). Sandbox may deny the stat — treat
+ // unreadable as fine rather than alarm on a false negative.
+ private var installStillValid: Bool {
+ guard !installedPath.isEmpty else { return false }
+ let dest = (try? FileManager.default.destinationOfSymbolicLink(atPath: installedPath)) ?? bundled.path
+ return FileManager.default.fileExists(atPath: dest)
+ }
+
+ private func install() {
+ note = nil
+ fallbackCommand = nil
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.canCreateDirectories = true
+ panel.allowsMultipleSelection = false
+ panel.message = "Choose where to install the eleven command. /usr/local/bin keeps it on your PATH."
+ panel.prompt = "Install Here"
+ panel.directoryURL = URL(fileURLWithPath: "/usr/local/bin", isDirectory: true)
+ guard panel.runModal() == .OK, let dir = panel.url else { return }
+
+ let target = dir.appendingPathComponent("eleven")
+ let accessing = dir.startAccessingSecurityScopedResource()
+ defer { if accessing { dir.stopAccessingSecurityScopedResource() } }
+ do {
+ try? FileManager.default.removeItem(at: target)
+ try FileManager.default.createSymbolicLink(at: target, withDestinationURL: bundled)
+ installedPath = target.path
+ note = onPath(dir.path)
+ ? "Installed — open a new terminal and run `eleven signin`."
+ : "Installed. \(dir.path) isn’t usually on PATH — add it, or reinstall to /usr/local/bin."
+ } catch {
+ fallbackCommand = "sudo mkdir -p /usr/local/bin && sudo ln -sf \"\(bundled.path)\" /usr/local/bin/eleven"
+ }
+ }
+
+ // The folders shells have on PATH without any profile edits.
+ private func onPath(_ dir: String) -> Bool {
+ ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin", "/bin"].contains(dir)
+ }
+}
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index e80b58c..6107434 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -54,6 +54,7 @@ struct MacSettingsView: View {
} header: {
Text("Text size")
}
+ CLIToolSection()
Section("Profile") {
Picker("Server", selection: $selectedInstanceID) {
ForEach(app.instances) { inst in
diff --git a/ios/project.yml b/ios/project.yml
index 84ff63d..591db25 100644
--- a/ios/project.yml
+++ b/ios/project.yml
@@ -122,6 +122,9 @@ targets:
# ios/release-mac.sh). Sparkle's bundled XPC helpers need no entitlement
# exceptions on top of this for a non-sandboxed app.
ENABLE_HARDENED_RUNTIME: YES
+ # The "Embed eleven CLI" phase runs `go build` (GOCACHE lives outside
+ # the build dir), which Xcode's script sandbox would refuse.
+ ENABLE_USER_SCRIPT_SANDBOXING: NO
configs:
# Debug carries the webcredentials association for Mac "Sign in with
# Eleven" (?mode=developer — never ships; Release keeps the plain
@@ -129,6 +132,38 @@ targets:
# registered in the team (device 8VWU966HQ3, 2026-07-08).
Debug:
CODE_SIGN_ENTITLEMENTS: LChatMac/LChatMac-Dev.entitlements
+ # Bundle the eleven CLI (../eleven, docs/eleven-cli.md) at
+ # Contents/Helpers/eleven so Settings can offer "install the command-line
+ # tool" — the app and the CLI ship as one artifact. Built universal
+ # (arm64+x86_64, CGO off) and signed here because Xcode only auto-signs
+ # nested code in Frameworks/PlugIns; hardened runtime + (release) a secure
+ # timestamp keep notarization happy. Runs before the product's own CodeSign
+ # step, so the app seal covers it.
+ postBuildScripts:
+ - name: Embed eleven CLI
+ shell: /bin/bash
+ outputFiles:
+ - $(TARGET_BUILD_DIR)/$(CONTENTS_FOLDER_PATH)/Helpers/eleven
+ script: |
+ set -euo pipefail
+ GO="$(command -v go || true)"
+ # Xcode's PATH is minimal — look where Go installs actually live.
+ for g in /usr/local/go/bin/go /opt/homebrew/bin/go /usr/local/bin/go; do
+ [ -n "$GO" ] && break
+ [ -x "$g" ] && GO="$g"
+ done
+ [ -n "$GO" ] || { echo "error: embedding the eleven CLI needs Go on this machine (brew install go)" >&2; exit 1; }
+ HELPERS="$TARGET_BUILD_DIR/$CONTENTS_FOLDER_PATH/Helpers"
+ mkdir -p "$HELPERS"
+ TMPD="$(mktemp -d)"; trap 'rm -rf "$TMPD"' EXIT
+ cd "$SRCROOT/.."
+ CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 "$GO" build -trimpath -o "$TMPD/eleven-arm64" ./eleven
+ CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 "$GO" build -trimpath -o "$TMPD/eleven-amd64" ./eleven
+ lipo -create -output "$HELPERS/eleven" "$TMPD/eleven-arm64" "$TMPD/eleven-amd64"
+ TS=()
+ [ "$CONFIGURATION" = "Release" ] && TS=(--timestamp)
+ codesign --force --options runtime ${TS[@]+"${TS[@]}"} \
+ --sign "${EXPANDED_CODE_SIGN_IDENTITY:--}" "$HELPERS/eleven"
info:
path: LChatMac/Info.plist
properties: