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
|
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 {
|
enum AnthropicOAuth {
|
||||||
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
|
private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "anthropic-oauth")
|
||||||
|
|
||||||
@@ -107,6 +153,7 @@ enum AnthropicOAuth {
|
|||||||
enum PiOAuthStore {
|
enum PiOAuthStore {
|
||||||
static let oauthFilename = "oauth.json"
|
static let oauthFilename = "oauth.json"
|
||||||
private static let providerKey = "anthropic"
|
private static let providerKey = "anthropic"
|
||||||
|
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||||
|
|
||||||
enum AnthropicOAuthStatus: Equatable {
|
enum AnthropicOAuthStatus: Equatable {
|
||||||
case missingFile
|
case missingFile
|
||||||
@@ -123,18 +170,26 @@ enum PiOAuthStore {
|
|||||||
|
|
||||||
var shortDescription: String {
|
var shortDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .missingFile: "oauth.json not found"
|
case .missingFile: "Pi OAuth token file not found"
|
||||||
case .unreadableFile: "oauth.json not readable"
|
case .unreadableFile: "Pi OAuth token file not readable"
|
||||||
case .invalidJSON: "oauth.json invalid"
|
case .invalidJSON: "Pi OAuth token file invalid"
|
||||||
case .missingProviderEntry: "oauth.json has no anthropic entry"
|
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
|
||||||
case .missingTokens: "anthropic entry missing tokens"
|
case .missingTokens: "Anthropic entry missing tokens"
|
||||||
case .connected: "OAuth credentials found"
|
case .connected: "Pi OAuth credentials found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static func oauthDir() -> URL {
|
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(".pi", isDirectory: true)
|
||||||
.appendingPathComponent("agent", isDirectory: true)
|
.appendingPathComponent("agent", isDirectory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
@MainActor
|
@MainActor
|
||||||
struct ConfigSettings: View {
|
struct ConfigSettings: View {
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
|
private let state = AppStateStore.shared
|
||||||
private let labelColumnWidth: CGFloat = 120
|
private let labelColumnWidth: CGFloat = 120
|
||||||
private static let browserAttachOnlyHelp =
|
private static let browserAttachOnlyHelp =
|
||||||
"When enabled, the browser server will only connect if the clawd browser is already running."
|
"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
|
@State private var browserAttachOnly: Bool = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView { self.content }
|
||||||
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())
|
|
||||||
}
|
|
||||||
.onChange(of: self.modelCatalogPath) { _, _ in
|
.onChange(of: self.modelCatalogPath) { _, _ in
|
||||||
Task { await self.loadModels() }
|
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 {
|
private func gridLabel(_ text: String) -> some View {
|
||||||
Text(text)
|
Text(text)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
@@ -424,6 +474,13 @@ struct ConfigSettings: View {
|
|||||||
return "Context window: \(human) tokens"
|
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 {
|
private struct PlainSettingsGroupBoxStyle: GroupBoxStyle {
|
||||||
func makeBody(configuration: Configuration) -> some View {
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
private func endActiveAlert() {
|
private func endActiveAlert() {
|
||||||
guard let alert = self.activeAlert else { return }
|
guard let alert = self.activeAlert else { return }
|
||||||
if let parent = alert.window.sheetParent {
|
if let parent = alert.window.sheetParent {
|
||||||
parent.endSheet(alert.window, returnCode: .abortModalResponse)
|
parent.endSheet(alert.window, returnCode: .abort)
|
||||||
}
|
}
|
||||||
self.activeAlert = nil
|
self.activeAlert = nil
|
||||||
self.activeRequestId = nil
|
self.activeRequestId = nil
|
||||||
|
|||||||
@@ -325,7 +325,7 @@ struct OnboardingView: View {
|
|||||||
|
|
||||||
private func anthropicAuthPage() -> some View {
|
private func anthropicAuthPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Sign in to Claude")
|
Text("Connect Claude")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text("Give your model the token it needs!")
|
Text("Give your model the token it needs!")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
@@ -422,7 +422,7 @@ struct OnboardingView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
Text(
|
Text(
|
||||||
"You can also use an Anthropic API key, but this UI is instructions-only for now " +
|
"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)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.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.applyPreferredPlacement(CanvasPlacement(x: 120, y: 200, width: 520, height: 680))
|
||||||
controller.showCanvas(path: "/")
|
controller.showCanvas(path: "/")
|
||||||
_ = await controller.eval(javaScript: "1 + 1")
|
_ = try await controller.eval(javaScript: "1 + 1")
|
||||||
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
|
controller.windowDidMove(Notification(name: NSWindow.didMoveNotification))
|
||||||
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
|
controller.windowDidEndLiveResize(Notification(name: NSWindow.didEndLiveResizeNotification))
|
||||||
controller.hideCanvas()
|
controller.hideCanvas()
|
||||||
@@ -45,4 +45,3 @@ struct CanvasWindowSmokeTests {
|
|||||||
controller.close()
|
controller.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ struct PiOAuthStoreTests {
|
|||||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
#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
|
@Test
|
||||||
func acceptsPiFormatTokens() throws {
|
func acceptsPiFormatTokens() throws {
|
||||||
let url = try self.writeOAuthFile([
|
let url = try self.writeOAuthFile([
|
||||||
|
|||||||
Reference in New Issue
Block a user