Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CLI/CLI+AgentWrappers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ extension ProgramaCLI {
return prefix.contains("cmux claude wrapper - injects hooks and session tracking")
}

private func resolveExecutableInSearchPath(
func resolveExecutableInSearchPath(
_ name: String,
searchPath: String?,
skip: ((String) -> Bool)? = nil
Expand All @@ -33,14 +33,20 @@ extension ProgramaCLI {
return nil
}

private func resolveClaudeExecutable(searchPath: String?) -> String? {
func resolveClaudeExecutable(searchPath: String?) -> String? {
resolveExecutableInSearchPath(
"claude",
searchPath: searchPath,
skip: { self.isProgramaClaudeWrapper(at: $0) }
)
}

/// Resolves the `codex` CLI executable from PATH. Unlike Claude, Programa does not
/// ship a codex wrapper, so no skip predicate is needed.
func resolveCodexExecutable(searchPath: String?) -> String? {
resolveExecutableInSearchPath("codex", searchPath: searchPath)
}

private func claudeTeamsHasExplicitTeammateMode(commandArgs: [String]) -> Bool {
commandArgs.contains { arg in
arg == "--teammate-mode" || arg.hasPrefix("--teammate-mode=")
Expand Down
271 changes: 271 additions & 0 deletions CLI/CLI+Aside.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import Foundation

/// Locates the Aside CLI (https://docs.aside.com) on disk. Pure and process-free so
/// it can be unit tested without touching the real filesystem.
struct AsideCLILocator {
static func resolve(environment: [String: String], fileExists: (String) -> Bool) -> String? {
if let home = environment["HOME"], !home.isEmpty {
let localBin = (home as NSString).appendingPathComponent(".local/bin/aside")
if fileExists(localBin) {
return localBin
}
let cliApp = (home as NSString).appendingPathComponent(".aside/cli/Aside CLI.app/Contents/MacOS/aside")
if fileExists(cliApp) {
return cliApp
}
}
let entries = environment["PATH"]?.split(separator: ":").map(String.init) ?? []
for entry in entries where !entry.isEmpty {
let candidate = (entry as NSString).appendingPathComponent("aside")
if fileExists(candidate) {
return candidate
}
}
return nil
}
}

/// Builds the command plan for registering/removing Aside's MCP server(s) with
/// Claude Code and Codex. Pure so the exact argv can be unit tested without
/// spawning a process.
struct AsideMCPPlan {
let claudeCommands: [[String]]
let codexCommands: [[String]]

static func build(
asidePath: String,
claudeExecutable: String?,
codexExecutable: String?,
withDevTools: Bool,
install: Bool
) -> AsideMCPPlan {
var claudeCommands: [[String]] = []
var codexCommands: [[String]] = []

if let claude = claudeExecutable {
if install {
claudeCommands.append([
claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside", "--", asidePath, "mcp",
])
if withDevTools {
claudeCommands.append([
claude, "mcp", "add", "--scope", "user", "--transport", "stdio", "aside-devtools",
"--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223",
])
}
} else {
claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside"])
claudeCommands.append([claude, "mcp", "remove", "--scope", "user", "aside-devtools"])
}
}

if let codex = codexExecutable {
if install {
codexCommands.append([codex, "mcp", "add", "aside", "--", asidePath, "mcp"])
if withDevTools {
codexCommands.append([
codex, "mcp", "add", "aside-devtools",
"--", "npx", "-y", "chrome-devtools-mcp@latest", "--browserUrl", "http://127.0.0.1:9223",
])
}
} else {
codexCommands.append([codex, "mcp", "remove", "aside"])
codexCommands.append([codex, "mcp", "remove", "aside-devtools"])
}
}

return AsideMCPPlan(claudeCommands: claudeCommands, codexCommands: codexCommands)
}
}

extension ProgramaCLI {
/// Report-only probe of Aside's Chrome DevTools Protocol endpoint. Returns the
/// `webSocketDebuggerUrl` from `http://127.0.0.1:9223/json/version` when Aside is
/// running, nil otherwise (including on any network/parse failure).
func asideDevToolsEndpoint(timeout: TimeInterval = 2) -> String? {
guard let url = URL(string: "http://127.0.0.1:9223/json/version") else { return nil }
var request = URLRequest(url: url)
request.timeoutInterval = timeout
let semaphore = DispatchSemaphore(value: 0)
var result: String?
let task = URLSession.shared.dataTask(with: request) { data, _, _ in
defer { semaphore.signal() }
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let webSocketDebuggerUrl = json["webSocketDebuggerUrl"] as? String else { return }
result = webSocketDebuggerUrl
}
task.resume()
_ = semaphore.wait(timeout: .now() + timeout + 0.5)
return result
}

private func asideResolveClients(environment: [String: String]) -> (claude: String?, codex: String?) {
let searchPath = environment["PATH"]
return (
resolveClaudeExecutable(searchPath: searchPath),
resolveCodexExecutable(searchPath: searchPath)
)
}

private func asideIsRegistered(executable: String, serverName: String) -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = ["mcp", "get", serverName]
process.standardOutput = FileHandle.nullDevice
process.standardError = FileHandle.nullDevice
do {
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
} catch {
return false
}
}

private func asideRunCommand(_ command: [String]) throws {
guard let executable = command.first else { return }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = Array(command.dropFirst())
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
throw CLIError(message: "Command failed (\(process.terminationStatus)): \(command.joined(separator: " "))")
}
}

func runAside(arguments: [String]) throws {
let subcommand = arguments.first?.lowercased() ?? "help"
let withDevTools = arguments.contains("--with-devtools")
let skipConfirm = arguments.contains("--yes") || arguments.contains("-y")
let environment = ProcessInfo.processInfo.environment

let asidePath = AsideCLILocator.resolve(environment: environment) { FileManager.default.isExecutableFile(atPath: $0) }
let (claudeExecutable, codexExecutable) = asideResolveClients(environment: environment)

switch subcommand {
case "status":
if let asidePath {
print("Aside CLI: \(asidePath)")
} else {
print("Aside CLI: not found, install from https://docs.aside.com/help/developers")
}
if let endpoint = asideDevToolsEndpoint() {
print("DevTools: \(endpoint)")
} else {
print("DevTools: not reachable (is Aside running?)")
}
if let claudeExecutable {
let registered = asideIsRegistered(executable: claudeExecutable, serverName: "aside")
print("Claude Code: \(claudeExecutable): aside \(registered ? "registered" : "not registered")")
} else {
print("Claude Code: not found")
}
if let codexExecutable {
let registered = asideIsRegistered(executable: codexExecutable, serverName: "aside")
print("Codex: \(codexExecutable): aside \(registered ? "registered" : "not registered")")
} else {
print("Codex: not found")
}
return

case "install-mcp", "uninstall-mcp":
let install = subcommand == "install-mcp"
// Removal only needs the client CLIs; the Aside binary may already be gone.
if install, asidePath == nil {
throw CLIError(message: "Aside CLI not found. Install it from https://docs.aside.com/help/developers")
}
let planAsidePath = asidePath ?? "aside"
if claudeExecutable == nil, codexExecutable == nil {
throw CLIError(message: "Neither Claude Code nor Codex CLI was found on PATH.")
}

print("Aside CLI: \(asidePath ?? "not found")")
if let endpoint = asideDevToolsEndpoint() {
print("DevTools: \(endpoint)")
}
if claudeExecutable == nil {
print("Claude Code: not found, skipping")
}
if codexExecutable == nil {
print("Codex: not found, skipping")
}

let plan = AsideMCPPlan.build(
asidePath: planAsidePath,
claudeExecutable: claudeExecutable,
codexExecutable: codexExecutable,
withDevTools: withDevTools,
install: install
)

var pendingCommands: [(client: String, serverName: String, command: [String])] = []
for command in plan.claudeCommands {
let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside"
pendingCommands.append((client: "Claude Code", serverName: serverName, command: command))
}
for command in plan.codexCommands {
let serverName = command.contains("aside-devtools") ? "aside-devtools" : "aside"
pendingCommands.append((client: "Codex", serverName: serverName, command: command))
}

guard !pendingCommands.isEmpty else {
print("Nothing to do.")
return
}

print("")
print("The following commands will run:")
for entry in pendingCommands {
print(" \(entry.command.joined(separator: " "))")
}

if !skipConfirm {
print("Apply these changes? [Y/n] ", terminator: "")
// EOF (closed or non-interactive stdin) counts as "no": never fail open.
guard let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() else {
print("")
print("Aborted (no confirmation on stdin; pass --yes to skip the prompt).")
return
}
if !response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}

var failures: [String] = []
for entry in pendingCommands {
guard let executable = entry.command.first else { continue }
let registered = asideIsRegistered(executable: executable, serverName: entry.serverName)
if install, registered {
print("\(entry.client): \(entry.serverName) already registered, skipping")
continue
}
if !install, !registered {
print("\(entry.client): \(entry.serverName) not registered, skipping")
continue
}
print("Running: \(entry.command.joined(separator: " "))")
do {
try asideRunCommand(entry.command)
} catch {
// Keep going so one client's failure never leaves the other client's
// registration untouched; report everything at the end.
failures.append("\(entry.client) \(entry.serverName): \(error)")
}
}
print("")
if failures.isEmpty {
print(install ? "Installed." : "Removed.")
return
}
throw CLIError(message: (install ? "Some registrations failed:\n " : "Some removals failed:\n ") + failures.joined(separator: "\n "))

default:
print("Usage: programa aside <status|install-mcp|uninstall-mcp> [--with-devtools] [--yes]")
throw CLIError(message: "Unknown aside subcommand: \(subcommand)")
}
}
}
3 changes: 3 additions & 0 deletions CLI/CLICommandDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ struct CLICommandDispatcher {
explicitPassword: socketPasswordArg
)
return
case "aside":
try cli.runAside(arguments: commandArgs)
return
default:
break
}
Expand Down
19 changes: 19 additions & 0 deletions CLI/programa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1153,6 +1153,23 @@ struct ProgramaCLI {
""",
execute: nil
),
CommandDescriptor(
names: ["aside"],
helpLines: ["aside <status|install-mcp|uninstall-mcp> [--with-devtools] [--yes]"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa aside <status|install-mcp|uninstall-mcp> [--with-devtools] [--yes]

Detect the Aside agent browser CLI and register its MCP server with
Claude Code and Codex. `status` reports the detected aside binary,
the DevTools endpoint if Aside is running, and each client's
registration state. `install-mcp` runs `claude mcp add` / `codex mcp add`
for each detected client; `--with-devtools` also registers a
chrome-devtools-mcp server pointed at Aside's DevTools port.
`uninstall-mcp` removes both. `--yes`/`-y` skips the confirmation prompt.
""",
execute: nil
),

CommandDescriptor(
names: ["ping"],
Expand Down Expand Up @@ -6470,6 +6487,8 @@ struct ProgramaCLI {
return
case "codex", "claude", "opencode":
_ = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1)
case "aside":
_ = try parse(booleans: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1)
// Commands with richer bespoke contracts are validated by their
// dedicated cases in `validateArguments`.
case "ping", "focus-panel", "read-screen", "wait-surface", "set-progress", "list-log", "watch-events":
Expand Down
4 changes: 4 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@
6C9DA4528D8FCFF501A5FA8C /* programa-mcp in Copy CLI */ = {isa = PBXBuildFile; fileRef = 0AC9A9E0A68EF163F87A0C83 /* programa-mcp */; };
B9000031A1B2C3D4E5F60719 /* CLI+Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */; };
B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */; };
A51DE0700000000000000001 /* CLI+Aside.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51DE0700000000000000002 /* CLI+Aside.swift */; };
B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */; };
B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */; };
B900003BA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */; };
Expand Down Expand Up @@ -661,6 +662,7 @@
30DC1E7B0A701824297403A5 /* FocusTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FocusTools.swift"; sourceTree = "<group>"; };
B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Markdown.swift"; sourceTree = "<group>"; };
B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Browser.swift"; sourceTree = "<group>"; };
A51DE0700000000000000002 /* CLI+Aside.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Aside.swift"; sourceTree = "<group>"; };
B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Themes.swift"; sourceTree = "<group>"; };
B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Tree.swift"; sourceTree = "<group>"; };
B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+TmuxCompat.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1075,6 +1077,7 @@
B9000001A1B2C3D4E5F60719 /* programa.swift */,
B9000030A1B2C3D4E5F60719 /* CLI+Markdown.swift */,
B9000034A1B2C3D4E5F60719 /* CLI+Browser.swift */,
A51DE0700000000000000002 /* CLI+Aside.swift */,
B9000036A1B2C3D4E5F60719 /* CLI+Themes.swift */,
B9000038A1B2C3D4E5F60719 /* CLI+Tree.swift */,
B900003AA1B2C3D4E5F60719 /* CLI+TmuxCompat.swift */,
Expand Down Expand Up @@ -1705,6 +1708,7 @@
RVPN00000000000000000010 /* CLI+Review.swift in Sources */,
RCAP000001 /* CLI+Recap.swift in Sources */,
B9000035A1B2C3D4E5F60719 /* CLI+Browser.swift in Sources */,
A51DE0700000000000000001 /* CLI+Aside.swift in Sources */,
B9000037A1B2C3D4E5F60719 /* CLI+Themes.swift in Sources */,
THTM0003 /* TerminalThemeStore.swift in Sources */,
B9000039A1B2C3D4E5F60719 /* CLI+Tree.swift in Sources */,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Choose separate light and dark Ghostty themes in `Settings → Appearance → Te

Agents running inside programa (Claude Code, Codex, OpenCode) can drive the app itself, splitting panes, reading a sibling pane's output, spawning and coordinating a helper agent, all without stealing your focus. `programa claude/codex/opencode install-integration` installs [`SKILL.md`](SKILL.md) alongside the existing hooks; see [docs/agent-skill.md](docs/agent-skill.md) for the full walkthrough.

The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md).
The same control surface is also available over MCP, for agents that speak it natively. Point your client at `Programa.app/Contents/Resources/bin/programa-mcp`; see [docs/mcp-server.md](docs/mcp-server.md). The MCP server also exposes programa's embedded browser as `browser_*` tools, and `programa aside install-mcp` registers the [Aside](https://aside.com) browser with Claude Code and Codex for logged-in sites; see [docs/aside-browser.md](docs/aside-browser.md).

## Community

Expand Down
Loading
Loading