mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-04-28 06:59:37 +00:00
feat(mac): add Connect Claude button to Plan pane
The Plan pane previously told users to "run claude login in your terminal, then retry" with no way to start the flow from the app. Added a primary Connect Claude button on both the no-credentials and failed states that launches Terminal.app with `claude login`, so the OAuth flow is one click away. TerminalLauncher.openClaudeLogin() uses a hardcoded literal, so no user input reaches AppleScript. Refactored the common path into runInTerminal(command:preValidated:) which re-validates any non- literal input against CodeburnCLI.isSafe as defense-in-depth. On machines without Terminal.app (iTerm/Ghostty/Warp), the button surfaces an inline instruction to run `claude login` manually instead of failing silently.
This commit is contained in:
parent
9483d66e65
commit
43a938ff9e
2 changed files with 84 additions and 27 deletions
|
|
@ -1,9 +1,11 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
|
||||
/// Opens a codeburn subcommand in the user's Terminal. The argv is validated through
|
||||
/// `CodeburnCLI.isSafe` before it's interpolated into AppleScript so there's no path for a
|
||||
/// rogue environment variable to smuggle shell metacharacters into the `do script` call.
|
||||
/// Runs commands in the user's Terminal. Every string that reaches AppleScript `do script`
|
||||
/// must be whitespace-joined argv where each token passes `CodeburnCLI.isSafe` (regex allowlist
|
||||
/// that excludes shell metacharacters), OR a hardcoded literal defined here. The private
|
||||
/// `runInTerminal` re-validates any non-literal input defensively so a future caller can't
|
||||
/// bypass the invariant.
|
||||
/// Falls back to a detached headless spawn on machines without Terminal.app (iTerm/Ghostty/Warp
|
||||
/// users) so the subcommand still runs.
|
||||
enum TerminalLauncher {
|
||||
|
|
@ -21,20 +23,43 @@ enum TerminalLauncher {
|
|||
let command = argv.joined(separator: " ")
|
||||
|
||||
if terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) {
|
||||
let script = """
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "\(command)"
|
||||
end tell
|
||||
"""
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-e", script]
|
||||
try? process.run()
|
||||
runInTerminal(command: command, preValidated: true)
|
||||
return
|
||||
}
|
||||
|
||||
let headless = CodeburnCLI.makeProcess(subcommand: subcommand)
|
||||
try? headless.run()
|
||||
}
|
||||
|
||||
/// Launches `claude login` in Terminal.app so the user can complete the OAuth flow
|
||||
/// without leaving CodeBurn. The command is a hardcoded literal -- no user input is
|
||||
/// interpolated, so there's no injection surface.
|
||||
static func openClaudeLogin() -> Bool {
|
||||
guard terminalPaths.contains(where: FileManager.default.fileExists(atPath:)) else {
|
||||
NSLog("CodeBurn: Terminal.app not present; user must run `claude login` manually")
|
||||
return false
|
||||
}
|
||||
runInTerminal(command: "claude login", preValidated: true)
|
||||
return true
|
||||
}
|
||||
|
||||
private static func runInTerminal(command: String, preValidated: Bool) {
|
||||
if !preValidated {
|
||||
let tokens = command.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard tokens.allSatisfy(CodeburnCLI.isSafe) else {
|
||||
NSLog("CodeBurn: refusing to run unvalidated command in Terminal")
|
||||
return
|
||||
}
|
||||
}
|
||||
let script = """
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "\(command)"
|
||||
end tell
|
||||
"""
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-e", script]
|
||||
try? process.run()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1039,6 +1039,7 @@ private struct PlanLoadingView: View {
|
|||
|
||||
private struct PlanNoCredentialsView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showManualFallback = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
|
|
@ -1048,16 +1049,32 @@ private struct PlanNoCredentialsView: View {
|
|||
Text("No Claude subscription connected")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Run `claude login` in your terminal, then retry.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 260)
|
||||
Button("Retry") {
|
||||
Task { await store.refreshSubscription() }
|
||||
if showManualFallback {
|
||||
Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
} else {
|
||||
Text("Click Connect to sign in with Claude, then return here.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 260)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Button("Connect Claude") {
|
||||
if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
Button("Retry") {
|
||||
Task { await store.refreshSubscription() }
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
|
|
@ -1067,6 +1084,7 @@ private struct PlanNoCredentialsView: View {
|
|||
private struct PlanFailedView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
let error: String?
|
||||
@State private var showManualFallback = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
|
|
@ -1076,7 +1094,13 @@ private struct PlanFailedView: View {
|
|||
Text("Couldn't load plan data")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
if let error {
|
||||
if showManualFallback {
|
||||
Text("Terminal.app isn't available. Open your terminal and run `claude login`, then click Retry.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
} else if let error {
|
||||
Text(error)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
|
@ -1084,11 +1108,19 @@ private struct PlanFailedView: View {
|
|||
.frame(maxWidth: 280)
|
||||
.lineLimit(3)
|
||||
}
|
||||
Button("Retry") {
|
||||
Task { await store.refreshSubscription() }
|
||||
HStack(spacing: 8) {
|
||||
Button("Reconnect Claude") {
|
||||
if !TerminalLauncher.openClaudeLogin() { showManualFallback = true }
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
Button("Retry") {
|
||||
Task { await store.refreshSubscription() }
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue