diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c17cfc22..9efe26ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ ### Features - 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. +- 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 - 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: document Discord `sendMessage` media attachments and `to` format clarification. - Gateway: document port configuration + multi-instance isolation. +- Onboarding/Config: add protocol notes for wizard + schema RPC. ## 2.0.0-beta5 — 2026-01-03 diff --git a/apps/macos/Sources/Clawdis/GatewayConnection.swift b/apps/macos/Sources/Clawdis/GatewayConnection.swift index fc038fa85..c64291958 100644 --- a/apps/macos/Sources/Clawdis/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdis/GatewayConnection.swift @@ -51,6 +51,10 @@ actor GatewayConnection { case providersStatus = "providers.status" case configGet = "config.get" 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 webLoginStart = "web.login.start" case webLoginWait = "web.login.wait" diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index fc7f13ac6..c2e941715 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -86,6 +86,7 @@ struct OnboardingView: View { @State var gatewayDiscovery: GatewayDiscoveryModel @State var onboardingChatModel: ClawdisChatViewModel @State var onboardingSkillsModel = SkillsSettingsModel() + @State var onboardingWizard = OnboardingWizardModel() @State var didLoadOnboardingSkills = false @State var localGatewayProbe: LocalGatewayProbe? @Bindable var state: AppState @@ -95,6 +96,7 @@ struct OnboardingView: View { let contentHeight: CGFloat = 460 let connectionPageIndex = 1 let anthropicAuthPageIndex = 2 + let wizardPageIndex = 3 let onboardingChatPageIndex = 8 static let clipboardPoll: AnyPublisher = { @@ -119,7 +121,7 @@ struct OnboardingView: View { case .unconfigured: needsBootstrap ? [0, 1, 8, 9] : [0, 1, 9] 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 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 { let bundlePath = Bundle.main.bundlePath return "ln -sf '\(bundlePath)/Contents/Resources/Relay/clawdis' /usr/local/bin/clawdis" diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift index bba52c330..b3107a786 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Actions.swift @@ -11,6 +11,7 @@ extension OnboardingView { } func selectUnconfiguredGateway() { + Task { await self.onboardingWizard.cancelIfRunning() } self.state.connectionMode = .unconfigured self.preferredGatewayID = nil self.showAdvancedConnection = false @@ -18,6 +19,7 @@ extension OnboardingView { } func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) { + Task { await self.onboardingWizard.cancelIfRunning() } self.preferredGatewayID = gateway.stableID BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID) @@ -47,6 +49,7 @@ extension OnboardingView { } func handleNext() { + if self.isWizardBlocking { return } if self.currentPage < self.pageCount - 1 { withAnimation { self.currentPage += 1 } } else { diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift index 744ec3c79..35f041889 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Layout.swift @@ -46,6 +46,10 @@ extension OnboardingView { 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 { self.stopPermissionMonitoring() self.stopDiscovery() @@ -81,6 +85,7 @@ extension OnboardingView { } var navigationBar: some View { + let wizardLockIndex = self.wizardPageOrderIndex HStack(spacing: 20) { ZStack(alignment: .leading) { Button(action: {}, label: { @@ -107,6 +112,7 @@ extension OnboardingView { HStack(spacing: 8) { ForEach(0.. (wizardLockIndex ?? 0) Button { withAnimation { self.currentPage = index } } label: { @@ -115,6 +121,8 @@ extension OnboardingView { .frame(width: 8, height: 8) } .buttonStyle(.plain) + .disabled(isLocked) + .opacity(isLocked ? 0.3 : 1) } } @@ -126,6 +134,7 @@ extension OnboardingView { } .keyboardShortcut(.return) .buttonStyle(.borderedProminent) + .disabled(!self.canAdvance) } .padding(.horizontal, 28) .padding(.bottom, 13) diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift index 569e1c3e0..6265950fd 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Pages.swift @@ -13,6 +13,8 @@ extension OnboardingView { self.connectionPage() case 2: self.anthropicAuthPage() + case 3: + self.wizardPage() case 5: self.permissionsPage() case 6: diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift b/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift index fa515e697..4ed31bff2 100644 --- a/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift +++ b/apps/macos/Sources/Clawdis/OnboardingView+Testing.swift @@ -47,6 +47,7 @@ extension OnboardingView { _ = view.welcomePage() _ = view.connectionPage() _ = view.anthropicAuthPage() + _ = view.wizardPage() _ = view.permissionsPage() _ = view.cliPage() _ = view.workspacePage() diff --git a/apps/macos/Sources/Clawdis/OnboardingView+Wizard.swift b/apps/macos/Sources/Clawdis/OnboardingView+Wizard.swift new file mode 100644 index 000000000..477f033d5 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingView+Wizard.swift @@ -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) + } + } + } +} diff --git a/apps/macos/Sources/Clawdis/OnboardingWizard.swift b/apps/macos/Sources/Clawdis/OnboardingWizard.swift new file mode 100644 index 000000000..4ac805619 --- /dev/null +++ b/apps/macos/Sources/Clawdis/OnboardingWizard.swift @@ -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 + + 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 + } +} diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 9fe91a988..112ba9feb 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -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 let enabled: Bool public let phase: String? diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift index 05bce9b58..c053f842b 100644 --- a/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingViewSmokeTests.swift @@ -17,7 +17,7 @@ struct OnboardingViewSmokeTests { @Test func pageOrderOmitsWorkspaceAndIdentitySteps() { let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false) #expect(!order.contains(7)) - #expect(!order.contains(3)) + #expect(order.contains(3)) } @Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() { diff --git a/apps/macos/Tests/ClawdisIPCTests/OnboardingWizardStepViewTests.swift b/apps/macos/Tests/ClawdisIPCTests/OnboardingWizardStepViewTests.swift new file mode 100644 index 000000000..ee779ce51 --- /dev/null +++ b/apps/macos/Tests/ClawdisIPCTests/OnboardingWizardStepViewTests.swift @@ -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 + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 73ed4dba9..0fae80f2d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`) - 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) ```json5 diff --git a/docs/control-ui.md b/docs/control-ui.md index 01f6e3139..5f1ed3c25 100644 --- a/docs/control-ui.md +++ b/docs/control-ui.md @@ -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.*`) - Nodes: list + caps (`node.list`) - 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`) ## Tailnet access (recommended) diff --git a/docs/onboarding-config-protocol.md b/docs/onboarding-config-protocol.md new file mode 100644 index 000000000..64da823b6 --- /dev/null +++ b/docs/onboarding-config-protocol.md @@ -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. diff --git a/docs/wizard.md b/docs/wizard.md index 2b84c6ef7..425fd30f2 100644 --- a/docs/wizard.md +++ b/docs/wizard.md @@ -115,6 +115,11 @@ clawdis onboard --non-interactive \ Add `--json` for a machine‑readable 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 re‑implementing onboarding logic. + ## Signal setup (signal-cli) The wizard can install `signal-cli` from GitHub releases: diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index aeb6e06e7..b64b45927 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -180,13 +180,13 @@ function cleanSchemaForGemini(schema: unknown): unknown { cleaned[key] = cleanSchemaForGemini(value); } else if (key === "anyOf" && Array.isArray(value)) { // 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)) { // 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)) { // Clean each allOf variant - cleaned[key] = value.map((v) => cleanSchemaForGemini(v)); + cleaned[key] = value.map((variant) => cleanSchemaForGemini(variant)); } else if ( key === "additionalProperties" && value && @@ -265,12 +265,12 @@ function normalizeToolParameters(tool: AnyAgentTool): AnyAgentTool { .map(([key]) => key) : undefined; - const { anyOf: _unusedAnyOf, ...restSchema } = schema; + const nextSchema: Record = { ...schema }; return { ...tool, parameters: cleanSchemaForGemini({ - ...restSchema, - type: "object", + ...nextSchema, + type: nextSchema.type ?? "object", properties: Object.keys(mergedProperties).length > 0 ? mergedProperties diff --git a/src/auto-reply/transcription.test.ts b/src/auto-reply/transcription.test.ts index c103bfeaa..e0802eb93 100644 --- a/src/auto-reply/transcription.test.ts +++ b/src/auto-reply/transcription.test.ts @@ -8,6 +8,7 @@ vi.mock("../globals.js", () => ({ isVerbose: () => false, shouldLogVerbose: () => false, logVerbose: vi.fn(), + shouldLogVerbose: () => false, })); vi.mock("../process/exec.js", () => ({ diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 3d59e5efa..dbaf037eb 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -24,6 +24,7 @@ import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; +import { createClackPrompter } from "../wizard/clack-prompter.js"; import { isRemoteEnvironment, loginAntigravityVpsAware, @@ -419,6 +420,7 @@ export async function runConfigureWizard( intro( opts.command === "update" ? "Clawdis update wizard" : "Clawdis configure", ); + const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); let baseConfig: ClawdisConfig = snapshot.valid ? snapshot.config : {}; @@ -490,7 +492,7 @@ export async function runConfigureWizard( ) as "local" | "remote"; if (mode === "remote") { - let remoteConfig = await promptRemoteGatewayConfig(baseConfig, runtime); + let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter); remoteConfig = applyWizardMetadata(remoteConfig, { command: opts.command, mode, @@ -565,7 +567,7 @@ export async function runConfigureWizard( } if (selected.includes("providers")) { - nextConfig = await setupProviders(nextConfig, runtime, { + nextConfig = await setupProviders(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, }); @@ -573,7 +575,7 @@ export async function runConfigureWizard( if (selected.includes("skills")) { const wsDir = resolveUserPath(workspaceDir); - nextConfig = await setupSkills(nextConfig, wsDir, runtime); + nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); } nextConfig = applyWizardMetadata(nextConfig, { diff --git a/src/commands/onboard-interactive.ts b/src/commands/onboard-interactive.ts index dfc2c22a3..29c457ce9 100644 --- a/src/commands/onboard-interactive.ts +++ b/src/commands/onboard-interactive.ts @@ -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 { defaultRuntime } from "../runtime.js"; -import { resolveUserPath, sleep } from "../utils.js"; -import { - isRemoteEnvironment, - loginAntigravityVpsAware, -} 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"; +import { createClackPrompter } from "../wizard/clack-prompter.js"; +import { runOnboardingWizard } from "../wizard/onboarding.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; +import type { OnboardOptions } from "./onboard-types.js"; export async function runInteractiveOnboarding( opts: OnboardOptions, runtime: RuntimeEnv = defaultRuntime, ) { - printWizardHeader(runtime); - 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 = { - 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); + const prompter = createClackPrompter(); try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + await runOnboardingWizard(opts, runtime, prompter); } 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."); } diff --git a/src/commands/onboard-providers.ts b/src/commands/onboard-providers.ts index af537ec06..50aeb50b2 100644 --- a/src/commands/onboard-providers.ts +++ b/src/commands/onboard-providers.ts @@ -1,15 +1,12 @@ import fs from "node:fs/promises"; 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 { loginWeb } from "../provider-web.js"; import type { RuntimeEnv } from "../runtime.js"; import { normalizeE164 } from "../utils.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 { installSignalCli } from "./signal-install.js"; @@ -27,8 +24,8 @@ async function detectWhatsAppLinked(): Promise { return await pathExists(credsPath); } -function noteProviderPrimer(): void { - note( +async function noteProviderPrimer(prompter: WizardPrompter): Promise { + await prompter.note( [ "WhatsApp: links via WhatsApp Web (scan QR), stores creds for future sends.", "Telegram: Bot API (token from @BotFather), replies via your bot.", @@ -40,8 +37,8 @@ function noteProviderPrimer(): void { ); } -function noteTelegramTokenHelp(): void { - note( +async function noteTelegramTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( [ "1) Open Telegram and chat with @BotFather", "2) Run /newbot (or /mybots)", @@ -52,8 +49,8 @@ function noteTelegramTokenHelp(): void { ); } -function noteDiscordTokenHelp(): void { - note( +async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise { + await prompter.note( [ "1) Discord Developer Portal → Applications → New Application", "2) Bot → Add Bot → Reset Token → copy token", @@ -76,13 +73,14 @@ function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) { async function promptWhatsAppAllowFrom( cfg: ClawdisConfig, - runtime: RuntimeEnv, + _runtime: RuntimeEnv, + prompter: WizardPrompter, ): Promise { const existingAllowFrom = cfg.whatsapp?.allowFrom ?? []; const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - note( + await prompter.note( [ "WhatsApp direct chats are gated by `whatsapp.allowFrom`.", 'Default (unset) = self-chat only; use "*" to allow anyone.', @@ -105,40 +103,34 @@ async function promptWhatsAppAllowFrom( { value: "any", label: "Anyone (*)" }, ] as const); - const mode = guardCancel( - await select({ - message: "Who can trigger the bot via WhatsApp?", - options: options.map((opt) => ({ value: opt.value, label: opt.label })), - }), - runtime, - ) as (typeof options)[number]["value"]; + const mode = (await prompter.select({ + message: "Who can trigger the bot via WhatsApp?", + options: options.map((opt) => ({ value: opt.value, label: opt.label })), + })) as (typeof options)[number]["value"]; if (mode === "keep") return cfg; if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined); if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]); - const allowRaw = guardCancel( - await text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) return "Required"; - const parts = raw - .split(/[\n,;]+/g) - .map((p) => p.trim()) - .filter(Boolean); - if (parts.length === 0) return "Required"; - for (const part of parts) { - if (part === "*") continue; - const normalized = normalizeE164(part); - if (!normalized) return `Invalid number: ${part}`; - } - return undefined; - }, - }), - runtime, - ); + const allowRaw = await prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) return "Required"; + const parts = raw + .split(/[\n,;]+/g) + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length === 0) return "Required"; + for (const part of parts) { + if (part === "*") continue; + const normalized = normalizeE164(part); + if (!normalized) return `Invalid number: ${part}`; + } + return undefined; + }, + }); const parts = String(allowRaw) .split(/[\n,;]+/g) @@ -154,6 +146,7 @@ async function promptWhatsAppAllowFrom( export async function setupProviders( cfg: ClawdisConfig, runtime: RuntimeEnv, + prompter: WizardPrompter, options?: { allowDisable?: boolean; allowSignalInstall?: boolean }, ): Promise { const whatsappLinked = await detectWhatsAppLinked(); @@ -174,91 +167,63 @@ export async function setupProviders( const imessageCliPath = cfg.imessage?.cliPath ?? "imsg"; const imessageCliDetected = await detectBinary(imessageCliPath); - note( + await prompter.note( [ - `WhatsApp: ${ - whatsappLinked ? chalk.green("linked") : chalk.red("not linked") - }`, - `Telegram: ${ - telegramConfigured - ? chalk.green("configured") - : chalk.yellow("needs token") - }`, - `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})`, + `WhatsApp: ${whatsappLinked ? "linked" : "not linked"}`, + `Telegram: ${telegramConfigured ? "configured" : "needs token"}`, + `Discord: ${discordConfigured ? "configured" : "needs token"}`, + `Signal: ${signalConfigured ? "configured" : "needs setup"}`, + `iMessage: ${imessageConfigured ? "configured" : "needs setup"}`, + `signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`, + `imsg: ${imessageCliDetected ? "found" : "missing"} (${imessageCliPath})`, ].join("\n"), "Provider status", ); - const shouldConfigure = guardCancel( - await confirm({ - message: "Configure chat providers now?", - initialValue: true, - }), - runtime, - ); + const shouldConfigure = await prompter.confirm({ + message: "Configure chat providers now?", + initialValue: true, + }); if (!shouldConfigure) return cfg; - noteProviderPrimer(); + await noteProviderPrimer(prompter); - const selection = guardCancel( - await multiselect({ - message: "Select providers", - options: [ - { - value: "whatsapp", - label: "WhatsApp (QR link)", - hint: whatsappLinked ? "linked" : "not linked", - }, - { - value: "telegram", - label: "Telegram (Bot API)", - hint: telegramConfigured ? "configured" : "needs token", - }, - { - value: "discord", - label: "Discord (Bot API)", - hint: discordConfigured ? "configured" : "needs token", - }, - { - value: "signal", - label: "Signal (signal-cli)", - hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", - }, - { - value: "imessage", - label: "iMessage (imsg)", - hint: imessageCliDetected ? "imsg found" : "imsg missing", - }, - ], - }), - runtime, - ) as ProviderChoice[]; + const selection = (await prompter.multiselect({ + message: "Select providers", + options: [ + { + value: "whatsapp", + label: "WhatsApp (QR link)", + hint: whatsappLinked ? "linked" : "not linked", + }, + { + value: "telegram", + label: "Telegram (Bot API)", + hint: telegramConfigured ? "configured" : "needs token", + }, + { + value: "discord", + label: "Discord (Bot API)", + hint: discordConfigured ? "configured" : "needs token", + }, + { + value: "signal", + label: "Signal (signal-cli)", + hint: signalCliDetected ? "signal-cli found" : "signal-cli missing", + }, + { + value: "imessage", + label: "iMessage (imsg)", + hint: imessageCliDetected ? "imsg found" : "imsg missing", + }, + ], + })) as ProviderChoice[]; let next = cfg; if (selection.includes("whatsapp")) { if (!whatsappLinked) { - note( + await prompter.note( [ "Scan the QR with WhatsApp on your phone.", "Credentials are stored under ~/.clawdis/credentials/ for future runs.", @@ -266,15 +231,12 @@ export async function setupProviders( "WhatsApp linking", ); } - const wantsLink = guardCancel( - await confirm({ - message: whatsappLinked - ? "WhatsApp already linked. Re-link now?" - : "Link WhatsApp now (QR)?", - initialValue: !whatsappLinked, - }), - runtime, - ); + const wantsLink = await prompter.confirm({ + message: whatsappLinked + ? "WhatsApp already linked. Re-link now?" + : "Link WhatsApp now (QR)?", + initialValue: !whatsappLinked, + }); if (wantsLink) { try { await loginWeb(false, "web"); @@ -282,25 +244,25 @@ export async function setupProviders( runtime.error(`WhatsApp login failed: ${String(err)}`); } } 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")) { let token: string | null = null; if (!telegramConfigured) { - noteTelegramTokenHelp(); + await noteTelegramTokenHelp(prompter); } if (telegramEnv && !cfg.telegram?.botToken) { - const keepEnv = guardCancel( - await confirm({ - message: "TELEGRAM_BOT_TOKEN detected. Use env var?", - initialValue: true, - }), - runtime, - ); + const keepEnv = await prompter.confirm({ + message: "TELEGRAM_BOT_TOKEN detected. Use env var?", + initialValue: true, + }); if (keepEnv) { next = { ...next, @@ -311,43 +273,31 @@ export async function setupProviders( }; } else { token = String( - guardCancel( - await text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } } else if (cfg.telegram?.botToken) { - const keep = guardCancel( - await confirm({ - message: "Telegram token already configured. Keep it?", - initialValue: true, - }), - runtime, - ); + const keep = await prompter.confirm({ + message: "Telegram token already configured. Keep it?", + initialValue: true, + }); if (!keep) { token = String( - guardCancel( - await text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } } else { token = String( - guardCancel( - await text({ - message: "Enter Telegram bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Telegram bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } @@ -366,16 +316,13 @@ export async function setupProviders( if (selection.includes("discord")) { let token: string | null = null; if (!discordConfigured) { - noteDiscordTokenHelp(); + await noteDiscordTokenHelp(prompter); } if (discordEnv && !cfg.discord?.token) { - const keepEnv = guardCancel( - await confirm({ - message: "DISCORD_BOT_TOKEN detected. Use env var?", - initialValue: true, - }), - runtime, - ); + const keepEnv = await prompter.confirm({ + message: "DISCORD_BOT_TOKEN detected. Use env var?", + initialValue: true, + }); if (keepEnv) { next = { ...next, @@ -386,43 +333,31 @@ export async function setupProviders( }; } else { token = String( - guardCancel( - await text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } } else if (cfg.discord?.token) { - const keep = guardCancel( - await confirm({ - message: "Discord token already configured. Keep it?", - initialValue: true, - }), - runtime, - ); + const keep = await prompter.confirm({ + message: "Discord token already configured. Keep it?", + initialValue: true, + }); if (!keep) { token = String( - guardCancel( - await text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } } else { token = String( - guardCancel( - await text({ - message: "Enter Discord bot token", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Enter Discord bot token", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } @@ -442,33 +377,39 @@ export async function setupProviders( let resolvedCliPath = signalCliPath; let cliDetected = signalCliDetected; if (options?.allowSignalInstall) { - const wantsInstall = guardCancel( - await confirm({ - message: cliDetected - ? "signal-cli detected. Reinstall/update now?" - : "signal-cli not found. Install now?", - initialValue: !cliDetected, - }), - runtime, - ); + const wantsInstall = await prompter.confirm({ + message: cliDetected + ? "signal-cli detected. Reinstall/update now?" + : "signal-cli not found. Install now?", + initialValue: !cliDetected, + }); if (wantsInstall) { try { const result = await installSignalCli(runtime); if (result.ok && result.cliPath) { cliDetected = true; 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) { - note(result.error ?? "signal-cli install failed.", "Signal"); + await prompter.note( + result.error ?? "signal-cli install failed.", + "Signal", + ); } } catch (err) { - note(`signal-cli install failed: ${String(err)}`, "Signal"); + await prompter.note( + `signal-cli install failed: ${String(err)}`, + "Signal", + ); } } } if (!cliDetected) { - note( + await prompter.note( "signal-cli not found. Install it, then rerun this step or set signal.cliPath.", "Signal", ); @@ -476,25 +417,19 @@ export async function setupProviders( let account = cfg.signal?.account ?? ""; if (account) { - const keep = guardCancel( - await confirm({ - message: `Signal account set (${account}). Keep it?`, - initialValue: true, - }), - runtime, - ); + const keep = await prompter.confirm({ + message: `Signal account set (${account}). Keep it?`, + initialValue: true, + }); if (!keep) account = ""; } if (!account) { account = String( - guardCancel( - await text({ - message: "Signal bot number (E.164)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Signal bot number (E.164)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } @@ -510,7 +445,7 @@ export async function setupProviders( }; } - note( + await prompter.note( [ 'Link device with: signal-cli link -n "Clawdis"', "Scan QR in Signal → Linked Devices", @@ -523,17 +458,17 @@ export async function setupProviders( if (selection.includes("imessage")) { let resolvedCliPath = imessageCliPath; if (!imessageCliDetected) { - const entered = guardCancel( - await text({ - message: "imsg CLI path", - initialValue: resolvedCliPath, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); + const entered = await prompter.text({ + message: "imsg CLI path", + initialValue: resolvedCliPath, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); resolvedCliPath = String(entered).trim(); 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.", "Grant Automation permission for Messages when prompted.", @@ -560,13 +495,10 @@ export async function setupProviders( if (options?.allowDisable) { if (!selection.includes("telegram") && telegramConfigured) { - const disable = guardCancel( - await confirm({ - message: "Disable Telegram provider?", - initialValue: false, - }), - runtime, - ); + const disable = await prompter.confirm({ + message: "Disable Telegram provider?", + initialValue: false, + }); if (disable) { next = { ...next, @@ -576,13 +508,10 @@ export async function setupProviders( } if (!selection.includes("discord") && discordConfigured) { - const disable = guardCancel( - await confirm({ - message: "Disable Discord provider?", - initialValue: false, - }), - runtime, - ); + const disable = await prompter.confirm({ + message: "Disable Discord provider?", + initialValue: false, + }); if (disable) { next = { ...next, @@ -592,13 +521,10 @@ export async function setupProviders( } if (!selection.includes("signal") && signalConfigured) { - const disable = guardCancel( - await confirm({ - message: "Disable Signal provider?", - initialValue: false, - }), - runtime, - ); + const disable = await prompter.confirm({ + message: "Disable Signal provider?", + initialValue: false, + }); if (disable) { next = { ...next, @@ -608,13 +534,10 @@ export async function setupProviders( } if (!selection.includes("imessage") && imessageConfigured) { - const disable = guardCancel( - await confirm({ - message: "Disable iMessage provider?", - initialValue: false, - }), - runtime, - ); + const disable = await prompter.confirm({ + message: "Disable iMessage provider?", + initialValue: false, + }); if (disable) { next = { ...next, diff --git a/src/commands/onboard-remote.ts b/src/commands/onboard-remote.ts index ad19defab..3ffe133ab 100644 --- a/src/commands/onboard-remote.ts +++ b/src/commands/onboard-remote.ts @@ -1,10 +1,8 @@ -import { confirm, note, select, spinner, text } from "@clack/prompts"; - import type { ClawdisConfig } from "../config/config.js"; import type { GatewayBonjourBeacon } from "../infra/bonjour-discovery.js"; import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; -import type { RuntimeEnv } from "../runtime.js"; -import { detectBinary, guardCancel } from "./onboard-helpers.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { detectBinary } from "./onboard-helpers.js"; const DEFAULT_GATEWAY_URL = "ws://127.0.0.1:18789"; @@ -28,7 +26,7 @@ function ensureWsUrl(value: string): string { export async function promptRemoteGatewayConfig( cfg: ClawdisConfig, - runtime: RuntimeEnv, + prompter: WizardPrompter, ): Promise { let selectedBeacon: GatewayBonjourBeacon | null = null; let suggestedUrl = cfg.gateway?.remote?.url ?? DEFAULT_GATEWAY_URL; @@ -36,25 +34,21 @@ export async function promptRemoteGatewayConfig( const hasBonjourTool = (await detectBinary("dns-sd")) || (await detectBinary("avahi-browse")); const wantsDiscover = hasBonjourTool - ? guardCancel( - await confirm({ - message: "Discover gateway on LAN (Bonjour)?", - initialValue: true, - }), - runtime, - ) + ? await prompter.confirm({ + message: "Discover gateway on LAN (Bonjour)?", + initialValue: true, + }) : false; if (!hasBonjourTool) { - note( + await prompter.note( "Bonjour discovery requires dns-sd (macOS) or avahi-browse (Linux).", "Discovery", ); } if (wantsDiscover) { - const spin = spinner(); - spin.start("Searching for gateways…"); + const spin = prompter.progress("Searching for gateways…"); const beacons = await discoverGatewayBeacons({ timeoutMs: 2000 }); spin.stop( beacons.length > 0 @@ -63,19 +57,16 @@ export async function promptRemoteGatewayConfig( ); if (beacons.length > 0) { - const selection = guardCancel( - await select({ - message: "Select gateway", - options: [ - ...beacons.map((beacon, index) => ({ - value: String(index), - label: buildLabel(beacon), - })), - { value: "manual", label: "Enter URL manually" }, - ], - }), - runtime, - ); + const selection = await prompter.select({ + message: "Select gateway", + options: [ + ...beacons.map((beacon, index) => ({ + value: String(index), + label: buildLabel(beacon), + })), + { value: "manual", label: "Enter URL manually" }, + ], + }); if (selection !== "manual") { const idx = Number.parseInt(String(selection), 10); selectedBeacon = Number.isFinite(idx) ? (beacons[idx] ?? null) : null; @@ -87,24 +78,21 @@ export async function promptRemoteGatewayConfig( const host = pickHost(selectedBeacon); const port = selectedBeacon.gatewayPort ?? 18789; if (host) { - const mode = guardCancel( - await select({ - message: "Connection method", - options: [ - { - value: "direct", - label: `Direct gateway WS (${host}:${port})`, - }, - { value: "ssh", label: "SSH tunnel (loopback)" }, - ], - }), - runtime, - ); + const mode = await prompter.select({ + message: "Connection method", + options: [ + { + value: "direct", + label: `Direct gateway WS (${host}:${port})`, + }, + { value: "ssh", label: "SSH tunnel (loopback)" }, + ], + }); if (mode === "direct") { suggestedUrl = `ws://${host}:${port}`; } else { suggestedUrl = DEFAULT_GATEWAY_URL; - note( + await prompter.note( [ "Start a tunnel before using the CLI:", `ssh -N -L 18789:127.0.0.1:18789 @${host}${ @@ -117,42 +105,33 @@ export async function promptRemoteGatewayConfig( } } - const urlInput = guardCancel( - await text({ - message: "Gateway WebSocket URL", - initialValue: suggestedUrl, - validate: (value) => - String(value).trim().startsWith("ws://") || - String(value).trim().startsWith("wss://") - ? undefined - : "URL must start with ws:// or wss://", - }), - runtime, - ); + const urlInput = await prompter.text({ + message: "Gateway WebSocket URL", + initialValue: suggestedUrl, + validate: (value) => + String(value).trim().startsWith("ws://") || + String(value).trim().startsWith("wss://") + ? undefined + : "URL must start with ws:// or wss://", + }); const url = ensureWsUrl(String(urlInput)); - const authChoice = guardCancel( - await select({ - message: "Gateway auth", - options: [ - { value: "token", label: "Token (recommended)" }, - { value: "off", label: "No auth" }, - ], - }), - runtime, - ) as "token" | "off"; + const authChoice = (await prompter.select({ + message: "Gateway auth", + options: [ + { value: "token", label: "Token (recommended)" }, + { value: "off", label: "No auth" }, + ], + })) as "token" | "off"; let token = cfg.gateway?.remote?.token ?? ""; if (authChoice === "token") { token = String( - guardCancel( - await text({ - message: "Gateway token", - initialValue: token, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: "Gateway token", + initialValue: token, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ).trim(); } else { token = ""; diff --git a/src/commands/onboard-skills.ts b/src/commands/onboard-skills.ts index 5365625c1..7b52fc069 100644 --- a/src/commands/onboard-skills.ts +++ b/src/commands/onboard-skills.ts @@ -1,17 +1,9 @@ -import { - confirm, - multiselect, - note, - select, - spinner, - text, -} from "@clack/prompts"; - import { installSkill } from "../agents/skills-install.js"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdisConfig } from "../config/config.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 { const cleaned = message @@ -58,6 +50,7 @@ export async function setupSkills( cfg: ClawdisConfig, workspaceDir: string, runtime: RuntimeEnv, + prompter: WizardPrompter, ): Promise { const report = buildWorkspaceSkillStatus(workspaceDir, { config: cfg }); const eligible = report.skills.filter((s) => s.eligible); @@ -66,7 +59,7 @@ export async function setupSkills( ); const blocked = report.skills.filter((s) => s.blockedByAllowlist); - note( + await prompter.note( [ `Eligible: ${eligible.length}`, `Missing requirements: ${missing.length}`, @@ -75,22 +68,16 @@ export async function setupSkills( "Skills status", ); - const shouldConfigure = guardCancel( - await confirm({ - message: "Configure skills now? (recommended)", - initialValue: true, - }), - runtime, - ); + const shouldConfigure = await prompter.confirm({ + message: "Configure skills now? (recommended)", + initialValue: true, + }); if (!shouldConfigure) return cfg; - const nodeManager = guardCancel( - await select({ - message: "Preferred node manager for skill installs", - options: resolveNodeManagerOptions(), - }), - runtime, - ) as "npm" | "pnpm" | "bun"; + const nodeManager = (await prompter.select({ + message: "Preferred node manager for skill installs", + options: resolveNodeManagerOptions(), + })) as "npm" | "pnpm" | "bun"; let next: ClawdisConfig = { ...cfg, @@ -107,24 +94,21 @@ export async function setupSkills( (skill) => skill.install.length > 0 && skill.missing.bins.length > 0, ); if (installable.length > 0) { - const toInstall = guardCancel( - await multiselect({ - message: "Install missing skill dependencies", - options: [ - { - value: "__skip__", - label: "Skip for now", - hint: "Continue without installing dependencies", - }, - ...installable.map((skill) => ({ - value: skill.name, - label: `${skill.emoji ?? "🧩"} ${skill.name}`, - hint: formatSkillHint(skill), - })), - ], - }), - runtime, - ); + const toInstall = await prompter.multiselect({ + message: "Install missing skill dependencies", + options: [ + { + value: "__skip__", + label: "Skip for now", + hint: "Continue without installing dependencies", + }, + ...installable.map((skill) => ({ + value: skill.name, + label: `${skill.emoji ?? "🧩"} ${skill.name}`, + hint: formatSkillHint(skill), + })), + ], + }); const selected = (toInstall as string[]).filter( (name) => name !== "__skip__", @@ -134,8 +118,7 @@ export async function setupSkills( if (!target || target.install.length === 0) continue; const installId = target.install[0]?.id; if (!installId) continue; - const spin = spinner(); - spin.start(`Installing ${name}…`); + const spin = prompter.progress(`Installing ${name}…`); const result = await installSkill({ workspaceDir, skillName: target.name, @@ -161,22 +144,16 @@ export async function setupSkills( for (const skill of missing) { if (!skill.primaryEnv || skill.missing.env.length === 0) continue; - const wantsKey = guardCancel( - await confirm({ - message: `Set ${skill.primaryEnv} for ${skill.name}?`, - initialValue: false, - }), - runtime, - ); + const wantsKey = await prompter.confirm({ + message: `Set ${skill.primaryEnv} for ${skill.name}?`, + initialValue: false, + }); if (!wantsKey) continue; const apiKey = String( - guardCancel( - await text({ - message: `Enter ${skill.primaryEnv}`, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ), + await prompter.text({ + message: `Enter ${skill.primaryEnv}`, + validate: (value) => (value?.trim() ? undefined : "Required"), + }), ); next = upsertSkillEntry(next, skill.skillKey, { apiKey: apiKey.trim() }); } diff --git a/src/config/config.ts b/src/config/config.ts index 8a774ae4a..0d9080a4d 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -573,6 +573,12 @@ export type ClawdisConfig = { * - "message_end": end of the whole assistant message (may include tool blocks) */ 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; /** Max inbound media size in MB for agent-visible attachments (text note or future image attach). */ mediaMaxMb?: number; @@ -900,7 +906,7 @@ const HooksGmailSchema = z }) .optional(); -const ClawdisSchema = z.object({ +export const ClawdisSchema = z.object({ identity: z .object({ name: z.string().optional(), @@ -990,6 +996,19 @@ const ClawdisSchema = z.object({ blockStreamingBreak: z .union([z.literal("text_end"), z.literal("message_end")]) .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(), mediaMaxMb: z.number().positive().optional(), typingIntervalSeconds: z.number().int().positive().optional(), diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts new file mode 100644 index 000000000..c5de85aed --- /dev/null +++ b/src/config/schema.test.ts @@ -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 }; + 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(); + }); +}); diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 000000000..1407da3f8 --- /dev/null +++ b/src/config/schema.ts @@ -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; + +export type ConfigSchema = ReturnType; + +export type ConfigSchemaResponse = { + schema: ConfigSchema; + uiHints: ConfigUiHints; + version: string; + generatedAt: string; +}; + +const GROUP_LABELS: Record = { + 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 = { + 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 = { + "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 = { + "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 = { + "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; +} diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 6ae6a67d7..1740b497c 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -146,11 +146,8 @@ export class GatewayClient { const pending = this.pending.get(parsed.id); if (!pending) return; // If the payload is an ack with status accepted, keep waiting for final. - const payload = parsed.payload; - const status = - payload && typeof payload === "object" && "status" in payload - ? (payload as { status?: unknown }).status - : undefined; + const payload = parsed.payload as { status?: unknown } | undefined; + const status = payload?.status; if (pending.expectFinal && status === "accepted") { return; } diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 02c7306b4..aed0e6f39 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -11,6 +11,10 @@ import { ChatSendParamsSchema, type ConfigGetParams, ConfigGetParamsSchema, + type ConfigSchemaParams, + ConfigSchemaParamsSchema, + type ConfigSchemaResponse, + ConfigSchemaResponseSchema, type ConfigSetParams, ConfigSetParamsSchema, type ConnectParams, @@ -105,6 +109,22 @@ import { WebLoginStartParamsSchema, type WebLoginWaitParams, 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"; const ajv = new ( @@ -174,6 +194,21 @@ export const validateConfigGetParams = ajv.compile( export const validateConfigSetParams = ajv.compile( ConfigSetParamsSchema, ); +export const validateConfigSchemaParams = ajv.compile( + ConfigSchemaParamsSchema, +); +export const validateWizardStartParams = ajv.compile( + WizardStartParamsSchema, +); +export const validateWizardNextParams = ajv.compile( + WizardNextParamsSchema, +); +export const validateWizardCancelParams = ajv.compile( + WizardCancelParamsSchema, +); +export const validateWizardStatusParams = ajv.compile( + WizardStatusParamsSchema, +); export const validateTalkModeParams = ajv.compile(TalkModeParamsSchema); export const validateProvidersStatusParams = ajv.compile( @@ -258,6 +293,16 @@ export { SessionsCompactParamsSchema, ConfigGetParamsSchema, ConfigSetParamsSchema, + ConfigSchemaParamsSchema, + ConfigSchemaResponseSchema, + WizardStartParamsSchema, + WizardNextParamsSchema, + WizardCancelParamsSchema, + WizardStatusParamsSchema, + WizardStepSchema, + WizardNextResultSchema, + WizardStartResultSchema, + WizardStatusResultSchema, ProvidersStatusParamsSchema, WebLoginStartParamsSchema, WebLoginWaitParamsSchema, @@ -304,6 +349,16 @@ export type { NodePairApproveParams, ConfigGetParams, ConfigSetParams, + ConfigSchemaParams, + ConfigSchemaResponse, + WizardStartParams, + WizardNextParams, + WizardCancelParams, + WizardStatusParams, + WizardStep, + WizardNextResult, + WizardStartResult, + WizardStatusResult, TalkModeParams, ProvidersStatusParams, WebLoginStartParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 5f92796f1..2db637eef 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -342,6 +342,157 @@ export const ConfigSetParamsSchema = Type.Object( { 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( { enabled: Type.Boolean(), @@ -680,6 +831,16 @@ export const ProtocolSchemas: Record = { SessionsCompactParams: SessionsCompactParamsSchema, ConfigGetParams: ConfigGetParamsSchema, ConfigSetParams: ConfigSetParamsSchema, + ConfigSchemaParams: ConfigSchemaParamsSchema, + ConfigSchemaResponse: ConfigSchemaResponseSchema, + WizardStartParams: WizardStartParamsSchema, + WizardNextParams: WizardNextParamsSchema, + WizardCancelParams: WizardCancelParamsSchema, + WizardStatusParams: WizardStatusParamsSchema, + WizardStep: WizardStepSchema, + WizardNextResult: WizardNextResultSchema, + WizardStartResult: WizardStartResultSchema, + WizardStatusResult: WizardStatusResultSchema, TalkModeParams: TalkModeParamsSchema, ProvidersStatusParams: ProvidersStatusParamsSchema, WebLoginStartParams: WebLoginStartParamsSchema, @@ -737,6 +898,16 @@ export type SessionsDeleteParams = Static; export type SessionsCompactParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; +export type ConfigSchemaParams = Static; +export type ConfigSchemaResponse = Static; +export type WizardStartParams = Static; +export type WizardNextParams = Static; +export type WizardCancelParams = Static; +export type WizardStatusParams = Static; +export type WizardStep = Static; +export type WizardNextResult = Static; +export type WizardStartResult = Static; +export type WizardStatusResult = Static; export type TalkModeParams = Static; export type ProvidersStatusParams = Static; export type WebLoginStartParams = Static; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 3f2d35bd4..a6ad437d3 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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( + "../config/config.js", + ); const resolveConfigPath = () => path.join(os.homedir(), ".clawdis", "clawdis.json"); @@ -222,6 +225,7 @@ vi.mock("../config/config.js", () => { }); return { + ...actual, CONFIG_PATH_CLAWDIS: resolveConfigPath(), STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()), get isNixMode() { @@ -381,7 +385,10 @@ function onceMessage( }); } -async function startServerWithClient(token?: string) { +async function startServerWithClient( + token?: string, + opts?: Parameters[1], +) { const port = await getFreePort(); const prev = process.env.CLAWDIS_GATEWAY_TOKEN; if (token === undefined) { @@ -389,7 +396,7 @@ async function startServerWithClient(token?: string) { } else { 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}`); await new Promise((resolve) => ws.once("open", resolve)); 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 () => { const prevToken = process.env.TELEGRAM_BOT_TOKEN; delete process.env.TELEGRAM_BOT_TOKEN; diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 8cacfd33b..864fc8573 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -62,6 +62,7 @@ import { validateConfigObject, writeConfigFile, } from "../config/config.js"; +import { buildConfigSchema } from "../config/schema.js"; import { buildGroupDisplayName, loadSessionStore, @@ -170,6 +171,8 @@ import type { WebProviderStatus } from "../web/auto-reply.js"; import { startWebLoginWithQr, waitForWebLogin } from "../web/login-qr.js"; import { sendMessageWhatsApp } from "../web/outbound.js"; import { getWebAuthAgeMs, logoutWeb, readWebSelfId } from "../web/session.js"; +import { runOnboardingWizard } from "../wizard/onboarding.js"; +import { WizardSession } from "../wizard/session.js"; import { assertGatewayAuthConfigured, authorizeGatewayConnect, @@ -392,6 +395,7 @@ import { validateChatHistoryParams, validateChatSendParams, validateConfigGetParams, + validateConfigSchemaParams, validateConfigSetParams, validateConnectParams, validateCronAddParams, @@ -426,6 +430,10 @@ import { validateWakeParams, validateWebLoginStartParams, validateWebLoginWaitParams, + validateWizardCancelParams, + validateWizardNextParams, + validateWizardStartParams, + validateWizardStatusParams, } from "./protocol/index.js"; import { DEFAULT_WS_SLOW_MS, getGatewayWsLogStyle } from "./ws-logging.js"; @@ -504,6 +512,11 @@ const METHODS = [ "status", "config.get", "config.set", + "config.schema", + "wizard.start", + "wizard.next", + "wizard.cancel", + "wizard.status", "talk.mode", "models.list", "skills.status", @@ -602,6 +615,14 @@ export type GatewayServerOptions = { * Test-only: allow canvas host startup even when NODE_ENV/VITEST would disable it. */ 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; }; function isLoopbackAddress(ip: string | undefined): boolean { @@ -1432,6 +1453,23 @@ export async function startGatewayServer( ); } + const wizardRunner = opts.wizardRunner ?? runOnboardingWizard; + const wizardSessions = new Map(); + + 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 headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { @@ -2801,6 +2839,20 @@ export async function startGatewayServer( const snapshot = await readConfigFileSnapshot(); 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": { const params = parseParams(); if (!validateConfigSetParams(params)) { @@ -5306,6 +5358,23 @@ export async function startGatewayServer( respond(true, snapshot, undefined); break; } + case "config.schema": { + const params = (req.params ?? {}) as Record; + 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": { const params = (req.params ?? {}) as Record; if (!validateConfigSetParams(params)) { @@ -5363,6 +5432,171 @@ export async function startGatewayServer( ); break; } + case "wizard.start": { + const params = (req.params ?? {}) as Record; + 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; + 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; + 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; + 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": { if ( client && diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts new file mode 100644 index 000000000..2619d8f10 --- /dev/null +++ b/src/wizard/clack-prompter.ts @@ -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(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), + }; + }, + }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts new file mode 100644 index 000000000..eb49a3853 --- /dev/null +++ b/src/wizard/onboarding.ts @@ -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 = { + 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."); +} diff --git a/src/wizard/prompts.ts b/src/wizard/prompts.ts new file mode 100644 index 000000000..7aa496b76 --- /dev/null +++ b/src/wizard/prompts.ts @@ -0,0 +1,52 @@ +export type WizardSelectOption = { + value: T; + label: string; + hint?: string; +}; + +export type WizardSelectParams = { + message: string; + options: Array>; + initialValue?: T; +}; + +export type WizardMultiSelectParams = { + message: string; + options: Array>; + 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; + outro: (message: string) => Promise; + note: (message: string, title?: string) => Promise; + select: (params: WizardSelectParams) => Promise; + multiselect: (params: WizardMultiSelectParams) => Promise; + text: (params: WizardTextParams) => Promise; + confirm: (params: WizardConfirmParams) => Promise; + progress: (label: string) => WizardProgress; +}; + +export class WizardCancelledError extends Error { + constructor(message = "wizard cancelled") { + super(message); + this.name = "WizardCancelledError"; + } +} diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts new file mode 100644 index 000000000..fd0ab7d32 --- /dev/null +++ b/src/wizard/session.test.ts @@ -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"); + }); +}); diff --git a/src/wizard/session.ts b/src/wizard/session.ts new file mode 100644 index 000000000..48d5724e7 --- /dev/null +++ b/src/wizard/session.ts @@ -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 = { + promise: Promise; + resolve: (value: T) => void; + reject: (err: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (err: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +class WizardSessionPrompter implements WizardPrompter { + constructor(private session: WizardSession) {} + + async intro(title: string): Promise { + await this.prompt({ + type: "note", + title, + message: "", + executor: "client", + }); + } + + async outro(message: string): Promise { + await this.prompt({ + type: "note", + title: "Done", + message, + executor: "client", + }); + } + + async note(message: string, title?: string): Promise { + await this.prompt({ type: "note", title, message, executor: "client" }); + } + + async select(params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValue?: T; + }): Promise { + 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(params: { + message: string; + options: Array<{ value: T; label: string; hint?: string }>; + initialValues?: T[]; + }): Promise { + 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 { + 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 { + 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): Promise { + return await this.session.awaitAnswer({ + ...step, + id: randomUUID(), + }); + } +} + +export class WizardSession { + private currentStep: WizardStep | null = null; + private stepDeferred: Deferred | null = null; + private answerDeferred = new Map>(); + private status: WizardSessionStatus = "running"; + private error: string | undefined; + + constructor(private runner: (prompter: WizardPrompter) => Promise) { + const prompter = new WizardSessionPrompter(this); + void this.run(prompter); + } + + async next(): Promise { + 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 { + 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 { + if (this.status !== "running") { + throw new Error("wizard: session not running"); + } + this.pushStep(step); + const deferred = createDeferred(); + 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; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index a265f5f90..eb2abc67e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -58,7 +58,7 @@ import { } from "./controllers/skills"; import { loadNodes } from "./controllers/nodes"; 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 { loadDebug, callDebugMethod } from "./controllers/debug"; @@ -95,6 +95,11 @@ export type AppViewState = { configIssues: unknown[]; configSaving: boolean; configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaLoading: boolean; + configUiHints: Record; + configForm: Record | null; + configFormMode: "form" | "raw"; providersLoading: boolean; providersSnapshot: ProvidersStatusSnapshot | null; providersError: string | null; @@ -392,7 +397,14 @@ export function renderApp(state: AppViewState) { loading: state.configLoading, saving: state.configSaving, connected: state.connected, + schema: state.configSchema, + schemaLoading: state.configSchemaLoading, + uiHints: state.configUiHints, + formMode: state.configFormMode, + formValue: state.configForm, onRawChange: (next) => (state.configRaw = next), + onFormModeChange: (mode) => (state.configFormMode = mode), + onFormPatch: (path, value) => updateConfigFormValue(state, path, value), onReload: () => loadConfig(state), onSave: () => saveConfig(state), }) diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f589aa906..9a2817a67 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -16,6 +16,7 @@ import { } from "./theme-transition"; import type { ConfigSnapshot, + ConfigUiHints, CronJob, CronRunLogEntry, CronStatus, @@ -41,7 +42,11 @@ import { type ChatEventPayload, } from "./controllers/chat"; import { loadNodes } from "./controllers/nodes"; -import { loadConfig } from "./controllers/config"; +import { + loadConfig, + loadConfigSchema, + updateConfigFormValue, +} from "./controllers/config"; import { loadProviders, logoutWhatsApp, @@ -120,6 +125,13 @@ export class ClawdisApp extends LitElement { @state() configIssues: unknown[] = []; @state() configSaving = false; @state() configSnapshot: ConfigSnapshot | null = null; + @state() configSchema: unknown | null = null; + @state() configSchemaVersion: string | null = null; + @state() configSchemaLoading = false; + @state() configUiHints: ConfigUiHints = {}; + @state() configForm: Record | null = null; + @state() configFormDirty = false; + @state() configFormMode: "form" | "raw" = "form"; @state() providersLoading = false; @state() providersSnapshot: ProvidersStatusSnapshot | null = null; @@ -447,7 +459,10 @@ export class ClawdisApp extends LitElement { await Promise.all([loadChatHistory(this), loadSessions(this)]); 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); } diff --git a/ui/src/ui/config-form.browser.test.ts b/ui/src/ui/config-form.browser.test.ts new file mode 100644 index 000000000..9ade22641 --- /dev/null +++ b/ui/src/ui/config-form.browser.test.ts @@ -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"], []); + }); +}); diff --git a/ui/src/ui/controllers/config.ts b/ui/src/ui/controllers/config.ts index e1fc9811b..a18f60feb 100644 --- a/ui/src/ui/controllers/config.ts +++ b/ui/src/ui/controllers/config.ts @@ -1,5 +1,9 @@ import type { GatewayBrowserClient } from "../gateway"; -import type { ConfigSnapshot } from "../types"; +import type { + ConfigSchemaResponse, + ConfigSnapshot, + ConfigUiHints, +} from "../types"; import { defaultDiscordActions, type DiscordActionForm, @@ -20,6 +24,13 @@ export type ConfigState = { configIssues: unknown[]; configSaving: boolean; configSnapshot: ConfigSnapshot | null; + configSchema: unknown | null; + configSchemaVersion: string | null; + configSchemaLoading: boolean; + configUiHints: ConfigUiHints; + configForm: Record | null; + configFormDirty: boolean; + configFormMode: "form" | "raw"; lastError: string | null; telegramForm: TelegramForm; 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) { state.configSnapshot = snapshot; if (typeof snapshot.raw === "string") { @@ -239,6 +276,10 @@ export function applyConfigSnapshot(state: ConfigState, snapshot: ConfigSnapshot state.discordConfigStatus = configInvalid; state.signalConfigStatus = configInvalid; state.imessageConfigStatus = configInvalid; + + if (!state.configFormDirty) { + state.configForm = cloneConfigObject(snapshot.config ?? {}); + } } export async function saveConfig(state: ConfigState) { @@ -246,7 +287,12 @@ export async function saveConfig(state: ConfigState) { state.configSaving = true; state.lastError = null; 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); } catch (err) { state.lastError = String(err); @@ -254,3 +300,101 @@ export async function saveConfig(state: ConfigState) { state.configSaving = false; } } + +export function updateConfigFormValue( + state: ConfigState, + path: Array, + value: unknown, +) { + const base = cloneConfigObject(state.configForm ?? {}); + setPathValue(base, path, value); + state.configForm = base; + state.configFormDirty = true; +} + +export function removeConfigFormValue( + state: ConfigState, + path: Array, +) { + const base = cloneConfigObject(state.configForm ?? {}); + removePathValue(base, path); + state.configForm = base; + state.configFormDirty = true; +} + +function cloneConfigObject(value: T): T { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +function setPathValue( + obj: Record | unknown[], + path: Array, + value: unknown, +) { + if (path.length === 0) return; + let current: Record | 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); + } + current = current[key] as Record | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + const record = current as Record; + if (record[key] == null) { + record[key] = + typeof nextKey === "number" ? [] : ({} as Record); + } + current = record[key] as Record | 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)[lastKey] = value; + } +} + +function removePathValue( + obj: Record | unknown[], + path: Array, +) { + if (path.length === 0) return; + let current: Record | 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 | unknown[]; + } else { + if (typeof current !== "object" || current == null) return; + current = (current as Record)[key] as + | Record + | 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)[lastKey]; + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index dd7fac7f2..3091d5496 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -140,6 +140,26 @@ export type ConfigSnapshot = { 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; + +export type ConfigSchemaResponse = { + schema: unknown; + uiHints: ConfigUiHints; + version: string; + generatedAt: string; +}; + export type PresenceEntry = { instanceId?: string | null; host?: string | null; diff --git a/ui/src/ui/views/config-form.ts b/ui/src/ui/views/config-form.ts new file mode 100644 index 000000000..29bb0af99 --- /dev/null +++ b/ui/src/ui/views/config-form.ts @@ -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 | null; + onPatch: (path: Array, value: unknown) => void; +}; + +type JsonSchema = { + type?: string | string[]; + title?: string; + description?: string; + properties?: Record; + items?: JsonSchema | JsonSchema[]; + enum?: unknown[]; + default?: unknown; + anyOf?: JsonSchema[]; + oneOf?: JsonSchema[]; + allOf?: JsonSchema[]; +}; + +export function renderConfigForm(props: ConfigFormProps) { + if (!props.schema) { + return html`
Schema unavailable.
`; + } + const schema = props.schema as JsonSchema; + const value = props.value ?? {}; + if (schemaType(schema) !== "object" || !schema.properties) { + return html`
Unsupported schema. Use Raw.
`; + } + 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` +
+ ${sorted.map(([key, node]) => + renderNode({ + schema: node, + value: (value as Record)[key], + path: [key], + hints: props.uiHints, + onPatch: props.onPatch, + }), + )} +
+ `; +} + +function renderNode(params: { + schema: JsonSchema; + value: unknown; + path: Array; + hints: ConfigUiHints; + onPatch: (path: Array, 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`
+ ${label}: unsupported schema node. Use Raw. +
`; + } + + if (type === "object") { + const props = schema.properties ?? {}; + const entries = Object.entries(props); + if (entries.length === 0) return nothing; + return html` +
+ ${label} + ${help ? html`
${help}
` : nothing} + ${entries.map(([key, node]) => + renderNode({ + schema: node, + value: value && typeof value === "object" ? (value as any)[key] : undefined, + path: [...path, key], + hints, + onPatch, + }), + )} +
+ `; + } + + if (type === "array") { + const itemSchema = Array.isArray(schema.items) + ? schema.items[0] + : schema.items; + const arr = Array.isArray(value) ? value : []; + return html` +
+
+ ${label} + +
+ ${help ? html`
${help}
` : nothing} + ${arr.map((entry, index) => + html`
+ ${itemSchema + ? renderNode({ + schema: itemSchema, + value: entry, + path: [...path, index], + hints, + onPatch, + }) + : nothing} + +
`, + )} +
+ `; + } + + if (schema.enum) { + return html` + + `; + } + + if (type === "boolean") { + return html` + + `; + } + + if (type === "number" || type === "integer") { + return html` + + `; + } + + if (type === "string") { + const isSensitive = hint?.sensitive ?? isSensitivePath(path); + const placeholder = hint?.placeholder ?? (isSensitive ? "••••" : ""); + return html` + + `; + } + + return html`
+ ${label} +
Unsupported type. Use Raw.
+
`; +} + +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, hints: ConfigUiHints) { + const key = pathKey(path); + return hints[key]; +} + +function pathKey(path: Array): 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): boolean { + const key = pathKey(path).toLowerCase(); + return ( + key.includes("token") || + key.includes("password") || + key.includes("secret") || + key.includes("apikey") || + key.endsWith("key") + ); +} diff --git a/ui/src/ui/views/config.ts b/ui/src/ui/views/config.ts index 9c72ee81c..389d41b0e 100644 --- a/ui/src/ui/views/config.ts +++ b/ui/src/ui/views/config.ts @@ -1,4 +1,6 @@ import { html, nothing } from "lit"; +import type { ConfigUiHints } from "../types"; +import { renderConfigForm } from "./config-form"; export type ConfigProps = { raw: string; @@ -7,7 +9,14 @@ export type ConfigProps = { loading: boolean; saving: boolean; connected: boolean; + schema: unknown | null; + schemaLoading: boolean; + uiHints: ConfigUiHints; + formMode: "form" | "raw"; + formValue: Record | null; onRawChange: (next: string) => void; + onFormModeChange: (mode: "form" | "raw") => void; + onFormPatch: (path: Array, value: unknown) => void; onReload: () => void; onSave: () => void; }; @@ -23,6 +32,21 @@ export function renderConfig(props: ConfigProps) { ${validity}
+
+ + +
@@ -41,14 +65,25 @@ export function renderConfig(props: ConfigProps) { require a gateway restart.
- + ${props.formMode === "form" + ? html`
+ ${props.schemaLoading + ? html`
Loading schema…
` + : renderConfigForm({ + schema: props.schema, + uiHints: props.uiHints, + value: props.formValue, + onPatch: props.onFormPatch, + })} +
` + : html``} ${props.issues.length > 0 ? html`
@@ -58,4 +93,3 @@ export function renderConfig(props: ConfigProps) { `; } -