refactor: move OAuth storage and drop legacy sessions
This commit is contained in:
@@ -6,7 +6,7 @@ import SwiftUI
|
||||
struct AnthropicAuthControls: View {
|
||||
let connectionMode: AppState.ConnectionMode
|
||||
|
||||
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
@State private var pkce: AnthropicOAuth.PKCE?
|
||||
@State private var code: String = ""
|
||||
@State private var busy = false
|
||||
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if self.connectionMode != .local {
|
||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host where Pi runs.")
|
||||
Text("Gateway isn’t running locally; OAuth must be created on the gateway host.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
|
||||
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
|
||||
|
||||
Button("Refresh") {
|
||||
self.refresh()
|
||||
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
Text(ClawdisOAuthStore.oauthURL().path)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -123,7 +123,11 @@ struct AnthropicAuthControls: View {
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
|
||||
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
if imported != nil {
|
||||
self.statusText = "Imported existing OAuth credentials."
|
||||
}
|
||||
}
|
||||
|
||||
private func startOAuth() {
|
||||
@@ -161,11 +165,11 @@ struct AnthropicAuthControls: View {
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refresh()
|
||||
self.pkce = nil
|
||||
self.code = ""
|
||||
self.statusText = "Connected. Pi can now use Claude via OAuth."
|
||||
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
|
||||
} catch {
|
||||
self.statusText = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
|
||||
|
||||
var shortLabel: String {
|
||||
switch self {
|
||||
case .oauthFile: "OAuth (Pi token file)"
|
||||
case .oauthFile: "OAuth (Clawdis token file)"
|
||||
case .oauthEnv: "OAuth (env var)"
|
||||
case .apiKeyEnv: "API key (env var)"
|
||||
case .missing: "Missing credentials"
|
||||
@@ -36,7 +36,8 @@ enum AnthropicAuthMode: Equatable {
|
||||
enum AnthropicAuthResolver {
|
||||
static func resolve(
|
||||
environment: [String: String] = ProcessInfo.processInfo.environment,
|
||||
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
|
||||
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
) -> AnthropicAuthMode
|
||||
{
|
||||
if oauthStatus.isConnected { return .oauthFile }
|
||||
|
||||
@@ -92,7 +93,7 @@ enum AnthropicOAuth {
|
||||
URLQueryItem(name: "scope", value: self.scopes),
|
||||
URLQueryItem(name: "code_challenge", value: pkce.challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256"),
|
||||
// Match Pi: state is the verifier.
|
||||
// Match legacy flow: state is the verifier.
|
||||
URLQueryItem(name: "state", value: pkce.verifier),
|
||||
]
|
||||
return components.url!
|
||||
@@ -140,7 +141,7 @@ enum AnthropicOAuth {
|
||||
])
|
||||
}
|
||||
|
||||
// Match Pi: expiresAt = now + expires_in - 5 minutes.
|
||||
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
|
||||
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
|
||||
+ Int64(expiresIn * 1000)
|
||||
- Int64(5 * 60 * 1000)
|
||||
@@ -150,10 +151,11 @@ enum AnthropicOAuth {
|
||||
}
|
||||
}
|
||||
|
||||
enum PiOAuthStore {
|
||||
enum ClawdisOAuthStore {
|
||||
static let oauthFilename = "oauth.json"
|
||||
private static let providerKey = "anthropic"
|
||||
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
|
||||
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
|
||||
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
|
||||
|
||||
enum AnthropicOAuthStatus: Equatable {
|
||||
case missingFile
|
||||
@@ -170,18 +172,18 @@ enum PiOAuthStore {
|
||||
|
||||
var shortDescription: String {
|
||||
switch self {
|
||||
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 .missingFile: "Clawdis OAuth token file not found"
|
||||
case .unreadableFile: "Clawdis OAuth token file not readable"
|
||||
case .invalidJSON: "Clawdis OAuth token file invalid"
|
||||
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
|
||||
case .missingTokens: "Anthropic entry missing tokens"
|
||||
case .connected: "Pi OAuth credentials found"
|
||||
case .connected: "Clawdis OAuth credentials found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func oauthDir() -> URL {
|
||||
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
|
||||
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
@@ -190,14 +192,58 @@ enum PiOAuthStore {
|
||||
}
|
||||
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".pi", isDirectory: true)
|
||||
.appendingPathComponent("agent", isDirectory: true)
|
||||
.appendingPathComponent(".clawdis", isDirectory: true)
|
||||
.appendingPathComponent("credentials", isDirectory: true)
|
||||
}
|
||||
|
||||
static func oauthURL() -> URL {
|
||||
self.oauthDir().appendingPathComponent(self.oauthFilename)
|
||||
}
|
||||
|
||||
static func legacyOAuthURLs() -> [URL] {
|
||||
var urls: [URL] = []
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!override.isEmpty
|
||||
{
|
||||
let expanded = NSString(string: override).expandingTildeInPath
|
||||
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
|
||||
}
|
||||
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
|
||||
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
|
||||
|
||||
var seen = Set<String>()
|
||||
return urls.filter { url in
|
||||
let path = url.standardizedFileURL.path
|
||||
if seen.contains(path) { return false }
|
||||
seen.insert(path)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
|
||||
let dest = self.oauthURL()
|
||||
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
|
||||
|
||||
for url in self.legacyOAuthURLs() {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { continue }
|
||||
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
|
||||
guard let storage = self.loadStorage(at: url) else { continue }
|
||||
do {
|
||||
try self.saveStorage(storage)
|
||||
return url
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
|
||||
self.anthropicOAuthStatus(at: self.oauthURL())
|
||||
}
|
||||
@@ -240,17 +286,15 @@ enum PiOAuthStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func loadStorage(at url: URL) -> [String: Any]? {
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
|
||||
return json as? [String: Any]
|
||||
}
|
||||
|
||||
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
|
||||
let url = self.oauthURL()
|
||||
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
|
||||
let data = try? Data(contentsOf: url),
|
||||
let json = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let dict = json as? [String: Any]
|
||||
{
|
||||
dict
|
||||
} else {
|
||||
[:]
|
||||
}
|
||||
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
|
||||
|
||||
var updated = existing
|
||||
updated[self.providerKey] = [
|
||||
|
||||
@@ -152,7 +152,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
|
||||
private var anthropicAuthHelpText: String {
|
||||
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
|
||||
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
|
||||
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ struct OnboardingView: View {
|
||||
@State private var anthropicAuthStatus: String?
|
||||
@State private var anthropicAuthBusy = false
|
||||
@State private var anthropicAuthConnected = false
|
||||
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||
@State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
|
||||
@State private var anthropicAuthAutoDetectClipboard = true
|
||||
@State private var anthropicAuthAutoConnectClipboard = true
|
||||
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
|
||||
@@ -530,7 +530,7 @@ struct OnboardingView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 540)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
||||
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
@@ -555,14 +555,14 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
Text(
|
||||
"This lets Pi use Claude immediately. Credentials are stored at " +
|
||||
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
|
||||
"This lets Clawdis use Claude immediately. Credentials are stored at " +
|
||||
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(PiOAuthStore.oauthURL().path)
|
||||
Text(ClawdisOAuthStore.oauthURL().path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
@@ -571,7 +571,7 @@ struct OnboardingView: View {
|
||||
Spacer()
|
||||
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
|
||||
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
@@ -683,9 +683,9 @@ struct OnboardingView: View {
|
||||
code: parsed.code,
|
||||
state: parsed.state,
|
||||
verifier: pkce.verifier)
|
||||
try PiOAuthStore.saveAnthropicOAuth(creds)
|
||||
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
|
||||
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
|
||||
} catch {
|
||||
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
|
||||
}
|
||||
@@ -717,7 +717,8 @@ struct OnboardingView: View {
|
||||
}
|
||||
|
||||
private func refreshAnthropicOAuthStatus() {
|
||||
let status = PiOAuthStore.anthropicOAuthStatus()
|
||||
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
|
||||
let status = ClawdisOAuthStore.anthropicOAuthStatus()
|
||||
self.anthropicAuthDetectedStatus = status
|
||||
self.anthropicAuthConnected = status.isConnected
|
||||
}
|
||||
@@ -947,8 +948,8 @@ struct OnboardingView: View {
|
||||
self.featureRow(
|
||||
title: "Remote gateway checklist",
|
||||
subtitle: """
|
||||
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
|
||||
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
|
||||
On your gateway host: install/update the `clawdis` package and make sure credentials exist
|
||||
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
|
||||
""",
|
||||
systemImage: "network")
|
||||
Divider()
|
||||
|
||||
@@ -83,8 +83,6 @@ enum SessionActions {
|
||||
}
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
|
||||
urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl"))
|
||||
urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl"))
|
||||
return urls
|
||||
}()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import Testing
|
||||
struct AnthropicAuthResolverTests {
|
||||
@Test
|
||||
func prefersOAuthFileOverEnv() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
let key = "CLAWDIS_OAUTH_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
@@ -61,4 +61,3 @@ struct AnthropicAuthResolverTests {
|
||||
#expect(mode == .missing)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,18 +3,18 @@ import Testing
|
||||
@testable import Clawdis
|
||||
|
||||
@Suite
|
||||
struct PiOAuthStoreTests {
|
||||
struct ClawdisOAuthStoreTests {
|
||||
@Test
|
||||
func returnsMissingWhenFileAbsent() {
|
||||
let url = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
|
||||
.appendingPathComponent("oauth.json")
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
|
||||
}
|
||||
|
||||
@Test
|
||||
func usesEnvOverrideForPiAgentDir() throws {
|
||||
let key = "PI_CODING_AGENT_DIR"
|
||||
func usesEnvOverrideForClawdisOAuthDir() throws {
|
||||
let key = "CLAWDIS_OAUTH_DIR"
|
||||
let previous = ProcessInfo.processInfo.environment[key]
|
||||
defer {
|
||||
if let previous {
|
||||
@@ -25,10 +25,10 @@ struct PiOAuthStoreTests {
|
||||
}
|
||||
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
|
||||
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
|
||||
setenv(key, dir.path, 1)
|
||||
|
||||
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -42,7 +42,7 @@ struct PiOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -55,7 +55,7 @@ struct PiOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,7 +68,7 @@ struct PiOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -81,7 +81,7 @@ struct PiOAuthStoreTests {
|
||||
],
|
||||
])
|
||||
|
||||
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
||||
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
|
||||
}
|
||||
|
||||
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
summary: "Agent runtime (embedded Pi), workspace contract, and session bootstrap"
|
||||
summary: "Agent runtime (embedded p-mono), workspace contract, and session bootstrap"
|
||||
read_when:
|
||||
- Changing agent runtime, workspace bootstrap, or session behavior
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Agent Runtime 🤖
|
||||
|
||||
CLAWDIS runs a single agent runtime: **Pi (embedded, in-process)**.
|
||||
CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal name: **p**).
|
||||
|
||||
## Workspace (required)
|
||||
|
||||
@@ -30,7 +30,7 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a
|
||||
|
||||
## Built-in tools (internal)
|
||||
|
||||
Pi’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
|
||||
p’s embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; it’s guidance for how *you* want them used.
|
||||
|
||||
## Skills
|
||||
|
||||
@@ -41,11 +41,12 @@ Clawdis loads skills from three locations (workspace wins on name conflict):
|
||||
|
||||
Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`).
|
||||
|
||||
## SDK integration
|
||||
## p-mono integration
|
||||
|
||||
The embedded agent uses the `@mariozechner/pi-coding-agent` SDK for sessions and discovery.
|
||||
- Hooks, custom tools, and slash commands are discovered via the SDK (from `~/.pi/agent` and `<workspace>/.pi` settings).
|
||||
- Bootstrap files are injected as SDK project context (see “Project Context” in the system prompt).
|
||||
Clawdis reuses pieces of the p-mono codebase (models/tools), but **session management, discovery, and tool wiring are Clawdis-owned**.
|
||||
|
||||
- No p-coding agent runtime.
|
||||
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
|
||||
|
||||
## Peter @ steipete (only)
|
||||
|
||||
@@ -65,6 +66,7 @@ Session transcripts are stored as JSONL at:
|
||||
- `~/.clawdis/sessions/<SessionId>.jsonl`
|
||||
|
||||
The session ID is stable and chosen by CLAWDIS.
|
||||
Legacy Pi/Tau session folders are **not** read.
|
||||
|
||||
## Steering while streaming
|
||||
|
||||
|
||||
@@ -20,13 +20,13 @@ App bundle layout:
|
||||
- `clawdis …` (CLI)
|
||||
- `clawdis gateway-daemon …` (LaunchAgent daemon)
|
||||
- `Clawdis.app/Contents/Resources/Relay/package.json`
|
||||
- tiny “Pi compatibility” file (see below)
|
||||
- tiny “p runtime compatibility” file (see below)
|
||||
- `Clawdis.app/Contents/Resources/Relay/theme/`
|
||||
- Pi TUI theme payload (optional, but strongly recommended)
|
||||
- p TUI theme payload (optional, but strongly recommended)
|
||||
|
||||
Why the sidecar files matter:
|
||||
- `@mariozechner/pi-coding-agent` detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`).
|
||||
- So even if bun can embed assets, Pi currently expects filesystem paths. Keep the sidecar files.
|
||||
- The embedded p runtime detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`).
|
||||
- So even if bun can embed assets, the runtime expects filesystem paths. Keep the sidecar files.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace bootstrap ritual)"
|
||||
read_when:
|
||||
- Designing the macOS onboarding assistant
|
||||
- Implementing Pi authentication or identity setup
|
||||
- Implementing Anthropic auth or identity setup
|
||||
---
|
||||
<!-- {% raw %} -->
|
||||
# Onboarding (macOS app)
|
||||
|
||||
This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
|
||||
This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
|
||||
|
||||
## Page order (high level)
|
||||
|
||||
@@ -19,58 +19,42 @@ This doc describes the intended **first-run onboarding** for Clawdis. The goal i
|
||||
|
||||
First question: where does the **Gateway** run?
|
||||
|
||||
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write Pi’s token store locally.
|
||||
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
|
||||
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
|
||||
|
||||
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
|
||||
|
||||
## 2) Local-only: Connect Claude (Anthropic OAuth)
|
||||
|
||||
This is the “bind Pi to Clawdis” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
|
||||
This is the “bind Clawdis to Anthropic” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
|
||||
|
||||
### Recommended: OAuth
|
||||
|
||||
The macOS app should:
|
||||
- Start the Anthropic OAuth (PKCE) flow in the user’s browser.
|
||||
- Ask the user to paste the `code#state` value.
|
||||
- Exchange it for tokens and write Pi-compatible credentials to:
|
||||
- `~/.pi/agent/oauth.json` (file mode `0600`, directory mode `0700`)
|
||||
- Exchange it for tokens and write credentials to:
|
||||
- `~/.clawdis/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
|
||||
|
||||
Why this location matters: it makes Pi work immediately (Clawdis doesn’t need a terminal and doesn’t need to re-implement Pi’s auth plumbing later).
|
||||
Why this location matters: it’s the Clawdis-owned OAuth store.
|
||||
On first run, Clawdis can import existing OAuth tokens from legacy p/Claude locations if present.
|
||||
|
||||
### Alternative: API key (instructions only)
|
||||
|
||||
Offer an “API key” option, but for now it is **instructions only**:
|
||||
- Get an Anthropic API key.
|
||||
- Provide it to Pi (or to Clawdis’s Pi invocation) via your preferred mechanism.
|
||||
- Provide it to Clawdis via your preferred mechanism (env/config).
|
||||
|
||||
Note: environment variables are often confusing when the Gateway is launched by a GUI app (launchd environment != your shell).
|
||||
|
||||
### Provider/model safety rule
|
||||
|
||||
Clawdis should **always pass** `--provider` and `--model` when invoking Pi (don’t rely on Pi defaults).
|
||||
Clawdis should **always pass** `--provider` and `--model` when invoking the embedded agent (don’t rely on defaults).
|
||||
|
||||
Until that is hard-coded, the equivalent configuration is:
|
||||
Example (CLI):
|
||||
|
||||
```json5
|
||||
{
|
||||
inbound: {
|
||||
reply: {
|
||||
mode: "command",
|
||||
command: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"--model",
|
||||
"claude-opus-4-5",
|
||||
"{{BodyStripped}}"
|
||||
],
|
||||
agent: { kind: "pi", format: "json" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```bash
|
||||
clawdis agent --mode rpc --provider anthropic --model claude-opus-4-5 "<message>"
|
||||
```
|
||||
|
||||
If the user skips auth, onboarding should be clear: the agent likely won’t respond until auth is configured.
|
||||
@@ -136,10 +120,10 @@ Daily memory lives under `memory/` in the workspace:
|
||||
|
||||
## Remote mode note (why OAuth is hidden)
|
||||
|
||||
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where Pi runs).
|
||||
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs).
|
||||
|
||||
For now, remote onboarding should:
|
||||
- explain why OAuth isn’t shown
|
||||
- point the user at the credential location (`~/.pi/agent/oauth.json`) and the workspace location on the gateway host
|
||||
- point the user at the credential location (`~/.clawdis/credentials/oauth.json`) and the workspace location on the gateway host
|
||||
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
|
||||
<!-- {% endraw %} -->
|
||||
|
||||
@@ -18,6 +18,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
|
||||
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
|
||||
- Transcripts: `~/.clawdis/sessions/<SessionId>.jsonl` (one file per session id).
|
||||
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
|
||||
- Clawdis does **not** read legacy Pi/Tau session folders.
|
||||
|
||||
## Mapping transports → session keys
|
||||
- Direct chats (WhatsApp, Telegram, desktop Web Chat) all collapse to the **primary key** so they share context.
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
|
||||
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
|
||||
import {
|
||||
setOAuthStorage,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type Model,
|
||||
type OAuthStorage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import {
|
||||
buildSystemPrompt,
|
||||
createAgentSession,
|
||||
@@ -18,7 +25,7 @@ import { formatToolAggregate } from "../auto-reply/tool-meta.js";
|
||||
import type { ClawdisConfig } from "../config/config.js";
|
||||
import { splitMediaFromOutput } from "../media/parse.js";
|
||||
import { enqueueCommand } from "../process/command-queue.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
||||
import {
|
||||
buildBootstrapContextFiles,
|
||||
@@ -75,6 +82,109 @@ type EmbeddedPiQueueHandle = {
|
||||
|
||||
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
|
||||
|
||||
const OAUTH_FILENAME = "oauth.json";
|
||||
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
|
||||
const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent");
|
||||
let oauthStorageConfigured = false;
|
||||
let cachedDefaultApiKey: ReturnType<typeof defaultGetApiKey> | null = null;
|
||||
|
||||
function resolveClawdisOAuthPath(): string {
|
||||
const overrideDir =
|
||||
process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
|
||||
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
|
||||
}
|
||||
|
||||
function resolveAgentDir(): string {
|
||||
const override =
|
||||
process.env.CLAWDIS_AGENT_DIR?.trim() ||
|
||||
process.env.PI_CODING_AGENT_DIR?.trim() ||
|
||||
DEFAULT_AGENT_DIR;
|
||||
return resolveUserPath(override);
|
||||
}
|
||||
|
||||
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
|
||||
if (!fsSync.existsSync(pathname)) return null;
|
||||
try {
|
||||
const content = fsSync.readFileSync(pathname, "utf8");
|
||||
const json = JSON.parse(content) as OAuthStorage;
|
||||
if (!json || typeof json !== "object") return null;
|
||||
return json;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
|
||||
const entry = storage.anthropic as
|
||||
| {
|
||||
refresh?: string;
|
||||
refresh_token?: string;
|
||||
refreshToken?: string;
|
||||
access?: string;
|
||||
access_token?: string;
|
||||
accessToken?: string;
|
||||
}
|
||||
| undefined;
|
||||
if (!entry) return false;
|
||||
const refresh =
|
||||
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
|
||||
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
|
||||
return Boolean(refresh.trim() && access.trim());
|
||||
}
|
||||
|
||||
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
|
||||
const dir = path.dirname(pathname);
|
||||
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
fsSync.writeFileSync(
|
||||
pathname,
|
||||
`${JSON.stringify(storage, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
fsSync.chmodSync(pathname, 0o600);
|
||||
}
|
||||
|
||||
function legacyOAuthPaths(): string[] {
|
||||
const paths: string[] = [];
|
||||
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
|
||||
if (piOverride) {
|
||||
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
|
||||
}
|
||||
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
|
||||
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
|
||||
return Array.from(new Set(paths));
|
||||
}
|
||||
|
||||
function importLegacyOAuthIfNeeded(destPath: string): void {
|
||||
if (fsSync.existsSync(destPath)) return;
|
||||
for (const legacyPath of legacyOAuthPaths()) {
|
||||
const storage = loadOAuthStorageAt(legacyPath);
|
||||
if (!storage || !hasAnthropicOAuth(storage)) continue;
|
||||
saveOAuthStorageAt(destPath, storage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOAuthStorage(): void {
|
||||
if (oauthStorageConfigured) return;
|
||||
oauthStorageConfigured = true;
|
||||
const oauthPath = resolveClawdisOAuthPath();
|
||||
importLegacyOAuthIfNeeded(oauthPath);
|
||||
setOAuthStorage({
|
||||
load: () => loadOAuthStorageAt(oauthPath) ?? {},
|
||||
save: (storage) => saveOAuthStorageAt(oauthPath, storage),
|
||||
});
|
||||
}
|
||||
|
||||
function getDefaultApiKey() {
|
||||
if (!cachedDefaultApiKey) {
|
||||
ensureOAuthStorage();
|
||||
cachedDefaultApiKey = defaultGetApiKey();
|
||||
}
|
||||
return cachedDefaultApiKey;
|
||||
}
|
||||
|
||||
export function queueEmbeddedPiMessage(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
@@ -106,14 +216,13 @@ function resolveModel(
|
||||
return { model };
|
||||
}
|
||||
|
||||
const defaultApiKey = defaultGetApiKey();
|
||||
|
||||
async function getApiKeyForModel(model: Model<Api>): Promise<string> {
|
||||
ensureOAuthStorage();
|
||||
if (model.provider === "anthropic") {
|
||||
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
|
||||
if (oauthEnv?.trim()) return oauthEnv.trim();
|
||||
}
|
||||
const key = await defaultApiKey(model);
|
||||
const key = await getDefaultApiKey()(model);
|
||||
if (key) return key;
|
||||
throw new Error(`No API key found for provider "${model.provider}"`);
|
||||
}
|
||||
@@ -175,9 +284,10 @@ export async function runEmbeddedPiAgent(params: {
|
||||
const provider =
|
||||
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir =
|
||||
process.env.PI_CODING_AGENT_DIR ??
|
||||
path.join(os.homedir(), ".pi", "agent");
|
||||
const agentDir = resolveAgentDir();
|
||||
if (!process.env.PI_CODING_AGENT_DIR) {
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
}
|
||||
const { model, error } = resolveModel(provider, modelId, agentDir);
|
||||
if (!model) {
|
||||
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);
|
||||
|
||||
@@ -720,8 +720,6 @@ function readSessionMessages(
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed?.message) {
|
||||
messages.push(parsed.message);
|
||||
} else if (parsed?.role && parsed?.content) {
|
||||
messages.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore bad lines
|
||||
@@ -742,19 +740,6 @@ function resolveSessionTranscriptCandidates(
|
||||
candidates.push(
|
||||
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
|
||||
);
|
||||
candidates.push(
|
||||
path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`),
|
||||
);
|
||||
candidates.push(
|
||||
path.join(
|
||||
os.homedir(),
|
||||
".tau",
|
||||
"agent",
|
||||
"sessions",
|
||||
"clawdis",
|
||||
`${sessionId}.jsonl`,
|
||||
),
|
||||
);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
@@ -1516,7 +1501,21 @@ export async function startGatewayServer(
|
||||
);
|
||||
return;
|
||||
}
|
||||
logTelegram.info("starting provider");
|
||||
let telegramBotLabel = "";
|
||||
try {
|
||||
const probe = await probeTelegram(
|
||||
telegramToken.trim(),
|
||||
2500,
|
||||
cfg.telegram?.proxy,
|
||||
);
|
||||
const username = probe.ok ? probe.bot?.username?.trim() : null;
|
||||
if (username) telegramBotLabel = ` (@${username})`;
|
||||
} catch (err) {
|
||||
if (isVerbose()) {
|
||||
logTelegram.debug(`bot probe failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
logTelegram.info(`starting provider${telegramBotLabel}`);
|
||||
telegramAbort = new AbortController();
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
|
||||
Reference in New Issue
Block a user