feat: unify onboarding + config schema

This commit is contained in:
Peter Steinberger 2026-01-03 16:04:19 +01:00
parent 0f85080d81
commit 53baba71fa
43 changed files with 3478 additions and 1011 deletions

View File

@ -9,6 +9,8 @@
### Features ### Features
- Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app. - Gateway: support `gateway.port` + `CLAWDIS_GATEWAY_PORT` across CLI, TUI, and macOS app.
- UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI. - UI: centralize tool display metadata and show action/detail summaries across Web Chat, SwiftUI, Android, and the TUI.
- Onboarding: shared wizard engine powering CLI + macOS via gateway wizard RPC.
- Config: expose schema + UI hints for generic config forms (Web UI + future clients).
### Fixes ### Fixes
- Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm. - Telegram: chunk block-stream replies to avoid “message is too long” errors (#124) — thanks @mukhtharcm.
@ -27,6 +29,7 @@
- Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince. - Skills: clarify bear-notes token + callback usage (#120) — thanks @tylerwince.
- Skills: document Discord `sendMessage` media attachments and `to` format clarification. - Skills: document Discord `sendMessage` media attachments and `to` format clarification.
- Gateway: document port configuration + multi-instance isolation. - Gateway: document port configuration + multi-instance isolation.
- Onboarding/Config: add protocol notes for wizard + schema RPC.
## 2.0.0-beta5 — 2026-01-03 ## 2.0.0-beta5 — 2026-01-03

View File

@ -51,6 +51,10 @@ actor GatewayConnection {
case providersStatus = "providers.status" case providersStatus = "providers.status"
case configGet = "config.get" case configGet = "config.get"
case configSet = "config.set" case configSet = "config.set"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
case wizardStatus = "wizard.status"
case talkMode = "talk.mode" case talkMode = "talk.mode"
case webLoginStart = "web.login.start" case webLoginStart = "web.login.start"
case webLoginWait = "web.login.wait" case webLoginWait = "web.login.wait"

View File

@ -86,6 +86,7 @@ struct OnboardingView: View {
@State var gatewayDiscovery: GatewayDiscoveryModel @State var gatewayDiscovery: GatewayDiscoveryModel
@State var onboardingChatModel: ClawdisChatViewModel @State var onboardingChatModel: ClawdisChatViewModel
@State var onboardingSkillsModel = SkillsSettingsModel() @State var onboardingSkillsModel = SkillsSettingsModel()
@State var onboardingWizard = OnboardingWizardModel()
@State var didLoadOnboardingSkills = false @State var didLoadOnboardingSkills = false
@State var localGatewayProbe: LocalGatewayProbe? @State var localGatewayProbe: LocalGatewayProbe?
@Bindable var state: AppState @Bindable var state: AppState
@ -95,6 +96,7 @@ struct OnboardingView: View {
let contentHeight: CGFloat = 460 let contentHeight: CGFloat = 460
let connectionPageIndex = 1 let connectionPageIndex = 1
let anthropicAuthPageIndex = 2 let anthropicAuthPageIndex = 2
let wizardPageIndex = 3
let onboardingChatPageIndex = 8 let onboardingChatPageIndex = 8
static let clipboardPoll: AnyPublisher<Date, Never> = { static let clipboardPoll: AnyPublisher<Date, Never> = {
@ -119,7 +121,7 @@ struct OnboardingView: View {
case .unconfigured: case .unconfigured:
needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9] needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9]
case .local: case .local:
needsBootstrap ? [0, 1, 2, 5, 6, 8, 9] : [0, 1, 2, 5, 6, 9] needsBootstrap ? [0, 1, 3, 5, 8, 9] : [0, 1, 3, 5, 9]
} }
} }
@ -133,6 +135,11 @@ struct OnboardingView: View {
} }
var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" }
var wizardPageOrderIndex: Int? { self.pageOrder.firstIndex(of: self.wizardPageIndex) }
var isWizardBlocking: Bool {
self.activePageIndex == self.wizardPageIndex && !self.onboardingWizard.isComplete
}
var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let bundlePath = Bundle.main.bundlePath let bundlePath = Bundle.main.bundlePath
return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis" return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis"

View File

@ -11,6 +11,7 @@ extension OnboardingView {
} }
func selectUnconfiguredGateway() { func selectUnconfiguredGateway() {
Task { await self.onboardingWizard.cancelIfRunning() }
self.state.connectionMode = .unconfigured self.state.connectionMode = .unconfigured
self.preferredGatewayID = nil self.preferredGatewayID = nil
self.showAdvancedConnection = false self.showAdvancedConnection = false
@ -18,6 +19,7 @@ extension OnboardingView {
} }
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
Task { await self.onboardingWizard.cancelIfRunning() }
self.preferredGatewayID = gateway.stableID self.preferredGatewayID = gateway.stableID
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID) BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
@ -47,6 +49,7 @@ extension OnboardingView {
} }
func handleNext() { func handleNext() {
if self.isWizardBlocking { return }
if self.currentPage < self.pageCount - 1 { if self.currentPage < self.pageCount - 1 {
withAnimation { self.currentPage += 1 } withAnimation { self.currentPage += 1 }
} else { } else {

View File

@ -46,6 +46,10 @@ extension OnboardingView {
self.currentPage = max(0, self.pageOrder.count - 1) self.currentPage = max(0, self.pageOrder.count - 1)
} }
} }
.onChange(of: self.onboardingWizard.isComplete) { _, newValue in
guard newValue, self.activePageIndex == self.wizardPageIndex else { return }
self.handleNext()
}
.onDisappear { .onDisappear {
self.stopPermissionMonitoring() self.stopPermissionMonitoring()
self.stopDiscovery() self.stopDiscovery()
@ -81,6 +85,7 @@ extension OnboardingView {
} }
var navigationBar: some View { var navigationBar: some View {
let wizardLockIndex = self.wizardPageOrderIndex
HStack(spacing: 20) { HStack(spacing: 20) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
Button(action: {}, label: { Button(action: {}, label: {
@ -107,6 +112,7 @@ extension OnboardingView {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(0..<self.pageCount, id: \.self) { index in ForEach(0..<self.pageCount, id: \.self) { index in
let isLocked = wizardLockIndex != nil && !self.onboardingWizard.isComplete && index > (wizardLockIndex ?? 0)
Button { Button {
withAnimation { self.currentPage = index } withAnimation { self.currentPage = index }
} label: { } label: {
@ -115,6 +121,8 @@ extension OnboardingView {
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.disabled(isLocked)
.opacity(isLocked ? 0.3 : 1)
} }
} }
@ -126,6 +134,7 @@ extension OnboardingView {
} }
.keyboardShortcut(.return) .keyboardShortcut(.return)
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(!self.canAdvance)
} }
.padding(.horizontal, 28) .padding(.horizontal, 28)
.padding(.bottom, 13) .padding(.bottom, 13)

View File

@ -13,6 +13,8 @@ extension OnboardingView {
self.connectionPage() self.connectionPage()
case 2: case 2:
self.anthropicAuthPage() self.anthropicAuthPage()
case 3:
self.wizardPage()
case 5: case 5:
self.permissionsPage() self.permissionsPage()
case 6: case 6:

View File

@ -47,6 +47,7 @@ extension OnboardingView {
_ = view.welcomePage() _ = view.welcomePage()
_ = view.connectionPage() _ = view.connectionPage()
_ = view.anthropicAuthPage() _ = view.anthropicAuthPage()
_ = view.wizardPage()
_ = view.permissionsPage() _ = view.permissionsPage()
_ = view.cliPage() _ = view.cliPage()
_ = view.workspacePage() _ = view.workspacePage()

View File

@ -0,0 +1,62 @@
import SwiftUI
extension OnboardingView {
func wizardPage() -> some View {
self.onboardingPage {
VStack(spacing: 16) {
Text("Setup Wizard")
.font(.largeTitle.weight(.semibold))
Text("Follow the guided setup from the Gateway. This keeps onboarding in sync with the CLI.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
self.onboardingCard(spacing: 14, padding: 16) {
if let error = self.onboardingWizard.errorMessage {
Text("Wizard error")
.font(.headline)
Text(error)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Retry") {
self.onboardingWizard.reset()
Task {
await self.onboardingWizard.startIfNeeded(
mode: self.state.connectionMode,
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
}
}
.buttonStyle(.borderedProminent)
} else if self.onboardingWizard.isStarting {
HStack(spacing: 8) {
ProgressView()
Text("Starting wizard…")
.foregroundStyle(.secondary)
}
} else if let step = self.onboardingWizard.currentStep {
OnboardingWizardStepView(
step: step,
isSubmitting: self.onboardingWizard.isSubmitting)
{ value in
Task { await self.onboardingWizard.submit(step: step, value: value) }
}
.id(step.id)
} else if self.onboardingWizard.isComplete {
Text("Wizard complete. Continue to the next step.")
.font(.headline)
} else {
Text("Waiting for wizard…")
.foregroundStyle(.secondary)
}
}
}
.task {
await self.onboardingWizard.startIfNeeded(
mode: self.state.connectionMode,
workspace: self.workspacePath.isEmpty ? nil : self.workspacePath)
}
}
}
}

View File

@ -0,0 +1,400 @@
import ClawdisProtocol
import Foundation
import Observation
import OSLog
import SwiftUI
private let onboardingWizardLogger = Logger(subsystem: "com.clawdis", category: "onboarding.wizard")
@MainActor
@Observable
final class OnboardingWizardModel {
private(set) var sessionId: String?
private(set) var currentStep: WizardStep?
private(set) var status: String?
private(set) var errorMessage: String?
var isStarting = false
var isSubmitting = false
var isComplete: Bool { self.status == "done" }
var isRunning: Bool { self.status == "running" }
func reset() {
self.sessionId = nil
self.currentStep = nil
self.status = nil
self.errorMessage = nil
self.isStarting = false
self.isSubmitting = false
}
func startIfNeeded(mode: AppState.ConnectionMode, workspace: String? = nil) async {
guard self.sessionId == nil, !self.isStarting else { return }
guard mode == .local else { return }
self.isStarting = true
self.errorMessage = nil
defer { self.isStarting = false }
do {
var params: [String: AnyCodable] = ["mode": AnyCodable("local")]
if let workspace, !workspace.isEmpty {
params["workspace"] = AnyCodable(workspace)
}
let res: WizardStartResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardStart,
params: params)
applyStartResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
onboardingWizardLogger.error("start failed: \(error.localizedDescription, privacy: .public)")
}
}
func submit(step: WizardStep, value: AnyCodable?) async {
guard let sessionId, !self.isSubmitting else { return }
self.isSubmitting = true
self.errorMessage = nil
defer { self.isSubmitting = false }
do {
var params: [String: AnyCodable] = ["sessionId": AnyCodable(sessionId)]
var answer: [String: AnyCodable] = ["stepId": AnyCodable(step.id)]
if let value {
answer["value"] = value
}
params["answer"] = AnyCodable(answer)
let res: WizardNextResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardNext,
params: params)
applyNextResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
onboardingWizardLogger.error("submit failed: \(error.localizedDescription, privacy: .public)")
}
}
func cancelIfRunning() async {
guard let sessionId, self.isRunning else { return }
do {
let res: WizardStatusResult = try await GatewayConnection.shared.requestDecoded(
method: .wizardCancel,
params: ["sessionId": AnyCodable(sessionId)])
applyStatusResult(res)
} catch {
self.status = "error"
self.errorMessage = error.localizedDescription
onboardingWizardLogger.error("cancel failed: \(error.localizedDescription, privacy: .public)")
}
}
private func applyStartResult(_ res: WizardStartResult) {
self.sessionId = res.sessionid
self.status = anyCodableStringValue(res.status) ?? (res.done ? "done" : "running")
self.errorMessage = res.error
self.currentStep = decodeWizardStep(res.step)
if res.done { self.currentStep = nil }
}
private func applyNextResult(_ res: WizardNextResult) {
self.status = anyCodableStringValue(res.status) ?? self.status
self.errorMessage = res.error
self.currentStep = decodeWizardStep(res.step)
if res.done { self.currentStep = nil }
if res.done || anyCodableStringValue(res.status) == "done" || anyCodableStringValue(res.status) == "cancelled"
|| anyCodableStringValue(res.status) == "error" {
self.sessionId = nil
}
}
private func applyStatusResult(_ res: WizardStatusResult) {
self.status = anyCodableStringValue(res.status) ?? "unknown"
self.errorMessage = res.error
self.currentStep = nil
self.sessionId = nil
}
}
struct OnboardingWizardStepView: View {
let step: WizardStep
let isSubmitting: Bool
let onSubmit: (AnyCodable?) -> Void
@State private var textValue: String
@State private var confirmValue: Bool
@State private var selectedIndex: Int
@State private var selectedIndices: Set<Int>
private let optionItems: [WizardOptionItem]
init(step: WizardStep, isSubmitting: Bool, onSubmit: @escaping (AnyCodable?) -> Void) {
self.step = step
self.isSubmitting = isSubmitting
self.onSubmit = onSubmit
let options = parseWizardOptions(step.options).enumerated().map { index, option in
WizardOptionItem(index: index, option: option)
}
self.optionItems = options
let initialText = anyCodableString(step.initialvalue)
let initialConfirm = anyCodableBool(step.initialvalue)
let initialIndex = options.firstIndex(where: { anyCodableEqual($0.option.value, step.initialvalue) }) ?? 0
let initialMulti = Set(
options.filter { option in
anyCodableArray(step.initialvalue).contains { anyCodableEqual($0, option.option.value) }
}.map { $0.index }
)
_textValue = State(initialValue: initialText)
_confirmValue = State(initialValue: initialConfirm)
_selectedIndex = State(initialValue: initialIndex)
_selectedIndices = State(initialValue: initialMulti)
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let title = step.title, !title.isEmpty {
Text(title)
.font(.title2.weight(.semibold))
}
if let message = step.message, !message.isEmpty {
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
switch wizardStepType(step) {
case "note":
EmptyView()
case "text":
textField
case "confirm":
Toggle("", isOn: $confirmValue)
.toggleStyle(.switch)
case "select":
selectOptions
case "multiselect":
multiselectOptions
case "progress":
ProgressView()
.controlSize(.small)
case "action":
EmptyView()
default:
Text("Unsupported step type")
.foregroundStyle(.secondary)
}
Button(action: submit) {
Text(wizardStepType(step) == "action" ? "Run" : "Continue")
.frame(minWidth: 120)
}
.buttonStyle(.borderedProminent)
.disabled(isSubmitting || isBlocked)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var textField: some View {
let isSensitive = step.sensitive == true
if isSensitive {
return AnyView(
SecureField(step.placeholder ?? "", text: $textValue)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 360)
)
}
return AnyView(
TextField(step.placeholder ?? "", text: $textValue)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 360)
)
}
private var selectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in
Button {
selectedIndex = item.index
} label: {
HStack(alignment: .top, spacing: 8) {
Image(systemName: selectedIndex == item.index ? "largecircle.fill.circle" : "circle")
.foregroundStyle(.accent)
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
.foregroundStyle(.primary)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.buttonStyle(.plain)
}
}
}
private var multiselectOptions: some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(optionItems) { item in
Toggle(isOn: Binding(get: {
selectedIndices.contains(item.index)
}, set: { newValue in
if newValue {
selectedIndices.insert(item.index)
} else {
selectedIndices.remove(item.index)
}
})) {
VStack(alignment: .leading, spacing: 2) {
Text(item.option.label)
if let hint = item.option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
}
private var isBlocked: Bool {
let type = wizardStepType(step)
if type == "select" { return optionItems.isEmpty }
if type == "multiselect" { return optionItems.isEmpty }
return false
}
private func submit() {
switch wizardStepType(step) {
case "note", "progress":
onSubmit(nil)
case "text":
onSubmit(AnyCodable(textValue))
case "confirm":
onSubmit(AnyCodable(confirmValue))
case "select":
guard optionItems.indices.contains(selectedIndex) else {
onSubmit(nil)
return
}
let option = optionItems[selectedIndex].option
onSubmit(option.value ?? AnyCodable(option.label))
case "multiselect":
let values = optionItems
.filter { selectedIndices.contains($0.index) }
.map { $0.option.value ?? AnyCodable($0.option.label) }
onSubmit(AnyCodable(values))
case "action":
onSubmit(AnyCodable(true))
default:
onSubmit(nil)
}
}
}
private struct WizardOptionItem: Identifiable {
let index: Int
let option: WizardOption
var id: Int { index }
}
private struct WizardOption {
let value: AnyCodable?
let label: String
let hint: String?
}
private func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? {
guard let raw else { return nil }
do {
let data = try JSONEncoder().encode(raw)
return try JSONDecoder().decode(WizardStep.self, from: data)
} catch {
onboardingWizardLogger.error("wizard step decode failed: \(error.localizedDescription, privacy: .public)")
return nil
}
}
private func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] {
guard let raw else { return [] }
return raw.map { entry in
let value = entry["value"]
let label = (entry["label"]?.value as? String) ?? ""
let hint = entry["hint"]?.value as? String
return WizardOption(value: value, label: label, hint: hint)
}
}
private func wizardStepType(_ step: WizardStep) -> String {
(step.type.value as? String) ?? ""
}
private func anyCodableString(_ value: AnyCodable?) -> String {
switch value?.value {
case let string as String:
return string
case let int as Int:
return String(int)
case let double as Double:
return String(double)
case let bool as Bool:
return bool ? "true" : "false"
default:
return ""
}
}
private func anyCodableStringValue(_ value: AnyCodable?) -> String? {
value?.value as? String
}
private func anyCodableBool(_ value: AnyCodable?) -> Bool {
switch value?.value {
case let bool as Bool:
return bool
case let string as String:
return string.lowercased() == "true"
default:
return false
}
}
private func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] {
switch value?.value {
case let arr as [AnyCodable]:
return arr
case let arr as [Any]:
return arr.map { AnyCodable($0) }
default:
return []
}
}
private func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool {
switch (lhs?.value, rhs?.value) {
case let (l as String, r as String):
return l == r
case let (l as Int, r as Int):
return l == r
case let (l as Double, r as Double):
return l == r
case let (l as Bool, r as Bool):
return l == r
case let (l as String, r as Int):
return l == String(r)
case let (l as Int, r as String):
return String(l) == r
case let (l as String, r as Double):
return l == String(r)
case let (l as Double, r as String):
return String(l) == r
default:
return false
}
}

View File

@ -701,6 +701,210 @@ public struct ConfigSetParams: Codable {
} }
} }
public struct ConfigSchemaParams: Codable {
}
public struct ConfigSchemaResponse: Codable {
public let schema: AnyCodable
public let uihints: [String: AnyCodable]
public let version: String
public let generatedat: String
public init(
schema: AnyCodable,
uihints: [String: AnyCodable],
version: String,
generatedat: String
) {
self.schema = schema
self.uihints = uihints
self.version = version
self.generatedat = generatedat
}
private enum CodingKeys: String, CodingKey {
case schema
case uihints = "uiHints"
case version
case generatedat = "generatedAt"
}
}
public struct WizardStartParams: Codable {
public let mode: AnyCodable?
public let workspace: String?
public init(
mode: AnyCodable?,
workspace: String?
) {
self.mode = mode
self.workspace = workspace
}
private enum CodingKeys: String, CodingKey {
case mode
case workspace
}
}
public struct WizardNextParams: Codable {
public let sessionid: String
public let answer: [String: AnyCodable]?
public init(
sessionid: String,
answer: [String: AnyCodable]?
) {
self.sessionid = sessionid
self.answer = answer
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case answer
}
}
public struct WizardCancelParams: Codable {
public let sessionid: String
public init(
sessionid: String
) {
self.sessionid = sessionid
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
}
}
public struct WizardStatusParams: Codable {
public let sessionid: String
public init(
sessionid: String
) {
self.sessionid = sessionid
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
}
}
public struct WizardStep: Codable {
public let id: String
public let type: AnyCodable
public let title: String?
public let message: String?
public let options: [[String: AnyCodable]]?
public let initialvalue: AnyCodable?
public let placeholder: String?
public let sensitive: Bool?
public let executor: AnyCodable?
public init(
id: String,
type: AnyCodable,
title: String?,
message: String?,
options: [[String: AnyCodable]]?,
initialvalue: AnyCodable?,
placeholder: String?,
sensitive: Bool?,
executor: AnyCodable?
) {
self.id = id
self.type = type
self.title = title
self.message = message
self.options = options
self.initialvalue = initialvalue
self.placeholder = placeholder
self.sensitive = sensitive
self.executor = executor
}
private enum CodingKeys: String, CodingKey {
case id
case type
case title
case message
case options
case initialvalue = "initialValue"
case placeholder
case sensitive
case executor
}
}
public struct WizardNextResult: Codable {
public let done: Bool
public let step: [String: AnyCodable]?
public let status: AnyCodable?
public let error: String?
public init(
done: Bool,
step: [String: AnyCodable]?,
status: AnyCodable?,
error: String?
) {
self.done = done
self.step = step
self.status = status
self.error = error
}
private enum CodingKeys: String, CodingKey {
case done
case step
case status
case error
}
}
public struct WizardStartResult: Codable {
public let sessionid: String
public let done: Bool
public let step: [String: AnyCodable]?
public let status: AnyCodable?
public let error: String?
public init(
sessionid: String,
done: Bool,
step: [String: AnyCodable]?,
status: AnyCodable?,
error: String?
) {
self.sessionid = sessionid
self.done = done
self.step = step
self.status = status
self.error = error
}
private enum CodingKeys: String, CodingKey {
case sessionid = "sessionId"
case done
case step
case status
case error
}
}
public struct WizardStatusResult: Codable {
public let status: AnyCodable
public let error: String?
public init(
status: AnyCodable,
error: String?
) {
self.status = status
self.error = error
}
private enum CodingKeys: String, CodingKey {
case status
case error
}
}
public struct TalkModeParams: Codable { public struct TalkModeParams: Codable {
public let enabled: Bool public let enabled: Bool
public let phase: String? public let phase: String?

View File

@ -17,7 +17,7 @@ struct OnboardingViewSmokeTests {
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() { @Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false) let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
#expect(!order.contains(7)) #expect(!order.contains(7))
#expect(!order.contains(3)) #expect(order.contains(3))
} }
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {

View File

@ -0,0 +1,42 @@
import SwiftUI
import Testing
import ClawdisProtocol
@testable import Clawdis
@Suite(.serialized)
@MainActor
struct OnboardingWizardStepViewTests {
@Test func noteStepBuilds() {
let step = WizardStep(
id: "step-1",
type: AnyCodable("note"),
title: "Welcome",
message: "Hello",
options: nil,
initialvalue: nil,
placeholder: nil,
sensitive: nil,
executor: nil)
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
_ = view.body
}
@Test func selectStepBuilds() {
let options: [[String: AnyCodable]] = [
["value": AnyCodable("local"), "label": AnyCodable("Local"), "hint": AnyCodable("This Mac")],
["value": AnyCodable("remote"), "label": AnyCodable("Remote")],
]
let step = WizardStep(
id: "step-2",
type: AnyCodable("select"),
title: "Mode",
message: "Choose a mode",
options: options,
initialvalue: AnyCodable("local"),
placeholder: nil,
sensitive: nil,
executor: nil)
let view = OnboardingWizardStepView(step: step, isSubmitting: false, onSubmit: { _ in })
_ = view.body
}
}

View File

@ -16,6 +16,14 @@ If the file is missing, CLAWDIS uses safe-ish defaults (embedded Pi agent + per-
- tune the embedded agent (`agent`) and session behavior (`session`) - tune the embedded agent (`agent`) and session behavior (`session`)
- set the agent's identity (`identity`) - set the agent's identity (`identity`)
## Schema + UI hints
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch.
Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
better forms without hard-coding config knowledge.
## Minimal config (recommended starting point) ## Minimal config (recommended starting point)
```json5 ```json5

View File

@ -26,6 +26,7 @@ The dashboard settings panel lets you store a token; passwords are not persisted
- Skills: status, enable/disable, install, API key updates (`skills.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`)
- Nodes: list + caps (`node.list`) - Nodes: list + caps (`node.list`)
- Config: view/edit `~/.clawdis/clawdis.json` (`config.get`, `config.set`) - Config: view/edit `~/.clawdis/clawdis.json` (`config.get`, `config.set`)
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`) - Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
## Tailnet access (recommended) ## Tailnet access (recommended)

View File

@ -0,0 +1,29 @@
# Onboarding + Config Protocol
Purpose: shared onboarding + config surfaces across CLI, macOS app, and Web UI.
## Components
- Wizard engine: `src/wizard` (session + prompts + onboarding state).
- CLI: `src/commands/onboard-*.ts` uses the wizard with the CLI prompter.
- Gateway RPC: wizard + config schema endpoints serve UI clients.
- macOS: SwiftUI onboarding uses the wizard step model.
- Web UI: config form renders from JSON Schema + hints.
## Gateway RPC
- `wizard.start` params: `{ mode?: "local"|"remote", workspace?: string }`
- `wizard.next` params: `{ sessionId, answer?: { stepId, value? } }`
- `wizard.cancel` params: `{ sessionId }`
- `wizard.status` params: `{ sessionId }`
- `config.schema` params: `{}`
Responses (shape)
- Wizard: `{ sessionId, done, step?, status?, error? }`
- Config schema: `{ schema, uiHints, version, generatedAt }`
## UI Hints
- `uiHints` keyed by path; optional metadata (label/help/group/order/advanced/sensitive/placeholder).
- Sensitive fields render as password inputs; no redaction layer.
- Unsupported schema nodes fall back to the raw JSON editor.
## Notes
- This doc is the single place to track protocol refactors for onboarding/config.

View File

@ -115,6 +115,11 @@ clawdis onboard --non-interactive \
Add `--json` for a machinereadable summary. Add `--json` for a machinereadable summary.
## Gateway wizard RPC
The Gateway exposes the wizard flow over RPC (`wizard.start`, `wizard.next`, `wizard.cancel`, `wizard.status`).
Clients (macOS app, Control UI) can render steps without reimplementing onboarding logic.
## Signal setup (signal-cli) ## Signal setup (signal-cli)
The wizard can install `signal-cli` from GitHub releases: The wizard can install `signal-cli` from GitHub releases:

View File

@ -180,13 +180,13 @@ function cleanSchemaForGemini(schema: unknown): unknown {
cleaned[key] = cleanSchemaForGemini(value); cleaned[key] = cleanSchemaForGemini(value);
} else if (key === "anyOf" && Array.isArray(value)) { } else if (key === "anyOf" && Array.isArray(value)) {
// Clean each anyOf variant // Clean each anyOf variant
cleaned[key] = value.map((v) => cleanSchemaForGemini(v)); cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "oneOf" && Array.isArray(value)) { } else if (key === "oneOf" && Array.isArray(value)) {
// Clean each oneOf variant // Clean each oneOf variant
cleaned[key] = value.map((v) => cleanSchemaForGemini(v)); cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if (key === "allOf" && Array.isArray(value)) { } else if (key === "allOf" && Array.isArray(value)) {
// Clean each allOf variant // Clean each allOf variant
cleaned[key] = value.map((v) => cleanSchemaForGemini(v)); cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant));
} else if ( } else if (
key === "additionalProperties" && key === "additionalProperties" &&
value && value &&
@ -265,12 +265,12 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool {
.map(([key]) => key) .map(([key]) => key)
: undefined; : undefined;
const { anyOf: _unusedAnyOf, ...restSchema } = schema; const nextSchema: Record<string, unknown> = { ...schema };
return { return {
...tool, ...tool,
parameters: cleanSchemaForGemini({ parameters: cleanSchemaForGemini({
...restSchema, ...nextSchema,
type: "object", type: nextSchema.type ?? "object",
properties: properties:
Object.keys(mergedProperties).length > 0 Object.keys(mergedProperties).length > 0
? mergedProperties ? mergedProperties

View File

@ -8,6 +8,7 @@ vi.mock("../globals.js", () => ({
isVerbose: () => false, isVerbose: () => false,
shouldLogVerbose: () => false, shouldLogVerbose: () => false,
logVerbose: vi.fn(), logVerbose: vi.fn(),
shouldLogVerbose: () => false,
})); }));
vi.mock("../process/exec.js", () => ({ vi.mock("../process/exec.js", () => ({

View File

@ -24,6 +24,7 @@ import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { resolveUserPath, sleep } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { import {
isRemoteEnvironment, isRemoteEnvironment,
loginAntigravityVpsAware, loginAntigravityVpsAware,
@ -419,6 +420,7 @@ export async function runConfigureWizard(
intro( intro(
opts.command === "update" ? "Clawdis update wizard" : "Clawdis configure", opts.command === "update" ? "Clawdis update wizard" : "Clawdis configure",
); );
const prompter = createClackPrompter();
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {};
@ -490,7 +492,7 @@ export async function runConfigureWizard(
) as "local" | "remote"; ) as "local" | "remote";
if (mode === "remote") { if (mode === "remote") {
let remoteConfig = await promptRemoteGatewayConfig(baseConfig, runtime); let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
remoteConfig = applyWizardMetadata(remoteConfig, { remoteConfig = applyWizardMetadata(remoteConfig, {
command: opts.command, command: opts.command,
mode, mode,
@ -565,7 +567,7 @@ export async function runConfigureWizard(
} }
if (selected.includes("providers")) { if (selected.includes("providers")) {
nextConfig = await setupProviders(nextConfig, runtime, { nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowDisable: true, allowDisable: true,
allowSignalInstall: true, allowSignalInstall: true,
}); });
@ -573,7 +575,7 @@ export async function runConfigureWizard(
if (selected.includes("skills")) { if (selected.includes("skills")) {
const wsDir = resolveUserPath(workspaceDir); const wsDir = resolveUserPath(workspaceDir);
nextConfig = await setupSkills(nextConfig, wsDir, runtime); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter);
} }
nextConfig = applyWizardMetadata(nextConfig, { nextConfig = applyWizardMetadata(nextConfig, {

View File

@ -1,594 +1,22 @@
import path from "node:path";
import {
confirm,
intro,
note,
outro,
select,
spinner,
text,
} from "@clack/prompts";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js";
import { import { runOnboardingWizard } from "../wizard/onboarding.js";
isRemoteEnvironment, import { WizardCancelledError } from "../wizard/prompts.js";
loginAntigravityVpsAware, import type { OnboardOptions } from "./onboard-types.js";
} from "./antigravity-oauth.js";
import { healthCommand } from "./health.js";
import {
applyMinimaxConfig,
setAnthropicApiKey,
writeOAuthCredentials,
} from "./onboard-auth.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
ensureWorkspaceAndSessions,
guardCancel,
handleReset,
openUrl,
printWizardHeader,
probeGatewayReachable,
randomToken,
resolveControlUiLinks,
summarizeExistingConfig,
} from "./onboard-helpers.js";
import { setupProviders } from "./onboard-providers.js";
import { promptRemoteGatewayConfig } from "./onboard-remote.js";
import { setupSkills } from "./onboard-skills.js";
import type {
AuthChoice,
GatewayAuthChoice,
OnboardMode,
OnboardOptions,
ResetScope,
} from "./onboard-types.js";
export async function runInteractiveOnboarding( export async function runInteractiveOnboarding(
opts: OnboardOptions, opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime, runtime: RuntimeEnv = defaultRuntime,
) { ) {
printWizardHeader(runtime); const prompter = createClackPrompter();
intro("Clawdis onboarding");
const snapshot = await readConfigFileSnapshot();
let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists) {
const title = snapshot.valid
? "Existing config detected"
: "Invalid config";
note(summarizeExistingConfig(baseConfig), title);
if (!snapshot.valid && snapshot.issues.length > 0) {
note(
snapshot.issues
.map((iss) => `- ${iss.path}: ${iss.message}`)
.join("\n"),
"Config issues",
);
}
const action = guardCancel(
await select({
message: "Config handling",
options: [
{ value: "keep", label: "Use existing values" },
{ value: "modify", label: "Update values" },
{ value: "reset", label: "Reset" },
],
}),
runtime,
);
if (action === "reset") {
const workspaceDefault = baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE;
const resetScope = guardCancel(
await select({
message: "Reset scope",
options: [
{ value: "config", label: "Config only" },
{
value: "config+creds+sessions",
label: "Config + creds + sessions",
},
{
value: "full",
label: "Full reset (config + creds + sessions + workspace)",
},
],
}),
runtime,
) as ResetScope;
await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
baseConfig = {};
} else if (action === "keep" && !snapshot.valid) {
baseConfig = {};
}
}
const localPort = resolveGatewayPort(baseConfig);
const localUrl = `ws://127.0.0.1:${localPort}`;
const localProbe = await probeGatewayReachable({
url: localUrl,
token: process.env.CLAWDIS_GATEWAY_TOKEN,
password:
baseConfig.gateway?.auth?.password ??
process.env.CLAWDIS_GATEWAY_PASSWORD,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: baseConfig.gateway?.remote?.token,
})
: null;
const mode =
opts.mode ??
(guardCancel(
await select({
message: "Where will the Gateway run?",
options: [
{
value: "local",
label: "Local (this machine)",
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
},
{
value: "remote",
label: "Remote (info-only)",
hint: !remoteUrl
? "No remote URL configured yet"
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
},
],
}),
runtime,
) as OnboardMode);
if (mode === "remote") {
let nextConfig = await promptRemoteGatewayConfig(baseConfig, runtime);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`);
outro("Remote gateway configured.");
return;
}
const workspaceInput =
opts.workspace ??
(guardCancel(
await text({
message: "Workspace directory",
initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE,
}),
runtime,
) as string);
const workspaceDir = resolveUserPath(
workspaceInput.trim() || DEFAULT_WORKSPACE,
);
let nextConfig: ClawdisConfig = {
...baseConfig,
agent: {
...baseConfig.agent,
workspace: workspaceDir,
},
gateway: {
...baseConfig.gateway,
mode: "local",
},
};
const authChoice = guardCancel(
await select({
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
],
}),
runtime,
) as AuthChoice;
if (authChoice === "oauth") {
note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = spinner();
spin.start("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
runtime.log(`Open: ${url}`);
},
async () => {
const code = guardCancel(
await text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
return String(code);
},
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds);
}
} catch (err) {
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = spinner();
spin.start("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.message("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.message(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
// Set default model to Claude Opus 4.5 via Antigravity
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = guardCancel(
await text({
message: "Enter Anthropic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
await setAnthropicApiKey(String(key).trim());
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
}
const portRaw = guardCancel(
await text({
message: "Gateway port",
initialValue: String(localPort),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
}),
runtime,
);
const port = Number.parseInt(String(portRaw), 10);
let bind = guardCancel(
await select({
message: "Gateway bind",
options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN" },
{ value: "tailnet", label: "Tailnet" },
{ value: "auto", label: "Auto" },
],
}),
runtime,
) as "loopback" | "lan" | "tailnet" | "auto";
let authMode = guardCancel(
await select({
message: "Gateway auth",
options: [
{
value: "off",
label: "Off (loopback only)",
hint: "Recommended for single-machine setups",
},
{
value: "token",
label: "Token",
hint: "Use for multi-machine access or non-loopback binds",
},
{ value: "password", label: "Password" },
],
}),
runtime,
) as GatewayAuthChoice;
const tailscaleMode = guardCancel(
await select({
message: "Tailscale exposure",
options: [
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
{
value: "serve",
label: "Serve",
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
},
{
value: "funnel",
label: "Funnel",
hint: "Public HTTPS via Tailscale Funnel (internet)",
},
],
}),
runtime,
) as "off" | "serve" | "funnel";
let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") {
tailscaleResetOnExit = Boolean(
guardCancel(
await confirm({
message: "Reset Tailscale serve/funnel on exit?",
initialValue: false,
}),
runtime,
),
);
}
if (tailscaleMode !== "off" && bind !== "loopback") {
note(
"Tailscale requires bind=loopback. Adjusting bind to loopback.",
"Note",
);
bind = "loopback";
}
if (authMode === "off" && bind !== "loopback") {
note("Non-loopback bind requires auth. Switching to token auth.", "Note");
authMode = "token";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
}
let gatewayToken: string | undefined;
if (authMode === "token") {
const tokenInput = guardCancel(
await text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: randomToken(),
}),
runtime,
);
gatewayToken = String(tokenInput).trim() || randomToken();
}
if (authMode === "password") {
const password = guardCancel(
await text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
runtime,
);
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "password",
password: String(password).trim(),
},
},
};
} else if (authMode === "token") {
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
},
},
};
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
port,
bind,
tailscale: {
...nextConfig.gateway?.tailscale,
mode: tailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
},
};
nextConfig = await setupProviders(nextConfig, runtime, {
allowSignalInstall: true,
});
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime);
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
const installDaemon = guardCancel(
await confirm({
message: "Install Gateway daemon (recommended)",
initialValue: true,
}),
runtime,
);
if (installDaemon) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
const action = guardCancel(
await select({
message: "Gateway service already installed",
options: [
{ value: "restart", label: "Restart" },
{ value: "reinstall", label: "Reinstall" },
{ value: "skip", label: "Skip" },
],
}),
runtime,
);
if (action === "restart") {
await service.restart({ stdout: process.stdout });
} else if (action === "reinstall") {
await service.uninstall({ env: process.env, stdout: process.stdout });
}
}
if (
!loaded ||
(loaded && (await service.isLoaded({ env: process.env })) === false)
) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({ port, dev: devMode });
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDIS_GATEWAY_TOKEN: gatewayToken,
CLAWDIS_LAUNCHD_LABEL:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
}
}
await sleep(1500);
try { try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); await runOnboardingWizard(opts, runtime, prompter);
} catch (err) { } catch (err) {
runtime.error(`Health check failed: ${String(err)}`); if (err instanceof WizardCancelledError) {
runtime.exit(0);
return;
}
throw err;
} }
note(
[
"Add nodes for extra features:",
"- macOS app (system + notifications)",
"- iOS app (camera/canvas)",
"- Android app (camera/canvas)",
].join("\n"),
"Optional apps",
);
note(
(() => {
const links = resolveControlUiLinks({ bind, port });
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
const authedUrl = `${links.httpUrl}${tokenParam}`;
return [
`Web UI: ${links.httpUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`,
]
.filter(Boolean)
.join("\n");
})(),
"Control UI",
);
const wantsOpen = guardCancel(
await confirm({
message: "Open Control UI now?",
initialValue: true,
}),
runtime,
);
if (wantsOpen) {
const links = resolveControlUiLinks({ bind, port });
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
await openUrl(`${links.httpUrl}${tokenParam}`);
}
outro("Onboarding complete.");
} }

View File

@ -1,15 +1,12 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { confirm, multiselect, note, select, text } from "@clack/prompts";
import chalk from "chalk";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import { loginWeb } from "../provider-web.js"; import { loginWeb } from "../provider-web.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
import { resolveWebAuthDir } from "../web/session.js"; import { resolveWebAuthDir } from "../web/session.js";
import { detectBinary, guardCancel } from "./onboard-helpers.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary } from "./onboard-helpers.js";
import type { ProviderChoice } from "./onboard-types.js"; import type { ProviderChoice } from "./onboard-types.js";
import { installSignalCli } from "./signal-install.js"; import { installSignalCli } from "./signal-install.js";
@ -27,8 +24,8 @@ async function detectWhatsAppLinked(): Promise<boolean> {
return await pathExists(credsPath); return await pathExists(credsPath);
} }
function noteProviderPrimer(): void { async function noteProviderPrimer(prompter: WizardPrompter): Promise<void> {
note( await prompter.note(
[ [
"WhatsApp: links via WhatsApp Web (scan QR), stores creds for future sends.", "WhatsApp: links via WhatsApp Web (scan QR), stores creds for future sends.",
"Telegram: Bot API (token from @BotFather), replies via your bot.", "Telegram: Bot API (token from @BotFather), replies via your bot.",
@ -40,8 +37,8 @@ function noteProviderPrimer(): void {
); );
} }
function noteTelegramTokenHelp(): void { async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise<void> {
note( await prompter.note(
[ [
"1) Open Telegram and chat with @BotFather", "1) Open Telegram and chat with @BotFather",
"2) Run /newbot (or /mybots)", "2) Run /newbot (or /mybots)",
@ -52,8 +49,8 @@ function noteTelegramTokenHelp(): void {
); );
} }
function noteDiscordTokenHelp(): void { async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
note( await prompter.note(
[ [
"1) Discord Developer Portal → Applications → New Application", "1) Discord Developer Portal → Applications → New Application",
"2) Bot → Add Bot → Reset Token → copy token", "2) Bot → Add Bot → Reset Token → copy token",
@ -76,13 +73,14 @@ function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
async function promptWhatsAppAllowFrom( async function promptWhatsAppAllowFrom(
cfg: ClawdisConfig, cfg: ClawdisConfig,
runtime: RuntimeEnv, _runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<ClawdisConfig> { ): Promise<ClawdisConfig> {
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingLabel = const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
note( await prompter.note(
[ [
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.", "WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
'Default (unset) = self-chat only; use "*" to allow anyone.', 'Default (unset) = self-chat only; use "*" to allow anyone.',
@ -105,40 +103,34 @@ async function promptWhatsAppAllowFrom(
{ value: "any", label: "Anyone (*)" }, { value: "any", label: "Anyone (*)" },
] as const); ] as const);
const mode = guardCancel( const mode = (await prompter.select({
await select({ message: "Who can trigger the bot via WhatsApp?",
message: "Who can trigger the bot via WhatsApp?", options: options.map((opt) => ({ value: opt.value, label: opt.label })),
options: options.map((opt) => ({ value: opt.value, label: opt.label })), })) as (typeof options)[number]["value"];
}),
runtime,
) as (typeof options)[number]["value"];
if (mode === "keep") return cfg; if (mode === "keep") return cfg;
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined); if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]); if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
const allowRaw = guardCancel( const allowRaw = await prompter.text({
await text({ message: "Allowed sender numbers (comma-separated, E.164)",
message: "Allowed sender numbers (comma-separated, E.164)", placeholder: "+15555550123, +447700900123",
placeholder: "+15555550123, +447700900123", validate: (value) => {
validate: (value) => { const raw = String(value ?? "").trim();
const raw = String(value ?? "").trim(); if (!raw) return "Required";
if (!raw) return "Required"; const parts = raw
const parts = raw .split(/[\n,;]+/g)
.split(/[\n,;]+/g) .map((p) => p.trim())
.map((p) => p.trim()) .filter(Boolean);
.filter(Boolean); if (parts.length === 0) return "Required";
if (parts.length === 0) return "Required"; for (const part of parts) {
for (const part of parts) { if (part === "*") continue;
if (part === "*") continue; const normalized = normalizeE164(part);
const normalized = normalizeE164(part); if (!normalized) return `Invalid number: ${part}`;
if (!normalized) return `Invalid number: ${part}`; }
} return undefined;
return undefined; },
}, });
}),
runtime,
);
const parts = String(allowRaw) const parts = String(allowRaw)
.split(/[\n,;]+/g) .split(/[\n,;]+/g)
@ -154,6 +146,7 @@ async function promptWhatsAppAllowFrom(
export async function setupProviders( export async function setupProviders(
cfg: ClawdisConfig, cfg: ClawdisConfig,
runtime: RuntimeEnv, runtime: RuntimeEnv,
prompter: WizardPrompter,
options?: { allowDisable?: boolean; allowSignalInstall?: boolean }, options?: { allowDisable?: boolean; allowSignalInstall?: boolean },
): Promise<ClawdisConfig> { ): Promise<ClawdisConfig> {
const whatsappLinked = await detectWhatsAppLinked(); const whatsappLinked = await detectWhatsAppLinked();
@ -174,91 +167,63 @@ export async function setupProviders(
const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; const imessageCliPath = cfg.imessage?.cliPath ?? "imsg";
const imessageCliDetected = await detectBinary(imessageCliPath); const imessageCliDetected = await detectBinary(imessageCliPath);
note( await prompter.note(
[ [
`WhatsApp: ${ `WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`,
whatsappLinked ? chalk.green("linked") : chalk.red("not linked") `Telegram: ${telegramConfigured ? "configured" : "needs token"}`,
}`, `Discord: ${discordConfigured ? "configured" : "needs token"}`,
`Telegram: ${ `Signal: ${signalConfigured ? "configured" : "needs setup"}`,
telegramConfigured `iMessage: ${imessageConfigured ? "configured" : "needs setup"}`,
? chalk.green("configured") `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
: chalk.yellow("needs token") `imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`,
}`,
`Discord: ${
discordConfigured
? chalk.green("configured")
: chalk.yellow("needs token")
}`,
`Signal: ${
signalConfigured
? chalk.green("configured")
: chalk.yellow("needs setup")
}`,
`iMessage: ${
imessageConfigured
? chalk.green("configured")
: chalk.yellow("needs setup")
}`,
`signal-cli: ${
signalCliDetected ? chalk.green("found") : chalk.red("missing")
} (${signalCliPath})`,
`imsg: ${
imessageCliDetected ? chalk.green("found") : chalk.red("missing")
} (${imessageCliPath})`,
].join("\n"), ].join("\n"),
"Provider status", "Provider status",
); );
const shouldConfigure = guardCancel( const shouldConfigure = await prompter.confirm({
await confirm({ message: "Configure chat providers now?",
message: "Configure chat providers now?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!shouldConfigure) return cfg; if (!shouldConfigure) return cfg;
noteProviderPrimer(); await noteProviderPrimer(prompter);
const selection = guardCancel( const selection = (await prompter.multiselect({
await multiselect({ message: "Select providers",
message: "Select providers", options: [
options: [ {
{ value: "whatsapp",
value: "whatsapp", label: "WhatsApp (QR link)",
label: "WhatsApp (QR link)", hint: whatsappLinked ? "linked" : "not linked",
hint: whatsappLinked ? "linked" : "not linked", },
}, {
{ value: "telegram",
value: "telegram", label: "Telegram (Bot API)",
label: "Telegram (Bot API)", hint: telegramConfigured ? "configured" : "needs token",
hint: telegramConfigured ? "configured" : "needs token", },
}, {
{ value: "discord",
value: "discord", label: "Discord (Bot API)",
label: "Discord (Bot API)", hint: discordConfigured ? "configured" : "needs token",
hint: discordConfigured ? "configured" : "needs token", },
}, {
{ value: "signal",
value: "signal", label: "Signal (signal-cli)",
label: "Signal (signal-cli)", hint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", },
}, {
{ value: "imessage",
value: "imessage", label: "iMessage (imsg)",
label: "iMessage (imsg)", hint: imessageCliDetected ? "imsg found" : "imsg missing",
hint: imessageCliDetected ? "imsg found" : "imsg missing", },
}, ],
], })) as ProviderChoice[];
}),
runtime,
) as ProviderChoice[];
let next = cfg; let next = cfg;
if (selection.includes("whatsapp")) { if (selection.includes("whatsapp")) {
if (!whatsappLinked) { if (!whatsappLinked) {
note( await prompter.note(
[ [
"Scan the QR with WhatsApp on your phone.", "Scan the QR with WhatsApp on your phone.",
"Credentials are stored under ~/.clawdis/credentials/ for future runs.", "Credentials are stored under ~/.clawdis/credentials/ for future runs.",
@ -266,15 +231,12 @@ export async function setupProviders(
"WhatsApp linking", "WhatsApp linking",
); );
} }
const wantsLink = guardCancel( const wantsLink = await prompter.confirm({
await confirm({ message: whatsappLinked
message: whatsappLinked ? "WhatsApp already linked. Re-link now?"
? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?",
: "Link WhatsApp now (QR)?", initialValue: !whatsappLinked,
initialValue: !whatsappLinked, });
}),
runtime,
);
if (wantsLink) { if (wantsLink) {
try { try {
await loginWeb(false, "web"); await loginWeb(false, "web");
@ -282,25 +244,25 @@ export async function setupProviders(
runtime.error(`WhatsApp login failed: ${String(err)}`); runtime.error(`WhatsApp login failed: ${String(err)}`);
} }
} else if (!whatsappLinked) { } else if (!whatsappLinked) {
note("Run `clawdis login` later to link WhatsApp.", "WhatsApp"); await prompter.note(
"Run `clawdis login` later to link WhatsApp.",
"WhatsApp",
);
} }
next = await promptWhatsAppAllowFrom(next, runtime); next = await promptWhatsAppAllowFrom(next, runtime, prompter);
} }
if (selection.includes("telegram")) { if (selection.includes("telegram")) {
let token: string | null = null; let token: string | null = null;
if (!telegramConfigured) { if (!telegramConfigured) {
noteTelegramTokenHelp(); await noteTelegramTokenHelp(prompter);
} }
if (telegramEnv && !cfg.telegram?.botToken) { if (telegramEnv && !cfg.telegram?.botToken) {
const keepEnv = guardCancel( const keepEnv = await prompter.confirm({
await confirm({ message: "TELEGRAM_BOT_TOKEN detected. Use env var?",
message: "TELEGRAM_BOT_TOKEN detected. Use env var?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (keepEnv) { if (keepEnv) {
next = { next = {
...next, ...next,
@ -311,43 +273,31 @@ export async function setupProviders(
}; };
} else { } else {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Telegram bot token",
message: "Enter Telegram bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
} else if (cfg.telegram?.botToken) { } else if (cfg.telegram?.botToken) {
const keep = guardCancel( const keep = await prompter.confirm({
await confirm({ message: "Telegram token already configured. Keep it?",
message: "Telegram token already configured. Keep it?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!keep) { if (!keep) {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Telegram bot token",
message: "Enter Telegram bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
} else { } else {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Telegram bot token",
message: "Enter Telegram bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
@ -366,16 +316,13 @@ export async function setupProviders(
if (selection.includes("discord")) { if (selection.includes("discord")) {
let token: string | null = null; let token: string | null = null;
if (!discordConfigured) { if (!discordConfigured) {
noteDiscordTokenHelp(); await noteDiscordTokenHelp(prompter);
} }
if (discordEnv && !cfg.discord?.token) { if (discordEnv && !cfg.discord?.token) {
const keepEnv = guardCancel( const keepEnv = await prompter.confirm({
await confirm({ message: "DISCORD_BOT_TOKEN detected. Use env var?",
message: "DISCORD_BOT_TOKEN detected. Use env var?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (keepEnv) { if (keepEnv) {
next = { next = {
...next, ...next,
@ -386,43 +333,31 @@ export async function setupProviders(
}; };
} else { } else {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Discord bot token",
message: "Enter Discord bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
} else if (cfg.discord?.token) { } else if (cfg.discord?.token) {
const keep = guardCancel( const keep = await prompter.confirm({
await confirm({ message: "Discord token already configured. Keep it?",
message: "Discord token already configured. Keep it?", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!keep) { if (!keep) {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Discord bot token",
message: "Enter Discord bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
} else { } else {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Enter Discord bot token",
message: "Enter Discord bot token", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
@ -442,33 +377,39 @@ export async function setupProviders(
let resolvedCliPath = signalCliPath; let resolvedCliPath = signalCliPath;
let cliDetected = signalCliDetected; let cliDetected = signalCliDetected;
if (options?.allowSignalInstall) { if (options?.allowSignalInstall) {
const wantsInstall = guardCancel( const wantsInstall = await prompter.confirm({
await confirm({ message: cliDetected
message: cliDetected ? "signal-cli detected. Reinstall/update now?"
? "signal-cli detected. Reinstall/update now?" : "signal-cli not found. Install now?",
: "signal-cli not found. Install now?", initialValue: !cliDetected,
initialValue: !cliDetected, });
}),
runtime,
);
if (wantsInstall) { if (wantsInstall) {
try { try {
const result = await installSignalCli(runtime); const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) { if (result.ok && result.cliPath) {
cliDetected = true; cliDetected = true;
resolvedCliPath = result.cliPath; resolvedCliPath = result.cliPath;
note(`Installed signal-cli at ${result.cliPath}`, "Signal"); await prompter.note(
`Installed signal-cli at ${result.cliPath}`,
"Signal",
);
} else if (!result.ok) { } else if (!result.ok) {
note(result.error ?? "signal-cli install failed.", "Signal"); await prompter.note(
result.error ?? "signal-cli install failed.",
"Signal",
);
} }
} catch (err) { } catch (err) {
note(`signal-cli install failed: ${String(err)}`, "Signal"); await prompter.note(
`signal-cli install failed: ${String(err)}`,
"Signal",
);
} }
} }
} }
if (!cliDetected) { if (!cliDetected) {
note( await prompter.note(
"signal-cli not found. Install it, then rerun this step or set signal.cliPath.", "signal-cli not found. Install it, then rerun this step or set signal.cliPath.",
"Signal", "Signal",
); );
@ -476,25 +417,19 @@ export async function setupProviders(
let account = cfg.signal?.account ?? ""; let account = cfg.signal?.account ?? "";
if (account) { if (account) {
const keep = guardCancel( const keep = await prompter.confirm({
await confirm({ message: `Signal account set (${account}). Keep it?`,
message: `Signal account set (${account}). Keep it?`, initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!keep) account = ""; if (!keep) account = "";
} }
if (!account) { if (!account) {
account = String( account = String(
guardCancel( await prompter.text({
await text({ message: "Signal bot number (E.164)",
message: "Signal bot number (E.164)", validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} }
@ -510,7 +445,7 @@ export async function setupProviders(
}; };
} }
note( await prompter.note(
[ [
'Link device with: signal-cli link -n "Clawdis"', 'Link device with: signal-cli link -n "Clawdis"',
"Scan QR in Signal → Linked Devices", "Scan QR in Signal → Linked Devices",
@ -523,17 +458,17 @@ export async function setupProviders(
if (selection.includes("imessage")) { if (selection.includes("imessage")) {
let resolvedCliPath = imessageCliPath; let resolvedCliPath = imessageCliPath;
if (!imessageCliDetected) { if (!imessageCliDetected) {
const entered = guardCancel( const entered = await prompter.text({
await text({ message: "imsg CLI path",
message: "imsg CLI path", initialValue: resolvedCliPath,
initialValue: resolvedCliPath, validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), });
}),
runtime,
);
resolvedCliPath = String(entered).trim(); resolvedCliPath = String(entered).trim();
if (!resolvedCliPath) { if (!resolvedCliPath) {
note("imsg CLI path required to enable iMessage.", "iMessage"); await prompter.note(
"imsg CLI path required to enable iMessage.",
"iMessage",
);
} }
} }
@ -548,7 +483,7 @@ export async function setupProviders(
}; };
} }
note( await prompter.note(
[ [
"Ensure Clawdis has Full Disk Access to Messages DB.", "Ensure Clawdis has Full Disk Access to Messages DB.",
"Grant Automation permission for Messages when prompted.", "Grant Automation permission for Messages when prompted.",
@ -560,13 +495,10 @@ export async function setupProviders(
if (options?.allowDisable) { if (options?.allowDisable) {
if (!selection.includes("telegram") && telegramConfigured) { if (!selection.includes("telegram") && telegramConfigured) {
const disable = guardCancel( const disable = await prompter.confirm({
await confirm({ message: "Disable Telegram provider?",
message: "Disable Telegram provider?", initialValue: false,
initialValue: false, });
}),
runtime,
);
if (disable) { if (disable) {
next = { next = {
...next, ...next,
@ -576,13 +508,10 @@ export async function setupProviders(
} }
if (!selection.includes("discord") && discordConfigured) { if (!selection.includes("discord") && discordConfigured) {
const disable = guardCancel( const disable = await prompter.confirm({
await confirm({ message: "Disable Discord provider?",
message: "Disable Discord provider?", initialValue: false,
initialValue: false, });
}),
runtime,
);
if (disable) { if (disable) {
next = { next = {
...next, ...next,
@ -592,13 +521,10 @@ export async function setupProviders(
} }
if (!selection.includes("signal") && signalConfigured) { if (!selection.includes("signal") && signalConfigured) {
const disable = guardCancel( const disable = await prompter.confirm({
await confirm({ message: "Disable Signal provider?",
message: "Disable Signal provider?", initialValue: false,
initialValue: false, });
}),
runtime,
);
if (disable) { if (disable) {
next = { next = {
...next, ...next,
@ -608,13 +534,10 @@ export async function setupProviders(
} }
if (!selection.includes("imessage") && imessageConfigured) { if (!selection.includes("imessage") && imessageConfigured) {
const disable = guardCancel( const disable = await prompter.confirm({
await confirm({ message: "Disable iMessage provider?",
message: "Disable iMessage provider?", initialValue: false,
initialValue: false, });
}),
runtime,
);
if (disable) { if (disable) {
next = { next = {
...next, ...next,

View File

@ -1,10 +1,8 @@
import { confirm, note, select, spinner, text } from "@clack/prompts";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js";
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { detectBinary, guardCancel } from "./onboard-helpers.js"; import { detectBinary } from "./onboard-helpers.js";
const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789";
@ -28,7 +26,7 @@ function ensureWsUrl(value: string): string {
export async function promptRemoteGatewayConfig( export async function promptRemoteGatewayConfig(
cfg: ClawdisConfig, cfg: ClawdisConfig,
runtime: RuntimeEnv, prompter: WizardPrompter,
): Promise<ClawdisConfig> { ): Promise<ClawdisConfig> {
let selectedBeacon: GatewayBonjourBeacon | null = null; let selectedBeacon: GatewayBonjourBeacon | null = null;
let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL; let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL;
@ -36,25 +34,21 @@ export async function promptRemoteGatewayConfig(
const hasBonjourTool = const hasBonjourTool =
(await detectBinary("dns-sd")) || (await detectBinary("avahi-browse")); (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse"));
const wantsDiscover = hasBonjourTool const wantsDiscover = hasBonjourTool
? guardCancel( ? await prompter.confirm({
await confirm({ message: "Discover gateway on LAN (Bonjour)?",
message: "Discover gateway on LAN (Bonjour)?", initialValue: true,
initialValue: true, })
}),
runtime,
)
: false; : false;
if (!hasBonjourTool) { if (!hasBonjourTool) {
note( await prompter.note(
"Bonjour discovery requires dns-sd (macOS) or avahi-browse (Linux).", "Bonjour discovery requires dns-sd (macOS) or avahi-browse (Linux).",
"Discovery", "Discovery",
); );
} }
if (wantsDiscover) { if (wantsDiscover) {
const spin = spinner(); const spin = prompter.progress("Searching for gateways…");
spin.start("Searching for gateways…");
const beacons = await discoverGatewayBeacons({ timeoutMs: 2000 }); const beacons = await discoverGatewayBeacons({ timeoutMs: 2000 });
spin.stop( spin.stop(
beacons.length > 0 beacons.length > 0
@ -63,19 +57,16 @@ export async function promptRemoteGatewayConfig(
); );
if (beacons.length > 0) { if (beacons.length > 0) {
const selection = guardCancel( const selection = await prompter.select({
await select({ message: "Select gateway",
message: "Select gateway", options: [
options: [ ...beacons.map((beacon, index) => ({
...beacons.map((beacon, index) => ({ value: String(index),
value: String(index), label: buildLabel(beacon),
label: buildLabel(beacon), })),
})), { value: "manual", label: "Enter URL manually" },
{ value: "manual", label: "Enter URL manually" }, ],
], });
}),
runtime,
);
if (selection !== "manual") { if (selection !== "manual") {
const idx = Number.parseInt(String(selection), 10); const idx = Number.parseInt(String(selection), 10);
selectedBeacon = Number.isFinite(idx) ? (beacons[idx] ?? null) : null; selectedBeacon = Number.isFinite(idx) ? (beacons[idx] ?? null) : null;
@ -87,24 +78,21 @@ export async function promptRemoteGatewayConfig(
const host = pickHost(selectedBeacon); const host = pickHost(selectedBeacon);
const port = selectedBeacon.gatewayPort ?? 18789; const port = selectedBeacon.gatewayPort ?? 18789;
if (host) { if (host) {
const mode = guardCancel( const mode = await prompter.select({
await select({ message: "Connection method",
message: "Connection method", options: [
options: [ {
{ value: "direct",
value: "direct", label: `Direct gateway WS (${host}:${port})`,
label: `Direct gateway WS (${host}:${port})`, },
}, { value: "ssh", label: "SSH tunnel (loopback)" },
{ value: "ssh", label: "SSH tunnel (loopback)" }, ],
], });
}),
runtime,
);
if (mode === "direct") { if (mode === "direct") {
suggestedUrl = `ws://${host}:${port}`; suggestedUrl = `ws://${host}:${port}`;
} else { } else {
suggestedUrl = DEFAULT_GATEWAY_URL; suggestedUrl = DEFAULT_GATEWAY_URL;
note( await prompter.note(
[ [
"Start a tunnel before using the CLI:", "Start a tunnel before using the CLI:",
`ssh -N -L 18789:127.0.0.1:18789 <user>@${host}${ `ssh -N -L 18789:127.0.0.1:18789 <user>@${host}${
@ -117,42 +105,33 @@ export async function promptRemoteGatewayConfig(
} }
} }
const urlInput = guardCancel( const urlInput = await prompter.text({
await text({ message: "Gateway WebSocket URL",
message: "Gateway WebSocket URL", initialValue: suggestedUrl,
initialValue: suggestedUrl, validate: (value) =>
validate: (value) => String(value).trim().startsWith("ws://") ||
String(value).trim().startsWith("ws://") || String(value).trim().startsWith("wss://")
String(value).trim().startsWith("wss://") ? undefined
? undefined : "URL must start with ws:// or wss://",
: "URL must start with ws:// or wss://", });
}),
runtime,
);
const url = ensureWsUrl(String(urlInput)); const url = ensureWsUrl(String(urlInput));
const authChoice = guardCancel( const authChoice = (await prompter.select({
await select({ message: "Gateway auth",
message: "Gateway auth", options: [
options: [ { value: "token", label: "Token (recommended)" },
{ value: "token", label: "Token (recommended)" }, { value: "off", label: "No auth" },
{ value: "off", label: "No auth" }, ],
], })) as "token" | "off";
}),
runtime,
) as "token" | "off";
let token = cfg.gateway?.remote?.token ?? ""; let token = cfg.gateway?.remote?.token ?? "";
if (authChoice === "token") { if (authChoice === "token") {
token = String( token = String(
guardCancel( await prompter.text({
await text({ message: "Gateway token",
message: "Gateway token", initialValue: token,
initialValue: token, validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
).trim(); ).trim();
} else { } else {
token = ""; token = "";

View File

@ -1,17 +1,9 @@
import {
confirm,
multiselect,
note,
select,
spinner,
text,
} from "@clack/prompts";
import { installSkill } from "../agents/skills-install.js"; import { installSkill } from "../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js"; import type { ClawdisConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { guardCancel, resolveNodeManagerOptions } from "./onboard-helpers.js"; import type { WizardPrompter } from "../wizard/prompts.js";
import { resolveNodeManagerOptions } from "./onboard-helpers.js";
function summarizeInstallFailure(message: string): string | undefined { function summarizeInstallFailure(message: string): string | undefined {
const cleaned = message const cleaned = message
@ -58,6 +50,7 @@ export async function setupSkills(
cfg: ClawdisConfig, cfg: ClawdisConfig,
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<ClawdisConfig> { ): Promise<ClawdisConfig> {
const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg });
const eligible = report.skills.filter((s) => s.eligible); const eligible = report.skills.filter((s) => s.eligible);
@ -66,7 +59,7 @@ export async function setupSkills(
); );
const blocked = report.skills.filter((s) => s.blockedByAllowlist); const blocked = report.skills.filter((s) => s.blockedByAllowlist);
note( await prompter.note(
[ [
`Eligible: ${eligible.length}`, `Eligible: ${eligible.length}`,
`Missing requirements: ${missing.length}`, `Missing requirements: ${missing.length}`,
@ -75,22 +68,16 @@ export async function setupSkills(
"Skills status", "Skills status",
); );
const shouldConfigure = guardCancel( const shouldConfigure = await prompter.confirm({
await confirm({ message: "Configure skills now? (recommended)",
message: "Configure skills now? (recommended)", initialValue: true,
initialValue: true, });
}),
runtime,
);
if (!shouldConfigure) return cfg; if (!shouldConfigure) return cfg;
const nodeManager = guardCancel( const nodeManager = (await prompter.select({
await select({ message: "Preferred node manager for skill installs",
message: "Preferred node manager for skill installs", options: resolveNodeManagerOptions(),
options: resolveNodeManagerOptions(), })) as "npm" | "pnpm" | "bun";
}),
runtime,
) as "npm" | "pnpm" | "bun";
let next: ClawdisConfig = { let next: ClawdisConfig = {
...cfg, ...cfg,
@ -107,24 +94,21 @@ export async function setupSkills(
(skill) => skill.install.length > 0 && skill.missing.bins.length > 0, (skill) => skill.install.length > 0 && skill.missing.bins.length > 0,
); );
if (installable.length > 0) { if (installable.length > 0) {
const toInstall = guardCancel( const toInstall = await prompter.multiselect({
await multiselect({ message: "Install missing skill dependencies",
message: "Install missing skill dependencies", options: [
options: [ {
{ value: "__skip__",
value: "__skip__", label: "Skip for now",
label: "Skip for now", hint: "Continue without installing dependencies",
hint: "Continue without installing dependencies", },
}, ...installable.map((skill) => ({
...installable.map((skill) => ({ value: skill.name,
value: skill.name, label: `${skill.emoji ?? "🧩"} ${skill.name}`,
label: `${skill.emoji ?? "🧩"} ${skill.name}`, hint: formatSkillHint(skill),
hint: formatSkillHint(skill), })),
})), ],
], });
}),
runtime,
);
const selected = (toInstall as string[]).filter( const selected = (toInstall as string[]).filter(
(name) => name !== "__skip__", (name) => name !== "__skip__",
@ -134,8 +118,7 @@ export async function setupSkills(
if (!target || target.install.length === 0) continue; if (!target || target.install.length === 0) continue;
const installId = target.install[0]?.id; const installId = target.install[0]?.id;
if (!installId) continue; if (!installId) continue;
const spin = spinner(); const spin = prompter.progress(`Installing ${name}`);
spin.start(`Installing ${name}`);
const result = await installSkill({ const result = await installSkill({
workspaceDir, workspaceDir,
skillName: target.name, skillName: target.name,
@ -161,22 +144,16 @@ export async function setupSkills(
for (const skill of missing) { for (const skill of missing) {
if (!skill.primaryEnv || skill.missing.env.length === 0) continue; if (!skill.primaryEnv || skill.missing.env.length === 0) continue;
const wantsKey = guardCancel( const wantsKey = await prompter.confirm({
await confirm({ message: `Set ${skill.primaryEnv} for ${skill.name}?`,
message: `Set ${skill.primaryEnv} for ${skill.name}?`, initialValue: false,
initialValue: false, });
}),
runtime,
);
if (!wantsKey) continue; if (!wantsKey) continue;
const apiKey = String( const apiKey = String(
guardCancel( await prompter.text({
await text({ message: `Enter ${skill.primaryEnv}`,
message: `Enter ${skill.primaryEnv}`, validate: (value) => (value?.trim() ? undefined : "Required"),
validate: (value) => (value?.trim() ? undefined : "Required"), }),
}),
runtime,
),
); );
next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() });
} }

View File

@ -573,6 +573,12 @@ export type ClawdisConfig = {
* - "message_end": end of the whole assistant message (may include tool blocks) * - "message_end": end of the whole assistant message (may include tool blocks)
*/ */
blockStreamingBreak?: "text_end" | "message_end"; blockStreamingBreak?: "text_end" | "message_end";
/** Soft block chunking for streamed replies (min/max chars, prefer paragraph/newline). */
blockStreamingChunk?: {
minChars?: number;
maxChars?: number;
breakPreference?: "paragraph" | "newline" | "sentence";
};
timeoutSeconds?: number; timeoutSeconds?: number;
/** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */
mediaMaxMb?: number; mediaMaxMb?: number;
@ -900,7 +906,7 @@ const HooksGmailSchema = z
}) })
.optional(); .optional();
const ClawdisSchema = z.object({ export const ClawdisSchema = z.object({
identity: z identity: z
.object({ .object({
name: z.string().optional(), name: z.string().optional(),
@ -990,6 +996,19 @@ const ClawdisSchema = z.object({
blockStreamingBreak: z blockStreamingBreak: z
.union([z.literal("text_end"), z.literal("message_end")]) .union([z.literal("text_end"), z.literal("message_end")])
.optional(), .optional(),
blockStreamingChunk: z
.object({
minChars: z.number().int().positive().optional(),
maxChars: z.number().int().positive().optional(),
breakPreference: z
.union([
z.literal("paragraph"),
z.literal("newline"),
z.literal("sentence"),
])
.optional(),
})
.optional(),
timeoutSeconds: z.number().int().positive().optional(), timeoutSeconds: z.number().int().positive().optional(),
mediaMaxMb: z.number().positive().optional(), mediaMaxMb: z.number().positive().optional(),
typingIntervalSeconds: z.number().int().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(),

16
src/config/schema.test.ts Normal file
View File

@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { buildConfigSchema } from "./schema.js";
describe("config schema", () => {
it("exports schema + hints", () => {
const res = buildConfigSchema();
const schema = res.schema as { properties?: Record<string, unknown> };
expect(schema.properties?.gateway).toBeTruthy();
expect(schema.properties?.agent).toBeTruthy();
expect(res.uiHints.gateway?.label).toBe("Gateway");
expect(res.uiHints["gateway.auth.token"]?.sensitive).toBe(true);
expect(res.version).toBeTruthy();
expect(res.generatedAt).toBeTruthy();
});
});

161
src/config/schema.ts Normal file
View File

@ -0,0 +1,161 @@
import { VERSION } from "../version.js";
import { ClawdisSchema } from "./config.js";
export type ConfigUiHint = {
label?: string;
help?: string;
group?: string;
order?: number;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ConfigUiHints = Record<string, ConfigUiHint>;
export type ConfigSchema = ReturnType<typeof ClawdisSchema.toJSONSchema>;
export type ConfigSchemaResponse = {
schema: ConfigSchema;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
const GROUP_LABELS: Record<string, string> = {
identity: "Identity",
wizard: "Wizard",
logging: "Logging",
gateway: "Gateway",
agent: "Agent",
models: "Models",
routing: "Routing",
messages: "Messages",
session: "Session",
cron: "Cron",
hooks: "Hooks",
ui: "UI",
browser: "Browser",
talk: "Talk",
telegram: "Telegram",
discord: "Discord",
signal: "Signal",
imessage: "iMessage",
whatsapp: "WhatsApp",
skills: "Skills",
discovery: "Discovery",
presence: "Presence",
voicewake: "Voice Wake",
};
const GROUP_ORDER: Record<string, number> = {
identity: 10,
wizard: 20,
gateway: 30,
agent: 40,
models: 50,
routing: 60,
messages: 70,
session: 80,
cron: 90,
hooks: 100,
ui: 110,
browser: 120,
talk: 130,
telegram: 140,
discord: 150,
signal: 160,
imessage: 170,
whatsapp: 180,
skills: 190,
discovery: 200,
presence: 210,
voicewake: 220,
logging: 900,
};
const FIELD_LABELS: Record<string, string> = {
"gateway.remote.url": "Remote Gateway URL",
"gateway.remote.token": "Remote Gateway Token",
"gateway.remote.password": "Remote Gateway Password",
"gateway.auth.token": "Gateway Token",
"gateway.auth.password": "Gateway Password",
"agent.workspace": "Workspace",
"agent.model": "Default Model",
"ui.seamColor": "Accent Color",
"browser.controlUrl": "Browser Control URL",
"talk.apiKey": "Talk API Key",
"telegram.botToken": "Telegram Bot Token",
"discord.token": "Discord Bot Token",
"signal.account": "Signal Account",
"imessage.cliPath": "iMessage CLI Path",
};
const FIELD_HELP: Record<string, string> = {
"gateway.remote.url": "Remote Gateway WebSocket URL (ws:// or wss://).",
"gateway.auth.token":
"Required for multi-machine access or non-loopback binds.",
"gateway.auth.password": "Required for Tailscale funnel.",
};
const FIELD_PLACEHOLDERS: Record<string, string> = {
"gateway.remote.url": "ws://host:18789",
};
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
function isSensitivePath(path: string): boolean {
return SENSITIVE_PATTERNS.some((pattern) => pattern.test(path));
}
function buildBaseHints(): ConfigUiHints {
const hints: ConfigUiHints = {};
for (const [group, label] of Object.entries(GROUP_LABELS)) {
hints[group] = {
label,
group: label,
order: GROUP_ORDER[group],
};
}
for (const [path, label] of Object.entries(FIELD_LABELS)) {
hints[path] = { ...(hints[path] ?? {}), label };
}
for (const [path, help] of Object.entries(FIELD_HELP)) {
hints[path] = { ...(hints[path] ?? {}), help };
}
for (const [path, placeholder] of Object.entries(FIELD_PLACEHOLDERS)) {
hints[path] = { ...(hints[path] ?? {}), placeholder };
}
return hints;
}
function applySensitiveHints(hints: ConfigUiHints): ConfigUiHints {
const next = { ...hints };
for (const key of Object.keys(next)) {
if (isSensitivePath(key)) {
next[key] = { ...next[key], sensitive: true };
}
}
return next;
}
let cached: ConfigSchemaResponse | null = null;
export function buildConfigSchema(): ConfigSchemaResponse {
if (cached) return cached;
const schema = ClawdisSchema.toJSONSchema({
target: "draft-07",
unrepresentable: "any",
});
schema.title = "ClawdisConfig";
const hints = applySensitiveHints(buildBaseHints());
const next = {
schema,
uiHints: hints,
version: VERSION,
generatedAt: new Date().toISOString(),
};
cached = next;
return next;
}

View File

@ -146,11 +146,8 @@ export class GatewayClient {
const pending = this.pending.get(parsed.id); const pending = this.pending.get(parsed.id);
if (!pending) return; if (!pending) return;
// If the payload is an ack with status accepted, keep waiting for final. // If the payload is an ack with status accepted, keep waiting for final.
const payload = parsed.payload; const payload = parsed.payload as { status?: unknown } | undefined;
const status = const status = payload?.status;
payload && typeof payload === "object" && "status" in payload
? (payload as { status?: unknown }).status
: undefined;
if (pending.expectFinal && status === "accepted") { if (pending.expectFinal && status === "accepted") {
return; return;
} }

View File

@ -11,6 +11,10 @@ import {
ChatSendParamsSchema, ChatSendParamsSchema,
type ConfigGetParams, type ConfigGetParams,
ConfigGetParamsSchema, ConfigGetParamsSchema,
type ConfigSchemaParams,
ConfigSchemaParamsSchema,
type ConfigSchemaResponse,
ConfigSchemaResponseSchema,
type ConfigSetParams, type ConfigSetParams,
ConfigSetParamsSchema, ConfigSetParamsSchema,
type ConnectParams, type ConnectParams,
@ -105,6 +109,22 @@ import {
WebLoginStartParamsSchema, WebLoginStartParamsSchema,
type WebLoginWaitParams, type WebLoginWaitParams,
WebLoginWaitParamsSchema, WebLoginWaitParamsSchema,
type WizardCancelParams,
WizardCancelParamsSchema,
type WizardNextParams,
WizardNextParamsSchema,
type WizardNextResult,
WizardNextResultSchema,
type WizardStartParams,
WizardStartParamsSchema,
type WizardStartResult,
WizardStartResultSchema,
type WizardStatusParams,
WizardStatusParamsSchema,
type WizardStatusResult,
WizardStatusResultSchema,
type WizardStep,
WizardStepSchema,
} from "./schema.js"; } from "./schema.js";
const ajv = new ( const ajv = new (
@ -174,6 +194,21 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
export const validateConfigSetParams = ajv.compile<ConfigSetParams>( export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
ConfigSetParamsSchema, ConfigSetParamsSchema,
); );
export const validateConfigSchemaParams = ajv.compile<ConfigSchemaParams>(
ConfigSchemaParamsSchema,
);
export const validateWizardStartParams = ajv.compile<WizardStartParams>(
WizardStartParamsSchema,
);
export const validateWizardNextParams = ajv.compile<WizardNextParams>(
WizardNextParamsSchema,
);
export const validateWizardCancelParams = ajv.compile<WizardCancelParams>(
WizardCancelParamsSchema,
);
export const validateWizardStatusParams = ajv.compile<WizardStatusParams>(
WizardStatusParamsSchema,
);
export const validateTalkModeParams = export const validateTalkModeParams =
ajv.compile<TalkModeParams>(TalkModeParamsSchema); ajv.compile<TalkModeParams>(TalkModeParamsSchema);
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>( export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
@ -258,6 +293,16 @@ export {
SessionsCompactParamsSchema, SessionsCompactParamsSchema,
ConfigGetParamsSchema, ConfigGetParamsSchema,
ConfigSetParamsSchema, ConfigSetParamsSchema,
ConfigSchemaParamsSchema,
ConfigSchemaResponseSchema,
WizardStartParamsSchema,
WizardNextParamsSchema,
WizardCancelParamsSchema,
WizardStatusParamsSchema,
WizardStepSchema,
WizardNextResultSchema,
WizardStartResultSchema,
WizardStatusResultSchema,
ProvidersStatusParamsSchema, ProvidersStatusParamsSchema,
WebLoginStartParamsSchema, WebLoginStartParamsSchema,
WebLoginWaitParamsSchema, WebLoginWaitParamsSchema,
@ -304,6 +349,16 @@ export type {
NodePairApproveParams, NodePairApproveParams,
ConfigGetParams, ConfigGetParams,
ConfigSetParams, ConfigSetParams,
ConfigSchemaParams,
ConfigSchemaResponse,
WizardStartParams,
WizardNextParams,
WizardCancelParams,
WizardStatusParams,
WizardStep,
WizardNextResult,
WizardStartResult,
WizardStatusResult,
TalkModeParams, TalkModeParams,
ProvidersStatusParams, ProvidersStatusParams,
WebLoginStartParams, WebLoginStartParams,

View File

@ -342,6 +342,157 @@ export const ConfigSetParamsSchema = Type.Object(
{ additionalProperties: false }, { additionalProperties: false },
); );
export const ConfigSchemaParamsSchema = Type.Object(
{},
{ additionalProperties: false },
);
export const ConfigUiHintSchema = Type.Object(
{
label: Type.Optional(Type.String()),
help: Type.Optional(Type.String()),
group: Type.Optional(Type.String()),
order: Type.Optional(Type.Integer()),
advanced: Type.Optional(Type.Boolean()),
sensitive: Type.Optional(Type.Boolean()),
placeholder: Type.Optional(Type.String()),
itemTemplate: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
export const ConfigSchemaResponseSchema = Type.Object(
{
schema: Type.Unknown(),
uiHints: Type.Record(Type.String(), ConfigUiHintSchema),
version: NonEmptyString,
generatedAt: NonEmptyString,
},
{ additionalProperties: false },
);
export const WizardStartParamsSchema = Type.Object(
{
mode: Type.Optional(
Type.Union([Type.Literal("local"), Type.Literal("remote")]),
),
workspace: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const WizardAnswerSchema = Type.Object(
{
stepId: NonEmptyString,
value: Type.Optional(Type.Unknown()),
},
{ additionalProperties: false },
);
export const WizardNextParamsSchema = Type.Object(
{
sessionId: NonEmptyString,
answer: Type.Optional(WizardAnswerSchema),
},
{ additionalProperties: false },
);
export const WizardCancelParamsSchema = Type.Object(
{
sessionId: NonEmptyString,
},
{ additionalProperties: false },
);
export const WizardStatusParamsSchema = Type.Object(
{
sessionId: NonEmptyString,
},
{ additionalProperties: false },
);
export const WizardStepOptionSchema = Type.Object(
{
value: Type.Unknown(),
label: NonEmptyString,
hint: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const WizardStepSchema = Type.Object(
{
id: NonEmptyString,
type: Type.Union([
Type.Literal("note"),
Type.Literal("select"),
Type.Literal("text"),
Type.Literal("confirm"),
Type.Literal("multiselect"),
Type.Literal("progress"),
Type.Literal("action"),
]),
title: Type.Optional(Type.String()),
message: Type.Optional(Type.String()),
options: Type.Optional(Type.Array(WizardStepOptionSchema)),
initialValue: Type.Optional(Type.Unknown()),
placeholder: Type.Optional(Type.String()),
sensitive: Type.Optional(Type.Boolean()),
executor: Type.Optional(
Type.Union([Type.Literal("gateway"), Type.Literal("client")]),
),
},
{ additionalProperties: false },
);
export const WizardNextResultSchema = Type.Object(
{
done: Type.Boolean(),
step: Type.Optional(WizardStepSchema),
status: Type.Optional(
Type.Union([
Type.Literal("running"),
Type.Literal("done"),
Type.Literal("cancelled"),
Type.Literal("error"),
]),
),
error: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const WizardStartResultSchema = Type.Object(
{
sessionId: NonEmptyString,
done: Type.Boolean(),
step: Type.Optional(WizardStepSchema),
status: Type.Optional(
Type.Union([
Type.Literal("running"),
Type.Literal("done"),
Type.Literal("cancelled"),
Type.Literal("error"),
]),
),
error: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const WizardStatusResultSchema = Type.Object(
{
status: Type.Union([
Type.Literal("running"),
Type.Literal("done"),
Type.Literal("cancelled"),
Type.Literal("error"),
]),
error: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const TalkModeParamsSchema = Type.Object( export const TalkModeParamsSchema = Type.Object(
{ {
enabled: Type.Boolean(), enabled: Type.Boolean(),
@ -680,6 +831,16 @@ export const ProtocolSchemas: Record<string, TSchema> = {
SessionsCompactParams: SessionsCompactParamsSchema, SessionsCompactParams: SessionsCompactParamsSchema,
ConfigGetParams: ConfigGetParamsSchema, ConfigGetParams: ConfigGetParamsSchema,
ConfigSetParams: ConfigSetParamsSchema, ConfigSetParams: ConfigSetParamsSchema,
ConfigSchemaParams: ConfigSchemaParamsSchema,
ConfigSchemaResponse: ConfigSchemaResponseSchema,
WizardStartParams: WizardStartParamsSchema,
WizardNextParams: WizardNextParamsSchema,
WizardCancelParams: WizardCancelParamsSchema,
WizardStatusParams: WizardStatusParamsSchema,
WizardStep: WizardStepSchema,
WizardNextResult: WizardNextResultSchema,
WizardStartResult: WizardStartResultSchema,
WizardStatusResult: WizardStatusResultSchema,
TalkModeParams: TalkModeParamsSchema, TalkModeParams: TalkModeParamsSchema,
ProvidersStatusParams: ProvidersStatusParamsSchema, ProvidersStatusParams: ProvidersStatusParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema,
@ -737,6 +898,16 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>; export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>; export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>; export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
export type ConfigSchemaParams = Static<typeof ConfigSchemaParamsSchema>;
export type ConfigSchemaResponse = Static<typeof ConfigSchemaResponseSchema>;
export type WizardStartParams = Static<typeof WizardStartParamsSchema>;
export type WizardNextParams = Static<typeof WizardNextParamsSchema>;
export type WizardCancelParams = Static<typeof WizardCancelParamsSchema>;
export type WizardStatusParams = Static<typeof WizardStatusParamsSchema>;
export type WizardStep = Static<typeof WizardStepSchema>;
export type WizardNextResult = Static<typeof WizardNextResultSchema>;
export type WizardStartResult = Static<typeof WizardStartResultSchema>;
export type WizardStatusResult = Static<typeof WizardStatusResultSchema>;
export type TalkModeParams = Static<typeof TalkModeParamsSchema>; export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>; export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>; export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;

View File

@ -152,7 +152,10 @@ vi.mock("../config/sessions.js", async () => {
}), }),
}; };
}); });
vi.mock("../config/config.js", () => { vi.mock("../config/config.js", async () => {
const actual = await vi.importActual<typeof import("../config/config.js")>(
"../config/config.js",
);
const resolveConfigPath = () => const resolveConfigPath = () =>
path.join(os.homedir(), ".clawdis", "clawdis.json"); path.join(os.homedir(), ".clawdis", "clawdis.json");
@ -222,6 +225,7 @@ vi.mock("../config/config.js", () => {
}); });
return { return {
...actual,
CONFIG_PATH_CLAWDIS: resolveConfigPath(), CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
get isNixMode() { get isNixMode() {
@ -381,7 +385,10 @@ function onceMessage<T = unknown>(
}); });
} }
async function startServerWithClient(token?: string) { async function startServerWithClient(
token?: string,
opts?: Parameters<typeof startGatewayServer>[1],
) {
const port = await getFreePort(); const port = await getFreePort();
const prev = process.env.CLAWDIS_GATEWAY_TOKEN; const prev = process.env.CLAWDIS_GATEWAY_TOKEN;
if (token === undefined) { if (token === undefined) {
@ -389,7 +396,7 @@ async function startServerWithClient(token?: string) {
} else { } else {
process.env.CLAWDIS_GATEWAY_TOKEN = token; process.env.CLAWDIS_GATEWAY_TOKEN = token;
} }
const server = await startGatewayServer(port); const server = await startGatewayServer(port, opts);
const ws = new WebSocket(`ws://127.0.0.1:${port}`); const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve)); await new Promise<void>((resolve) => ws.once("open", resolve));
return { server, ws, port, prevToken: prev }; return { server, ws, port, prevToken: prev };
@ -2299,6 +2306,110 @@ describe("gateway server", () => {
}, },
); );
test("config.schema returns schema + hints", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{
schema?: { properties?: { gateway?: unknown } };
uiHints?: { gateway?: { label?: string } };
}>(ws, "config.schema", {});
expect(res.ok).toBe(true);
expect(res.payload?.schema?.properties?.gateway).toBeTruthy();
expect(res.payload?.uiHints?.gateway?.label).toBe("Gateway");
ws.close();
await server.close();
});
test("wizard.start and wizard.next drive steps", async () => {
const { server, ws } = await startServerWithClient(undefined, {
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.note("Welcome");
const name = await prompter.text({ message: "Name" });
await prompter.note(`Hello ${name}`);
},
});
await connectOk(ws);
const startRes = await rpcReq<{
sessionId?: string;
step?: { id?: string; type?: string };
}>(ws, "wizard.start", {});
expect(startRes.ok).toBe(true);
const sessionId = startRes.payload?.sessionId ?? "";
const firstStep = startRes.payload?.step;
expect(sessionId).not.toBe("");
expect(firstStep?.type).toBe("note");
const runningRes = await rpcReq(ws, "wizard.start", {});
expect(runningRes.ok).toBe(false);
expect(runningRes.error?.message).toMatch(/wizard already running/i);
const nextOne = await rpcReq<{
step?: { id?: string; type?: string };
done?: boolean;
}>(ws, "wizard.next", {
sessionId,
answer: { stepId: firstStep?.id, value: null },
});
expect(nextOne.ok).toBe(true);
const textStep = nextOne.payload?.step;
expect(textStep?.type).toBe("text");
const nextTwo = await rpcReq<{
step?: { id?: string; type?: string };
done?: boolean;
}>(ws, "wizard.next", {
sessionId,
answer: { stepId: textStep?.id, value: "Peter" },
});
expect(nextTwo.ok).toBe(true);
const finalStep = nextTwo.payload?.step;
expect(finalStep?.type).toBe("note");
const done = await rpcReq<{
done?: boolean;
status?: string;
}>(ws, "wizard.next", {
sessionId,
answer: { stepId: finalStep?.id, value: null },
});
expect(done.ok).toBe(true);
expect(done.payload?.done).toBe(true);
expect(done.payload?.status).toBe("done");
ws.close();
await server.close();
});
test("wizard.cancel ends the session", async () => {
const { server, ws } = await startServerWithClient(undefined, {
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.note("Welcome");
await prompter.text({ message: "Name" });
},
});
await connectOk(ws);
const startRes = await rpcReq<{
sessionId?: string;
step?: { id?: string; type?: string };
}>(ws, "wizard.start", {});
expect(startRes.ok).toBe(true);
const sessionId = startRes.payload?.sessionId ?? "";
expect(sessionId).not.toBe("");
const cancelRes = await rpcReq<{ status?: string }>(ws, "wizard.cancel", {
sessionId,
});
expect(cancelRes.ok).toBe(true);
expect(cancelRes.payload?.status).toBe("cancelled");
ws.close();
await server.close();
});
test("providers.status returns snapshot without probe", async () => { test("providers.status returns snapshot without probe", async () => {
const prevToken = process.env.TELEGRAM_BOT_TOKEN; const prevToken = process.env.TELEGRAM_BOT_TOKEN;
delete process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN;

View File

@ -62,6 +62,7 @@ import {
validateConfigObject, validateConfigObject,
writeConfigFile, writeConfigFile,
} from "../config/config.js"; } from "../config/config.js";
import { buildConfigSchema } from "../config/schema.js";
import { import {
buildGroupDisplayName, buildGroupDisplayName,
loadSessionStore, loadSessionStore,
@ -170,6 +171,8 @@ import type { WebProviderStatus } from "../web/auto-reply.js";
import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js";
import { sendMessageWhatsApp } from "../web/outbound.js"; import { sendMessageWhatsApp } from "../web/outbound.js";
import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js";
import { runOnboardingWizard } from "../wizard/onboarding.js";
import { WizardSession } from "../wizard/session.js";
import { import {
assertGatewayAuthConfigured, assertGatewayAuthConfigured,
authorizeGatewayConnect, authorizeGatewayConnect,
@ -392,6 +395,7 @@ import {
validateChatHistoryParams, validateChatHistoryParams,
validateChatSendParams, validateChatSendParams,
validateConfigGetParams, validateConfigGetParams,
validateConfigSchemaParams,
validateConfigSetParams, validateConfigSetParams,
validateConnectParams, validateConnectParams,
validateCronAddParams, validateCronAddParams,
@ -426,6 +430,10 @@ import {
validateWakeParams, validateWakeParams,
validateWebLoginStartParams, validateWebLoginStartParams,
validateWebLoginWaitParams, validateWebLoginWaitParams,
validateWizardCancelParams,
validateWizardNextParams,
validateWizardStartParams,
validateWizardStatusParams,
} from "./protocol/index.js"; } from "./protocol/index.js";
import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js";
@ -504,6 +512,11 @@ const METHODS = [
"status", "status",
"config.get", "config.get",
"config.set", "config.set",
"config.schema",
"wizard.start",
"wizard.next",
"wizard.cancel",
"wizard.status",
"talk.mode", "talk.mode",
"models.list", "models.list",
"skills.status", "skills.status",
@ -602,6 +615,14 @@ export type GatewayServerOptions = {
* Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it. * Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it.
*/ */
allowCanvasHostInTests?: boolean; allowCanvasHostInTests?: boolean;
/**
* Test-only: override the onboarding wizard runner.
*/
wizardRunner?: (
opts: import("../commands/onboard-types.js").OnboardOptions,
runtime: import("../runtime.js").RuntimeEnv,
prompter: import("../wizard/prompts.js").WizardPrompter,
) => Promise<void>;
}; };
function isLoopbackAddress(ip: string | undefined): boolean { function isLoopbackAddress(ip: string | undefined): boolean {
@ -1432,6 +1453,23 @@ export async function startGatewayServer(
); );
} }
const wizardRunner = opts.wizardRunner ?? runOnboardingWizard;
const wizardSessions = new Map<string, WizardSession>();
const findRunningWizard = (): string | null => {
for (const [id, session] of wizardSessions) {
if (session.getStatus() === "running") return id;
}
return null;
};
const purgeWizardSession = (id: string) => {
const session = wizardSessions.get(id);
if (!session) return;
if (session.getStatus() === "running") return;
wizardSessions.delete(id);
};
const normalizeHookHeaders = (req: IncomingMessage) => { const normalizeHookHeaders = (req: IncomingMessage) => {
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(req.headers)) { for (const [key, value] of Object.entries(req.headers)) {
@ -2801,6 +2839,20 @@ export async function startGatewayServer(
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
return { ok: true, payloadJSON: JSON.stringify(snapshot) }; return { ok: true, payloadJSON: JSON.stringify(snapshot) };
} }
case "config.schema": {
const params = parseParams();
if (!validateConfigSchemaParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
},
};
}
const schema = buildConfigSchema();
return { ok: true, payloadJSON: JSON.stringify(schema) };
}
case "config.set": { case "config.set": {
const params = parseParams(); const params = parseParams();
if (!validateConfigSetParams(params)) { if (!validateConfigSetParams(params)) {
@ -5306,6 +5358,23 @@ export async function startGatewayServer(
respond(true, snapshot, undefined); respond(true, snapshot, undefined);
break; break;
} }
case "config.schema": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigSchemaParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
),
);
break;
}
const schema = buildConfigSchema();
respond(true, schema, undefined);
break;
}
case "config.set": { case "config.set": {
const params = (req.params ?? {}) as Record<string, unknown>; const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateConfigSetParams(params)) { if (!validateConfigSetParams(params)) {
@ -5363,6 +5432,171 @@ export async function startGatewayServer(
); );
break; break;
} }
case "wizard.start": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWizardStartParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid wizard.start params: ${formatValidationErrors(validateWizardStartParams.errors)}`,
),
);
break;
}
const running = findRunningWizard();
if (running) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "wizard already running"),
);
break;
}
const sessionId = randomUUID();
const opts = {
mode: params.mode as "local" | "remote" | undefined,
workspace:
typeof params.workspace === "string"
? params.workspace
: undefined,
};
const session = new WizardSession((prompter) =>
wizardRunner(opts, defaultRuntime, prompter),
);
wizardSessions.set(sessionId, session);
const result = await session.next();
if (result.done) {
purgeWizardSession(sessionId);
}
respond(true, { sessionId, ...result }, undefined);
break;
}
case "wizard.next": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWizardNextParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid wizard.next params: ${formatValidationErrors(validateWizardNextParams.errors)}`,
),
);
break;
}
const sessionId = params.sessionId as string;
const session = wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
break;
}
const answer = params.answer as
| { stepId?: string; value?: unknown }
| undefined;
if (answer) {
if (session.getStatus() !== "running") {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"wizard not running",
),
);
break;
}
try {
await session.answer(
String(answer.stepId ?? ""),
answer.value,
);
} catch (err) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, formatForLog(err)),
);
break;
}
}
const result = await session.next();
if (result.done) {
purgeWizardSession(sessionId);
}
respond(true, result, undefined);
break;
}
case "wizard.cancel": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWizardCancelParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid wizard.cancel params: ${formatValidationErrors(validateWizardCancelParams.errors)}`,
),
);
break;
}
const sessionId = params.sessionId as string;
const session = wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
break;
}
session.cancel();
const status = {
status: session.getStatus(),
error: session.getError(),
};
wizardSessions.delete(sessionId);
respond(true, status, undefined);
break;
}
case "wizard.status": {
const params = (req.params ?? {}) as Record<string, unknown>;
if (!validateWizardStatusParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid wizard.status params: ${formatValidationErrors(validateWizardStatusParams.errors)}`,
),
);
break;
}
const sessionId = params.sessionId as string;
const session = wizardSessions.get(sessionId);
if (!session) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "wizard not found"),
);
break;
}
const status = {
status: session.getStatus(),
error: session.getError(),
};
if (status.status !== "running") {
wizardSessions.delete(sessionId);
}
respond(true, status, undefined);
break;
}
case "talk.mode": { case "talk.mode": {
if ( if (
client && client &&

View File

@ -0,0 +1,84 @@
import {
cancel,
confirm,
intro,
isCancel,
multiselect,
note,
type Option,
outro,
select,
spinner,
text,
} from "@clack/prompts";
import type { WizardProgress, WizardPrompter } from "./prompts.js";
import { WizardCancelledError } from "./prompts.js";
function guardCancel<T>(value: T | symbol): T {
if (isCancel(value)) {
cancel("Setup cancelled.");
throw new WizardCancelledError();
}
return value as T;
}
export function createClackPrompter(): WizardPrompter {
return {
intro: async (title) => {
intro(title);
},
outro: async (message) => {
outro(message);
},
note: async (message, title) => {
note(message, title);
},
select: async (params) =>
guardCancel(
await select({
message: params.message,
options: params.options.map((opt) => {
const base = { value: opt.value, label: opt.label };
return opt.hint === undefined ? base : { ...base, hint: opt.hint };
}) as Option<(typeof params.options)[number]["value"]>[],
initialValue: params.initialValue,
}),
),
multiselect: async (params) =>
guardCancel(
await multiselect({
message: params.message,
options: params.options.map((opt) => {
const base = { value: opt.value, label: opt.label };
return opt.hint === undefined ? base : { ...base, hint: opt.hint };
}) as Option<(typeof params.options)[number]["value"]>[],
initialValues: params.initialValues,
}),
),
text: async (params) =>
guardCancel(
await text({
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
validate: params.validate,
}),
),
confirm: async (params) =>
guardCancel(
await confirm({
message: params.message,
initialValue: params.initialValue,
}),
),
progress: (label: string): WizardProgress => {
const spin = spinner();
spin.start(label);
return {
update: (message) => spin.message(message),
stop: (message) => spin.stop(message),
};
},
};
}

535
src/wizard/onboarding.ts Normal file
View File

@ -0,0 +1,535 @@
import path from "node:path";
import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai";
import {
isRemoteEnvironment,
loginAntigravityVpsAware,
} from "../commands/antigravity-oauth.js";
import { healthCommand } from "../commands/health.js";
import {
applyMinimaxConfig,
setAnthropicApiKey,
writeOAuthCredentials,
} from "../commands/onboard-auth.js";
import {
applyWizardMetadata,
DEFAULT_WORKSPACE,
ensureWorkspaceAndSessions,
handleReset,
openUrl,
printWizardHeader,
probeGatewayReachable,
randomToken,
resolveControlUiLinks,
summarizeExistingConfig,
} from "../commands/onboard-helpers.js";
import { setupProviders } from "../commands/onboard-providers.js";
import { promptRemoteGatewayConfig } from "../commands/onboard-remote.js";
import { setupSkills } from "../commands/onboard-skills.js";
import type {
AuthChoice,
GatewayAuthChoice,
OnboardMode,
OnboardOptions,
ResetScope,
} from "../commands/onboard-types.js";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { GATEWAY_LAUNCH_AGENT_LABEL } from "../daemon/constants.js";
import { resolveGatewayProgramArguments } from "../daemon/program-args.js";
import { resolveGatewayService } from "../daemon/service.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, sleep } from "../utils.js";
import type { WizardPrompter } from "./prompts.js";
export async function runOnboardingWizard(
opts: OnboardOptions,
runtime: RuntimeEnv = defaultRuntime,
prompter: WizardPrompter,
) {
printWizardHeader(runtime);
await prompter.intro("Clawdis onboarding");
const snapshot = await readConfigFileSnapshot();
let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists) {
const title = snapshot.valid
? "Existing config detected"
: "Invalid config";
await prompter.note(summarizeExistingConfig(baseConfig), title);
if (!snapshot.valid && snapshot.issues.length > 0) {
await prompter.note(
snapshot.issues
.map((iss) => `- ${iss.path}: ${iss.message}`)
.join("\n"),
"Config issues",
);
}
const action = (await prompter.select({
message: "Config handling",
options: [
{ value: "keep", label: "Use existing values" },
{ value: "modify", label: "Update values" },
{ value: "reset", label: "Reset" },
],
})) as "keep" | "modify" | "reset";
if (action === "reset") {
const workspaceDefault = baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE;
const resetScope = (await prompter.select({
message: "Reset scope",
options: [
{ value: "config", label: "Config only" },
{
value: "config+creds+sessions",
label: "Config + creds + sessions",
},
{
value: "full",
label: "Full reset (config + creds + sessions + workspace)",
},
],
})) as ResetScope;
await handleReset(resetScope, resolveUserPath(workspaceDefault), runtime);
baseConfig = {};
} else if (action === "keep" && !snapshot.valid) {
baseConfig = {};
}
}
const localPort = resolveGatewayPort(baseConfig);
const localUrl = `ws://127.0.0.1:${localPort}`;
const localProbe = await probeGatewayReachable({
url: localUrl,
token: process.env.CLAWDIS_GATEWAY_TOKEN,
password:
baseConfig.gateway?.auth?.password ??
process.env.CLAWDIS_GATEWAY_PASSWORD,
});
const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? "";
const remoteProbe = remoteUrl
? await probeGatewayReachable({
url: remoteUrl,
token: baseConfig.gateway?.remote?.token,
})
: null;
const mode =
opts.mode ??
((await prompter.select({
message: "Where will the Gateway run?",
options: [
{
value: "local",
label: "Local (this machine)",
hint: localProbe.ok
? `Gateway reachable (${localUrl})`
: `No gateway detected (${localUrl})`,
},
{
value: "remote",
label: "Remote (info-only)",
hint: !remoteUrl
? "No remote URL configured yet"
: remoteProbe?.ok
? `Gateway reachable (${remoteUrl})`
: `Configured but unreachable (${remoteUrl})`,
},
],
})) as OnboardMode);
if (mode === "remote") {
let nextConfig = await promptRemoteGatewayConfig(baseConfig, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`);
await prompter.outro("Remote gateway configured.");
return;
}
const workspaceInput =
opts.workspace ??
(await prompter.text({
message: "Workspace directory",
initialValue: baseConfig.agent?.workspace ?? DEFAULT_WORKSPACE,
}));
const workspaceDir = resolveUserPath(
workspaceInput.trim() || DEFAULT_WORKSPACE,
);
let nextConfig: ClawdisConfig = {
...baseConfig,
agent: {
...baseConfig.agent,
workspace: workspaceDir,
},
gateway: {
...baseConfig.gateway,
mode: "local",
},
};
const authChoice = (await prompter.select({
message: "Model/auth choice",
options: [
{ value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" },
{
value: "antigravity",
label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)",
},
{ value: "apiKey", label: "Anthropic API key" },
{ value: "minimax", label: "Minimax M2.1 (LM Studio)" },
{ value: "skip", label: "Skip for now" },
],
})) as AuthChoice;
if (authChoice === "oauth") {
await prompter.note(
"Browser will open. Paste the code shown after login (code#state).",
"Anthropic OAuth",
);
const spin = prompter.progress("Waiting for authorization…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAnthropic(
async (url) => {
await openUrl(url);
runtime.log(`Open: ${url}`);
},
async () => {
const code = await prompter.text({
message: "Paste authorization code (code#state)",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return String(code);
},
);
spin.stop("OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("anthropic", oauthCreds);
}
} catch (err) {
spin.stop("OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "antigravity") {
const isRemote = isRemoteEnvironment();
await prompter.note(
isRemote
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account that has Antigravity access.",
"The callback will be captured automatically on localhost:51121.",
].join("\n"),
"Google Antigravity OAuth",
);
const spin = prompter.progress("Starting OAuth flow…");
let oauthCreds: OAuthCredentials | null = null;
try {
oauthCreds = await loginAntigravityVpsAware(
async (url) => {
if (isRemote) {
spin.stop("OAuth URL ready");
runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`);
} else {
spin.update("Complete sign-in in browser…");
await openUrl(url);
runtime.log(`Open: ${url}`);
}
},
(msg) => spin.update(msg),
);
spin.stop("Antigravity OAuth complete");
if (oauthCreds) {
await writeOAuthCredentials("google-antigravity", oauthCreds);
nextConfig = {
...nextConfig,
agent: {
...nextConfig.agent,
model: "google-antigravity/claude-opus-4-5-thinking",
},
};
await prompter.note(
"Default model set to google-antigravity/claude-opus-4-5-thinking",
"Model configured",
);
}
} catch (err) {
spin.stop("Antigravity OAuth failed");
runtime.error(String(err));
}
} else if (authChoice === "apiKey") {
const key = await prompter.text({
message: "Enter Anthropic API key",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
await setAnthropicApiKey(String(key).trim());
} else if (authChoice === "minimax") {
nextConfig = applyMinimaxConfig(nextConfig);
}
const portRaw = await prompter.text({
message: "Gateway port",
initialValue: String(localPort),
validate: (value) =>
Number.isFinite(Number(value)) ? undefined : "Invalid port",
});
const port = Number.parseInt(String(portRaw), 10);
let bind = (await prompter.select({
message: "Gateway bind",
options: [
{ value: "loopback", label: "Loopback (127.0.0.1)" },
{ value: "lan", label: "LAN" },
{ value: "tailnet", label: "Tailnet" },
{ value: "auto", label: "Auto" },
],
})) as "loopback" | "lan" | "tailnet" | "auto";
let authMode = (await prompter.select({
message: "Gateway auth",
options: [
{
value: "off",
label: "Off (loopback only)",
hint: "Recommended for single-machine setups",
},
{
value: "token",
label: "Token",
hint: "Use for multi-machine access or non-loopback binds",
},
{ value: "password", label: "Password" },
],
})) as GatewayAuthChoice;
const tailscaleMode = (await prompter.select({
message: "Tailscale exposure",
options: [
{ value: "off", label: "Off", hint: "No Tailscale exposure" },
{
value: "serve",
label: "Serve",
hint: "Private HTTPS for your tailnet (devices on Tailscale)",
},
{
value: "funnel",
label: "Funnel",
hint: "Public HTTPS via Tailscale Funnel (internet)",
},
],
})) as "off" | "serve" | "funnel";
let tailscaleResetOnExit = false;
if (tailscaleMode !== "off") {
tailscaleResetOnExit = Boolean(
await prompter.confirm({
message: "Reset Tailscale serve/funnel on exit?",
initialValue: false,
}),
);
}
if (tailscaleMode !== "off" && bind !== "loopback") {
await prompter.note(
"Tailscale requires bind=loopback. Adjusting bind to loopback.",
"Note",
);
bind = "loopback";
}
if (authMode === "off" && bind !== "loopback") {
await prompter.note(
"Non-loopback bind requires auth. Switching to token auth.",
"Note",
);
authMode = "token";
}
if (tailscaleMode === "funnel" && authMode !== "password") {
await prompter.note("Tailscale funnel requires password auth.", "Note");
authMode = "password";
}
let gatewayToken: string | undefined;
if (authMode === "token") {
const tokenInput = await prompter.text({
message: "Gateway token (blank to generate)",
placeholder: "Needed for multi-machine or non-loopback access",
initialValue: randomToken(),
});
gatewayToken = String(tokenInput).trim() || randomToken();
}
if (authMode === "password") {
const password = await prompter.text({
message: "Gateway password",
validate: (value) => (value?.trim() ? undefined : "Required"),
});
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "password",
password: String(password).trim(),
},
},
};
} else if (authMode === "token") {
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
auth: {
...nextConfig.gateway?.auth,
mode: "token",
token: gatewayToken,
},
},
};
}
nextConfig = {
...nextConfig,
gateway: {
...nextConfig.gateway,
port,
bind,
tailscale: {
...nextConfig.gateway?.tailscale,
mode: tailscaleMode,
resetOnExit: tailscaleResetOnExit,
},
},
};
nextConfig = await setupProviders(nextConfig, runtime, prompter, {
allowSignalInstall: true,
});
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDIS}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime);
nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter);
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
const installDaemon = await prompter.confirm({
message: "Install Gateway daemon (recommended)",
initialValue: true,
});
if (installDaemon) {
const service = resolveGatewayService();
const loaded = await service.isLoaded({ env: process.env });
if (loaded) {
const action = (await prompter.select({
message: "Gateway service already installed",
options: [
{ value: "restart", label: "Restart" },
{ value: "reinstall", label: "Reinstall" },
{ value: "skip", label: "Skip" },
],
})) as "restart" | "reinstall" | "skip";
if (action === "restart") {
await service.restart({ stdout: process.stdout });
} else if (action === "reinstall") {
await service.uninstall({ env: process.env, stdout: process.stdout });
}
}
if (
!loaded ||
(loaded && (await service.isLoaded({ env: process.env })) === false)
) {
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
const { programArguments, workingDirectory } =
await resolveGatewayProgramArguments({ port, dev: devMode });
const environment: Record<string, string | undefined> = {
PATH: process.env.PATH,
CLAWDIS_GATEWAY_TOKEN: gatewayToken,
CLAWDIS_LAUNCHD_LABEL:
process.platform === "darwin"
? GATEWAY_LAUNCH_AGENT_LABEL
: undefined,
};
await service.install({
env: process.env,
stdout: process.stdout,
programArguments,
workingDirectory,
environment,
});
}
}
await sleep(1500);
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
} catch (err) {
runtime.error(`Health check failed: ${String(err)}`);
}
await prompter.note(
[
"Add nodes for extra features:",
"- macOS app (system + notifications)",
"- iOS app (camera/canvas)",
"- Android app (camera/canvas)",
].join("\n"),
"Optional apps",
);
await prompter.note(
(() => {
const links = resolveControlUiLinks({ bind, port });
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
const authedUrl = `${links.httpUrl}${tokenParam}`;
return [
`Web UI: ${links.httpUrl}`,
tokenParam ? `Web UI (with token): ${authedUrl}` : undefined,
`Gateway WS: ${links.wsUrl}`,
]
.filter(Boolean)
.join("\n");
})(),
"Control UI",
);
const wantsOpen = await prompter.confirm({
message: "Open Control UI now?",
initialValue: true,
});
if (wantsOpen) {
const links = resolveControlUiLinks({ bind, port });
const tokenParam =
authMode === "token" && gatewayToken
? `?token=${encodeURIComponent(gatewayToken)}`
: "";
await openUrl(`${links.httpUrl}${tokenParam}`);
}
await prompter.outro("Onboarding complete.");
}

52
src/wizard/prompts.ts Normal file
View File

@ -0,0 +1,52 @@
export type WizardSelectOption<T = string> = {
value: T;
label: string;
hint?: string;
};
export type WizardSelectParams<T = string> = {
message: string;
options: Array<WizardSelectOption<T>>;
initialValue?: T;
};
export type WizardMultiSelectParams<T = string> = {
message: string;
options: Array<WizardSelectOption<T>>;
initialValues?: T[];
};
export type WizardTextParams = {
message: string;
initialValue?: string;
placeholder?: string;
validate?: (value: string) => string | undefined;
};
export type WizardConfirmParams = {
message: string;
initialValue?: boolean;
};
export type WizardProgress = {
update: (message: string) => void;
stop: (message?: string) => void;
};
export type WizardPrompter = {
intro: (title: string) => Promise<void>;
outro: (message: string) => Promise<void>;
note: (message: string, title?: string) => Promise<void>;
select: <T>(params: WizardSelectParams<T>) => Promise<T>;
multiselect: <T>(params: WizardMultiSelectParams<T>) => Promise<T[]>;
text: (params: WizardTextParams) => Promise<string>;
confirm: (params: WizardConfirmParams) => Promise<boolean>;
progress: (label: string) => WizardProgress;
};
export class WizardCancelledError extends Error {
constructor(message = "wizard cancelled") {
super(message);
this.name = "WizardCancelledError";
}
}

View File

@ -0,0 +1,69 @@
import { describe, expect, test } from "vitest";
import { WizardSession } from "./session.js";
function noteRunner() {
return new WizardSession(async (prompter) => {
await prompter.note("Welcome");
const name = await prompter.text({ message: "Name" });
await prompter.note(`Hello ${name}`);
});
}
describe("WizardSession", () => {
test("steps progress in order", async () => {
const session = noteRunner();
const first = await session.next();
expect(first.done).toBe(false);
expect(first.step?.type).toBe("note");
const secondPeek = await session.next();
expect(secondPeek.step?.id).toBe(first.step?.id);
if (!first.step) throw new Error("expected first step");
await session.answer(first.step.id, null);
const second = await session.next();
expect(second.done).toBe(false);
expect(second.step?.type).toBe("text");
if (!second.step) throw new Error("expected second step");
await session.answer(second.step.id, "Peter");
const third = await session.next();
expect(third.step?.type).toBe("note");
if (!third.step) throw new Error("expected third step");
await session.answer(third.step.id, null);
const done = await session.next();
expect(done.done).toBe(true);
expect(done.status).toBe("done");
});
test("invalid answers throw", async () => {
const session = noteRunner();
const first = await session.next();
await expect(session.answer("bad-id", null)).rejects.toThrow(
/wizard: no pending step/i,
);
if (!first.step) throw new Error("expected first step");
await session.answer(first.step.id, null);
});
test("cancel marks session and unblocks", async () => {
const session = new WizardSession(async (prompter) => {
await prompter.text({ message: "Name" });
});
const step = await session.next();
expect(step.step?.type).toBe("text");
session.cancel();
const done = await session.next();
expect(done.done).toBe(true);
expect(done.status).toBe("cancelled");
});
});

268
src/wizard/session.ts Normal file
View File

@ -0,0 +1,268 @@
import { randomUUID } from "node:crypto";
import {
WizardCancelledError,
type WizardProgress,
type WizardPrompter,
} from "./prompts.js";
export type WizardStepOption = {
value: unknown;
label: string;
hint?: string;
};
export type WizardStep = {
id: string;
type:
| "note"
| "select"
| "text"
| "confirm"
| "multiselect"
| "progress"
| "action";
title?: string;
message?: string;
options?: WizardStepOption[];
initialValue?: unknown;
placeholder?: string;
sensitive?: boolean;
executor?: "gateway" | "client";
};
export type WizardSessionStatus = "running" | "done" | "cancelled" | "error";
export type WizardNextResult = {
done: boolean;
step?: WizardStep;
status: WizardSessionStatus;
error?: string;
};
type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (err: unknown) => void;
};
function createDeferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (err: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
class WizardSessionPrompter implements WizardPrompter {
constructor(private session: WizardSession) {}
async intro(title: string): Promise<void> {
await this.prompt({
type: "note",
title,
message: "",
executor: "client",
});
}
async outro(message: string): Promise<void> {
await this.prompt({
type: "note",
title: "Done",
message,
executor: "client",
});
}
async note(message: string, title?: string): Promise<void> {
await this.prompt({ type: "note", title, message, executor: "client" });
}
async select<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValue?: T;
}): Promise<T> {
const res = await this.prompt({
type: "select",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValue,
executor: "client",
});
return res as T;
}
async multiselect<T>(params: {
message: string;
options: Array<{ value: T; label: string; hint?: string }>;
initialValues?: T[];
}): Promise<T[]> {
const res = await this.prompt({
type: "multiselect",
message: params.message,
options: params.options.map((opt) => ({
value: opt.value,
label: opt.label,
hint: opt.hint,
})),
initialValue: params.initialValues,
executor: "client",
});
return (Array.isArray(res) ? res : []) as T[];
}
async text(params: {
message: string;
initialValue?: string;
placeholder?: string;
validate?: (value: string) => string | undefined;
}): Promise<string> {
const res = await this.prompt({
type: "text",
message: params.message,
initialValue: params.initialValue,
placeholder: params.placeholder,
executor: "client",
});
const value = String(res ?? "");
const error = params.validate?.(value);
if (error) {
throw new Error(error);
}
return value;
}
async confirm(params: {
message: string;
initialValue?: boolean;
}): Promise<boolean> {
const res = await this.prompt({
type: "confirm",
message: params.message,
initialValue: params.initialValue,
executor: "client",
});
return Boolean(res);
}
progress(_label: string): WizardProgress {
return {
update: (_message) => {},
stop: (_message) => {},
};
}
private async prompt(step: Omit<WizardStep, "id">): Promise<unknown> {
return await this.session.awaitAnswer({
...step,
id: randomUUID(),
});
}
}
export class WizardSession {
private currentStep: WizardStep | null = null;
private stepDeferred: Deferred<WizardStep | null> | null = null;
private answerDeferred = new Map<string, Deferred<unknown>>();
private status: WizardSessionStatus = "running";
private error: string | undefined;
constructor(private runner: (prompter: WizardPrompter) => Promise<void>) {
const prompter = new WizardSessionPrompter(this);
void this.run(prompter);
}
async next(): Promise<WizardNextResult> {
if (this.currentStep) {
return { done: false, step: this.currentStep, status: this.status };
}
if (this.status !== "running") {
return { done: true, status: this.status, error: this.error };
}
if (!this.stepDeferred) {
this.stepDeferred = createDeferred();
}
const step = await this.stepDeferred.promise;
if (step) {
return { done: false, step, status: this.status };
}
return { done: true, status: this.status, error: this.error };
}
async answer(stepId: string, value: unknown): Promise<void> {
const deferred = this.answerDeferred.get(stepId);
if (!deferred) {
throw new Error("wizard: no pending step");
}
this.answerDeferred.delete(stepId);
this.currentStep = null;
deferred.resolve(value);
}
cancel() {
if (this.status !== "running") return;
this.status = "cancelled";
this.error = "cancelled";
this.currentStep = null;
for (const [, deferred] of this.answerDeferred) {
deferred.reject(new WizardCancelledError());
}
this.answerDeferred.clear();
this.resolveStep(null);
}
pushStep(step: WizardStep) {
this.currentStep = step;
this.resolveStep(step);
}
private async run(prompter: WizardPrompter) {
try {
await this.runner(prompter);
this.status = "done";
} catch (err) {
if (err instanceof WizardCancelledError) {
this.status = "cancelled";
this.error = err.message;
} else {
this.status = "error";
this.error = String(err);
}
} finally {
this.resolveStep(null);
}
}
async awaitAnswer(step: WizardStep): Promise<unknown> {
if (this.status !== "running") {
throw new Error("wizard: session not running");
}
this.pushStep(step);
const deferred = createDeferred<unknown>();
this.answerDeferred.set(step.id, deferred);
return await deferred.promise;
}
private resolveStep(step: WizardStep | null) {
if (!this.stepDeferred) return;
const deferred = this.stepDeferred;
this.stepDeferred = null;
deferred.resolve(step);
}
getStatus(): WizardSessionStatus {
return this.status;
}
getError(): string | undefined {
return this.error;
}
}

View File

@ -58,7 +58,7 @@ import {
} from "./controllers/skills"; } from "./controllers/skills";
import { loadNodes } from "./controllers/nodes"; import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat"; import { loadChatHistory } from "./controllers/chat";
import { loadConfig, saveConfig } from "./controllers/config"; import { loadConfig, saveConfig, updateConfigFormValue } from "./controllers/config";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron"; import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadDebug, callDebugMethod } from "./controllers/debug";
@ -95,6 +95,11 @@ export type AppViewState = {
configIssues: unknown[]; configIssues: unknown[];
configSaving: boolean; configSaving: boolean;
configSnapshot: ConfigSnapshot | null; configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaLoading: boolean;
configUiHints: Record<string, unknown>;
configForm: Record<string, unknown> | null;
configFormMode: "form" | "raw";
providersLoading: boolean; providersLoading: boolean;
providersSnapshot: ProvidersStatusSnapshot | null; providersSnapshot: ProvidersStatusSnapshot | null;
providersError: string | null; providersError: string | null;
@ -392,7 +397,14 @@ export function renderApp(state: AppViewState) {
loading: state.configLoading, loading: state.configLoading,
saving: state.configSaving, saving: state.configSaving,
connected: state.connected, connected: state.connected,
schema: state.configSchema,
schemaLoading: state.configSchemaLoading,
uiHints: state.configUiHints,
formMode: state.configFormMode,
formValue: state.configForm,
onRawChange: (next) => (state.configRaw = next), onRawChange: (next) => (state.configRaw = next),
onFormModeChange: (mode) => (state.configFormMode = mode),
onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
onReload: () => loadConfig(state), onReload: () => loadConfig(state),
onSave: () => saveConfig(state), onSave: () => saveConfig(state),
}) })

View File

@ -16,6 +16,7 @@ import {
} from "./theme-transition"; } from "./theme-transition";
import type { import type {
ConfigSnapshot, ConfigSnapshot,
ConfigUiHints,
CronJob, CronJob,
CronRunLogEntry, CronRunLogEntry,
CronStatus, CronStatus,
@ -41,7 +42,11 @@ import {
type ChatEventPayload, type ChatEventPayload,
} from "./controllers/chat"; } from "./controllers/chat";
import { loadNodes } from "./controllers/nodes"; import { loadNodes } from "./controllers/nodes";
import { loadConfig } from "./controllers/config"; import {
loadConfig,
loadConfigSchema,
updateConfigFormValue,
} from "./controllers/config";
import { import {
loadProviders, loadProviders,
logoutWhatsApp, logoutWhatsApp,
@ -120,6 +125,13 @@ export class ClawdisApp extends LitElement {
@state() configIssues: unknown[] = []; @state() configIssues: unknown[] = [];
@state() configSaving = false; @state() configSaving = false;
@state() configSnapshot: ConfigSnapshot | null = null; @state() configSnapshot: ConfigSnapshot | null = null;
@state() configSchema: unknown | null = null;
@state() configSchemaVersion: string | null = null;
@state() configSchemaLoading = false;
@state() configUiHints: ConfigUiHints = {};
@state() configForm: Record<string, unknown> | null = null;
@state() configFormDirty = false;
@state() configFormMode: "form" | "raw" = "form";
@state() providersLoading = false; @state() providersLoading = false;
@state() providersSnapshot: ProvidersStatusSnapshot | null = null; @state() providersSnapshot: ProvidersStatusSnapshot | null = null;
@ -447,7 +459,10 @@ export class ClawdisApp extends LitElement {
await Promise.all([loadChatHistory(this), loadSessions(this)]); await Promise.all([loadChatHistory(this), loadSessions(this)]);
this.scheduleChatScroll(); this.scheduleChatScroll();
} }
if (this.tab === "config") await loadConfig(this); if (this.tab === "config") {
await loadConfigSchema(this);
await loadConfig(this);
}
if (this.tab === "debug") await loadDebug(this); if (this.tab === "debug") await loadDebug(this);
} }

View File

@ -0,0 +1,106 @@
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";
import { renderConfigForm } from "./views/config-form";
const rootSchema = {
type: "object",
properties: {
gateway: {
type: "object",
properties: {
auth: {
type: "object",
properties: {
token: { type: "string" },
},
},
},
},
allowFrom: {
type: "array",
items: { type: "string" },
},
mode: {
type: "string",
enum: ["off", "token"],
},
enabled: {
type: "boolean",
},
},
};
describe("config form renderer", () => {
it("renders inputs and patches values", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
render(
renderConfigForm({
schema: rootSchema,
uiHints: {
"gateway.auth.token": { label: "Gateway Token", sensitive: true },
},
value: {},
onPatch,
}),
container,
);
const tokenInput = container.querySelector(
"input[type='password']",
) as HTMLInputElement | null;
expect(tokenInput).not.toBeNull();
if (!tokenInput) return;
tokenInput.value = "abc123";
tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(
["gateway", "auth", "token"],
"abc123",
);
const select = container.querySelector("select") as HTMLSelectElement | null;
expect(select).not.toBeNull();
if (!select) return;
select.value = "token";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["mode"], "token");
const checkbox = container.querySelector(
"input[type='checkbox']",
) as HTMLInputElement | null;
expect(checkbox).not.toBeNull();
if (!checkbox) return;
checkbox.checked = true;
checkbox.dispatchEvent(new Event("change", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
});
it("adds and removes array entries", () => {
const onPatch = vi.fn();
const container = document.createElement("div");
render(
renderConfigForm({
schema: rootSchema,
uiHints: {},
value: { allowFrom: ["+1"] },
onPatch,
}),
container,
);
const addButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Add",
);
expect(addButton).not.toBeUndefined();
addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);
const removeButton = Array.from(container.querySelectorAll("button")).find(
(btn) => btn.textContent?.trim() === "Remove",
);
expect(removeButton).not.toBeUndefined();
removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
});
});

View File

@ -1,5 +1,9 @@
import type { GatewayBrowserClient } from "../gateway"; import type { GatewayBrowserClient } from "../gateway";
import type { ConfigSnapshot } from "../types"; import type {
ConfigSchemaResponse,
ConfigSnapshot,
ConfigUiHints,
} from "../types";
import { import {
defaultDiscordActions, defaultDiscordActions,
type DiscordActionForm, type DiscordActionForm,
@ -20,6 +24,13 @@ export type ConfigState = {
configIssues: unknown[]; configIssues: unknown[];
configSaving: boolean; configSaving: boolean;
configSnapshot: ConfigSnapshot | null; configSnapshot: ConfigSnapshot | null;
configSchema: unknown | null;
configSchemaVersion: string | null;
configSchemaLoading: boolean;
configUiHints: ConfigUiHints;
configForm: Record<string, unknown> | null;
configFormDirty: boolean;
configFormMode: "form" | "raw";
lastError: string | null; lastError: string | null;
telegramForm: TelegramForm; telegramForm: TelegramForm;
discordForm: DiscordForm; discordForm: DiscordForm;
@ -45,6 +56,32 @@ export async function loadConfig(state: ConfigState) {
} }
} }
export async function loadConfigSchema(state: ConfigState) {
if (!state.client || !state.connected) return;
if (state.configSchemaLoading) return;
state.configSchemaLoading = true;
try {
const res = (await state.client.request(
"config.schema",
{},
)) as ConfigSchemaResponse;
applyConfigSchema(state, res);
} catch (err) {
state.lastError = String(err);
} finally {
state.configSchemaLoading = false;
}
}
export function applyConfigSchema(
state: ConfigState,
res: ConfigSchemaResponse,
) {
state.configSchema = res.schema ?? null;
state.configUiHints = res.uiHints ?? {};
state.configSchemaVersion = res.version ?? null;
}
export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) { export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot) {
state.configSnapshot = snapshot; state.configSnapshot = snapshot;
if (typeof snapshot.raw === "string") { if (typeof snapshot.raw === "string") {
@ -239,6 +276,10 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot
state.discordConfigStatus = configInvalid; state.discordConfigStatus = configInvalid;
state.signalConfigStatus = configInvalid; state.signalConfigStatus = configInvalid;
state.imessageConfigStatus = configInvalid; state.imessageConfigStatus = configInvalid;
if (!state.configFormDirty) {
state.configForm = cloneConfigObject(snapshot.config ?? {});
}
} }
export async function saveConfig(state: ConfigState) { export async function saveConfig(state: ConfigState) {
@ -246,7 +287,12 @@ export async function saveConfig(state: ConfigState) {
state.configSaving = true; state.configSaving = true;
state.lastError = null; state.lastError = null;
try { try {
await state.client.request("config.set", { raw: state.configRaw }); const raw =
state.configFormMode === "form" && state.configForm
? `${JSON.stringify(state.configForm, null, 2).trimEnd()}\n`
: state.configRaw;
await state.client.request("config.set", { raw });
state.configFormDirty = false;
await loadConfig(state); await loadConfig(state);
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);
@ -254,3 +300,101 @@ export async function saveConfig(state: ConfigState) {
state.configSaving = false; state.configSaving = false;
} }
} }
export function updateConfigFormValue(
state: ConfigState,
path: Array<string | number>,
value: unknown,
) {
const base = cloneConfigObject(state.configForm ?? {});
setPathValue(base, path, value);
state.configForm = base;
state.configFormDirty = true;
}
export function removeConfigFormValue(
state: ConfigState,
path: Array<string | number>,
) {
const base = cloneConfigObject(state.configForm ?? {});
removePathValue(base, path);
state.configForm = base;
state.configFormDirty = true;
}
function cloneConfigObject<T>(value: T): T {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
return JSON.parse(JSON.stringify(value)) as T;
}
function setPathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
value: unknown,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
const nextKey = path[i + 1];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
if (current[key] == null) {
current[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
const record = current as Record<string, unknown>;
if (record[key] == null) {
record[key] =
typeof nextKey === "number" ? [] : ({} as Record<string, unknown>);
}
current = record[key] as Record<string, unknown> | unknown[];
}
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) {
current[lastKey] = value;
}
return;
}
if (typeof current === "object" && current != null) {
(current as Record<string, unknown>)[lastKey] = value;
}
}
function removePathValue(
obj: Record<string, unknown> | unknown[],
path: Array<string | number>,
) {
if (path.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < path.length - 1; i += 1) {
const key = path[i];
if (typeof key === "number") {
if (!Array.isArray(current)) return;
current = current[key] as Record<string, unknown> | unknown[];
} else {
if (typeof current !== "object" || current == null) return;
current = (current as Record<string, unknown>)[key] as
| Record<string, unknown>
| unknown[];
}
if (current == null) return;
}
const lastKey = path[path.length - 1];
if (typeof lastKey === "number") {
if (Array.isArray(current)) {
current.splice(lastKey, 1);
}
return;
}
if (typeof current === "object" && current != null) {
delete (current as Record<string, unknown>)[lastKey];
}
}

View File

@ -140,6 +140,26 @@ export type ConfigSnapshot = {
issues?: ConfigSnapshotIssue[] | null; issues?: ConfigSnapshotIssue[] | null;
}; };
export type ConfigUiHint = {
label?: string;
help?: string;
group?: string;
order?: number;
advanced?: boolean;
sensitive?: boolean;
placeholder?: string;
itemTemplate?: unknown;
};
export type ConfigUiHints = Record<string, ConfigUiHint>;
export type ConfigSchemaResponse = {
schema: unknown;
uiHints: ConfigUiHints;
version: string;
generatedAt: string;
};
export type PresenceEntry = { export type PresenceEntry = {
instanceId?: string | null; instanceId?: string | null;
host?: string | null; host?: string | null;

View File

@ -0,0 +1,274 @@
import { html, nothing } from "lit";
import type { ConfigUiHint, ConfigUiHints } from "../types";
export type ConfigFormProps = {
schema: unknown | null;
uiHints: ConfigUiHints;
value: Record<string, unknown> | null;
onPatch: (path: Array<string | number>, value: unknown) => void;
};
type JsonSchema = {
type?: string | string[];
title?: string;
description?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema | JsonSchema[];
enum?: unknown[];
default?: unknown;
anyOf?: JsonSchema[];
oneOf?: JsonSchema[];
allOf?: JsonSchema[];
};
export function renderConfigForm(props: ConfigFormProps) {
if (!props.schema) {
return html`<div class="muted">Schema unavailable.</div>`;
}
const schema = props.schema as JsonSchema;
const value = props.value ?? {};
if (schemaType(schema) !== "object" || !schema.properties) {
return html`<div class="callout danger">Unsupported schema. Use Raw.</div>`;
}
const entries = Object.entries(schema.properties);
const sorted = entries.sort((a, b) => {
const orderA = hintForPath([a[0]], props.uiHints)?.order ?? 0;
const orderB = hintForPath([b[0]], props.uiHints)?.order ?? 0;
if (orderA !== orderB) return orderA - orderB;
return a[0].localeCompare(b[0]);
});
return html`
<div class="config-form">
${sorted.map(([key, node]) =>
renderNode({
schema: node,
value: (value as Record<string, unknown>)[key],
path: [key],
hints: props.uiHints,
onPatch: props.onPatch,
}),
)}
</div>
`;
}
function renderNode(params: {
schema: JsonSchema;
value: unknown;
path: Array<string | number>;
hints: ConfigUiHints;
onPatch: (path: Array<string | number>, value: unknown) => void;
}) {
const { schema, value, path, hints, onPatch } = params;
const type = schemaType(schema);
const hint = hintForPath(path, hints);
const label = hint?.label ?? schema.title ?? humanize(String(path.at(-1)));
const help = hint?.help ?? schema.description;
if (schema.anyOf || schema.oneOf || schema.allOf) {
return html`<div class="callout danger">
${label}: unsupported schema node. Use Raw.
</div>`;
}
if (type === "object") {
const props = schema.properties ?? {};
const entries = Object.entries(props);
if (entries.length === 0) return nothing;
return html`
<fieldset class="field-group">
<legend>${label}</legend>
${help ? html`<div class="muted">${help}</div>` : nothing}
${entries.map(([key, node]) =>
renderNode({
schema: node,
value: value && typeof value === "object" ? (value as any)[key] : undefined,
path: [...path, key],
hints,
onPatch,
}),
)}
</fieldset>
`;
}
if (type === "array") {
const itemSchema = Array.isArray(schema.items)
? schema.items[0]
: schema.items;
const arr = Array.isArray(value) ? value : [];
return html`
<div class="field">
<div class="row" style="justify-content: space-between;">
<span>${label}</span>
<button
class="btn"
@click=${() => {
const next = [...arr, defaultValue(itemSchema)];
onPatch(path, next);
}}
>
Add
</button>
</div>
${help ? html`<div class="muted">${help}</div>` : nothing}
${arr.map((entry, index) =>
html`<div class="array-item">
${itemSchema
? renderNode({
schema: itemSchema,
value: entry,
path: [...path, index],
hints,
onPatch,
})
: nothing}
<button
class="btn danger"
@click=${() => {
const next = arr.slice();
next.splice(index, 1);
onPatch(path, next);
}}
>
Remove
</button>
</div>`,
)}
</div>
`;
}
if (schema.enum) {
return html`
<label class="field">
<span>${label}</span>
${help ? html`<div class="muted">${help}</div>` : nothing}
<select
.value=${value == null ? "" : String(value)}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLSelectElement).value)}
>
${schema.enum.map(
(opt) => html`<option value=${String(opt)}>${String(opt)}</option>`,
)}
</select>
</label>
`;
}
if (type === "boolean") {
return html`
<label class="field">
<span>${label}</span>
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="checkbox"
.checked=${Boolean(value)}
@change=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).checked)}
/>
</label>
`;
}
if (type === "number" || type === "integer") {
return html`
<label class="field">
<span>${label}</span>
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type="number"
.value=${value == null ? "" : String(value)}
@input=${(e: Event) => {
const raw = (e.target as HTMLInputElement).value;
const parsed = raw === "" ? undefined : Number(raw);
onPatch(path, parsed);
}}
/>
</label>
`;
}
if (type === "string") {
const isSensitive = hint?.sensitive ?? isSensitivePath(path);
const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : "");
return html`
<label class="field">
<span>${label}</span>
${help ? html`<div class="muted">${help}</div>` : nothing}
<input
type=${isSensitive ? "password" : "text"}
placeholder=${placeholder}
.value=${value == null ? "" : String(value)}
@input=${(e: Event) =>
onPatch(path, (e.target as HTMLInputElement).value)}
/>
</label>
`;
}
return html`<div class="field">
<span>${label}</span>
<div class="muted">Unsupported type. Use Raw.</div>
</div>`;
}
function schemaType(schema: JsonSchema): string | undefined {
if (!schema) return undefined;
if (Array.isArray(schema.type)) {
const filtered = schema.type.filter((t) => t !== "null");
return filtered[0] ?? schema.type[0];
}
return schema.type;
}
function defaultValue(schema?: JsonSchema): unknown {
if (!schema) return "";
if (schema.default !== undefined) return schema.default;
const type = schemaType(schema);
switch (type) {
case "object":
return {};
case "array":
return [];
case "boolean":
return false;
case "number":
case "integer":
return 0;
case "string":
return "";
default:
return "";
}
}
function hintForPath(path: Array<string | number>, hints: ConfigUiHints) {
const key = pathKey(path);
return hints[key];
}
function pathKey(path: Array<string | number>): string {
return path.filter((segment) => typeof segment === "string").join(".");
}
function humanize(raw: string) {
return raw
.replace(/_/g, " ")
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/\s+/g, " ")
.replace(/^./, (m) => m.toUpperCase());
}
function isSensitivePath(path: Array<string | number>): boolean {
const key = pathKey(path).toLowerCase();
return (
key.includes("token") ||
key.includes("password") ||
key.includes("secret") ||
key.includes("apikey") ||
key.endsWith("key")
);
}

View File

@ -1,4 +1,6 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import type { ConfigUiHints } from "../types";
import { renderConfigForm } from "./config-form";
export type ConfigProps = { export type ConfigProps = {
raw: string; raw: string;
@ -7,7 +9,14 @@ export type ConfigProps = {
loading: boolean; loading: boolean;
saving: boolean; saving: boolean;
connected: boolean; connected: boolean;
schema: unknown | null;
schemaLoading: boolean;
uiHints: ConfigUiHints;
formMode: "form" | "raw";
formValue: Record<string, unknown> | null;
onRawChange: (next: string) => void; onRawChange: (next: string) => void;
onFormModeChange: (mode: "form" | "raw") => void;
onFormPatch: (path: Array<string | number>, value: unknown) => void;
onReload: () => void; onReload: () => void;
onSave: () => void; onSave: () => void;
}; };
@ -23,6 +32,21 @@ export function renderConfig(props: ConfigProps) {
<span class="pill">${validity}</span> <span class="pill">${validity}</span>
</div> </div>
<div class="row"> <div class="row">
<div class="toggle-group">
<button
class="btn ${props.formMode === "form" ? "primary" : ""}"
?disabled=${props.schemaLoading || !props.schema}
@click=${() => props.onFormModeChange("form")}
>
Form
</button>
<button
class="btn ${props.formMode === "raw" ? "primary" : ""}"
@click=${() => props.onFormModeChange("raw")}
>
Raw
</button>
</div>
<button class="btn" ?disabled=${props.loading} @click=${props.onReload}> <button class="btn" ?disabled=${props.loading} @click=${props.onReload}>
${props.loading ? "Loading…" : "Reload"} ${props.loading ? "Loading…" : "Reload"}
</button> </button>
@ -41,14 +65,25 @@ export function renderConfig(props: ConfigProps) {
require a gateway restart. require a gateway restart.
</div> </div>
<label class="field" style="margin-top: 12px;"> ${props.formMode === "form"
<span>Raw JSON5</span> ? html`<div style="margin-top: 12px;">
<textarea ${props.schemaLoading
.value=${props.raw} ? html`<div class="muted">Loading schema…</div>`
@input=${(e: Event) => : renderConfigForm({
props.onRawChange((e.target as HTMLTextAreaElement).value)} schema: props.schema,
></textarea> uiHints: props.uiHints,
</label> value: props.formValue,
onPatch: props.onFormPatch,
})}
</div>`
: html`<label class="field" style="margin-top: 12px;">
<span>Raw JSON5</span>
<textarea
.value=${props.raw}
@input=${(e: Event) =>
props.onRawChange((e.target as HTMLTextAreaElement).value)}
></textarea>
</label>`}
${props.issues.length > 0 ${props.issues.length > 0
? html`<div class="callout danger" style="margin-top: 12px;"> ? html`<div class="callout danger" style="margin-top: 12px;">
@ -58,4 +93,3 @@ export function renderConfig(props: ConfigProps) {
</section> </section>
`; `;
} }