feat(macos): show Anthropic auth mode + OAuth connect
This commit is contained in:
152
apps/macos/Sources/Clawdis/AnthropicAuthControls.swift
Normal file
152
apps/macos/Sources/Clawdis/AnthropicAuthControls.swift
Normal file
@@ -0,0 +1,152 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@State private var statusText: String?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode == .remote {
|
||||
Text("Gateway runs remotely; OAuth must be created on the gateway host where Pi runs.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
.fill(self.oauthStatus.isConnected ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(self.oauthStatus.shortDescription)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
self.startOAuth()
|
||||
} label: {
|
||||
if self.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
} else {
|
||||
Text(self.oauthStatus.isConnected ? "Re-auth (OAuth)" : "Open sign-in (OAuth)")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connectionMode == .remote || self.busy)
|
||||
|
||||
if self.pkce != nil {
|
||||
Button("Cancel") {
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = nil
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy)
|
||||
}
|
||||
}
|
||||
|
||||
if self.pkce != nil {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paste `code#state`")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("code#state", text: self.$code)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.disabled(self.busy)
|
||||
|
||||
Button("Connect") {
|
||||
Task { await self.finishOAuth() }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.busy || self.connectionMode == .remote || self.code
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
if let statusText, !statusText.isEmpty {
|
||||
Text(statusText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
do {
|
||||
let pkce = try AnthropicOAuth.generatePKCE()
|
||||
self.pkce = pkce
|
||||
let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce)
|
||||
NSWorkspace.shared.open(url)
|
||||
self.statusText = "Browser opened. After approving, paste the `code#state` value here."
|
||||
} catch {
|
||||
self.statusText = "Failed to start OAuth: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func finishOAuth() async {
|
||||
guard self.connectionMode == .local else { return }
|
||||
guard !self.busy else { return }
|
||||
guard let pkce = self.pkce else { return }
|
||||
self.busy = true
|
||||
defer { self.busy = false }
|
||||
|
||||
let trimmed = self.code.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let splits = trimmed.split(separator: "#", maxSplits: 1).map(String.init)
|
||||
let code = splits.first ?? ""
|
||||
let state = splits.count > 1 ? splits[1] : ""
|
||||
|
||||
do {
|
||||
let creds = try await AnthropicOAuth.exchangeCode(code: code, state: state, verifier: pkce.verifier)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. Pi can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,52 @@ struct AnthropicOAuthCredentials: Codable {
|
||||
let expires: Int64
|
||||
}
|
||||
|
||||
enum AnthropicAuthMode: Equatable {
|
||||
case oauthFile
|
||||
case oauthEnv
|
||||
case apiKeyEnv
|
||||
case missing
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (Pi token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool {
|
||||
switch self {
|
||||
case .missing: false
|
||||
case .oauthFile, .oauthEnv, .apiKeyEnv: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
if let token = environment["ANTHROPIC_OAUTH_TOKEN"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
{
|
||||
return .oauthEnv
|
||||
}
|
||||
|
||||
if let key = environment["ANTHROPIC_API_KEY"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!key.isEmpty
|
||||
{
|
||||
return .apiKeyEnv
|
||||
}
|
||||
|
||||
return .missing
|
||||
}
|
||||
}
|
||||
|
||||
enum AnthropicOAuth {
|
||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
|
||||
|
||||
@@ -107,6 +153,7 @@ enum AnthropicOAuth {
|
||||
enum PiOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
@@ -123,18 +170,26 @@ enum PiOAuthStore {
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
case .missingFile: "oauth.json not found"
|
||||
case .unreadableFile: "oauth.json not readable"
|
||||
case .invalidJSON: "oauth.json invalid"
|
||||
case .missingProviderEntry: "oauth.json has no anthropic entry"
|
||||
case .missingTokens: "anthropic entry missing tokens"
|
||||
case .connected: "OAuth credentials found"
|
||||
case .missingFile: "Pi OAuth token file not found"
|
||||
case .unreadableFile: "Pi OAuth token file not readable"
|
||||
case .invalidJSON: "Pi OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "Pi OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
return URL(fileURLWithPath: expanded, isDirectory: true)
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".pi", isDirectory: true)
|
||||
.appendingPathComponent("agent", isDirectory: true)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
@MainActor
|
||||
struct ConfigSettings: View {
|
||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||
private let state = AppStateStore.shared
|
||||
private let labelColumnWidth: CGFloat = 120
|
||||
private static let browserAttachOnlyHelp =
|
||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
||||
@@ -31,204 +32,7 @@ struct ConfigSettings: View {
|
||||
@State private var browserAttachOnly: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Web Chat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$webChatEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
ScrollView { self.content }
|
||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||
Task { await self.loadModels() }
|
||||
}
|
||||
@@ -245,6 +49,252 @@ struct ConfigSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.agentSection
|
||||
self.heartbeatSection
|
||||
self.webChatSection
|
||||
self.browserSection
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 18)
|
||||
.groupBoxStyle(PlainSettingsGroupBoxStyle())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var header: some View {
|
||||
Text("Clawdis CLI config")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Edit ~/.clawdis/clawdis.json (inbound.agent / inbound.session).")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
private var agentSection: some View {
|
||||
GroupBox("Agent") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Model")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
self.modelPicker
|
||||
self.customModelField
|
||||
self.modelMetaLabels
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var modelPicker: some View {
|
||||
Picker("Model", selection: self.$configModel) {
|
||||
ForEach(self.models) { choice in
|
||||
Text("\(choice.name) — \(choice.provider.uppercased())")
|
||||
.tag(choice.id)
|
||||
}
|
||||
Text("Manual entry…").tag("__custom__")
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(self.modelsLoading || (!self.modelError.isNilOrEmpty && self.models.isEmpty))
|
||||
.onChange(of: self.configModel) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var customModelField: some View {
|
||||
if self.configModel == "__custom__" {
|
||||
TextField("Enter model ID", text: self.$customModel)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.customModel) { _, newValue in
|
||||
self.configModel = newValue
|
||||
self.autosaveConfig()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelMetaLabels: some View {
|
||||
if let contextLabel = self.selectedContextLabel {
|
||||
Text(contextLabel)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let authMode = self.selectedAnthropicAuthMode {
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(authMode.isConfigured ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text("Anthropic auth: \(authMode.shortLabel)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(authMode.isConfigured ? Color.secondary : Color.orange)
|
||||
.help(self.anthropicAuthHelpText)
|
||||
|
||||
AnthropicAuthControls(connectionMode: self.state.connectionMode)
|
||||
}
|
||||
|
||||
if let modelError {
|
||||
Text(modelError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
private var heartbeatSection: some View {
|
||||
GroupBox("Heartbeat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Schedule")
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 12) {
|
||||
Stepper(
|
||||
value: Binding(
|
||||
get: { self.heartbeatMinutes ?? 10 },
|
||||
set: { self.heartbeatMinutes = $0; self.autosaveConfig() }),
|
||||
in: 0...720)
|
||||
{
|
||||
Text("Every \(self.heartbeatMinutes ?? 10) min")
|
||||
.frame(width: 150, alignment: .leading)
|
||||
}
|
||||
.help("Set to 0 to disable automatic heartbeats")
|
||||
|
||||
TextField("HEARTBEAT", text: self.$heartbeatBody)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.onChange(of: self.heartbeatBody) { _, _ in
|
||||
self.autosaveConfig()
|
||||
}
|
||||
.help("Message body sent on each heartbeat")
|
||||
}
|
||||
Text("Heartbeats keep agent sessions warm; 0 minutes disables them.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var webChatSection: some View {
|
||||
GroupBox("Web Chat") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$webChatEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Port")
|
||||
TextField("18788", value: self.$webChatPort, formatter: NumberFormatter())
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 100)
|
||||
.disabled(!self.webChatEnabled)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(
|
||||
"""
|
||||
Mac app connects to the gateway’s loopback web chat on this port.
|
||||
Remote mode uses SSH -L to forward it.
|
||||
""")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private var browserSection: some View {
|
||||
GroupBox("Browser (clawd)") {
|
||||
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
|
||||
GridRow {
|
||||
self.gridLabel("Enabled")
|
||||
Toggle("", isOn: self.$browserEnabled)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.onChange(of: self.browserEnabled) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Control URL")
|
||||
TextField("http://127.0.0.1:18791", text: self.$browserControlUrl)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(maxWidth: .infinity)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserControlUrl) { _, _ in self.autosaveConfig() }
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Browser path")
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let label = self.browserPathLabel {
|
||||
Text(label)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
} else {
|
||||
Text("—")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Accent")
|
||||
HStack(spacing: 8) {
|
||||
TextField("#FF4500", text: self.$browserColorHex)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 120)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserColorHex) { _, _ in self.autosaveConfig() }
|
||||
Circle()
|
||||
.fill(self.browserColor)
|
||||
.frame(width: 12, height: 12)
|
||||
.overlay(Circle().stroke(Color.secondary.opacity(0.25), lineWidth: 1))
|
||||
Text("lobster-orange")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
GridRow {
|
||||
self.gridLabel("Attach only")
|
||||
Toggle("", isOn: self.$browserAttachOnly)
|
||||
.labelsHidden()
|
||||
.toggleStyle(.checkbox)
|
||||
.disabled(!self.browserEnabled)
|
||||
.onChange(of: self.browserAttachOnly) { _, _ in self.autosaveConfig() }
|
||||
.help(Self.browserAttachOnlyHelp)
|
||||
}
|
||||
GridRow {
|
||||
Color.clear
|
||||
.frame(width: self.labelColumnWidth, height: 1)
|
||||
Text(Self.browserProfileNote)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
private func gridLabel(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -424,6 +474,13 @@ struct ConfigSettings: View {
|
||||
return "Context window: \(human) tokens"
|
||||
}
|
||||
|
||||
private var selectedAnthropicAuthMode: AnthropicAuthMode? {
|
||||
let chosenId = (self.configModel == "__custom__") ? self.customModel : self.configModel
|
||||
guard !chosenId.isEmpty, let choice = self.models.first(where: { $0.id == chosenId }) else { return nil }
|
||||
guard choice.provider.lowercased() == "anthropic" else { return nil }
|
||||
return AnthropicAuthResolver.resolve()
|
||||
}
|
||||
|
||||
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
|
||||
@@ -201,7 +201,7 @@ final class NodePairingApprovalPrompter {
|
||||
private func endActiveAlert() {
|
||||
guard let alert = self.activeAlert else { return }
|
||||
if let parent = alert.window.sheetParent {
|
||||
parent.endSheet(alert.window, returnCode: .abortModalResponse)
|
||||
parent.endSheet(alert.window, returnCode: .abort)
|
||||
}
|
||||
self.activeAlert = nil
|
||||
self.activeRequestId = nil
|
||||
|
||||
@@ -325,7 +325,7 @@ struct OnboardingView: View {
|
||||
|
||||
private func anthropicAuthPage() -> some View {
|
||||
self.onboardingPage {
|
||||
Text("Sign in to Claude")
|
||||
Text("Connect Claude")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("Give your model the token it needs!")
|
||||
.font(.body)
|
||||
@@ -422,7 +422,7 @@ struct OnboardingView: View {
|
||||
.font(.headline)
|
||||
Text(
|
||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
||||
"(GUI apps don’t automatically inherit your shell env vars).")
|
||||
"(GUI apps don’t automatically inherit your shell env vars like `ANTHROPIC_API_KEY`).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
let oauthFile = dir.appendingPathComponent("oauth.json")
|
||||
let payload = [
|
||||
"anthropic": [
|
||||
"type": "oauth",
|
||||
"refresh": "r1",
|
||||
"access": "a1",
|
||||
"expires": 1_234_567_890,
|
||||
],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
|
||||
try data.write(to: oauthFile, options: [.atomic])
|
||||
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-ignored",
|
||||
])
|
||||
#expect(mode == .oauthFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsOAuthEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_OAUTH_TOKEN": "token",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .oauthEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsAPIKeyEnvWhenPresent() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [
|
||||
"ANTHROPIC_API_KEY": "sk-ant-key",
|
||||
], oauthStatus: .missingFile)
|
||||
#expect(mode == .apiKeyEnv)
|
||||
}
|
||||
|
||||
@Test
|
||||
func reportsMissingWhenNothingConfigured() {
|
||||
let mode = AnthropicAuthResolver.resolve(environment: [:], oauthStatus: .missingFile)
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ struct CanvasWindowSmokeTests {
|
||||
|
||||
controller.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
|
||||
controller.showCanvas(path: "/")
|
||||
_ = await controller.eval(javaScript: "1 + 1")
|
||||
_ = try await controller.eval(javaScript: "1 + 1")
|
||||
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
|
||||
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
|
||||
controller.hideCanvas()
|
||||
@@ -45,4 +45,3 @@ struct CanvasWindowSmokeTests {
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,25 @@ struct PiOAuthStoreTests {
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForPiAgentDir() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
setenv(key, previous, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
func acceptsPiFormatTokens() throws {
|
||||
let url = try self.writeOAuthFile([
|
||||
|
||||
Reference in New Issue
Block a user