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>
4 files changed,
+162
−0
docs/eleven-cli.md+18 −0ios/LChatMac/CLIToolSection.swift+108 −0ios/LChatMac/MacSettingsView.swift+1 −0ios/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: |