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
1,001 changes: 1,001 additions & 0 deletions CLI/CLI+CodexConfig.swift

Large diffs are not rendered by default.

995 changes: 0 additions & 995 deletions CLI/CLI+Hooks.swift

Large diffs are not rendered by default.

145 changes: 145 additions & 0 deletions CLI/Commands/LocalCommands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation

extension ProgramaCLI {
/// `codex`, `claude` and `opencode`: one subcommand plus `--yes`. `-y` is a
/// single-dash token, so it arrives as a positional after the subcommand.
private var integrationInstallGrammar: CLIArgumentGrammar {
CLIArgumentGrammar(booleanOptions: ["yes", "y"], minPositionals: 1, maxPositionals: 2, shortFlags: ["-y"], validate: { command, parsed in
guard parsed.positional.count == 1 || parsed.positional[1] == "-y" else {
throw CLIError(message: "\(command): unexpected arguments: \(parsed.positional[1])")
}
})
}

/// Commands dispatched before any socket work (`CLICommandDispatcher.run()`),
/// listed here so `usage()` has one source for their help text.
func localCommandDescriptors() -> [CommandDescriptor] {
[
CommandDescriptor(
names: ["welcome"],
helpLines: ["welcome"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa welcome

Show a welcome screen with the programa logo and useful shortcuts.
Auto-runs once on first launch.
""",
grammar: CLIArgumentGrammar(),
execute: nil
),
CommandDescriptor(
names: ["shortcuts"],
helpLines: ["shortcuts"],
connectionPolicy: .local,
argumentContract: .handlerParsed,
detailedUsage: """
Usage: programa shortcuts

Open the Settings window to Keyboard Shortcuts.
""",
execute: nil
),
CommandDescriptor(
names: ["feedback"],
helpLines: ["feedback [--email <email> --body <text> [--image <path> ...]] (opens GitHub issues; direct submission disabled)"],
connectionPolicy: .local,
argumentContract: .handlerParsed,
detailedUsage: """
Usage: programa feedback
programa feedback --email <email> --body <text> [--image <path> ...]

Without args, opens the GitHub issues page (https://github.com/darkroomengineering/programa/issues) in your browser.

Direct feedback submission is disabled; --email/--body/--image are accepted but the app will
return an error telling you to report the issue on GitHub instead.

Flags:
--email <email> Contact email for follow-up (submission disabled)
--body <text> Feedback body (submission disabled)
--image <path> Attach an image file, repeat for multiple images (submission disabled)
""",
execute: nil
),
CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], connectionPolicy: .local, argumentContract: .handlerParsed, execute: nil),
CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, argumentContract: .handlerParsed, execute: nil),
CommandDescriptor(
names: ["codex"],
helpLines: ["codex <install-integration|uninstall-integration> [--yes]"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa codex <install-integration|uninstall-integration> [--yes]

Shows the changes and asks before applying them. --yes (-y) applies
without asking; without it a non-interactive stdin exits 1 unchanged.

Install or remove Programa's Codex notification hooks in
~/.codex/hooks.json (or $CODEX_HOME/hooks.json), the
`programa` agent skill (SKILL.md) into ~/.agents/skills/programa/,
and the bundled programa-mcp server (via `codex mcp add programa`).
""",
grammar: integrationInstallGrammar,
execute: nil
),
CommandDescriptor(
names: ["claude"],
helpLines: ["claude <install-integration|uninstall-integration> [--yes]"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa claude <install-integration|uninstall-integration> [--yes]

Install or remove Programa's persistent Claude Code hooks in
~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json),
and the `programa` agent skill (SKILL.md) into
~/.claude/skills/programa/.
Unlike the runtime wrapper, this makes the integration work from
any terminal, not just programa's.
--yes (-y) applies without asking; without it a non-interactive
stdin exits 1 unchanged.
""",
grammar: integrationInstallGrammar,
execute: nil
),
CommandDescriptor(
names: ["opencode"],
helpLines: ["opencode <install-integration|uninstall-integration> [--yes]"],
connectionPolicy: .local,
detailedUsage: """
Usage: programa opencode <install-integration|uninstall-integration> [--yes]

Install or remove Programa's OpenCode plugin in
~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js),
and the `programa` agent skill (SKILL.md) into
~/.config/opencode/skills/programa/ (or $OPENCODE_CONFIG_DIR/skills/programa/),
and the bundled programa-mcp server as `mcp.programa` in
~/.config/opencode/opencode.json (or $OPENCODE_CONFIG_DIR/opencode.json).
A JSONC config is never rewritten; the command prints the entry to add.
OpenCode auto-loads local plugin files, so no npm install is needed.
The MCP registration is untested against a live OpenCode.
--yes (-y) applies without asking; without it a non-interactive
stdin exits 1 unchanged.
""",
grammar: integrationInstallGrammar,
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.
""",
grammar: CLIArgumentGrammar(booleanOptions: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1),
execute: nil
),
]
}
}
154 changes: 154 additions & 0 deletions CLI/Commands/NotificationCommands.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import Foundation

extension ProgramaCLI {
func notificationDescriptors() -> [CommandDescriptor] {
[
CommandDescriptor(
names: ["notify"],
helpLines: ["notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>]"],
detailedUsage: """
Usage: programa notify [flags]

Send a notification to a workspace/surface.

Flags:
--title <text> Notification title (default: "Notification")
--subtitle <text> Notification subtitle
--body <text> Notification body
--workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID)
--surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID)

Example:
programa notify --title "Build done" --body "All tests passed"
programa notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error"
""",
grammar: CLIArgumentGrammar(valueOptions: ["title", "subtitle", "body", "workspace", "surface"]),
execute: { ctx in
let title = self.optionValue(ctx.commandArgs, name: "--title") ?? "Notification"
let subtitle = self.optionValue(ctx.commandArgs, name: "--subtitle") ?? ""
let body = self.optionValue(ctx.commandArgs, name: "--body") ?? ""

let explicitWorkspaceArg = self.optionValue(ctx.commandArgs, name: "--workspace")
let preferTTYFallback = ctx.windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil
let callerWorkspaceArg = preferTTYFallback
? nil
: (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil)
let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg
let explicitSurfaceArg = self.optionValue(ctx.commandArgs, name: "--surface")
let envWorkspaceArg = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"]
let callerSurfaceArg = explicitSurfaceArg == nil
&& preferTTYFallback == false
&& ctx.windowId == nil
&& (explicitWorkspaceArg == nil || explicitWorkspaceArg == envWorkspaceArg)
? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"]
: nil
let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg

let targetWorkspace = try {
if explicitWorkspaceArg != nil {
return try self.resolveWorkspaceId(workspaceArg, client: ctx.client)
}
return try self.resolveWorkspaceIdAllowingFallback(workspaceArg, client: ctx.client)
}()
let targetSurface = try {
if explicitSurfaceArg != nil {
return try self.resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: ctx.client)
}
return try self.resolveSurfaceIdAllowingFallback(
surfaceArg,
workspaceId: targetWorkspace,
client: ctx.client
)
}()

let payload = try ctx.client.sendV2(method: V2MethodNames.notificationCreateForTarget, params: [
"workspace_id": targetWorkspace,
"surface_id": targetSurface,
"title": title,
"subtitle": subtitle,
"body": body,
])
self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK")
}
),

CommandDescriptor(
names: ["list-notifications"],
helpLines: ["list-notifications"],
detailedUsage: """
Usage: programa list-notifications

List queued notifications.
""",
grammar: CLIArgumentGrammar(),
execute: { ctx in
let listed = try ctx.client.sendV2(method: V2MethodNames.notificationList)
let notifications = listed["notifications"] as? [[String: Any]] ?? []
if ctx.jsonOutput {
let payload = notifications.enumerated().map { _, item -> [String: Any] in
var dict: [String: Any] = [
"id": (item["id"] as? String) ?? "",
"workspace_id": (item["workspace_id"] as? String) ?? "",
"is_read": (item["is_read"] as? Bool) ?? false,
"title": (item["title"] as? String) ?? "",
"subtitle": (item["subtitle"] as? String) ?? "",
"body": (item["body"] as? String) ?? "",
]
dict["surface_id"] = item["surface_id"] as? String ?? NSNull()
return dict
}
print(self.jsonString(["notifications": payload]))
} else if notifications.isEmpty {
print("No notifications")
} else {
let lines = notifications.enumerated().map { index, item -> String in
let surfaceText = (item["surface_id"] as? String) ?? "none"
let readText = ((item["is_read"] as? Bool) ?? false) ? "read" : "unread"
let id = (item["id"] as? String) ?? ""
let workspaceId = (item["workspace_id"] as? String) ?? ""
let title = (item["title"] as? String) ?? ""
let subtitle = (item["subtitle"] as? String) ?? ""
let body = (item["body"] as? String) ?? ""
return "\(index):\(id)|\(workspaceId)|\(surfaceText)|\(readText)|\(title)|\(subtitle)|\(body)"
}
print(lines.joined(separator: "\n"))
}
}
),

CommandDescriptor(
names: ["clear-notifications"],
helpLines: ["clear-notifications [--workspace <id|ref>]"],
detailedUsage: """
Usage: programa clear-notifications [--workspace <id|ref>]

Clear queued notifications. Run from a Programa terminal, it clears only
that terminal's workspace ($PROGRAMA_WORKSPACE_ID). Run anywhere else, or
with the global --window option, it clears every notification.

Flags:
--workspace <id|ref> Clear only this workspace's notifications

Example:
programa clear-notifications
programa clear-notifications --workspace workspace:2
""",
grammar: CLIArgumentGrammar(valueOptions: ["workspace"]),
execute: { ctx in
let response: [String: Any]
if let wsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") {
let wsId = try self.resolveWorkspaceId(wsFlag, client: ctx.client)
response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId])
} else if ctx.windowId == nil,
let envWs = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"],
let wsId = try? self.resolveWorkspaceId(envWs, client: ctx.client) {
response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId])
} else {
response = try ctx.client.sendV2(method: V2MethodNames.notificationClear)
}
self.printV2Payload(response.merging(["ok": true]) { $1 }, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK")
}
),
]
}
}
Loading
Loading