Add Codex CLI hooks integration (#2103)

* Add Codex CLI hooks integration

Adds `cmux codex install-hooks` to install lifecycle hooks into
~/.codex/hooks.json and enable the codex_hooks feature flag. The hooks
call `cmux codex-hook <event>` which gracefully no-ops (exit 0, prints
{}) when not running inside cmux, so they're safe to leave installed
globally.

Supported events: SessionStart (session tracking), UserPromptSubmit
(set Running status), Stop (completion notification + Idle status).

Install merges with existing user hooks and is idempotent. Uninstall
(`cmux codex uninstall-hooks`) removes only cmux-owned hooks,
identified by the `cmux codex-hook` command prefix.

* Show diff and ask for confirmation before modifying user config

install-hooks and uninstall-hooks now preview changes to hooks.json and
config.toml before applying, with a [Y/n] prompt. Pass --yes/-y to
skip confirmation.

Hook commands use `command -v cmux` guard so they silently no-op
(echo '{}') when cmux CLI is not on PATH (e.g. user runs codex in a
non-cmux terminal or after uninstalling cmux).

* Improve diff output with line numbers and context

install-hooks and uninstall-hooks now show unified-diff-style output
with line numbers and surrounding context lines, making it easier to
see exactly what will change in hooks.json and config.toml.

* Check CMUX_SURFACE_ID in shell guard before calling cmux

The hook shell command now checks [ -n "$CMUX_SURFACE_ID" ] first,
so it short-circuits to echo '{}' without ever invoking cmux when
not inside a cmux terminal. Prevents usage text and socket errors
from leaking into Codex hook output.

* Uninstall reverts config.toml; fix [features] section handling

uninstall-hooks now also removes codex_hooks from config.toml and
shows the diff for both files before asking for confirmation.

buildConfigWithCodexHooks uses exact TOML key matching instead of
substring contains, and inserts after the first [features] header
only (not replacingOccurrences which hit all matches).

---------

Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
Lawrence Chen 2026-03-24 22:27:44 -07:00 committed by GitHub
parent 03355ca3fc
commit 09e31448c9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1413,6 +1413,7 @@ struct CMUXCLI {
// so help text is available even when cmux is not running.
if command != "__tmux-compat",
command != "claude-teams",
command != "codex",
(commandArgs.contains("--help") || commandArgs.contains("-h")) {
if dispatchSubcommandHelp(command: command, commandArgs: commandArgs) {
return
@ -1463,6 +1464,27 @@ struct CMUXCLI {
return
}
// Codex hooks management (no socket needed)
if command == "codex" {
let sub = commandArgs.first?.lowercased() ?? "help"
if sub == "install-hooks" {
try runCodexInstallHooks()
return
} else if sub == "uninstall-hooks" {
try runCodexUninstallHooks()
return
}
}
// Codex hook handler: gracefully no-op when not inside cmux
// (before socket connection, so it doesn't fail when no socket exists)
if command == "codex-hook" {
guard ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] != nil else {
print("{}")
return
}
}
let client = SocketClient(path: resolvedSocketPath)
if resolvedSocketPath != socketPath {
cliTelemetry.breadcrumb(
@ -2245,6 +2267,17 @@ struct CMUXCLI {
throw error
}
case "codex-hook":
cliTelemetry.breadcrumb("codex-hook.dispatch")
do {
try runCodexHook(commandArgs: commandArgs, client: client, telemetry: cliTelemetry)
cliTelemetry.breadcrumb("codex-hook.completed")
} catch {
cliTelemetry.breadcrumb("codex-hook.failure")
cliTelemetry.captureError(stage: "codex_hook_dispatch", error: error)
throw error
}
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try sendV1Command("set_app_focus \(value)", client: client)
@ -7047,6 +7080,32 @@ struct CMUXCLI {
echo '{"session_id":"abc"}' | cmux claude-hook session-start
echo '{}' | cmux claude-hook stop
"""
case "codex":
return """
Usage: cmux codex <install-hooks|uninstall-hooks>
Manage Codex CLI hooks integration.
Subcommands:
install-hooks Install cmux hooks into ~/.codex/hooks.json
uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json
"""
case "codex-hook":
return """
Usage: cmux codex-hook <session-start|prompt-submit|stop> [flags]
Hook for Codex CLI integration. Reads JSON from stdin.
Gracefully no-ops when not running inside cmux.
Subcommands:
session-start Register a Codex session
prompt-submit Set Running status on user prompt
stop Send completion notification, set Idle
Flags:
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $CMUX_SURFACE_ID)
"""
case "browser":
return """
Usage: cmux browser [--surface <id|ref|index> | <surface>] <subcommand> [args]
@ -10982,6 +11041,552 @@ struct CMUXCLI {
.replacingOccurrences(of: "|", with: "¦")
}
// MARK: - Codex hooks
/// The hooks.json content that cmux installs into ~/.codex/.
/// Each hook calls `cmux codex-hook <event>` which gracefully no-ops
/// when not running inside cmux. The command checks for cmux on PATH
/// first so it silently succeeds even when cmux is not installed
/// (e.g. user opened codex in a non-cmux terminal).
private static func codexHookCommand(_ event: String) -> String {
"[ -n \"$CMUX_SURFACE_ID\" ] && command -v cmux >/dev/null 2>&1 && cmux codex-hook \(event) || echo '{}'"
}
private static let codexHooksJSON: [String: Any] = [
"hooks": [
"SessionStart": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("session-start"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"UserPromptSubmit": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("prompt-submit"),
"timeout": 10
] as [String: Any]]
] as [String: Any]],
"Stop": [[
"hooks": [[
"type": "command",
"command": codexHookCommand("stop"),
"timeout": 10
] as [String: Any]]
] as [String: Any]]
] as [String: Any]
]
/// Identifier used to detect cmux-owned hooks during uninstall.
private static let codexHookCommandMarker = "cmux codex-hook"
private func runCodexInstallHooks() throws {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]
?? NSString(string: "~/.codex").expandingTildeInPath
let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json")
let configPath = (codexHome as NSString).appendingPathComponent("config.toml")
let fm = FileManager.default
// Ensure ~/.codex/ exists
try fm.createDirectory(atPath: codexHome, withIntermediateDirectories: true, attributes: nil)
// Read existing state
let existingHooksContent: String? = fm.fileExists(atPath: hooksPath)
? (try? String(contentsOfFile: hooksPath, encoding: .utf8))
: nil
// Build merged hooks
var existing: [String: Any] = [:]
if let existingHooksContent,
let data = existingHooksContent.data(using: .utf8),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
existing = parsed
}
var hooks = existing["hooks"] as? [String: Any] ?? [:]
let cmuxHooks = Self.codexHooksJSON["hooks"] as! [String: Any]
for (eventName, cmuxGroups) in cmuxHooks {
guard let cmuxGroupArray = cmuxGroups as? [[String: Any]] else { continue }
var eventGroups = hooks[eventName] as? [[String: Any]] ?? []
eventGroups.removeAll { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false }
return groupHooks.allSatisfy { hook in
(hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true
}
}
eventGroups.append(contentsOf: cmuxGroupArray)
hooks[eventName] = eventGroups
}
existing["hooks"] = hooks
let newJsonData = try JSONSerialization.data(withJSONObject: existing, options: [.prettyPrinted, .sortedKeys])
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
// Build new config.toml content
let existingConfigContent: String = fm.fileExists(atPath: configPath)
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
: ""
let newConfigContent = buildConfigWithCodexHooks(existingConfigContent)
// Check if anything would change
let hooksChanged = existingHooksContent != newHooksContent
let configChanged = existingConfigContent != newConfigContent
if !hooksChanged && !configChanged {
print("cmux hooks are already installed. Nothing to change.")
return
}
// Show diff and ask for confirmation
if hooksChanged {
print(" \(hooksPath):")
if let existingHooksContent {
printSimpleDiff(old: existingHooksContent, new: newHooksContent)
} else {
print(" (new file)")
let lines = newHooksContent.components(separatedBy: "\n")
for (i, line) in lines.enumerated() {
let lineLabel = String(format: "%3d", i + 1)
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
}
}
print("")
}
if configChanged {
print(" \(configPath):")
if existingConfigContent.isEmpty {
print(" (new file)")
let lines = newConfigContent.components(separatedBy: "\n")
for (i, line) in lines.enumerated() where !line.isEmpty {
let lineLabel = String(format: "%3d", i + 1)
print(" \u{001B}[32m\(lineLabel) +\(line)\u{001B}[0m")
}
} else {
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
}
print("")
}
if !skipConfirm {
print("Apply these changes? [Y/n] ", terminator: "")
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}
// Apply changes
if hooksChanged {
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
}
if configChanged {
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
}
print("")
print("Installed. Hooks activate inside cmux and silently no-op elsewhere.")
print("To remove: cmux codex uninstall-hooks")
}
private func runCodexUninstallHooks() throws {
let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes")
|| ProcessInfo.processInfo.arguments.contains("-y")
let codexHome = ProcessInfo.processInfo.environment["CODEX_HOME"]
?? NSString(string: "~/.codex").expandingTildeInPath
let hooksPath = (codexHome as NSString).appendingPathComponent("hooks.json")
let configPath = (codexHome as NSString).appendingPathComponent("config.toml")
let fm = FileManager.default
guard fm.fileExists(atPath: hooksPath),
let data = try? Data(contentsOf: URL(fileURLWithPath: hooksPath)),
var parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
print("No hooks.json found at \(hooksPath)")
return
}
guard var hooks = parsed["hooks"] as? [String: Any] else {
print("No hooks section found in \(hooksPath)")
return
}
// Build the new state without cmux hooks
var removedCount = 0
for eventName in hooks.keys {
guard var eventGroups = hooks[eventName] as? [[String: Any]] else { continue }
let before = eventGroups.count
eventGroups.removeAll { group in
guard let groupHooks = group["hooks"] as? [[String: Any]] else { return false }
return groupHooks.allSatisfy { hook in
(hook["command"] as? String)?.contains(Self.codexHookCommandMarker) == true
}
}
removedCount += before - eventGroups.count
if eventGroups.isEmpty {
hooks.removeValue(forKey: eventName)
} else {
hooks[eventName] = eventGroups
}
}
// Build config.toml without codex_hooks
let existingConfigContent: String = fm.fileExists(atPath: configPath)
? ((try? String(contentsOfFile: configPath, encoding: .utf8)) ?? "")
: ""
let newConfigContent = buildConfigWithoutCodexHooks(existingConfigContent)
let configChanged = existingConfigContent != newConfigContent
if removedCount == 0 && !configChanged {
print("No cmux hooks found.")
return
}
parsed["hooks"] = hooks
let newJsonData = try JSONSerialization.data(withJSONObject: parsed, options: [.prettyPrinted, .sortedKeys])
let newHooksContent = String(data: newJsonData, encoding: .utf8) ?? ""
let oldHooksContent = String(data: data, encoding: .utf8) ?? ""
// Show diff and ask for confirmation
if removedCount > 0 {
print(" \(hooksPath):")
printSimpleDiff(old: oldHooksContent, new: newHooksContent)
print("")
}
if configChanged {
print(" \(configPath):")
printSimpleDiff(old: existingConfigContent, new: newConfigContent)
print("")
}
if !skipConfirm {
print("Apply these changes? [Y/n] ", terminator: "")
if let response = readLine()?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
!response.isEmpty && response != "y" && response != "yes" {
print("Aborted.")
return
}
}
if removedCount > 0 {
try newJsonData.write(to: URL(fileURLWithPath: hooksPath), options: .atomic)
}
if configChanged {
try newConfigContent.write(toFile: configPath, atomically: true, encoding: .utf8)
}
print("Removed cmux Codex hooks.")
}
/// Print a unified-diff-style view with context lines and line numbers.
private func printSimpleDiff(old: String, new: String, contextLines: Int = 2) {
let red = "\u{001B}[31m"
let green = "\u{001B}[32m"
let dim = "\u{001B}[2m"
let reset = "\u{001B}[0m"
let oldLines = old.components(separatedBy: "\n")
let newLines = new.components(separatedBy: "\n")
// Simple LCS-based diff: find matching lines
let lcs = longestCommonSubsequence(oldLines, newLines)
var oldIdx = 0, newIdx = 0, lcsIdx = 0
struct DiffLine {
enum Kind { case context, remove, add }
let kind: Kind
let lineNo: Int // 1-based, refers to old line for context/remove, new line for add
let text: String
}
var allDiffs: [DiffLine] = []
while oldIdx < oldLines.count || newIdx < newLines.count {
if lcsIdx < lcs.count && oldIdx < oldLines.count && newIdx < newLines.count
&& oldLines[oldIdx] == lcs[lcsIdx] && newLines[newIdx] == lcs[lcsIdx] {
allDiffs.append(DiffLine(kind: .context, lineNo: newIdx + 1, text: newLines[newIdx]))
oldIdx += 1; newIdx += 1; lcsIdx += 1
} else if oldIdx < oldLines.count && (lcsIdx >= lcs.count || oldLines[oldIdx] != lcs[lcsIdx]) {
allDiffs.append(DiffLine(kind: .remove, lineNo: oldIdx + 1, text: oldLines[oldIdx]))
oldIdx += 1
} else if newIdx < newLines.count {
allDiffs.append(DiffLine(kind: .add, lineNo: newIdx + 1, text: newLines[newIdx]))
newIdx += 1
}
}
// Find ranges with changes and expand by context
var changedIndices = Set<Int>()
for (i, d) in allDiffs.enumerated() where d.kind != .context {
for j in max(0, i - contextLines)...min(allDiffs.count - 1, i + contextLines) {
changedIndices.insert(j)
}
}
var lastPrinted = -1
for i in changedIndices.sorted() {
if lastPrinted >= 0 && i > lastPrinted + 1 {
print(" \(dim)...\(reset)")
}
let d = allDiffs[i]
let lineLabel = String(format: "%3d", d.lineNo)
switch d.kind {
case .context:
print(" \(dim)\(lineLabel) \(d.text)\(reset)")
case .remove:
print(" \(red)\(lineLabel) -\(d.text)\(reset)")
case .add:
print(" \(green)\(lineLabel) +\(d.text)\(reset)")
}
lastPrinted = i
}
}
private func longestCommonSubsequence(_ a: [String], _ b: [String]) -> [String] {
let m = a.count, n = b.count
var dp = Array(repeating: Array(repeating: 0, count: n + 1), count: m + 1)
for i in 1...m {
for j in 1...n {
if a[i - 1] == b[j - 1] {
dp[i][j] = dp[i - 1][j - 1] + 1
} else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
}
}
}
var result: [String] = []
var i = m, j = n
while i > 0 && j > 0 {
if a[i - 1] == b[j - 1] {
result.append(a[i - 1])
i -= 1; j -= 1
} else if dp[i - 1][j] > dp[i][j - 1] {
i -= 1
} else {
j -= 1
}
}
return result.reversed()
}
/// Returns config.toml content with codex_hooks = true under [features].
private func buildConfigWithCodexHooks(_ content: String) -> String {
var lines = content.components(separatedBy: "\n")
// Check if codex_hooks key already exists (exact key match at line start)
if let idx = lines.firstIndex(where: { isTomlKey($0, key: "codex_hooks") }) {
lines[idx] = "codex_hooks = true"
return lines.joined(separator: "\n")
}
// Find [features] section and insert after it (first occurrence only)
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
lines.insert("codex_hooks = true", at: idx + 1)
return lines.joined(separator: "\n")
}
// No [features] section, append one
var result = content
if !result.isEmpty && !result.hasSuffix("\n") {
result += "\n"
}
result += "\n[features]\ncodex_hooks = true\n"
return result
}
/// Returns config.toml content with codex_hooks removed from [features].
private func buildConfigWithoutCodexHooks(_ content: String) -> String {
var lines = content.components(separatedBy: "\n")
// Remove the codex_hooks line
lines.removeAll { isTomlKey($0, key: "codex_hooks") }
// If [features] section is now empty (only has the header, nothing before next section or EOF),
// remove the header too
if let idx = lines.firstIndex(where: { $0.trimmingCharacters(in: .whitespaces) == "[features]" }) {
let nextNonEmpty = lines[(idx + 1)...].firstIndex(where: {
!$0.trimmingCharacters(in: .whitespaces).isEmpty
})
let sectionEmpty = nextNonEmpty == nil || lines[nextNonEmpty!].trimmingCharacters(in: .whitespaces).hasPrefix("[")
if sectionEmpty {
lines.remove(at: idx)
}
}
return lines.joined(separator: "\n")
}
/// Check if a TOML line sets a specific key (ignoring comments and whitespace).
private func isTomlKey(_ line: String, key: String) -> Bool {
let trimmed = line.trimmingCharacters(in: .whitespaces)
guard !trimmed.hasPrefix("#") else { return false }
guard trimmed.hasPrefix(key) else { return false }
let rest = trimmed.dropFirst(key.count).trimmingCharacters(in: .whitespaces)
return rest.hasPrefix("=")
}
/// Codex hook handler. Gracefully no-ops when not running inside cmux.
private func runCodexHook(
commandArgs: [String],
client: SocketClient,
telemetry: CLISocketSentryTelemetry
) throws {
let env = ProcessInfo.processInfo.environment
// Graceful no-op: if not inside cmux, exit silently with valid JSON
guard env["CMUX_SURFACE_ID"] != nil else {
print("{}")
return
}
let subcommand = commandArgs.first?.lowercased() ?? "help"
let hookArgs = Array(commandArgs.dropFirst())
let hookWsFlag = optionValue(hookArgs, name: "--workspace")
let workspaceArg = hookWsFlag ?? env["CMUX_WORKSPACE_ID"]
let surfaceArg = optionValue(hookArgs, name: "--surface") ?? (hookWsFlag == nil ? env["CMUX_SURFACE_ID"] : nil)
let rawInput = String(data: FileHandle.standardInput.readDataToEndOfFile(), encoding: .utf8) ?? ""
let parsedInput = parseClaudeHookInput(rawInput: rawInput)
let sessionStore = ClaudeHookSessionStore(
processEnv: env.merging(
["CMUX_CLAUDE_HOOK_STATE_PATH": "~/.cmuxterm/codex-hook-sessions.json"],
uniquingKeysWith: { _, new in new }
)
)
telemetry.breadcrumb(
"codex-hook.input",
data: [
"subcommand": subcommand,
"has_session_id": parsedInput.sessionId != nil
]
)
switch subcommand {
case "session-start":
telemetry.breadcrumb("codex-hook.session-start")
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: nil,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: nil,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: parsedInput.cwd
)
}
print("{}")
case "prompt-submit":
telemetry.breadcrumb("codex-hook.prompt-submit")
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
_ = try? sendV1Command("clear_notifications --tab=\(workspaceId)", client: client)
try setCodexStatus(
client: client,
workspaceId: workspaceId,
value: "Running",
icon: "bolt.fill",
color: "#4C8DFF"
)
print("{}")
case "stop":
telemetry.breadcrumb("codex-hook.stop")
do {
let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) }
let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook(
preferred: mappedSession?.workspaceId,
fallback: workspaceArg,
client: client
)
let surfaceId = try resolvePreferredSurfaceIdForClaudeHook(
preferred: mappedSession?.surfaceId,
fallback: surfaceArg,
workspaceId: workspaceId,
client: client
)
// Build completion notification from Codex stop payload
let lastMessage = parsedInput.object?["last_assistant_message"] as? String
?? parsedInput.object?["lastAssistantMessage"] as? String
let cwd = parsedInput.cwd ?? mappedSession?.cwd
let projectName: String? = {
guard let cwd, !cwd.isEmpty else { return nil }
return URL(fileURLWithPath: NSString(string: cwd).expandingTildeInPath).lastPathComponent
}()
if let sessionId = parsedInput.sessionId {
try? sessionStore.upsert(
sessionId: sessionId,
workspaceId: workspaceId,
surfaceId: surfaceId,
cwd: cwd,
lastSubtitle: "Completed",
lastBody: lastMessage.map { truncate($0, maxLength: 200) }
)
}
// Send completion notification
var subtitle = "Completed"
if let projectName, !projectName.isEmpty {
subtitle = "Completed in \(projectName)"
}
let body = sanitizeNotificationField(
lastMessage.map { truncate(normalizedSingleLine($0), maxLength: 200) }
?? "Codex session completed"
)
let payload = "Codex|\(sanitizeNotificationField(subtitle))|\(body)"
_ = try? sendV1Command("notify_target \(workspaceId) \(surfaceId) \(payload)", client: client)
try? setCodexStatus(
client: client,
workspaceId: workspaceId,
value: "Idle",
icon: "pause.circle.fill",
color: "#8E8E93"
)
print("{}")
} catch {
if shouldIgnoreClaudeHookTeardownError(error) {
telemetry.breadcrumb("codex-hook.stop.ignored", data: ["error": String(describing: error)])
print("{}")
return
}
throw error
}
case "help", "--help", "-h":
print("cmux codex-hook <session-start|prompt-submit|stop> [--workspace <id>] [--surface <id>]")
default:
throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)")
}
}
private func setCodexStatus(
client: SocketClient,
workspaceId: String,
value: String,
icon: String,
color: String
) throws {
let cmd = "set_status codex \(value) --icon=\(icon) --color=\(color) --tab=\(workspaceId)"
_ = try client.send(command: cmd)
}
private func versionSummary() -> String {
let info = resolvedVersionInfo()
let commit = info["CMUXCommit"].flatMap { normalizedCommitHash($0) }
@ -11364,6 +11969,7 @@ struct CMUXCLI {
feedback [--email <email> --body <text> [--image <path> ...]]
themes [list|set|clear]
claude-teams [claude-args...]
codex <install-hooks|uninstall-hooks>
ping
version
capabilities