Compare commits
2 Commits
fix/node-i
...
fix/fallba
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c57c4a72d | ||
|
|
e56e0d4ee7 |
@@ -6,7 +6,6 @@ Docs: https://docs.clawd.bot
|
||||
|
||||
### Changes
|
||||
- Deps: update workspace + memory-lancedb dependencies.
|
||||
- Dev: use tsgo for dev/watch builds by default; set `CLAWDBOT_TS_COMPILER=tsc` to opt out.
|
||||
- Repo: remove the Peekaboo git submodule now that the SPM release is used.
|
||||
- Update: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`.
|
||||
- Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`.
|
||||
@@ -23,8 +22,8 @@ Docs: https://docs.clawd.bot
|
||||
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
|
||||
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
|
||||
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
|
||||
- TUI: add input history (up/down) for submitted messages. (#1348) — thanks @vignesh07.
|
||||
### Fixes
|
||||
- Tests: cover auth profile scoping when model fallback switches providers. (#1350) — thanks @Jackten.
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
||||
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||
- Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs.
|
||||
@@ -32,7 +31,6 @@ Docs: https://docs.clawd.bot
|
||||
- Diagnostics: emit message-flow diagnostics across channels via shared dispatch; gate heartbeat/webhook logging. (#1244) — thanks @oscargavin.
|
||||
- CLI: preserve cron delivery settings when editing message payloads. (#1322) — thanks @KrauseFx.
|
||||
- CLI: keep `clawdbot logs` output resilient to broken pipes while preserving progress output.
|
||||
- Nodes: enforce node.invoke timeouts for node handlers. (#1357) — thanks @vignesh07.
|
||||
- Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332) — thanks @dougvk.
|
||||
- Doctor: clarify plugin auto-enable hint text in the startup banner.
|
||||
- Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.
|
||||
|
||||
@@ -87,7 +87,15 @@ final class ControlChannel {
|
||||
|
||||
func configure() async {
|
||||
self.logger.info("control channel configure mode=local")
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func configure(mode: Mode = .local) async throws {
|
||||
@@ -103,7 +111,7 @@ final class ControlChannel {
|
||||
"target=\(target, privacy: .public) identitySet=\(idSet, privacy: .public)")
|
||||
self.state = .connecting
|
||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||
await self.refreshEndpoint(reason: "configure")
|
||||
await self.configure()
|
||||
} catch {
|
||||
self.state = .degraded(error.localizedDescription)
|
||||
throw error
|
||||
@@ -111,19 +119,6 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
func refreshEndpoint(reason: String) async {
|
||||
self.logger.info("control channel refresh endpoint reason=\(reason, privacy: .public)")
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.establishGatewayConnection()
|
||||
self.state = .connected
|
||||
PresenceReporter.shared.sendImmediate(reason: "connect")
|
||||
} catch {
|
||||
let message = self.friendlyGatewayMessage(error)
|
||||
self.state = .degraded(message)
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await GatewayConnection.shared.shutdown()
|
||||
self.state = .disconnected
|
||||
@@ -280,28 +275,18 @@ final class ControlChannel {
|
||||
}
|
||||
}
|
||||
|
||||
await self.refreshEndpoint(reason: "recovery:\(reasonText)")
|
||||
if case .connected = self.state {
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
self.logger.info("control channel recovery finished")
|
||||
} else if case let .degraded(message) = self.state {
|
||||
self.logger.error("control channel recovery failed \(message, privacy: .public)")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"control channel recovery failed \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
self.recoveryTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func establishGatewayConnection(timeoutMs: Int = 5000) async throws {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
let ok = try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs)
|
||||
if ok == false {
|
||||
throw NSError(
|
||||
domain: "Gateway",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "gateway health not ok"])
|
||||
}
|
||||
}
|
||||
|
||||
func sendSystemEvent(_ text: String, params: [String: AnyHashable] = [:]) async throws {
|
||||
var merged = params
|
||||
merged["text"] = AnyHashable(text)
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class GatewayConnectivityCoordinator {
|
||||
static let shared = GatewayConnectivityCoordinator()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway.connectivity")
|
||||
private var endpointTask: Task<Void, Never>?
|
||||
private var lastResolvedURL: URL?
|
||||
|
||||
private(set) var endpointState: GatewayEndpointState?
|
||||
private(set) var resolvedURL: URL?
|
||||
private(set) var resolvedMode: AppState.ConnectionMode?
|
||||
private(set) var resolvedHostLabel: String?
|
||||
|
||||
private init() {
|
||||
self.start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard self.endpointTask == nil else { return }
|
||||
self.endpointTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let stream = await GatewayEndpointStore.shared.subscribe()
|
||||
for await state in stream {
|
||||
await MainActor.run { self.handleEndpointState(state) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var localEndpointHostLabel: String? {
|
||||
guard self.resolvedMode == .local, let url = self.resolvedURL else { return nil }
|
||||
return Self.hostLabel(for: url)
|
||||
}
|
||||
|
||||
private func handleEndpointState(_ state: GatewayEndpointState) {
|
||||
self.endpointState = state
|
||||
switch state {
|
||||
case let .ready(mode, url, _, _):
|
||||
self.resolvedMode = mode
|
||||
self.resolvedURL = url
|
||||
self.resolvedHostLabel = Self.hostLabel(for: url)
|
||||
let urlChanged = self.lastResolvedURL?.absoluteString != url.absoluteString
|
||||
if urlChanged {
|
||||
self.lastResolvedURL = url
|
||||
Task { await ControlChannel.shared.refreshEndpoint(reason: "endpoint changed") }
|
||||
}
|
||||
case let .connecting(mode, _):
|
||||
self.resolvedMode = mode
|
||||
case let .unavailable(mode, _):
|
||||
self.resolvedMode = mode
|
||||
}
|
||||
}
|
||||
|
||||
private static func hostLabel(for url: URL) -> String {
|
||||
let host = url.host ?? url.absoluteString
|
||||
if let port = url.port { return "\(host):\(port)" }
|
||||
return host
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,6 @@ actor GatewayEndpointStore {
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
@@ -488,7 +487,6 @@ actor GatewayEndpointStore {
|
||||
guard currentHost == "127.0.0.1" || currentHost == "localhost" else { return nil }
|
||||
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
?? TailscaleService.fallbackTailnetIPv4()
|
||||
guard let tailscaleIP, !tailscaleIP.isEmpty else { return nil }
|
||||
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
|
||||
@@ -235,8 +235,8 @@ final class HealthStore {
|
||||
let lower = error.lowercased()
|
||||
if lower.contains("connection refused") {
|
||||
let port = GatewayEnvironment.gatewayPort()
|
||||
let host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
return "The gateway control port (\(host)) isn’t listening — restart Clawdbot to bring it back."
|
||||
return "The gateway control port (127.0.0.1:\(port)) isn’t listening — " +
|
||||
"restart Clawdbot to bring it back."
|
||||
}
|
||||
if lower.contains("timeout") {
|
||||
return "Timed out waiting for the control server; the gateway may be crashed or still starting."
|
||||
|
||||
@@ -13,7 +13,6 @@ struct ClawdbotApp: App {
|
||||
private let gatewayManager = GatewayProcessManager.shared
|
||||
private let controlChannel = ControlChannel.shared
|
||||
private let activityStore = WorkActivityStore.shared
|
||||
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
|
||||
@State private var statusItem: NSStatusItem?
|
||||
@State private var isMenuPresented = false
|
||||
@State private var isPanelVisible = false
|
||||
|
||||
@@ -469,7 +469,7 @@ extension MenuSessionsInjector {
|
||||
}
|
||||
case .local:
|
||||
platform = "local"
|
||||
host = GatewayConnectivityCoordinator.shared.localEndpointHostLabel ?? "127.0.0.1:\(port)"
|
||||
host = "127.0.0.1:\(port)"
|
||||
case .unconfigured:
|
||||
platform = nil
|
||||
host = nil
|
||||
|
||||
@@ -103,7 +103,6 @@ final class TailscaleService {
|
||||
}
|
||||
|
||||
func checkTailscaleStatus() async {
|
||||
let previousIP = self.tailscaleIP
|
||||
self.isInstalled = self.checkAppInstallation()
|
||||
if !self.isInstalled {
|
||||
self.isRunning = false
|
||||
@@ -148,10 +147,6 @@ final class TailscaleService {
|
||||
self.statusError = nil
|
||||
self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)")
|
||||
}
|
||||
|
||||
if previousIP != self.tailscaleIP {
|
||||
await GatewayEndpointStore.shared.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
func openTailscaleApp() {
|
||||
@@ -219,8 +214,4 @@ final class TailscaleService {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated static func fallbackTailnetIPv4() -> String? {
|
||||
Self.detectTailnetIPv4()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,35 +11,6 @@ private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
// Ensures the timeout can win even if the invoke task never completes.
|
||||
private actor InvokeTimeoutRace {
|
||||
private var finished = false
|
||||
private let continuation: CheckedContinuation<BridgeInvokeResponse, Never>
|
||||
private var invokeTask: Task<Void, Never>?
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
init(continuation: CheckedContinuation<BridgeInvokeResponse, Never>) {
|
||||
self.continuation = continuation
|
||||
}
|
||||
|
||||
func registerTasks(invoke: Task<Void, Never>, timeout: Task<Void, Never>) {
|
||||
self.invokeTask = invoke
|
||||
self.timeoutTask = timeout
|
||||
if finished {
|
||||
invoke.cancel()
|
||||
timeout.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func finish(_ response: BridgeInvokeResponse) {
|
||||
guard !finished else { return }
|
||||
finished = true
|
||||
continuation.resume(returning: response)
|
||||
invokeTask?.cancel()
|
||||
timeoutTask?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
public actor GatewayNodeSession {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
@@ -52,45 +23,6 @@ public actor GatewayNodeSession {
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
|
||||
static func invokeWithTimeout(
|
||||
request: BridgeInvokeRequest,
|
||||
timeoutMs: Int?,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async -> BridgeInvokeResponse {
|
||||
let timeout = max(0, timeoutMs ?? 0)
|
||||
guard timeout > 0 else {
|
||||
return await onInvoke(request)
|
||||
}
|
||||
|
||||
let cappedTimeout = min(timeout, Int(UInt64.max / 1_000_000))
|
||||
let timeoutResponse = BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: ClawdbotNodeError(
|
||||
code: .unavailable,
|
||||
message: "node invoke timed out")
|
||||
)
|
||||
|
||||
return await withCheckedContinuation { continuation in
|
||||
let race = InvokeTimeoutRace(continuation: continuation)
|
||||
let invokeTask = Task {
|
||||
let response = await onInvoke(request)
|
||||
await race.finish(response)
|
||||
}
|
||||
let timeoutTask = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(cappedTimeout) * 1_000_000)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await race.finish(timeoutResponse)
|
||||
}
|
||||
Task {
|
||||
await race.registerTasks(invoke: invokeTask, timeout: timeoutTask)
|
||||
}
|
||||
}
|
||||
}
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<EventFrame>.Continuation] = [:]
|
||||
private var canvasHostUrl: String?
|
||||
|
||||
@@ -235,11 +167,7 @@ public actor GatewayNodeSession {
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await Self.invokeWithTimeout(
|
||||
request: req,
|
||||
timeoutMs: request.timeoutMs,
|
||||
onInvoke: onInvoke
|
||||
)
|
||||
let response = await onInvoke(req)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
@@ -252,10 +180,8 @@ public actor GatewayNodeSession {
|
||||
"id": AnyCodable(request.id),
|
||||
"nodeId": AnyCodable(request.nodeId),
|
||||
"ok": AnyCodable(response.ok),
|
||||
"payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()),
|
||||
]
|
||||
if let payloadJSON = response.payloadJSON {
|
||||
params["payloadJSON"] = AnyCodable(payloadJSON)
|
||||
}
|
||||
if let error = response.error {
|
||||
params["error"] = AnyCodable([
|
||||
"code": AnyCodable(error.code.rawValue),
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
|
||||
struct GatewayNodeSessionTests {
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsUnderlyingResponseBeforeTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 50,
|
||||
onInvoke: { req in
|
||||
#expect(req.id == "1")
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
#expect(response.payloadJSON == "{}")
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsTimeoutError() async {
|
||||
let request = BridgeInvokeRequest(id: "abc", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
try? await Task.sleep(nanoseconds: 200_000_000) // 200ms
|
||||
return BridgeInvokeResponse(id: "abc", ok: true, payloadJSON: "{}", error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == false)
|
||||
#expect(response.error?.code == .unavailable)
|
||||
#expect(response.error?.message.contains("timed out") == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutReturnsWhenHandlerNeverCompletes() async {
|
||||
let request = BridgeInvokeRequest(id: "stall", command: "x", paramsJSON: nil)
|
||||
let response = try? await AsyncTimeout.withTimeoutMs(
|
||||
timeoutMs: 200,
|
||||
onTimeout: { NSError(domain: "GatewayNodeSessionTests", code: 1) },
|
||||
operation: {
|
||||
await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 10,
|
||||
onInvoke: { _ in
|
||||
await withCheckedContinuation { _ in }
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response != nil)
|
||||
#expect(response?.ok == false)
|
||||
#expect(response?.error?.code == .unavailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
func invokeWithTimeoutZeroDisablesTimeout() async {
|
||||
let request = BridgeInvokeRequest(id: "1", command: "x", paramsJSON: nil)
|
||||
let response = await GatewayNodeSession.invokeWithTimeout(
|
||||
request: request,
|
||||
timeoutMs: 0,
|
||||
onInvoke: { req in
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: nil, error: nil)
|
||||
}
|
||||
)
|
||||
|
||||
#expect(response.ok == true)
|
||||
#expect(response.error == nil)
|
||||
}
|
||||
}
|
||||
@@ -825,9 +825,9 @@ Common options:
|
||||
- `--url`, `--token`, `--timeout`, `--json`
|
||||
|
||||
Subcommands:
|
||||
- `nodes status [--connected] [--last-connected <duration>]`
|
||||
- `nodes status`
|
||||
- `nodes describe --node <id|name|ip>`
|
||||
- `nodes list [--connected] [--last-connected <duration>]`
|
||||
- `nodes list`
|
||||
- `nodes pending`
|
||||
- `nodes approve <requestId>`
|
||||
- `nodes reject <requestId>`
|
||||
|
||||
@@ -18,18 +18,12 @@ Related:
|
||||
|
||||
```bash
|
||||
clawdbot nodes list
|
||||
clawdbot nodes list --connected
|
||||
clawdbot nodes list --last-connected 24h
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
clawdbot nodes status
|
||||
clawdbot nodes status --connected
|
||||
clawdbot nodes status --last-connected 24h
|
||||
```
|
||||
|
||||
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
|
||||
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||
|
||||
## Invoke / run
|
||||
|
||||
|
||||
@@ -34,81 +34,6 @@ clawdbot nodes rename --node <idOrNameOrIp> --name "Kitchen iPad"
|
||||
Notes:
|
||||
- `nodes rename` stores a display name override in the gateway pairing store.
|
||||
|
||||
## Remote node host (system.run)
|
||||
|
||||
Use a **node host** when your Gateway runs on one machine and you want commands
|
||||
to execute on another. The model still talks to the **gateway**; the gateway
|
||||
forwards `exec` calls to the **node host** when `host=node` is selected.
|
||||
|
||||
### What runs where
|
||||
- **Gateway host**: receives messages, runs the model, routes tool calls.
|
||||
- **Node host**: executes `system.run`/`system.which` on the node machine.
|
||||
- **Approvals**: enforced on the node host via `~/.clawdbot/exec-approvals.json`.
|
||||
|
||||
### Start a node host (foreground)
|
||||
|
||||
On the node machine:
|
||||
|
||||
```bash
|
||||
clawdbot node start --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
```
|
||||
|
||||
### Start a node host (service)
|
||||
|
||||
```bash
|
||||
clawdbot node service install --host <gateway-host> --port 18789 --display-name "Build Node"
|
||||
clawdbot node service start
|
||||
```
|
||||
|
||||
### Pair + name
|
||||
|
||||
On the gateway host:
|
||||
|
||||
```bash
|
||||
clawdbot nodes pending
|
||||
clawdbot nodes approve <requestId>
|
||||
clawdbot nodes list
|
||||
```
|
||||
|
||||
Naming options:
|
||||
- `--display-name` on `clawdbot node start/service install` (persists in `~/.clawdbot/node.json` on the node).
|
||||
- `clawdbot nodes rename --node <id|name|ip> --name "Build Node"` (gateway override).
|
||||
|
||||
### Allowlist the commands
|
||||
|
||||
Exec approvals are **per node host**. Add allowlist entries from the gateway:
|
||||
|
||||
```bash
|
||||
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/uname"
|
||||
clawdbot approvals allowlist add --node <id|name|ip> "/usr/bin/sw_vers"
|
||||
```
|
||||
|
||||
Approvals live on the node host at `~/.clawdbot/exec-approvals.json`.
|
||||
|
||||
### Point exec at the node
|
||||
|
||||
Configure defaults (gateway config):
|
||||
|
||||
```bash
|
||||
clawdbot config set tools.exec.host node
|
||||
clawdbot config set tools.exec.security allowlist
|
||||
clawdbot config set tools.exec.node "<id-or-name>"
|
||||
```
|
||||
|
||||
Or per session:
|
||||
|
||||
```
|
||||
/exec host=node security=allowlist node=<id-or-name>
|
||||
```
|
||||
|
||||
Once set, any `exec` call with `host=node` runs on the node host (subject to the
|
||||
node allowlist/approvals).
|
||||
|
||||
Related:
|
||||
- [Node host CLI](/cli/node)
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
|
||||
## Invoking commands
|
||||
|
||||
Low-level (raw RPC):
|
||||
@@ -289,9 +214,6 @@ Notes:
|
||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
||||
(see [Exec approvals](/tools/exec-approvals)).
|
||||
- On macOS, the headless node host prefers the companion app exec host when reachable and falls
|
||||
back to local execution if the app is unavailable. Set `CLAWDBOT_NODE_EXEC_HOST=app` to require
|
||||
the app, or `CLAWDBOT_NODE_EXEC_FALLBACK=0` to disable fallback.
|
||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
||||
|
||||
## Mac node mode
|
||||
|
||||
@@ -210,7 +210,6 @@
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/qrcode-terminal": "^0.12.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260120.1",
|
||||
"@vitest/coverage-v8": "^4.0.17",
|
||||
"docx-preview": "^0.3.7",
|
||||
"lit": "^3.3.2",
|
||||
@@ -232,7 +231,7 @@
|
||||
"overrides": {
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"hono": "4.11.4",
|
||||
"tar": "7.5.4"
|
||||
"tar": "7.5.3"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-ai@0.49.2": "patches/@mariozechner__pi-ai@0.49.2.patch"
|
||||
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -7,7 +7,7 @@ settings:
|
||||
overrides:
|
||||
'@sinclair/typebox': 0.34.47
|
||||
hono: 4.11.4
|
||||
tar: 7.5.4
|
||||
tar: 7.5.3
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.49.2':
|
||||
@@ -151,8 +151,8 @@ importers:
|
||||
specifier: 0.1.7-alpha.2
|
||||
version: 0.1.7-alpha.2
|
||||
tar:
|
||||
specifier: 7.5.4
|
||||
version: 7.5.4
|
||||
specifier: 7.5.3
|
||||
version: 7.5.3
|
||||
tslog:
|
||||
specifier: ^4.10.2
|
||||
version: 4.10.2
|
||||
@@ -202,9 +202,6 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260120.1
|
||||
version: 7.0.0-dev.20260120.1
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(@vitest/browser@4.0.17(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.17))(vitest@4.0.17)
|
||||
@@ -2533,45 +2530,6 @@ packages:
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-r3pWFuR2H7mn6ScwpH5jJljKQqKto0npVuJSk6pRwFwexpTyxOGmJTZJ1V0AWiisaNxU2+CNAqWFJSJYIE/QTg==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-cuC1+wLbUP+Ip2UT94G134fqRdp5w3b3dhcCO6/FQ4yXxvRNyv/WK+upHBUFDaeSOeHgDTyO9/QFYUWwC4If1A==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-zZGvEGY7wcHYefMZ87KNmvjN3NLIhsCMHEpHZiGCS3khKf+8z6ZsanrzCjOTodvL01VPyBzHxV1EtkSxAcLiQg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-vN6OYVySol/kQZjJGmAzd6L30SyVlCgmCXS8WjUYtE5clN0YrzQHop16RK29fYZHMxpkOniVBtRPxUYQANZBlQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-JBfNhWd/asd5MDeS3VgRvE24pGKBkmvLub6tsux6ypr+Yhy+o0WaAEzVpmlRYZUqss2ai5tvOu4dzPBXzZAtFw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-tTndRtYCq2xwgE0VkTi9ACNiJaV43+PqvBqCxk8ceYi3X36Ve+CCnwlZfZJ4k9NxZthtrAwF/kUmpC9iIYbq1w==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-oZia7hFL6k9pVepfonuPI86Jmyz6WlJKR57tWCDwRNmpA7odxuTq1PbvcYgy1z4+wHF1nnKKJY0PMAiq6ac18w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260120.1':
|
||||
resolution: {integrity: sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==}
|
||||
hasBin: true
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.2':
|
||||
resolution: {integrity: sha512-IlqQ/Gv22xUC1r/WQm4StLkYQmaaTsXAhUVsNE0+xiyf0yRFiH5++q78U3bw6bLKDCTmh0uqKB9eG9+Bt75Dkg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -4905,9 +4863,10 @@ packages:
|
||||
tailwindcss@4.1.17:
|
||||
resolution: {integrity: sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==}
|
||||
|
||||
tar@7.5.4:
|
||||
resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
|
||||
tar@7.5.3:
|
||||
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
|
||||
|
||||
thenify-all@1.6.0:
|
||||
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
|
||||
@@ -7740,37 +7699,6 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.0.9
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260120.1':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260120.1':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260120.1
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260120.1
|
||||
|
||||
'@typespec/ts-http-runtime@0.3.2':
|
||||
dependencies:
|
||||
http-proxy-agent: 7.0.2
|
||||
@@ -8268,7 +8196,7 @@ snapshots:
|
||||
npmlog: 6.0.2
|
||||
rc: 1.2.8
|
||||
semver: 7.7.3
|
||||
tar: 7.5.4
|
||||
tar: 7.5.3
|
||||
url-join: 4.0.1
|
||||
which: 2.0.2
|
||||
yargs: 17.7.2
|
||||
@@ -10510,7 +10438,7 @@ snapshots:
|
||||
|
||||
tailwindcss@4.1.17: {}
|
||||
|
||||
tar@7.5.4:
|
||||
tar@7.5.3:
|
||||
dependencies:
|
||||
'@isaacs/fs-minipass': 4.0.1
|
||||
chownr: 3.0.0
|
||||
|
||||
@@ -7,8 +7,6 @@ import process from "node:process";
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
|
||||
const projectArgs = ["--project", "tsconfig.json"];
|
||||
|
||||
const distRoot = path.join(cwd, "dist");
|
||||
const distEntry = path.join(distRoot, "entry.js");
|
||||
@@ -115,7 +113,7 @@ if (!shouldBuild()) {
|
||||
runNode();
|
||||
} else {
|
||||
logRunner("Building TypeScript (dist is stale).");
|
||||
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
|
||||
const build = spawn("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
|
||||
@@ -5,10 +5,8 @@ import process from "node:process";
|
||||
const args = process.argv.slice(2);
|
||||
const env = { ...process.env };
|
||||
const cwd = process.cwd();
|
||||
const compiler = env.CLAWDBOT_TS_COMPILER === "tsc" ? "tsc" : "tsgo";
|
||||
const projectArgs = ["--project", "tsconfig.json"];
|
||||
|
||||
const initialBuild = spawnSync("pnpm", ["exec", compiler, ...projectArgs], {
|
||||
const initialBuild = spawnSync("pnpm", ["exec", "tsc", "-p", "tsconfig.json"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
@@ -18,12 +16,7 @@ if (initialBuild.status !== 0) {
|
||||
process.exit(initialBuild.status ?? 1);
|
||||
}
|
||||
|
||||
const watchArgs =
|
||||
compiler === "tsc"
|
||||
? [...projectArgs, "--watch", "--preserveWatchOutput"]
|
||||
: [...projectArgs, "--watch"];
|
||||
|
||||
const compilerProcess = spawn("pnpm", ["exec", compiler, ...watchArgs], {
|
||||
const tsc = spawn("pnpm", ["exec", "tsc", "--watch", "--preserveWatchOutput"], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: "inherit",
|
||||
@@ -41,14 +34,14 @@ function cleanup(code = 0) {
|
||||
if (exiting) return;
|
||||
exiting = true;
|
||||
nodeProcess.kill("SIGTERM");
|
||||
compilerProcess.kill("SIGTERM");
|
||||
tsc.kill("SIGTERM");
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => cleanup(130));
|
||||
process.on("SIGTERM", () => cleanup(143));
|
||||
|
||||
compilerProcess.on("exit", (code) => {
|
||||
tsc.on("exit", (code) => {
|
||||
if (exiting) return;
|
||||
cleanup(code ?? 1);
|
||||
});
|
||||
|
||||
@@ -494,7 +494,12 @@ export function createExecTool(
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||
}
|
||||
const requiresAsk = hostAsk === "always" || hostAsk === "on-miss";
|
||||
const resolution = resolveCommandResolution(params.command, workdir, env);
|
||||
const allowlistMatch =
|
||||
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const requiresAsk =
|
||||
hostAsk === "always" ||
|
||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||
|
||||
let approvedByAsk = false;
|
||||
let approvalDecision: "allow-once" | "allow-always" | null = null;
|
||||
@@ -509,7 +514,7 @@ export function createExecTool(
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: null,
|
||||
resolvedPath: resolution?.resolvedPath ?? null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
@@ -527,7 +532,11 @@ export function createExecTool(
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else if (askFallback === "allowlist") {
|
||||
// Defer allowlist enforcement to the node host.
|
||||
if (!allowlistMatch) {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-once";
|
||||
} else {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
@@ -539,8 +548,32 @@ export function createExecTool(
|
||||
if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
approvalDecision = "allow-always";
|
||||
if (hostSecurity === "allowlist") {
|
||||
const pattern =
|
||||
resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
params.command.split(/\s+/).shift() ??
|
||||
"";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
|
||||
throw new Error("exec denied: allowlist miss");
|
||||
}
|
||||
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
allowlistMatch,
|
||||
params.command,
|
||||
resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
|
||||
@@ -63,7 +63,7 @@ function createBaseRun(params: { runOverrides?: Partial<FollowupRun["run"]> }) {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus",
|
||||
authProfileId: "anthropic:clawd",
|
||||
authProfileIdSource: "manual",
|
||||
authProfileIdSource: "user",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
elevatedLevel: "off",
|
||||
@@ -106,7 +106,7 @@ describe("authProfileId fallback scoping", () => {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus",
|
||||
authProfileId: "anthropic:clawd",
|
||||
authProfileIdSource: "manual",
|
||||
authProfileIdSource: "user",
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
import { registerBrowserActionInputCommands } from "./browser-cli-actions-input.js";
|
||||
import { registerBrowserActionObserveCommands } from "./browser-cli-actions-observe.js";
|
||||
import { registerBrowserDebugCommands } from "./browser-cli-debug.js";
|
||||
@@ -27,10 +26,7 @@ export function registerBrowserCli(program: Command) {
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples(
|
||||
[...browserCoreExamples, ...browserActionExamples].map((cmd) => [cmd, ""]),
|
||||
true,
|
||||
)}\n\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
`\nExamples:\n ${[...browserCoreExamples, ...browserActionExamples].join("\n ")}\n\n${theme.muted("Docs:")} ${formatDocsLink(
|
||||
"/cli/browser",
|
||||
"docs.clawd.bot/cli/browser",
|
||||
)}\n`,
|
||||
|
||||
@@ -3,8 +3,6 @@ import type { Command } from "commander";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { withProgress } from "./progress.js";
|
||||
|
||||
type DevicesRpcOpts = {
|
||||
@@ -98,11 +96,11 @@ function parseDevicePairingList(value: unknown): DevicePairingList {
|
||||
}
|
||||
|
||||
function formatTokenSummary(tokens: DeviceTokenSummary[] | undefined) {
|
||||
if (!tokens || tokens.length === 0) return "none";
|
||||
if (!tokens || tokens.length === 0) return "tokens: none";
|
||||
const parts = tokens
|
||||
.map((t) => `${t.role}${t.revokedAtMs ? " (revoked)" : ""}`)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
return parts.join(", ");
|
||||
return `tokens: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
export function registerDevicesCli(program: Command) {
|
||||
@@ -120,59 +118,32 @@ export function registerDevicesCli(program: Command) {
|
||||
return;
|
||||
}
|
||||
if (list.pending?.length) {
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Pending")} ${theme.muted(`(${list.pending.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Request", header: "Request", minWidth: 10 },
|
||||
{ key: "Device", header: "Device", minWidth: 16, flex: true },
|
||||
{ key: "Role", header: "Role", minWidth: 8 },
|
||||
{ key: "IP", header: "IP", minWidth: 12 },
|
||||
{ key: "Age", header: "Age", minWidth: 8 },
|
||||
{ key: "Flags", header: "Flags", minWidth: 8 },
|
||||
],
|
||||
rows: list.pending.map((req) => ({
|
||||
Request: req.requestId,
|
||||
Device: req.displayName || req.deviceId,
|
||||
Role: req.role ?? "",
|
||||
IP: req.remoteIp ?? "",
|
||||
Age: typeof req.ts === "number" ? `${formatAge(Date.now() - req.ts)} ago` : "",
|
||||
Flags: req.isRepair ? "repair" : "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log("Pending:");
|
||||
for (const req of list.pending) {
|
||||
const name = req.displayName || req.deviceId;
|
||||
const repair = req.isRepair ? " (repair)" : "";
|
||||
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||
const age =
|
||||
typeof req.ts === "number" ? ` · ${formatAge(Date.now() - req.ts)} ago` : "";
|
||||
const role = req.role ? ` · role: ${req.role}` : "";
|
||||
defaultRuntime.log(`- ${req.requestId}: ${name}${repair}${role}${ip}${age}`);
|
||||
}
|
||||
}
|
||||
if (list.paired?.length) {
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Paired")} ${theme.muted(`(${list.paired.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Device", header: "Device", minWidth: 16, flex: true },
|
||||
{ key: "Roles", header: "Roles", minWidth: 12, flex: true },
|
||||
{ key: "Scopes", header: "Scopes", minWidth: 12, flex: true },
|
||||
{ key: "Tokens", header: "Tokens", minWidth: 12, flex: true },
|
||||
{ key: "IP", header: "IP", minWidth: 12 },
|
||||
],
|
||||
rows: list.paired.map((device) => ({
|
||||
Device: device.displayName || device.deviceId,
|
||||
Roles: device.roles?.length ? device.roles.join(", ") : "",
|
||||
Scopes: device.scopes?.length ? device.scopes.join(", ") : "",
|
||||
Tokens: formatTokenSummary(device.tokens),
|
||||
IP: device.remoteIp ?? "",
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log("Paired:");
|
||||
for (const device of list.paired) {
|
||||
const name = device.displayName || device.deviceId;
|
||||
const roles = device.roles?.length ? `roles: ${device.roles.join(", ")}` : "roles: -";
|
||||
const scopes = device.scopes?.length
|
||||
? `scopes: ${device.scopes.join(", ")}`
|
||||
: "scopes: -";
|
||||
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
|
||||
const tokens = formatTokenSummary(device.tokens);
|
||||
defaultRuntime.log(`- ${name} · ${roles} · ${scopes} · ${tokens}${ip}`);
|
||||
}
|
||||
}
|
||||
if (!list.pending?.length && !list.paired?.length) {
|
||||
defaultRuntime.log(theme.muted("No device pairing entries."));
|
||||
defaultRuntime.log("No device pairing entries.");
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -189,7 +160,7 @@ export function registerDevicesCli(program: Command) {
|
||||
return;
|
||||
}
|
||||
const deviceId = (result as { device?: { deviceId?: string } })?.device?.deviceId;
|
||||
defaultRuntime.log(`${theme.success("Approved")} ${theme.command(deviceId ?? "ok")}`);
|
||||
defaultRuntime.log(`device approved: ${deviceId ?? "ok"}`);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -205,7 +176,7 @@ export function registerDevicesCli(program: Command) {
|
||||
return;
|
||||
}
|
||||
const deviceId = (result as { deviceId?: string })?.deviceId;
|
||||
defaultRuntime.log(`${theme.warn("Rejected")} ${theme.command(deviceId ?? "ok")}`);
|
||||
defaultRuntime.log(`device rejected: ${deviceId ?? "ok"}`);
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import type { ChannelDirectoryEntry } from "../channels/plugins/types.core.js";
|
||||
|
||||
function parseLimit(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
@@ -24,20 +22,9 @@ function parseLimit(value: unknown): number | null {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function buildRows(entries: Array<{ id: string; name?: string | undefined }>) {
|
||||
return entries.map((entry) => ({
|
||||
ID: entry.id,
|
||||
Name: entry.name?.trim() ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
function formatEntry(entry: ChannelDirectoryEntry): string {
|
||||
function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string {
|
||||
const name = entry.name?.trim();
|
||||
const handle = entry.handle?.trim();
|
||||
const handleLabel = handle ? (handle.startsWith("@") ? handle : `@${handle}`) : null;
|
||||
const label = [name, handleLabel].filter(Boolean).join(" ");
|
||||
if (!label) return entry.id;
|
||||
return `${label} ${theme.muted(`(${entry.id})`)}`;
|
||||
return name ? `${entry.id}\t${name}` : entry.id;
|
||||
}
|
||||
|
||||
export function registerDirectoryCli(program: Command) {
|
||||
@@ -90,21 +77,10 @@ export function registerDirectoryCli(program: Command) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
defaultRuntime.log(theme.muted("Not available."));
|
||||
defaultRuntime.log("not available");
|
||||
return;
|
||||
}
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(`${theme.heading("Self")}`);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||
],
|
||||
rows: buildRows([result]),
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log(formatEntry(result));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -135,22 +111,9 @@ export function registerDirectoryCli(program: Command) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No peers found."));
|
||||
return;
|
||||
for (const entry of result) {
|
||||
defaultRuntime.log(formatEntry(entry));
|
||||
}
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(`${theme.heading("Peers")} ${theme.muted(`(${result.length})`)}`);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||
],
|
||||
rows: buildRows(result),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -180,22 +143,9 @@ export function registerDirectoryCli(program: Command) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
if (result.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No groups found."));
|
||||
return;
|
||||
for (const entry of result) {
|
||||
defaultRuntime.log(formatEntry(entry));
|
||||
}
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(`${theme.heading("Groups")} ${theme.muted(`(${result.length})`)}`);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "ID", header: "ID", minWidth: 16, flex: true },
|
||||
{ key: "Name", header: "Name", minWidth: 18, flex: true },
|
||||
],
|
||||
rows: buildRows(result),
|
||||
}).trimEnd(),
|
||||
);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -7,6 +7,6 @@ describe("dns cli", () => {
|
||||
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const program = buildProgram();
|
||||
await program.parseAsync(["dns", "setup"], { from: "user" });
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("clawdbot.internal"));
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("Domain:"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,9 +7,7 @@ import type { Command } from "commander";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||
import { getWideAreaZonePath, WIDE_AREA_DISCOVERY_DOMAIN } from "../infra/widearea-dns.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
type RunOpts = { allowFailure?: boolean; inherit?: boolean };
|
||||
@@ -114,28 +112,14 @@ export function registerDnsCli(program: Command) {
|
||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||
const zonePath = getWideAreaZonePath();
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(theme.heading("DNS setup"));
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Key", header: "Key", minWidth: 18 },
|
||||
{ key: "Value", header: "Value", minWidth: 24, flex: true },
|
||||
],
|
||||
rows: [
|
||||
{ Key: "Domain", Value: WIDE_AREA_DISCOVERY_DOMAIN },
|
||||
{ Key: "Zone file", Value: zonePath },
|
||||
{
|
||||
Key: "Tailnet IP",
|
||||
Value: `${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
|
||||
},
|
||||
],
|
||||
}).trimEnd(),
|
||||
console.log(`Domain: ${WIDE_AREA_DISCOVERY_DOMAIN}`);
|
||||
console.log(`Zone file (gateway-owned): ${zonePath}`);
|
||||
console.log(
|
||||
`Detected tailnet IP: ${tailnetIPv4 ?? "—"}${tailnetIPv6 ? ` (v6 ${tailnetIPv6})` : ""}`,
|
||||
);
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Recommended ~/.clawdbot/clawdbot.json:"));
|
||||
defaultRuntime.log(
|
||||
console.log("");
|
||||
console.log("Recommended ~/.clawdbot/clawdbot.json:");
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
gateway: { bind: "auto" },
|
||||
@@ -145,16 +129,14 @@ export function registerDnsCli(program: Command) {
|
||||
2,
|
||||
),
|
||||
);
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Tailscale admin (DNS → Nameservers):"));
|
||||
defaultRuntime.log(
|
||||
theme.muted(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`),
|
||||
);
|
||||
defaultRuntime.log(theme.muted("- Restrict to domain (Split DNS): clawdbot.internal"));
|
||||
console.log("");
|
||||
console.log("Tailscale admin (DNS → Nameservers):");
|
||||
console.log(`- Add nameserver: ${tailnetIPv4 ?? "<this machine's tailnet IPv4>"}`);
|
||||
console.log(`- Restrict to domain (Split DNS): clawdbot.internal`);
|
||||
|
||||
if (!opts.apply) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.muted("Run with --apply to install CoreDNS and configure it."));
|
||||
console.log("");
|
||||
console.log("Run with --apply to install CoreDNS and configure it.");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -223,18 +205,16 @@ export function registerDnsCli(program: Command) {
|
||||
fs.writeFileSync(zonePath, zoneLines.join("\n"), "utf-8");
|
||||
}
|
||||
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(theme.heading("Starting CoreDNS (sudo)…"));
|
||||
console.log("");
|
||||
console.log("Starting CoreDNS (sudo)…");
|
||||
run("sudo", ["brew", "services", "restart", "coredns"], {
|
||||
inherit: true,
|
||||
});
|
||||
|
||||
if (cfg.discovery?.wideArea?.enabled !== true) {
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(
|
||||
theme.muted(
|
||||
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
|
||||
),
|
||||
console.log("");
|
||||
console.log(
|
||||
"Note: enable discovery.wideArea.enabled in ~/.clawdbot/clawdbot.json on the gateway and restart the gateway so it writes the DNS-SD zone.",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { theme } from "../terminal/theme.js";
|
||||
|
||||
export type HelpExample = readonly [command: string, description: string];
|
||||
|
||||
export function formatHelpExample(command: string, description: string): string {
|
||||
return ` ${theme.command(command)}\n ${theme.muted(description)}`;
|
||||
}
|
||||
|
||||
export function formatHelpExampleLine(command: string, description: string): string {
|
||||
if (!description) return ` ${theme.command(command)}`;
|
||||
return ` ${theme.command(command)} ${theme.muted(`# ${description}`)}`;
|
||||
}
|
||||
|
||||
export function formatHelpExamples(examples: readonly HelpExample[], inline = false): string {
|
||||
const formatter = inline ? formatHelpExampleLine : formatHelpExample;
|
||||
return examples.map(([command, description]) => formatter(command, description)).join("\n");
|
||||
}
|
||||
|
||||
export function formatHelpExampleGroup(
|
||||
label: string,
|
||||
examples: readonly HelpExample[],
|
||||
inline = false,
|
||||
) {
|
||||
return `${theme.muted(label)}\n${formatHelpExamples(examples, inline)}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
@@ -22,7 +23,6 @@ import { recordHookInstall } from "../hooks/installs.js";
|
||||
import { buildPluginStatusReport } from "../plugins/status.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
@@ -66,40 +66,50 @@ function buildHooksReport(config: ClawdbotConfig): HookStatusReport {
|
||||
return buildWorkspaceHookStatus(workspaceDir, { config, entries });
|
||||
}
|
||||
|
||||
function formatHookStatus(hook: HookStatusEntry): string {
|
||||
if (hook.eligible) return theme.success("✓ ready");
|
||||
if (hook.disabled) return theme.warn("⏸ disabled");
|
||||
return theme.error("✗ missing");
|
||||
}
|
||||
|
||||
function formatHookName(hook: HookStatusEntry): string {
|
||||
/**
|
||||
* Format a single hook for display in the list
|
||||
*/
|
||||
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
return `${emoji} ${theme.command(hook.name)}`;
|
||||
}
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓")
|
||||
: hook.disabled
|
||||
? chalk.yellow("disabled")
|
||||
: chalk.red("missing reqs");
|
||||
|
||||
function formatHookSource(hook: HookStatusEntry): string {
|
||||
if (!hook.managedByPlugin) return hook.source;
|
||||
return `plugin:${hook.pluginId ?? "unknown"}`;
|
||||
}
|
||||
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
|
||||
|
||||
function formatHookMissingSummary(hook: HookStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
if (hook.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
const desc = chalk.gray(
|
||||
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
|
||||
);
|
||||
const sourceLabel = hook.managedByPlugin
|
||||
? chalk.magenta(`plugin:${hook.pluginId ?? "unknown"}`)
|
||||
: "";
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
if (hook.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.env.length > 0) {
|
||||
missing.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.config.length > 0) {
|
||||
missing.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.os.length > 0) {
|
||||
missing.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}${sourceSuffix}`;
|
||||
}
|
||||
if (hook.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.env.length > 0) {
|
||||
missing.push(`env: ${hook.missing.env.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.config.length > 0) {
|
||||
missing.push(`config: ${hook.missing.config.join(", ")}`);
|
||||
}
|
||||
if (hook.missing.os.length > 0) {
|
||||
missing.push(`os: ${hook.missing.os.join(", ")}`);
|
||||
}
|
||||
return missing.join("; ");
|
||||
|
||||
const sourceSuffix = sourceLabel ? ` ${sourceLabel}` : "";
|
||||
return `${emoji} ${name} ${status} - ${desc}${sourceSuffix}`;
|
||||
}
|
||||
|
||||
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
|
||||
@@ -147,39 +157,27 @@ export function formatHooksList(report: HookStatusReport, opts: HooksListOptions
|
||||
}
|
||||
|
||||
const eligible = hooks.filter((h) => h.eligible);
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const rows = hooks.map((hook) => {
|
||||
const missing = formatHookMissingSummary(hook);
|
||||
return {
|
||||
Status: formatHookStatus(hook),
|
||||
Hook: formatHookName(hook),
|
||||
Description: theme.muted(hook.description),
|
||||
Source: formatHookSource(hook),
|
||||
Missing: missing ? theme.warn(missing) : "",
|
||||
};
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: "Status", header: "Status", minWidth: 10 },
|
||||
{ key: "Hook", header: "Hook", minWidth: 18, flex: true },
|
||||
{ key: "Description", header: "Description", minWidth: 24, flex: true },
|
||||
{ key: "Source", header: "Source", minWidth: 12, flex: true },
|
||||
];
|
||||
if (opts.verbose) {
|
||||
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
|
||||
}
|
||||
const notEligible = hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${theme.heading("Hooks")} ${theme.muted(`(${eligible.length}/${hooks.length} ready)`)}`,
|
||||
);
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns,
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
lines.push(chalk.bold.cyan("Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`));
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push(chalk.bold.green("Ready:"));
|
||||
for (const hook of eligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEligible.length > 0 && !opts.eligible) {
|
||||
if (eligible.length > 0) lines.push("");
|
||||
lines.push(chalk.bold.yellow("Not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -207,33 +205,33 @@ export function formatHookInfo(
|
||||
const lines: string[] = [];
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? theme.success("✓ Ready")
|
||||
? chalk.green("✓ Ready")
|
||||
: hook.disabled
|
||||
? theme.warn("⏸ Disabled")
|
||||
: theme.error("✗ Missing requirements");
|
||||
? chalk.yellow("⏸ Disabled")
|
||||
: chalk.red("✗ Missing requirements");
|
||||
|
||||
lines.push(`${emoji} ${theme.heading(hook.name)} ${status}`);
|
||||
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
|
||||
lines.push("");
|
||||
lines.push(hook.description);
|
||||
lines.push(chalk.white(hook.description));
|
||||
lines.push("");
|
||||
|
||||
// Details
|
||||
lines.push(theme.heading("Details:"));
|
||||
lines.push(chalk.bold("Details:"));
|
||||
if (hook.managedByPlugin) {
|
||||
lines.push(`${theme.muted(" Source:")} ${hook.source} (${hook.pluginId ?? "unknown"})`);
|
||||
lines.push(` Source: ${hook.source} (${hook.pluginId ?? "unknown"})`);
|
||||
} else {
|
||||
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
}
|
||||
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
|
||||
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
|
||||
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
|
||||
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
|
||||
if (hook.homepage) {
|
||||
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
|
||||
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
|
||||
}
|
||||
if (hook.events.length > 0) {
|
||||
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`);
|
||||
lines.push(` Events: ${hook.events.join(", ")}`);
|
||||
}
|
||||
if (hook.managedByPlugin) {
|
||||
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
|
||||
lines.push(` Managed by plugin; enable/disable via hooks CLI not available.`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
@@ -246,40 +244,40 @@ export function formatHookInfo(
|
||||
|
||||
if (hasRequirements) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Requirements:"));
|
||||
lines.push(chalk.bold("Requirements:"));
|
||||
if (hook.requirements.bins.length > 0) {
|
||||
const binsStatus = hook.requirements.bins.map((bin) => {
|
||||
const missing = hook.missing.bins.includes(bin);
|
||||
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
|
||||
lines.push(` Binaries: ${binsStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.anyBins.length > 0) {
|
||||
const anyBinsStatus =
|
||||
hook.missing.anyBins.length > 0
|
||||
? theme.error(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
|
||||
: theme.success(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
|
||||
lines.push(`${theme.muted(" Any binary:")} ${anyBinsStatus}`);
|
||||
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
|
||||
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
|
||||
lines.push(` Any binary: ${anyBinsStatus}`);
|
||||
}
|
||||
if (hook.requirements.env.length > 0) {
|
||||
const envStatus = hook.requirements.env.map((env) => {
|
||||
const missing = hook.missing.env.includes(env);
|
||||
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`);
|
||||
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
|
||||
lines.push(` Environment: ${envStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.config.length > 0) {
|
||||
const configStatus = hook.configChecks.map((check) => {
|
||||
return check.satisfied ? theme.success(`✓ ${check.path}`) : theme.error(`✗ ${check.path}`);
|
||||
return check.satisfied ? chalk.green(`✓ ${check.path}`) : chalk.red(`✗ ${check.path}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
|
||||
lines.push(` Config: ${configStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.os.length > 0) {
|
||||
const osStatus =
|
||||
hook.missing.os.length > 0
|
||||
? theme.error(`✗ (${hook.requirements.os.join(", ")})`)
|
||||
: theme.success(`✓ (${hook.requirements.os.join(", ")})`);
|
||||
lines.push(`${theme.muted(" OS:")} ${osStatus}`);
|
||||
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
|
||||
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
|
||||
lines.push(` OS: ${osStatus}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,15 +313,15 @@ export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptio
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.heading("Hooks Status"));
|
||||
lines.push(chalk.bold.cyan("Hooks Status"));
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Total hooks:")} ${report.hooks.length}`);
|
||||
lines.push(`${theme.success("Ready:")} ${eligible.length}`);
|
||||
lines.push(`${theme.warn("Not ready:")} ${notEligible.length}`);
|
||||
lines.push(`Total hooks: ${report.hooks.length}`);
|
||||
lines.push(chalk.green(`Ready: ${eligible.length}`));
|
||||
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
|
||||
|
||||
if (notEligible.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Hooks not ready:"));
|
||||
lines.push(chalk.bold.yellow("Hooks not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
const reasons = [];
|
||||
if (hook.disabled) reasons.push("disabled");
|
||||
@@ -376,9 +374,7 @@ export async function enableHook(hookName: string): Promise<void> {
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
defaultRuntime.log(
|
||||
`${theme.success("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
|
||||
);
|
||||
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export async function disableHook(hookName: string): Promise<void> {
|
||||
@@ -412,9 +408,7 @@ export async function disableHook(hookName: string): Promise<void> {
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
defaultRuntime.log(
|
||||
`${theme.warn("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${theme.command(hookName)}`,
|
||||
);
|
||||
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export function registerHooksCli(program: Command): void {
|
||||
@@ -437,11 +431,9 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const report = buildHooksReport(config);
|
||||
defaultRuntime.log(formatHooksList(report, opts));
|
||||
console.log(formatHooksList(report, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -454,11 +446,9 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const report = buildHooksReport(config);
|
||||
defaultRuntime.log(formatHookInfo(report, name, opts));
|
||||
console.log(formatHookInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -471,11 +461,9 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const report = buildHooksReport(config);
|
||||
defaultRuntime.log(formatHooksCheck(report, opts));
|
||||
console.log(formatHooksCheck(report, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -487,9 +475,7 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
await enableHook(name);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -501,9 +487,7 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
await disableHook(name);
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -586,7 +570,7 @@ export function registerHooksCli(program: Command): void {
|
||||
path: resolved,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -666,7 +650,7 @@ export function registerHooksCli(program: Command): void {
|
||||
spec: raw,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -742,15 +726,15 @@ export function registerHooksCli(program: Command): void {
|
||||
for (const hookId of targets) {
|
||||
const record = installs[hookId];
|
||||
if (!record) {
|
||||
defaultRuntime.log(theme.warn(`No install record for "${hookId}".`));
|
||||
defaultRuntime.log(chalk.yellow(`No install record for "${hookId}".`));
|
||||
continue;
|
||||
}
|
||||
if (record.source !== "npm") {
|
||||
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (source: ${record.source}).`));
|
||||
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (source: ${record.source}).`));
|
||||
continue;
|
||||
}
|
||||
if (!record.spec) {
|
||||
defaultRuntime.log(theme.warn(`Skipping "${hookId}" (missing npm spec).`));
|
||||
defaultRuntime.log(chalk.yellow(`Skipping "${hookId}" (missing npm spec).`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -765,11 +749,11 @@ export function registerHooksCli(program: Command): void {
|
||||
expectedHookPackId: hookId,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!probe.ok) {
|
||||
defaultRuntime.log(theme.error(`Failed to check ${hookId}: ${probe.error}`));
|
||||
defaultRuntime.log(chalk.red(`Failed to check ${hookId}: ${probe.error}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -789,11 +773,11 @@ export function registerHooksCli(program: Command): void {
|
||||
expectedHookPackId: hookId,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
defaultRuntime.log(theme.error(`Failed to update ${hookId}: ${result.error}`));
|
||||
defaultRuntime.log(chalk.red(`Failed to update ${hookId}: ${result.error}`));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -827,11 +811,9 @@ export function registerHooksCli(program: Command): void {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const report = buildHooksReport(config);
|
||||
defaultRuntime.log(formatHooksList(report, {}));
|
||||
console.log(formatHooksList(report, {}));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(
|
||||
`${theme.error("Error:")} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||
import type { NodesRpcOpts } from "./types.js";
|
||||
import { renderTable } from "../../terminal/table.js";
|
||||
import { parseDurationMs } from "../parse-duration.js";
|
||||
|
||||
function formatVersionLabel(raw: string) {
|
||||
const trimmed = raw.trim();
|
||||
@@ -44,84 +43,30 @@ function formatNodeVersions(node: {
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw !== "string" && typeof raw !== "number" && typeof raw !== "bigint") {
|
||||
defaultRuntime.error(`${label}: invalid duration`);
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
const value = String(raw).trim();
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
return parseDurationMs(value);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
defaultRuntime.error(`${label}: ${message}`);
|
||||
defaultRuntime.exit(1);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerNodesStatusCommands(nodes: Command) {
|
||||
nodesCallOpts(
|
||||
nodes
|
||||
.command("status")
|
||||
.description("List known nodes with connection status and capabilities")
|
||||
.option("--connected", "Only show connected nodes")
|
||||
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
await runNodesCommand("status", async () => {
|
||||
const connectedOnly = Boolean(opts.connected);
|
||||
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
|
||||
const result = (await callGatewayCli("node.list", opts, {})) as unknown;
|
||||
const obj =
|
||||
typeof result === "object" && result !== null
|
||||
? (result as Record<string, unknown>)
|
||||
: {};
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const { ok, warn, muted } = getNodesTheme();
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const now = Date.now();
|
||||
const nodes = parseNodeList(result);
|
||||
const lastConnectedById =
|
||||
sinceMs !== undefined
|
||||
? new Map(
|
||||
parsePairingList(await callGatewayCli("node.pair.list", opts, {})).paired.map(
|
||||
(entry) => [entry.nodeId, entry],
|
||||
),
|
||||
)
|
||||
: null;
|
||||
const filtered = nodes.filter((n) => {
|
||||
if (connectedOnly && !n.connected) return false;
|
||||
if (sinceMs !== undefined) {
|
||||
const paired = lastConnectedById?.get(n.nodeId);
|
||||
const lastConnectedAtMs =
|
||||
typeof paired?.lastConnectedAtMs === "number"
|
||||
? paired.lastConnectedAtMs
|
||||
: typeof n.connectedAtMs === "number"
|
||||
? n.connectedAtMs
|
||||
: undefined;
|
||||
if (typeof lastConnectedAtMs !== "number") return false;
|
||||
if (now - lastConnectedAtMs > sinceMs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
const ts = typeof obj.ts === "number" ? obj.ts : Date.now();
|
||||
defaultRuntime.log(JSON.stringify({ ...obj, ts, nodes: filtered }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const pairedCount = filtered.filter((n) => Boolean(n.paired)).length;
|
||||
const connectedCount = filtered.filter((n) => Boolean(n.connected)).length;
|
||||
const filteredLabel = filtered.length !== nodes.length ? ` (of ${nodes.length})` : "";
|
||||
const pairedCount = nodes.filter((n) => Boolean(n.paired)).length;
|
||||
const connectedCount = nodes.filter((n) => Boolean(n.connected)).length;
|
||||
defaultRuntime.log(
|
||||
`Known: ${filtered.length}${filteredLabel} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
|
||||
`Known: ${nodes.length} · Paired: ${pairedCount} · Connected: ${connectedCount}`,
|
||||
);
|
||||
if (filtered.length === 0) return;
|
||||
if (nodes.length === 0) return;
|
||||
|
||||
const rows = filtered.map((n) => {
|
||||
const rows = nodes.map((n) => {
|
||||
const name = n.displayName?.trim() ? n.displayName.trim() : n.nodeId;
|
||||
const perms = formatPermissions(n.permissions);
|
||||
const versions = formatNodeVersions(n);
|
||||
@@ -252,60 +197,21 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
nodes
|
||||
.command("list")
|
||||
.description("List pending and paired nodes")
|
||||
.option("--connected", "Only show connected nodes")
|
||||
.option("--last-connected <duration>", "Only show nodes connected within duration (e.g. 24h)")
|
||||
.action(async (opts: NodesRpcOpts) => {
|
||||
await runNodesCommand("list", async () => {
|
||||
const connectedOnly = Boolean(opts.connected);
|
||||
const sinceMs = parseSinceMs(opts.lastConnected, "Invalid --last-connected");
|
||||
const result = (await callGatewayCli("node.pair.list", opts, {})) as unknown;
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
return;
|
||||
}
|
||||
const { pending, paired } = parsePairingList(result);
|
||||
defaultRuntime.log(`Pending: ${pending.length} · Paired: ${paired.length}`);
|
||||
const { heading, muted, warn } = getNodesTheme();
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const now = Date.now();
|
||||
const hasFilters = connectedOnly || sinceMs !== undefined;
|
||||
const pendingRows = hasFilters ? [] : pending;
|
||||
const connectedById = hasFilters
|
||||
? new Map(
|
||||
parseNodeList(await callGatewayCli("node.list", opts, {})).map((node) => [
|
||||
node.nodeId,
|
||||
node,
|
||||
]),
|
||||
)
|
||||
: null;
|
||||
const filteredPaired = paired.filter((node) => {
|
||||
if (connectedOnly) {
|
||||
const live = connectedById?.get(node.nodeId);
|
||||
if (!live?.connected) return false;
|
||||
}
|
||||
if (sinceMs !== undefined) {
|
||||
const live = connectedById?.get(node.nodeId);
|
||||
const lastConnectedAtMs =
|
||||
typeof node.lastConnectedAtMs === "number"
|
||||
? node.lastConnectedAtMs
|
||||
: typeof live?.connectedAtMs === "number"
|
||||
? live.connectedAtMs
|
||||
: undefined;
|
||||
if (typeof lastConnectedAtMs !== "number") return false;
|
||||
if (now - lastConnectedAtMs > sinceMs) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const filteredLabel =
|
||||
hasFilters && filteredPaired.length !== paired.length ? ` (of ${paired.length})` : "";
|
||||
defaultRuntime.log(
|
||||
`Pending: ${pendingRows.length} · Paired: ${filteredPaired.length}${filteredLabel}`,
|
||||
);
|
||||
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(
|
||||
JSON.stringify({ pending: pendingRows, paired: filteredPaired }, null, 2),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pendingRows.length > 0) {
|
||||
const pendingRowsRendered = pendingRows.map((r) => ({
|
||||
if (pending.length > 0) {
|
||||
const pendingRows = pending.map((r) => ({
|
||||
Request: r.requestId,
|
||||
Node: r.displayName?.trim() ? r.displayName.trim() : r.nodeId,
|
||||
IP: r.remoteIp ?? "",
|
||||
@@ -327,30 +233,21 @@ export function registerNodesStatusCommands(nodes: Command) {
|
||||
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||
{ key: "Repair", header: "Repair", minWidth: 6 },
|
||||
],
|
||||
rows: pendingRowsRendered,
|
||||
rows: pendingRows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredPaired.length > 0) {
|
||||
const pairedRows = filteredPaired.map((n) => {
|
||||
const live = connectedById?.get(n.nodeId);
|
||||
const lastConnectedAtMs =
|
||||
if (paired.length > 0) {
|
||||
const pairedRows = paired.map((n) => ({
|
||||
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
||||
Id: n.nodeId,
|
||||
IP: n.remoteIp ?? "",
|
||||
LastConnect:
|
||||
typeof n.lastConnectedAtMs === "number"
|
||||
? n.lastConnectedAtMs
|
||||
: typeof live?.connectedAtMs === "number"
|
||||
? live.connectedAtMs
|
||||
: undefined;
|
||||
return {
|
||||
Node: n.displayName?.trim() ? n.displayName.trim() : n.nodeId,
|
||||
Id: n.nodeId,
|
||||
IP: n.remoteIp ?? "",
|
||||
LastConnect:
|
||||
typeof lastConnectedAtMs === "number"
|
||||
? `${formatAge(Math.max(0, now - lastConnectedAtMs))} ago`
|
||||
: muted("unknown"),
|
||||
};
|
||||
});
|
||||
? `${formatAge(Math.max(0, now - n.lastConnectedAtMs))} ago`
|
||||
: muted("unknown"),
|
||||
}));
|
||||
defaultRuntime.log("");
|
||||
defaultRuntime.log(heading("Paired"));
|
||||
defaultRuntime.log(
|
||||
|
||||
@@ -8,8 +8,6 @@ export type NodesRpcOpts = {
|
||||
params?: string;
|
||||
invokeTimeout?: string;
|
||||
idempotencyKey?: string;
|
||||
connected?: boolean;
|
||||
lastConnected?: string;
|
||||
target?: string;
|
||||
x?: string;
|
||||
y?: string;
|
||||
|
||||
@@ -71,9 +71,7 @@ describe("pairing cli", () => {
|
||||
await program.parseAsync(["pairing", "list", "--channel", "telegram"], {
|
||||
from: "user",
|
||||
});
|
||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
||||
expect(output).toContain("telegramUserId");
|
||||
expect(output).toContain("123");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("telegramUserId=123"));
|
||||
});
|
||||
|
||||
it("accepts channel as positional for list", async () => {
|
||||
@@ -133,9 +131,7 @@ describe("pairing cli", () => {
|
||||
await program.parseAsync(["pairing", "list", "--channel", "discord"], {
|
||||
from: "user",
|
||||
});
|
||||
const output = log.mock.calls.map(([value]) => String(value)).join("\n");
|
||||
expect(output).toContain("discordUserId");
|
||||
expect(output).toContain("999");
|
||||
expect(log).toHaveBeenCalledWith(expect.stringContaining("discordUserId=999"));
|
||||
});
|
||||
|
||||
it("accepts channel as positional for approve (npm-run compatible)", async () => {
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
listChannelPairingRequests,
|
||||
type PairingChannel,
|
||||
} from "../pairing/pairing-store.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
@@ -72,35 +70,18 @@ export function registerPairingCli(program: Command) {
|
||||
const channel = parseChannel(channelRaw, channels);
|
||||
const requests = await listChannelPairingRequests(channel);
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify({ channel, requests }, null, 2));
|
||||
console.log(JSON.stringify({ channel, requests }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
defaultRuntime.log(theme.muted(`No pending ${channel} pairing requests.`));
|
||||
console.log(`No pending ${channel} pairing requests.`);
|
||||
return;
|
||||
}
|
||||
const idLabel = resolvePairingIdLabel(channel);
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Pairing requests")} ${theme.muted(`(${requests.length})`)}`,
|
||||
);
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Code", header: "Code", minWidth: 10 },
|
||||
{ key: "ID", header: idLabel, minWidth: 12, flex: true },
|
||||
{ key: "Meta", header: "Meta", minWidth: 8, flex: true },
|
||||
{ key: "Requested", header: "Requested", minWidth: 12 },
|
||||
],
|
||||
rows: requests.map((r) => ({
|
||||
Code: r.code,
|
||||
ID: r.id,
|
||||
Meta: r.meta ? JSON.stringify(r.meta) : "",
|
||||
Requested: r.createdAt,
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
for (const r of requests) {
|
||||
const meta = r.meta ? JSON.stringify(r.meta) : "";
|
||||
const idLabel = resolvePairingIdLabel(channel);
|
||||
console.log(`${r.code} ${idLabel}=${r.id}${meta ? ` meta=${meta}` : ""} ${r.createdAt}`);
|
||||
}
|
||||
});
|
||||
|
||||
pairing
|
||||
@@ -132,13 +113,11 @@ export function registerPairingCli(program: Command) {
|
||||
throw new Error(`No pending pairing request found for code: ${String(resolvedCode)}`);
|
||||
}
|
||||
|
||||
defaultRuntime.log(
|
||||
`${theme.success("Approved")} ${theme.muted(channel)} sender ${theme.command(approved.id)}.`,
|
||||
);
|
||||
console.log(`Approved ${channel} sender ${approved.id}.`);
|
||||
|
||||
if (!opts.notify) return;
|
||||
await notifyApproved(channel, approved.id).catch((err) => {
|
||||
defaultRuntime.log(theme.warn(`Failed to notify requester: ${String(err)}`));
|
||||
console.log(`Failed to notify requester: ${String(err)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { loadConfig, writeConfigFile } from "../config/config.js";
|
||||
@@ -13,7 +14,6 @@ import { buildPluginStatusReport } from "../plugins/status.js";
|
||||
import { updateNpmInstalledPlugins } from "../plugins/update.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
@@ -35,19 +35,19 @@ export type PluginUpdateOptions = {
|
||||
function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||
const status =
|
||||
plugin.status === "loaded"
|
||||
? theme.success("loaded")
|
||||
? chalk.green("✓")
|
||||
: plugin.status === "disabled"
|
||||
? theme.warn("disabled")
|
||||
: theme.error("error");
|
||||
const name = theme.command(plugin.name || plugin.id);
|
||||
const idSuffix = plugin.name && plugin.name !== plugin.id ? theme.muted(` (${plugin.id})`) : "";
|
||||
? chalk.yellow("disabled")
|
||||
: chalk.red("error");
|
||||
const name = plugin.name ? chalk.white(plugin.name) : chalk.white(plugin.id);
|
||||
const idSuffix = plugin.name !== plugin.id ? chalk.gray(` (${plugin.id})`) : "";
|
||||
const desc = plugin.description
|
||||
? theme.muted(
|
||||
? chalk.gray(
|
||||
plugin.description.length > 60
|
||||
? `${plugin.description.slice(0, 57)}...`
|
||||
: plugin.description,
|
||||
)
|
||||
: theme.muted("(no description)");
|
||||
: chalk.gray("(no description)");
|
||||
|
||||
if (!verbose) {
|
||||
return `${name}${idSuffix} ${status} - ${desc}`;
|
||||
@@ -55,14 +55,14 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
||||
|
||||
const parts = [
|
||||
`${name}${idSuffix} ${status}`,
|
||||
` source: ${theme.muted(plugin.source)}`,
|
||||
` source: ${chalk.gray(plugin.source)}`,
|
||||
` origin: ${plugin.origin}`,
|
||||
];
|
||||
if (plugin.version) parts.push(` version: ${plugin.version}`);
|
||||
if (plugin.providerIds.length > 0) {
|
||||
parts.push(` providers: ${plugin.providerIds.join(", ")}`);
|
||||
}
|
||||
if (plugin.error) parts.push(theme.error(` error: ${plugin.error}`));
|
||||
if (plugin.error) parts.push(chalk.red(` error: ${plugin.error}`));
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function applySlotSelectionForPlugin(
|
||||
function logSlotWarnings(warnings: string[]) {
|
||||
if (warnings.length === 0) return;
|
||||
for (const warning of warnings) {
|
||||
defaultRuntime.log(theme.warn(warning));
|
||||
defaultRuntime.log(chalk.yellow(warning));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,51 +124,19 @@ export function registerPluginsCli(program: Command) {
|
||||
}
|
||||
|
||||
if (list.length === 0) {
|
||||
defaultRuntime.log(theme.muted("No plugins found."));
|
||||
return;
|
||||
}
|
||||
|
||||
const loaded = list.filter((p) => p.status === "loaded").length;
|
||||
defaultRuntime.log(
|
||||
`${theme.heading("Plugins")} ${theme.muted(`(${loaded}/${list.length} loaded)`)}`,
|
||||
);
|
||||
|
||||
if (!opts.verbose) {
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const rows = list.map((plugin) => ({
|
||||
Name: plugin.name || plugin.id,
|
||||
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
|
||||
Status:
|
||||
plugin.status === "loaded"
|
||||
? theme.success("loaded")
|
||||
: plugin.status === "disabled"
|
||||
? theme.warn("disabled")
|
||||
: theme.error("error"),
|
||||
Source: plugin.source,
|
||||
Version: plugin.version ?? "",
|
||||
Description: plugin.description ?? "",
|
||||
}));
|
||||
defaultRuntime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Name", header: "Name", minWidth: 14, flex: true },
|
||||
{ key: "ID", header: "ID", minWidth: 10, flex: true },
|
||||
{ key: "Status", header: "Status", minWidth: 10 },
|
||||
{ key: "Source", header: "Source", minWidth: 10 },
|
||||
{ key: "Version", header: "Version", minWidth: 8 },
|
||||
{ key: "Description", header: "Description", minWidth: 18, flex: true },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
defaultRuntime.log("No plugins found.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const loaded = list.filter((p) => p.status === "loaded").length;
|
||||
lines.push(
|
||||
`${chalk.bold.cyan("Plugins")} ${chalk.gray(`(${loaded}/${list.length} loaded)`)}`,
|
||||
);
|
||||
lines.push("");
|
||||
for (const plugin of list) {
|
||||
lines.push(formatPluginLine(plugin, true));
|
||||
lines.push("");
|
||||
lines.push(formatPluginLine(plugin, opts.verbose));
|
||||
if (opts.verbose) lines.push("");
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n").trim());
|
||||
});
|
||||
@@ -194,45 +162,43 @@ export function registerPluginsCli(program: Command) {
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.heading(plugin.name || plugin.id));
|
||||
lines.push(chalk.bold.cyan(plugin.name || plugin.id));
|
||||
if (plugin.name && plugin.name !== plugin.id) {
|
||||
lines.push(theme.muted(`id: ${plugin.id}`));
|
||||
lines.push(chalk.gray(`id: ${plugin.id}`));
|
||||
}
|
||||
if (plugin.description) lines.push(plugin.description);
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
|
||||
lines.push(`${theme.muted("Source:")} ${plugin.source}`);
|
||||
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
|
||||
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
|
||||
lines.push(`Status: ${plugin.status}`);
|
||||
lines.push(`Source: ${plugin.source}`);
|
||||
lines.push(`Origin: ${plugin.origin}`);
|
||||
if (plugin.version) lines.push(`Version: ${plugin.version}`);
|
||||
if (plugin.toolNames.length > 0) {
|
||||
lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`);
|
||||
lines.push(`Tools: ${plugin.toolNames.join(", ")}`);
|
||||
}
|
||||
if (plugin.hookNames.length > 0) {
|
||||
lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`);
|
||||
lines.push(`Hooks: ${plugin.hookNames.join(", ")}`);
|
||||
}
|
||||
if (plugin.gatewayMethods.length > 0) {
|
||||
lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`);
|
||||
lines.push(`Gateway methods: ${plugin.gatewayMethods.join(", ")}`);
|
||||
}
|
||||
if (plugin.providerIds.length > 0) {
|
||||
lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`);
|
||||
lines.push(`Providers: ${plugin.providerIds.join(", ")}`);
|
||||
}
|
||||
if (plugin.cliCommands.length > 0) {
|
||||
lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`);
|
||||
lines.push(`CLI commands: ${plugin.cliCommands.join(", ")}`);
|
||||
}
|
||||
if (plugin.services.length > 0) {
|
||||
lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`);
|
||||
lines.push(`Services: ${plugin.services.join(", ")}`);
|
||||
}
|
||||
if (plugin.error) lines.push(`${theme.error("Error:")} ${plugin.error}`);
|
||||
if (plugin.error) lines.push(chalk.red(`Error: ${plugin.error}`));
|
||||
if (install) {
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Install:")} ${install.source}`);
|
||||
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
|
||||
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
|
||||
if (install.installPath)
|
||||
lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
|
||||
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
|
||||
if (install.installedAt)
|
||||
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
|
||||
lines.push(`Install: ${install.source}`);
|
||||
if (install.spec) lines.push(`Spec: ${install.spec}`);
|
||||
if (install.sourcePath) lines.push(`Source path: ${install.sourcePath}`);
|
||||
if (install.installPath) lines.push(`Install path: ${install.installPath}`);
|
||||
if (install.version) lines.push(`Recorded version: ${install.version}`);
|
||||
if (install.installedAt) lines.push(`Installed at: ${install.installedAt}`);
|
||||
}
|
||||
defaultRuntime.log(lines.join("\n"));
|
||||
});
|
||||
@@ -342,7 +308,7 @@ export function registerPluginsCli(program: Command) {
|
||||
path: resolved,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -406,7 +372,7 @@ export function registerPluginsCli(program: Command) {
|
||||
spec: raw,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
@@ -464,17 +430,17 @@ export function registerPluginsCli(program: Command) {
|
||||
dryRun: opts.dryRun,
|
||||
logger: {
|
||||
info: (msg) => defaultRuntime.log(msg),
|
||||
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
|
||||
warn: (msg) => defaultRuntime.log(chalk.yellow(msg)),
|
||||
},
|
||||
});
|
||||
|
||||
for (const outcome of result.outcomes) {
|
||||
if (outcome.status === "error") {
|
||||
defaultRuntime.log(theme.error(outcome.message));
|
||||
defaultRuntime.log(chalk.red(outcome.message));
|
||||
continue;
|
||||
}
|
||||
if (outcome.status === "skipped") {
|
||||
defaultRuntime.log(theme.warn(outcome.message));
|
||||
defaultRuntime.log(chalk.yellow(outcome.message));
|
||||
continue;
|
||||
}
|
||||
defaultRuntime.log(outcome.message);
|
||||
@@ -501,14 +467,14 @@ export function registerPluginsCli(program: Command) {
|
||||
|
||||
const lines: string[] = [];
|
||||
if (errors.length > 0) {
|
||||
lines.push(theme.error("Plugin errors:"));
|
||||
lines.push(chalk.bold.red("Plugin errors:"));
|
||||
for (const entry of errors) {
|
||||
lines.push(`- ${entry.id}: ${entry.error ?? "failed to load"} (${entry.source})`);
|
||||
}
|
||||
}
|
||||
if (diags.length > 0) {
|
||||
if (lines.length > 0) lines.push("");
|
||||
lines.push(theme.warn("Diagnostics:"));
|
||||
lines.push(chalk.bold.yellow("Diagnostics:"));
|
||||
for (const diag of diags) {
|
||||
const target = diag.pluginId ? `${diag.pluginId}: ` : "";
|
||||
lines.push(`- ${target}${diag.message}`);
|
||||
|
||||
@@ -68,83 +68,6 @@ describe("cli program (nodes basics)", () => {
|
||||
expect(runtime.log).toHaveBeenCalledWith("Pending: 0 · Paired: 0");
|
||||
});
|
||||
|
||||
it("runs nodes list --connected and filters to connected nodes", async () => {
|
||||
const now = Date.now();
|
||||
callGateway.mockImplementation(async (opts: { method?: string }) => {
|
||||
if (opts.method === "node.pair.list") {
|
||||
return {
|
||||
pending: [],
|
||||
paired: [
|
||||
{
|
||||
nodeId: "n1",
|
||||
displayName: "One",
|
||||
remoteIp: "10.0.0.1",
|
||||
lastConnectedAtMs: now - 1_000,
|
||||
},
|
||||
{
|
||||
nodeId: "n2",
|
||||
displayName: "Two",
|
||||
remoteIp: "10.0.0.2",
|
||||
lastConnectedAtMs: now - 1_000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.list") {
|
||||
return {
|
||||
nodes: [
|
||||
{ nodeId: "n1", connected: true },
|
||||
{ nodeId: "n2", connected: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "list", "--connected"], { from: "user" });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.list" }));
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
expect(output).toContain("One");
|
||||
expect(output).not.toContain("Two");
|
||||
});
|
||||
|
||||
it("runs nodes status --last-connected and filters by age", async () => {
|
||||
const now = Date.now();
|
||||
callGateway.mockImplementation(async (opts: { method?: string }) => {
|
||||
if (opts.method === "node.list") {
|
||||
return {
|
||||
ts: now,
|
||||
nodes: [
|
||||
{ nodeId: "n1", displayName: "One", connected: false },
|
||||
{ nodeId: "n2", displayName: "Two", connected: false },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (opts.method === "node.pair.list") {
|
||||
return {
|
||||
pending: [],
|
||||
paired: [
|
||||
{ nodeId: "n1", lastConnectedAtMs: now - 1_000 },
|
||||
{ nodeId: "n2", lastConnectedAtMs: now - 2 * 24 * 60 * 60 * 1000 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["nodes", "status", "--last-connected", "24h"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(callGateway).toHaveBeenCalledWith(expect.objectContaining({ method: "node.pair.list" }));
|
||||
const output = runtime.log.mock.calls.map((c) => String(c[0] ?? "")).join("\n");
|
||||
expect(output).toContain("One");
|
||||
expect(output).not.toContain("Two");
|
||||
});
|
||||
|
||||
it("runs nodes status and calls node.list", async () => {
|
||||
callGateway.mockResolvedValue({
|
||||
ts: Date.now(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { agentsListCommand } from "../../commands/agents.js";
|
||||
import { healthCommand } from "../../commands/health.js";
|
||||
import { sessionsCommand } from "../../commands/sessions.js";
|
||||
import { statusCommand } from "../../commands/status.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { getFlagValue, getPositiveIntFlagValue, getVerboseFlag, hasFlag } from "../argv.js";
|
||||
import { registerBrowserCli } from "../browser-cli.js";
|
||||
@@ -45,6 +46,7 @@ const routeHealth: RouteSpec = {
|
||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||
if (timeoutMs === null) return false;
|
||||
setVerbose(verbose);
|
||||
await healthCommand({ json, timeoutMs, verbose }, defaultRuntime);
|
||||
return true;
|
||||
},
|
||||
@@ -61,6 +63,7 @@ const routeStatus: RouteSpec = {
|
||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||
const timeoutMs = getPositiveIntFlagValue(argv, "--timeout");
|
||||
if (timeoutMs === null) return false;
|
||||
setVerbose(verbose);
|
||||
await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime);
|
||||
return true;
|
||||
},
|
||||
@@ -70,10 +73,12 @@ const routeSessions: RouteSpec = {
|
||||
match: (path) => path[0] === "sessions",
|
||||
run: async (argv) => {
|
||||
const json = hasFlag(argv, "--json");
|
||||
const verbose = getVerboseFlag(argv);
|
||||
const store = getFlagValue(argv, "--store");
|
||||
if (store === null) return false;
|
||||
const active = getFlagValue(argv, "--active");
|
||||
if (active === null) return false;
|
||||
setVerbose(verbose);
|
||||
await sessionsCommand({ json, store, active }, defaultRuntime);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { Command } from "commander";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { emitCliBanner } from "../banner.js";
|
||||
import { getCommandPath, getVerboseFlag, hasHelpOrVersion } from "../argv.js";
|
||||
import { getCommandPath, hasHelpOrVersion } from "../argv.js";
|
||||
import { ensureConfigReady } from "./config-guard.js";
|
||||
import { ensurePluginRegistryLoaded } from "../plugin-registry.js";
|
||||
import { setVerbose } from "../../globals.js";
|
||||
|
||||
function setProcessTitleForCommand(actionCommand: Command) {
|
||||
let current: Command = actionCommand;
|
||||
@@ -25,11 +24,6 @@ export function registerPreActionHooks(program: Command, programVersion: string)
|
||||
emitCliBanner(programVersion);
|
||||
const argv = process.argv;
|
||||
if (hasHelpOrVersion(argv)) return;
|
||||
const verbose = getVerboseFlag(argv, { includeDebug: true });
|
||||
setVerbose(verbose);
|
||||
if (!verbose) {
|
||||
process.env.NODE_NO_WARNINGS ??= "1";
|
||||
}
|
||||
const commandPath = getCommandPath(argv, 2);
|
||||
if (commandPath[0] === "doctor") return;
|
||||
await ensureConfigReady({ runtime: defaultRuntime, commandPath });
|
||||
|
||||
@@ -12,7 +12,6 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { hasExplicitOptions } from "../command-options.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
import { createDefaultDeps } from "../deps.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { collectOption } from "./helpers.js";
|
||||
@@ -49,24 +48,13 @@ export function registerAgentCommands(program: Command, args: { agentChannelOpti
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
${theme.heading("Examples:")}
|
||||
${formatHelpExamples([
|
||||
['clawdbot agent --to +15555550123 --message "status update"', "Start a new session."],
|
||||
['clawdbot agent --agent ops --message "Summarize logs"', "Use a specific agent."],
|
||||
[
|
||||
'clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium',
|
||||
"Target a session with explicit thinking level.",
|
||||
],
|
||||
[
|
||||
'clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json',
|
||||
"Enable verbose logging and JSON output.",
|
||||
],
|
||||
['clawdbot agent --to +15555550123 --message "Summon reply" --deliver', "Deliver reply."],
|
||||
[
|
||||
'clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"',
|
||||
"Send reply to a different channel/target.",
|
||||
],
|
||||
])}
|
||||
Examples:
|
||||
clawdbot agent --to +15555550123 --message "status update"
|
||||
clawdbot agent --agent ops --message "Summarize logs"
|
||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||
clawdbot agent --to +15555550123 --message "Trace logs" --verbose on --json
|
||||
clawdbot agent --to +15555550123 --message "Summon reply" --deliver
|
||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent")}`,
|
||||
)
|
||||
@@ -152,15 +140,10 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
${theme.heading("Examples:")}
|
||||
${formatHelpExamples([
|
||||
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
|
||||
["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
|
||||
[
|
||||
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
|
||||
"Use a specific IDENTITY.md.",
|
||||
],
|
||||
])}
|
||||
Examples:
|
||||
clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"
|
||||
clawdbot agents set-identity --workspace ~/clawd --from-identity
|
||||
clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main
|
||||
`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
import type { ProgramContext } from "./context.js";
|
||||
import { createMessageCliHelpers } from "./message/helpers.js";
|
||||
import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js";
|
||||
@@ -29,22 +28,11 @@ export function registerMessageCommands(program: Command, ctx: ProgramContext) {
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
${theme.heading("Examples:")}
|
||||
${formatHelpExamples([
|
||||
['clawdbot message send --target +15555550123 --message "Hi"', "Send a text message."],
|
||||
[
|
||||
'clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg',
|
||||
"Send a message with media.",
|
||||
],
|
||||
[
|
||||
'clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi',
|
||||
"Create a Discord poll.",
|
||||
],
|
||||
[
|
||||
'clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"',
|
||||
"React to a message.",
|
||||
],
|
||||
])}
|
||||
Examples:
|
||||
clawdbot message send --target +15555550123 --message "Hi"
|
||||
clawdbot message send --target +15555550123 --message "Hi" --media photo.jpg
|
||||
clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi
|
||||
clawdbot message react --channel discord --target 123 --message-id 456 --emoji "✅"
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/message")}`,
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
import { parsePositiveIntOrUndefined } from "./helpers.js";
|
||||
|
||||
function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean {
|
||||
@@ -37,18 +36,15 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
.option("--debug", "Alias for --verbose", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["clawdbot status", "Show channel health + session summary."],
|
||||
["clawdbot status --all", "Full diagnosis (read-only)."],
|
||||
["clawdbot status --json", "Machine-readable output."],
|
||||
["clawdbot status --usage", "Show model provider usage/quota snapshots."],
|
||||
[
|
||||
"clawdbot status --deep",
|
||||
"Run channel probes (WA + Telegram + Discord + Slack + Signal).",
|
||||
],
|
||||
["clawdbot status --deep --timeout 5000", "Tighten probe timeout."],
|
||||
])}`,
|
||||
`
|
||||
Examples:
|
||||
clawdbot status # show linked account + session store summary
|
||||
clawdbot status --all # full diagnosis (read-only)
|
||||
clawdbot status --json # machine-readable output
|
||||
clawdbot status --usage # show model provider usage/quota snapshots
|
||||
clawdbot status --deep # run channel probes (WA + Telegram + Discord + Slack + Signal)
|
||||
clawdbot status --deep --timeout 5000 # tighten probe timeout
|
||||
clawdbot channels status # gateway channel runtime + probes`,
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
@@ -117,15 +113,14 @@ export function registerStatusHealthSessionsCommands(program: Command) {
|
||||
.option("--active <minutes>", "Only show sessions updated within the past N minutes")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["clawdbot sessions", "List all sessions."],
|
||||
["clawdbot sessions --active 120", "Only last 2 hours."],
|
||||
["clawdbot sessions --json", "Machine-readable output."],
|
||||
["clawdbot sessions --store ./tmp/sessions.json", "Use a specific session store."],
|
||||
])}\n\n${theme.muted(
|
||||
"Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.",
|
||||
)}`,
|
||||
`
|
||||
Examples:
|
||||
clawdbot sessions # list all sessions
|
||||
clawdbot sessions --active 120 # only last 2 hours
|
||||
clawdbot sessions --json # machine-readable output
|
||||
clawdbot sessions --store ./tmp/sessions.json
|
||||
|
||||
Shows token usage per session when the agent reports it; set agents.defaults.contextTokens to see % of your model window.`,
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
|
||||
@@ -21,7 +21,7 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => {
|
||||
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
|
||||
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
|
||||
|
||||
const { registerSubCliByName, registerSubCliCommands } = await import("./register.subclis.js");
|
||||
const { registerSubCliCommands } = await import("./register.subclis.js");
|
||||
|
||||
describe("registerSubCliCommands", () => {
|
||||
const originalArgv = process.argv;
|
||||
@@ -78,20 +78,4 @@ describe("registerSubCliCommands", () => {
|
||||
expect(registerNodesCli).toHaveBeenCalledTimes(1);
|
||||
expect(nodesAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces placeholder when registering a subcommand by name", async () => {
|
||||
process.argv = ["node", "clawdbot", "acp", "--help"];
|
||||
const program = new Command();
|
||||
program.name("clawdbot");
|
||||
registerSubCliCommands(program, process.argv);
|
||||
|
||||
await registerSubCliByName(program, "acp");
|
||||
|
||||
const names = program.commands.map((cmd) => cmd.name());
|
||||
expect(names.filter((name) => name === "acp")).toHaveLength(1);
|
||||
|
||||
await program.parseAsync(["node", "clawdbot", "acp"], { from: "user" });
|
||||
expect(registerAcpCli).toHaveBeenCalledTimes(1);
|
||||
expect(acpAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,8 +18,10 @@ const shouldRegisterPrimaryOnly = (argv: string[]) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
const shouldEagerRegisterSubcommands = (_argv: string[]) => {
|
||||
return isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS);
|
||||
const shouldEagerRegisterSubcommands = (argv: string[]) => {
|
||||
if (isTruthyEnvValue(process.env.CLAWDBOT_DISABLE_LAZY_SUBCOMMANDS)) return true;
|
||||
if (hasHelpOrVersion(argv)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const loadConfig = async (): Promise<ClawdbotConfig> => {
|
||||
@@ -232,15 +234,6 @@ function removeCommand(program: Command, command: Command) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerSubCliByName(program: Command, name: string): Promise<boolean> {
|
||||
const entry = entries.find((candidate) => candidate.name === name);
|
||||
if (!entry) return false;
|
||||
const existing = program.commands.find((cmd) => cmd.name() === entry.name);
|
||||
if (existing) removeCommand(program, existing);
|
||||
await entry.register(program);
|
||||
return true;
|
||||
}
|
||||
|
||||
function registerLazyCommand(program: Command, entry: SubCliEntry) {
|
||||
const placeholder = program.command(entry.name).description(entry.description);
|
||||
placeholder.allowUnknownOption(true);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js";
|
||||
import { formatUncaughtError } from "../infra/errors.js";
|
||||
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
|
||||
import { enableConsoleCapture } from "../logging.js";
|
||||
import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
|
||||
import { tryRouteCli } from "./route.js";
|
||||
|
||||
export function rewriteUpdateFlagArgv(argv: string[]): string[] {
|
||||
@@ -48,15 +47,7 @@ export async function runCli(argv: string[] = process.argv) {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
const parseArgv = rewriteUpdateFlagArgv(normalizedArgv);
|
||||
if (hasHelpOrVersion(parseArgv)) {
|
||||
const primary = getPrimaryCommand(parseArgv);
|
||||
if (primary) {
|
||||
const { registerSubCliByName } = await import("./program/register.subclis.js");
|
||||
await registerSubCliByName(program, primary);
|
||||
}
|
||||
}
|
||||
await program.parseAsync(parseArgv);
|
||||
await program.parseAsync(rewriteUpdateFlagArgv(normalizedArgv));
|
||||
}
|
||||
|
||||
function stripWindowsNodeExec(argv: string[]): string[] {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -13,34 +12,58 @@ type CommandOptions = Record<string, unknown>;
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
const SANDBOX_EXAMPLES = {
|
||||
main: [
|
||||
["clawdbot sandbox list", "List all sandbox containers."],
|
||||
["clawdbot sandbox list --browser", "List only browser containers."],
|
||||
["clawdbot sandbox recreate --all", "Recreate all containers."],
|
||||
["clawdbot sandbox recreate --session main", "Recreate a specific session."],
|
||||
["clawdbot sandbox recreate --agent mybot", "Recreate agent containers."],
|
||||
["clawdbot sandbox explain", "Explain effective sandbox config."],
|
||||
],
|
||||
list: [
|
||||
["clawdbot sandbox list", "List all sandbox containers."],
|
||||
["clawdbot sandbox list --browser", "List only browser containers."],
|
||||
["clawdbot sandbox list --json", "JSON output."],
|
||||
],
|
||||
recreate: [
|
||||
["clawdbot sandbox recreate --all", "Recreate all containers."],
|
||||
["clawdbot sandbox recreate --session main", "Recreate a specific session."],
|
||||
["clawdbot sandbox recreate --agent mybot", "Recreate a specific agent (includes sub-agents)."],
|
||||
["clawdbot sandbox recreate --browser --all", "Recreate only browser containers."],
|
||||
["clawdbot sandbox recreate --all --force", "Skip confirmation."],
|
||||
],
|
||||
explain: [
|
||||
["clawdbot sandbox explain", "Show effective sandbox config."],
|
||||
["clawdbot sandbox explain --session agent:main:main", "Explain a specific session."],
|
||||
["clawdbot sandbox explain --agent work", "Explain an agent sandbox."],
|
||||
["clawdbot sandbox explain --json", "JSON output."],
|
||||
],
|
||||
} as const;
|
||||
const EXAMPLES = {
|
||||
main: `
|
||||
Examples:
|
||||
clawdbot sandbox list # List all sandbox containers
|
||||
clawdbot sandbox list --browser # List only browser containers
|
||||
clawdbot sandbox recreate --all # Recreate all containers
|
||||
clawdbot sandbox recreate --session main # Recreate specific session
|
||||
clawdbot sandbox recreate --agent mybot # Recreate agent containers
|
||||
clawdbot sandbox explain # Explain effective sandbox config`,
|
||||
|
||||
list: `
|
||||
Examples:
|
||||
clawdbot sandbox list # List all sandbox containers
|
||||
clawdbot sandbox list --browser # List only browser containers
|
||||
clawdbot sandbox list --json # JSON output
|
||||
|
||||
Output includes:
|
||||
• Container name and status (running/stopped)
|
||||
• Docker image and whether it matches current config
|
||||
• Age (time since creation)
|
||||
• Idle time (time since last use)
|
||||
• Associated session/agent ID`,
|
||||
|
||||
recreate: `
|
||||
Examples:
|
||||
clawdbot sandbox recreate --all # Recreate all containers
|
||||
clawdbot sandbox recreate --session main # Specific session
|
||||
clawdbot sandbox recreate --agent mybot # Specific agent (includes sub-agents)
|
||||
clawdbot sandbox recreate --browser --all # All browser containers only
|
||||
clawdbot sandbox recreate --all --force # Skip confirmation
|
||||
|
||||
Why use this?
|
||||
After updating Docker images or sandbox configuration, existing containers
|
||||
continue running with old settings. This command removes them so they'll be
|
||||
recreated automatically with current config when next needed.
|
||||
|
||||
Filter options:
|
||||
--all Remove all sandbox containers
|
||||
--session Remove container for specific session key
|
||||
--agent Remove containers for agent (includes agent:id:* variants)
|
||||
|
||||
Modifiers:
|
||||
--browser Only affect browser containers (not regular sandbox)
|
||||
--force Skip confirmation prompt`,
|
||||
|
||||
explain: `
|
||||
Examples:
|
||||
clawdbot sandbox explain
|
||||
clawdbot sandbox explain --session agent:main:main
|
||||
clawdbot sandbox explain --agent work
|
||||
clawdbot sandbox explain --json`,
|
||||
};
|
||||
|
||||
function createRunner(
|
||||
commandFn: (opts: CommandOptions, runtime: typeof defaultRuntime) => Promise<void>,
|
||||
@@ -61,10 +84,7 @@ export function registerSandboxCli(program: Command) {
|
||||
const sandbox = program
|
||||
.command("sandbox")
|
||||
.description("Manage sandbox containers (Docker-based agent isolation)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.main)}\n`,
|
||||
)
|
||||
.addHelpText("after", EXAMPLES.main)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
@@ -81,17 +101,7 @@ export function registerSandboxCli(program: Command) {
|
||||
.description("List sandbox containers and their status")
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.option("--browser", "List browser containers only", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.list)}\n\n${theme.heading(
|
||||
"Output includes:",
|
||||
)}\n${theme.muted("- Container name and status (running/stopped)")}\n${theme.muted(
|
||||
"- Docker image and whether it matches current config",
|
||||
)}\n${theme.muted("- Age (time since creation)")}\n${theme.muted(
|
||||
"- Idle time (time since last use)",
|
||||
)}\n${theme.muted("- Associated session/agent ID")}`,
|
||||
)
|
||||
.addHelpText("after", EXAMPLES.list)
|
||||
.action(
|
||||
createRunner((opts) =>
|
||||
sandboxListCommand(
|
||||
@@ -114,25 +124,7 @@ export function registerSandboxCli(program: Command) {
|
||||
.option("--agent <id>", "Recreate containers for specific agent")
|
||||
.option("--browser", "Only recreate browser containers", false)
|
||||
.option("--force", "Skip confirmation prompt", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.recreate)}\n\n${theme.heading(
|
||||
"Why use this?",
|
||||
)}\n${theme.muted(
|
||||
"After updating Docker images or sandbox configuration, existing containers continue running with old settings.",
|
||||
)}\n${theme.muted(
|
||||
"This command removes them so they'll be recreated automatically with current config when next needed.",
|
||||
)}\n\n${theme.heading("Filter options:")}\n${theme.muted(
|
||||
" --all Remove all sandbox containers",
|
||||
)}\n${theme.muted(
|
||||
" --session Remove container for specific session key",
|
||||
)}\n${theme.muted(
|
||||
" --agent Remove containers for agent (includes agent:id:* variants)",
|
||||
)}\n\n${theme.heading("Modifiers:")}\n${theme.muted(
|
||||
" --browser Only affect browser containers (not regular sandbox)",
|
||||
)}\n${theme.muted(" --force Skip confirmation prompt")}`,
|
||||
)
|
||||
.addHelpText("after", EXAMPLES.recreate)
|
||||
.action(
|
||||
createRunner((opts) =>
|
||||
sandboxRecreateCommand(
|
||||
@@ -156,10 +148,7 @@ export function registerSandboxCli(program: Command) {
|
||||
.option("--session <key>", "Session key to inspect (defaults to agent main)")
|
||||
.option("--agent <id>", "Agent id to inspect (defaults to derived agent)")
|
||||
.option("--json", "Output result as JSON", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() => `\n${theme.heading("Examples:")}\n${formatHelpExamples(SANDBOX_EXAMPLES.explain)}\n`,
|
||||
)
|
||||
.addHelpText("after", EXAMPLES.explain)
|
||||
.action(
|
||||
createRunner((opts) =>
|
||||
sandboxExplainCommand(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { loadConfig } from "../config/config.js";
|
||||
@@ -120,7 +121,7 @@ export function registerSecurityCli(program: Command) {
|
||||
lines.push("");
|
||||
lines.push(heading(label));
|
||||
for (const f of list) {
|
||||
lines.push(`${theme.muted(f.checkId)} ${f.title}`);
|
||||
lines.push(`${chalk.gray(f.checkId)} ${f.title}`);
|
||||
lines.push(` ${f.detail}`);
|
||||
if (f.remediation?.trim()) lines.push(` ${muted(`Fix: ${f.remediation.trim()}`)}`);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Command } from "commander";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatHelpExampleGroup } from "./help-format.js";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
import {
|
||||
runDaemonInstall,
|
||||
@@ -21,6 +20,12 @@ import {
|
||||
} from "./node-cli/daemon.js";
|
||||
|
||||
export function registerServiceCli(program: Command) {
|
||||
const formatExample = (cmd: string, desc: string) =>
|
||||
` ${theme.command(cmd)}\n ${theme.muted(desc)}`;
|
||||
|
||||
const formatGroup = (label: string, examples: Array<[string, string]>) =>
|
||||
`${theme.muted(label)}\n${examples.map(([cmd, desc]) => formatExample(cmd, desc)).join("\n")}`;
|
||||
|
||||
const gatewayExamples: Array<[string, string]> = [
|
||||
["clawdbot service gateway status", "Show gateway service status + probe."],
|
||||
[
|
||||
@@ -45,12 +50,10 @@ export function registerServiceCli(program: Command) {
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExampleGroup(
|
||||
"Gateway:",
|
||||
gatewayExamples,
|
||||
)}\n\n${formatHelpExampleGroup("Node:", nodeExamples)}\n\n${theme.muted(
|
||||
"Docs:",
|
||||
)} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
|
||||
`\n${theme.heading("Examples:")}\n${formatGroup("Gateway:", gatewayExamples)}\n\n${formatGroup(
|
||||
"Node:",
|
||||
nodeExamples,
|
||||
)}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/service", "docs.clawd.bot/cli/service")}\n`,
|
||||
);
|
||||
|
||||
const gateway = service.command("gateway").description("Manage the Gateway service");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatCliCommand } from "./command-format.js";
|
||||
|
||||
@@ -31,36 +31,47 @@ function appendClawdHubHint(output: string, json?: boolean): string {
|
||||
return `${output}\n\nTip: use \`npx clawdhub\` to search, install, and sync skills.`;
|
||||
}
|
||||
|
||||
function formatSkillStatus(skill: SkillStatusEntry): string {
|
||||
if (skill.eligible) return theme.success("✓ ready");
|
||||
if (skill.disabled) return theme.warn("⏸ disabled");
|
||||
if (skill.blockedByAllowlist) return theme.warn("🚫 blocked");
|
||||
return theme.error("✗ missing");
|
||||
}
|
||||
|
||||
function formatSkillName(skill: SkillStatusEntry): string {
|
||||
/**
|
||||
* Format a single skill for display in the list
|
||||
*/
|
||||
function formatSkillLine(skill: SkillStatusEntry, verbose = false): string {
|
||||
const emoji = skill.emoji ?? "📦";
|
||||
return `${emoji} ${theme.command(skill.name)}`;
|
||||
}
|
||||
const status = skill.eligible
|
||||
? chalk.green("✓")
|
||||
: skill.disabled
|
||||
? chalk.yellow("disabled")
|
||||
: skill.blockedByAllowlist
|
||||
? chalk.yellow("blocked")
|
||||
: chalk.red("missing reqs");
|
||||
|
||||
function formatSkillMissingSummary(skill: SkillStatusEntry): string {
|
||||
const missing: string[] = [];
|
||||
if (skill.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
|
||||
const name = skill.eligible ? chalk.white(skill.name) : chalk.gray(skill.name);
|
||||
|
||||
const desc = chalk.gray(
|
||||
skill.description.length > 50 ? `${skill.description.slice(0, 47)}...` : skill.description,
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
if (skill.missing.bins.length > 0) {
|
||||
missing.push(`bins: ${skill.missing.bins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.env.length > 0) {
|
||||
missing.push(`env: ${skill.missing.env.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.config.length > 0) {
|
||||
missing.push(`config: ${skill.missing.config.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.os.length > 0) {
|
||||
missing.push(`os: ${skill.missing.os.join(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
|
||||
}
|
||||
if (skill.missing.anyBins.length > 0) {
|
||||
missing.push(`anyBins: ${skill.missing.anyBins.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.env.length > 0) {
|
||||
missing.push(`env: ${skill.missing.env.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.config.length > 0) {
|
||||
missing.push(`config: ${skill.missing.config.join(", ")}`);
|
||||
}
|
||||
if (skill.missing.os.length > 0) {
|
||||
missing.push(`os: ${skill.missing.os.join(", ")}`);
|
||||
}
|
||||
return missing.join("; ");
|
||||
|
||||
return `${emoji} ${name} ${status} - ${desc}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,39 +108,28 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
|
||||
}
|
||||
|
||||
const eligible = skills.filter((s) => s.eligible);
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
const rows = skills.map((skill) => {
|
||||
const missing = formatSkillMissingSummary(skill);
|
||||
return {
|
||||
Status: formatSkillStatus(skill),
|
||||
Skill: formatSkillName(skill),
|
||||
Description: theme.muted(skill.description),
|
||||
Source: skill.source ?? "",
|
||||
Missing: missing ? theme.warn(missing) : "",
|
||||
};
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ key: "Status", header: "Status", minWidth: 10 },
|
||||
{ key: "Skill", header: "Skill", minWidth: 18, flex: true },
|
||||
{ key: "Description", header: "Description", minWidth: 24, flex: true },
|
||||
{ key: "Source", header: "Source", minWidth: 10 },
|
||||
];
|
||||
if (opts.verbose) {
|
||||
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
|
||||
}
|
||||
const notEligible = skills.filter((s) => !s.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${theme.heading("Skills")} ${theme.muted(`(${eligible.length}/${skills.length} ready)`)}`,
|
||||
);
|
||||
lines.push(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns,
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
chalk.bold.cyan("Skills") + chalk.gray(` (${eligible.length}/${skills.length} ready)`),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push(chalk.bold.green("Ready:"));
|
||||
for (const skill of eligible) {
|
||||
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEligible.length > 0 && !opts.eligible) {
|
||||
if (eligible.length > 0) lines.push("");
|
||||
lines.push(chalk.bold.yellow("Not ready:"));
|
||||
for (const skill of notEligible) {
|
||||
lines.push(` ${formatSkillLine(skill, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return appendClawdHubHint(lines.join("\n"), opts.json);
|
||||
}
|
||||
@@ -161,27 +161,27 @@ export function formatSkillInfo(
|
||||
const lines: string[] = [];
|
||||
const emoji = skill.emoji ?? "📦";
|
||||
const status = skill.eligible
|
||||
? theme.success("✓ Ready")
|
||||
? chalk.green("✓ Ready")
|
||||
: skill.disabled
|
||||
? theme.warn("⏸ Disabled")
|
||||
? chalk.yellow("⏸ Disabled")
|
||||
: skill.blockedByAllowlist
|
||||
? theme.warn("🚫 Blocked by allowlist")
|
||||
: theme.error("✗ Missing requirements");
|
||||
? chalk.yellow("🚫 Blocked by allowlist")
|
||||
: chalk.red("✗ Missing requirements");
|
||||
|
||||
lines.push(`${emoji} ${theme.heading(skill.name)} ${status}`);
|
||||
lines.push(`${emoji} ${chalk.bold.cyan(skill.name)} ${status}`);
|
||||
lines.push("");
|
||||
lines.push(skill.description);
|
||||
lines.push(chalk.white(skill.description));
|
||||
lines.push("");
|
||||
|
||||
// Details
|
||||
lines.push(theme.heading("Details:"));
|
||||
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
|
||||
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
|
||||
lines.push(chalk.bold("Details:"));
|
||||
lines.push(` Source: ${skill.source}`);
|
||||
lines.push(` Path: ${chalk.gray(skill.filePath)}`);
|
||||
if (skill.homepage) {
|
||||
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
|
||||
lines.push(` Homepage: ${chalk.blue(skill.homepage)}`);
|
||||
}
|
||||
if (skill.primaryEnv) {
|
||||
lines.push(`${theme.muted(" Primary env:")} ${skill.primaryEnv}`);
|
||||
lines.push(` Primary env: ${skill.primaryEnv}`);
|
||||
}
|
||||
|
||||
// Requirements
|
||||
@@ -194,51 +194,51 @@ export function formatSkillInfo(
|
||||
|
||||
if (hasRequirements) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Requirements:"));
|
||||
lines.push(chalk.bold("Requirements:"));
|
||||
if (skill.requirements.bins.length > 0) {
|
||||
const binsStatus = skill.requirements.bins.map((bin) => {
|
||||
const missing = skill.missing.bins.includes(bin);
|
||||
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
|
||||
lines.push(` Binaries: ${binsStatus.join(", ")}`);
|
||||
}
|
||||
if (skill.requirements.anyBins.length > 0) {
|
||||
const anyBinsMissing = skill.missing.anyBins.length > 0;
|
||||
const anyBinsStatus = skill.requirements.anyBins.map((bin) => {
|
||||
const missing = anyBinsMissing;
|
||||
return missing ? theme.error(`✗ ${bin}`) : theme.success(`✓ ${bin}`);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Any binaries:")} ${anyBinsStatus.join(", ")}`);
|
||||
lines.push(` Any binaries: ${anyBinsStatus.join(", ")}`);
|
||||
}
|
||||
if (skill.requirements.env.length > 0) {
|
||||
const envStatus = skill.requirements.env.map((env) => {
|
||||
const missing = skill.missing.env.includes(env);
|
||||
return missing ? theme.error(`✗ ${env}`) : theme.success(`✓ ${env}`);
|
||||
return missing ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
|
||||
lines.push(` Environment: ${envStatus.join(", ")}`);
|
||||
}
|
||||
if (skill.requirements.config.length > 0) {
|
||||
const configStatus = skill.requirements.config.map((cfg) => {
|
||||
const missing = skill.missing.config.includes(cfg);
|
||||
return missing ? theme.error(`✗ ${cfg}`) : theme.success(`✓ ${cfg}`);
|
||||
return missing ? chalk.red(`✗ ${cfg}`) : chalk.green(`✓ ${cfg}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
|
||||
lines.push(` Config: ${configStatus.join(", ")}`);
|
||||
}
|
||||
if (skill.requirements.os.length > 0) {
|
||||
const osStatus = skill.requirements.os.map((osName) => {
|
||||
const missing = skill.missing.os.includes(osName);
|
||||
return missing ? theme.error(`✗ ${osName}`) : theme.success(`✓ ${osName}`);
|
||||
return missing ? chalk.red(`✗ ${osName}`) : chalk.green(`✓ ${osName}`);
|
||||
});
|
||||
lines.push(`${theme.muted(" OS:")} ${osStatus.join(", ")}`);
|
||||
lines.push(` OS: ${osStatus.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Install options
|
||||
if (skill.install.length > 0 && !skill.eligible) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Install options:"));
|
||||
lines.push(chalk.bold("Install options:"));
|
||||
for (const inst of skill.install) {
|
||||
lines.push(` ${theme.warn("→")} ${inst.label}`);
|
||||
lines.push(` ${chalk.yellow("→")} ${inst.label}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,17 +281,17 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.heading("Skills Status Check"));
|
||||
lines.push(chalk.bold.cyan("Skills Status Check"));
|
||||
lines.push("");
|
||||
lines.push(`${theme.muted("Total:")} ${report.skills.length}`);
|
||||
lines.push(`${theme.success("✓")} ${theme.muted("Eligible:")} ${eligible.length}`);
|
||||
lines.push(`${theme.warn("⏸")} ${theme.muted("Disabled:")} ${disabled.length}`);
|
||||
lines.push(`${theme.warn("🚫")} ${theme.muted("Blocked by allowlist:")} ${blocked.length}`);
|
||||
lines.push(`${theme.error("✗")} ${theme.muted("Missing requirements:")} ${missingReqs.length}`);
|
||||
lines.push(`Total: ${report.skills.length}`);
|
||||
lines.push(`${chalk.green("✓")} Eligible: ${eligible.length}`);
|
||||
lines.push(`${chalk.yellow("⏸")} Disabled: ${disabled.length}`);
|
||||
lines.push(`${chalk.yellow("🚫")} Blocked by allowlist: ${blocked.length}`);
|
||||
lines.push(`${chalk.red("✗")} Missing requirements: ${missingReqs.length}`);
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Ready to use:"));
|
||||
lines.push(chalk.bold.green("Ready to use:"));
|
||||
for (const skill of eligible) {
|
||||
const emoji = skill.emoji ?? "📦";
|
||||
lines.push(` ${emoji} ${skill.name}`);
|
||||
@@ -300,7 +300,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
|
||||
|
||||
if (missingReqs.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(theme.heading("Missing requirements:"));
|
||||
lines.push(chalk.bold.red("Missing requirements:"));
|
||||
for (const skill of missingReqs) {
|
||||
const emoji = skill.emoji ?? "📦";
|
||||
const missing: string[] = [];
|
||||
@@ -319,7 +319,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
|
||||
if (skill.missing.os.length > 0) {
|
||||
missing.push(`os: ${skill.missing.os.join(", ")}`);
|
||||
}
|
||||
lines.push(` ${emoji} ${skill.name} ${theme.muted(`(${missing.join("; ")})`)}`);
|
||||
lines.push(` ${emoji} ${skill.name} ${chalk.gray(`(${missing.join("; ")})`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ export function registerSkillsCli(program: Command) {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
|
||||
defaultRuntime.log(formatSkillsList(report, opts));
|
||||
console.log(formatSkillsList(report, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -367,7 +367,7 @@ export function registerSkillsCli(program: Command) {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
|
||||
defaultRuntime.log(formatSkillInfo(report, name, opts));
|
||||
console.log(formatSkillInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -383,7 +383,7 @@ export function registerSkillsCli(program: Command) {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
|
||||
defaultRuntime.log(formatSkillsCheck(report, opts));
|
||||
console.log(formatSkillsCheck(report, opts));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
@@ -396,7 +396,7 @@ export function registerSkillsCli(program: Command) {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceSkillStatus(workspaceDir, { config });
|
||||
defaultRuntime.log(formatSkillsList(report, {}));
|
||||
console.log(formatSkillsList(report, {}));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
defaultRuntime.exit(1);
|
||||
|
||||
@@ -447,7 +447,6 @@ describe("update-cli", () => {
|
||||
it("requires confirmation on downgrade when non-interactive", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||
try {
|
||||
setTty(false);
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||
@@ -484,45 +483,4 @@ describe("update-cli", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("allows downgrade with --yes in non-interactive mode", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-"));
|
||||
try {
|
||||
setTty(false);
|
||||
await fs.writeFile(
|
||||
path.join(tempDir, "package.json"),
|
||||
JSON.stringify({ name: "clawdbot", version: "2.0.0" }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { resolveClawdbotPackageRoot } = await import("../infra/clawdbot-root.js");
|
||||
const { resolveNpmChannelTag } = await import("../infra/update-check.js");
|
||||
const { runGatewayUpdate } = await import("../infra/update-runner.js");
|
||||
const { defaultRuntime } = await import("../runtime.js");
|
||||
const { updateCommand } = await import("./update-cli.js");
|
||||
|
||||
vi.mocked(resolveClawdbotPackageRoot).mockResolvedValue(tempDir);
|
||||
vi.mocked(resolveNpmChannelTag).mockResolvedValue({
|
||||
tag: "latest",
|
||||
version: "0.0.1",
|
||||
});
|
||||
vi.mocked(runGatewayUpdate).mockResolvedValue({
|
||||
status: "ok",
|
||||
mode: "npm",
|
||||
steps: [],
|
||||
durationMs: 100,
|
||||
});
|
||||
vi.mocked(defaultRuntime.error).mockClear();
|
||||
vi.mocked(defaultRuntime.exit).mockClear();
|
||||
|
||||
await updateCommand({ yes: true });
|
||||
|
||||
expect(defaultRuntime.error).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Downgrade confirmation required."),
|
||||
);
|
||||
expect(runGatewayUpdate).toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,7 +32,6 @@ import { formatCliCommand } from "./command-format.js";
|
||||
import { stylePromptMessage } from "../terminal/prompt-style.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { formatHelpExamples } from "./help-format.js";
|
||||
import {
|
||||
formatUpdateAvailableHint,
|
||||
formatUpdateOneLiner,
|
||||
@@ -46,7 +45,6 @@ export type UpdateCommandOptions = {
|
||||
channel?: string;
|
||||
tag?: string;
|
||||
timeout?: string;
|
||||
yes?: boolean;
|
||||
};
|
||||
export type UpdateStatusOptions = {
|
||||
json?: boolean;
|
||||
@@ -377,8 +375,6 @@ function printResult(result: UpdateRunResult, opts: PrintResultOptions) {
|
||||
}
|
||||
|
||||
export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
process.noDeprecation = true;
|
||||
process.env.NODE_NO_WARNINGS = "1";
|
||||
const timeoutMs = opts.timeout ? Number.parseInt(opts.timeout, 10) * 1000 : undefined;
|
||||
|
||||
if (timeoutMs !== undefined && (Number.isNaN(timeoutMs) || timeoutMs <= 0)) {
|
||||
@@ -431,7 +427,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
|
||||
const needsConfirm =
|
||||
currentVersion != null && (targetVersion == null || (cmp != null && cmp > 0));
|
||||
|
||||
if (needsConfirm && !opts.yes) {
|
||||
if (needsConfirm) {
|
||||
if (!process.stdin.isTTY || opts.json) {
|
||||
defaultRuntime.error(
|
||||
[
|
||||
@@ -671,46 +667,27 @@ export function registerUpdateCli(program: Command) {
|
||||
.option("--channel <stable|beta|dev>", "Persist update channel (git + npm)")
|
||||
.option("--tag <dist-tag|version>", "Override npm dist-tag or version for this update")
|
||||
.option("--timeout <seconds>", "Timeout for each update step in seconds (default: 1200)")
|
||||
.option("--yes", "Skip confirmation prompts (non-interactive)", false)
|
||||
.addHelpText("after", () => {
|
||||
const examples = [
|
||||
["clawdbot update", "Update a source checkout (git)"],
|
||||
["clawdbot update --channel beta", "Switch to beta channel (git + npm)"],
|
||||
["clawdbot update --channel dev", "Switch to dev channel (git + npm)"],
|
||||
["clawdbot update --tag beta", "One-off update to a dist-tag or version"],
|
||||
["clawdbot update --restart", "Update and restart the daemon"],
|
||||
["clawdbot update --json", "Output result as JSON"],
|
||||
["clawdbot update --yes", "Non-interactive (accept downgrade prompts)"],
|
||||
["clawdbot --update", "Shorthand for clawdbot update"],
|
||||
] as const;
|
||||
const fmtExamples = examples
|
||||
.map(([cmd, desc]) => ` ${theme.command(cmd)} ${theme.muted(`# ${desc}`)}`)
|
||||
.join("\n");
|
||||
return `
|
||||
${theme.heading("What this does:")}
|
||||
- Git checkouts: fetches, rebases, installs deps, builds, and runs doctor
|
||||
- npm installs: updates via detected package manager
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`
|
||||
Examples:
|
||||
clawdbot update # Update a source checkout (git)
|
||||
clawdbot update --channel beta # Switch to beta channel (git + npm)
|
||||
clawdbot update --channel dev # Switch to dev channel (git + npm)
|
||||
clawdbot update --tag beta # One-off update to a dist-tag or version
|
||||
clawdbot update --restart # Update and restart the daemon
|
||||
clawdbot update --json # Output result as JSON
|
||||
clawdbot --update # Shorthand for clawdbot update
|
||||
|
||||
${theme.heading("Switch channels:")}
|
||||
- Use --channel stable|beta|dev to persist the update channel in config
|
||||
- Run clawdbot update status to see the active channel and source
|
||||
- Use --tag <dist-tag|version> for a one-off npm update without persisting
|
||||
|
||||
${theme.heading("Non-interactive:")}
|
||||
- Use --yes to accept downgrade prompts
|
||||
- Combine with --channel/--tag/--restart/--json/--timeout as needed
|
||||
|
||||
${theme.heading("Examples:")}
|
||||
${fmtExamples}
|
||||
|
||||
${theme.heading("Notes:")}
|
||||
- Switch channels with --channel stable|beta|dev
|
||||
Notes:
|
||||
- For git installs: fetches, rebases, installs deps, builds, and runs doctor
|
||||
- For global installs: auto-updates via detected package manager when possible (see docs/install/updating.md)
|
||||
- Downgrades require confirmation (can break configuration)
|
||||
- Skips update if the working directory has uncommitted changes
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`;
|
||||
})
|
||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
await updateCommand({
|
||||
@@ -719,7 +696,6 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
|
||||
channel: opts.channel as string | undefined,
|
||||
tag: opts.tag as string | undefined,
|
||||
timeout: opts.timeout as string | undefined,
|
||||
yes: Boolean(opts.yes),
|
||||
});
|
||||
} catch (err) {
|
||||
defaultRuntime.error(String(err));
|
||||
@@ -735,15 +711,17 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/upda
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["clawdbot update status", "Show channel + version status."],
|
||||
["clawdbot update status --json", "JSON output."],
|
||||
["clawdbot update status --timeout 10", "Custom timeout."],
|
||||
])}\n\n${theme.heading("Notes:")}\n${theme.muted(
|
||||
"- Shows current update channel (stable/beta/dev) and source",
|
||||
)}\n${theme.muted("- Includes git tag/branch/SHA for source checkouts")}\n\n${theme.muted(
|
||||
"Docs:",
|
||||
)} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
||||
`
|
||||
Examples:
|
||||
clawdbot update status
|
||||
clawdbot update status --json
|
||||
clawdbot update status --timeout 10
|
||||
|
||||
Notes:
|
||||
- Shows current update channel (stable/beta/dev) and source
|
||||
- Includes git tag/branch/SHA for source checkouts
|
||||
|
||||
${theme.muted("Docs:")} ${formatDocsLink("/cli/update", "docs.clawd.bot/cli/update")}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
|
||||
@@ -38,24 +38,6 @@ function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeNodeInvokeResultParams(params: unknown): unknown {
|
||||
if (!params || typeof params !== "object") return params;
|
||||
const raw = params as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = { ...raw };
|
||||
if (normalized.payloadJSON === null) {
|
||||
delete normalized.payloadJSON;
|
||||
} else if (normalized.payloadJSON !== undefined && typeof normalized.payloadJSON !== "string") {
|
||||
if (normalized.payload === undefined) {
|
||||
normalized.payload = normalized.payloadJSON;
|
||||
}
|
||||
delete normalized.payloadJSON;
|
||||
}
|
||||
if (normalized.error === null) {
|
||||
delete normalized.error;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export const nodeHandlers: GatewayRequestHandlers = {
|
||||
"node.pair.request": async ({ params, respond, context }) => {
|
||||
if (!validateNodePairRequestParams(params)) {
|
||||
@@ -435,8 +417,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
},
|
||||
"node.invoke.result": async ({ params, respond, context, client }) => {
|
||||
const normalizedParams = normalizeNodeInvokeResultParams(params);
|
||||
if (!validateNodeInvokeResultParams(normalizedParams)) {
|
||||
if (!validateNodeInvokeResultParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
method: "node.invoke.result",
|
||||
@@ -444,7 +425,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const p = normalizedParams as {
|
||||
const p = params as {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
ok: boolean;
|
||||
|
||||
@@ -172,55 +172,4 @@ describe("gateway node command allowlist", () => {
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("accepts node invoke result with null payloadJSON", async () => {
|
||||
const { server, ws, port } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
|
||||
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||
resolveInvoke = resolve;
|
||||
});
|
||||
const nodeClient = await connectNodeClient({
|
||||
port,
|
||||
commands: ["canvas.snapshot"],
|
||||
instanceId: "node-null-payloadjson",
|
||||
displayName: "node-null-payloadjson",
|
||||
onEvent: (evt) => {
|
||||
if (evt.event === "node.invoke.request") {
|
||||
const payload = evt.payload as { id?: string; nodeId?: string };
|
||||
resolveInvoke?.(payload);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
|
||||
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
|
||||
expect(nodeId).toBeTruthy();
|
||||
|
||||
const invokeResP = rpcReq(ws, "node.invoke", {
|
||||
nodeId,
|
||||
command: "canvas.snapshot",
|
||||
params: { format: "png" },
|
||||
idempotencyKey: "allowlist-null-payloadjson",
|
||||
});
|
||||
|
||||
const payload = await invokeReqP;
|
||||
const requestId = payload?.id ?? "";
|
||||
const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson";
|
||||
|
||||
await nodeClient.request("node.invoke.result", {
|
||||
id: requestId,
|
||||
nodeId: nodeIdFromReq,
|
||||
ok: true,
|
||||
payloadJSON: null,
|
||||
});
|
||||
|
||||
const invokeRes = await invokeResP;
|
||||
expect(invokeRes.ok).toBe(true);
|
||||
|
||||
nodeClient.stop();
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -719,17 +719,13 @@ export function attachGatewayWsMessageHandler(params: {
|
||||
if (role === "node") {
|
||||
const context = buildRequestContext();
|
||||
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
|
||||
const instanceIdRaw = connectParams.client.instanceId;
|
||||
const instanceId = typeof instanceIdRaw === "string" ? instanceIdRaw.trim() : "";
|
||||
const nodeIdsForPairing = new Set<string>([nodeSession.nodeId]);
|
||||
if (instanceId) nodeIdsForPairing.add(instanceId);
|
||||
for (const nodeId of nodeIdsForPairing) {
|
||||
void updatePairedNodeMetadata(nodeId, {
|
||||
lastConnectedAtMs: nodeSession.connectedAtMs,
|
||||
}).catch((err) =>
|
||||
logGateway.warn(`failed to record last connect for ${nodeId}: ${formatForLog(err)}`),
|
||||
);
|
||||
}
|
||||
void updatePairedNodeMetadata(nodeSession.nodeId, {
|
||||
lastConnectedAtMs: nodeSession.connectedAtMs,
|
||||
}).catch((err) =>
|
||||
logGateway.warn(
|
||||
`failed to record last connect for ${nodeSession.nodeId}: ${formatForLog(err)}`,
|
||||
),
|
||||
);
|
||||
recordRemoteNodeInfo({
|
||||
nodeId: nodeSession.nodeId,
|
||||
displayName: nodeSession.displayName,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { buildNodeInvokeResultParams } from "./runner.js";
|
||||
|
||||
describe("buildNodeInvokeResultParams", () => {
|
||||
test("omits optional fields when null/undefined", () => {
|
||||
const params = buildNodeInvokeResultParams(
|
||||
{ id: "invoke-1", nodeId: "node-1", command: "system.run" },
|
||||
{ ok: true, payloadJSON: null, error: null },
|
||||
);
|
||||
|
||||
expect(params).toEqual({ id: "invoke-1", nodeId: "node-1", ok: true });
|
||||
expect("payloadJSON" in params).toBe(false);
|
||||
expect("error" in params).toBe(false);
|
||||
});
|
||||
|
||||
test("includes payloadJSON when provided", () => {
|
||||
const params = buildNodeInvokeResultParams(
|
||||
{ id: "invoke-2", nodeId: "node-2", command: "system.run" },
|
||||
{ ok: true, payloadJSON: '{"ok":true}' },
|
||||
);
|
||||
|
||||
expect(params.payloadJSON).toBe('{"ok":true}');
|
||||
});
|
||||
|
||||
test("includes payload when provided", () => {
|
||||
const params = buildNodeInvokeResultParams(
|
||||
{ id: "invoke-3", nodeId: "node-3", command: "system.run" },
|
||||
{ ok: false, payload: { reason: "bad" } },
|
||||
);
|
||||
|
||||
expect(params.payload).toEqual({ reason: "bad" });
|
||||
});
|
||||
});
|
||||
@@ -104,8 +104,7 @@ const OUTPUT_CAP = 200_000;
|
||||
const OUTPUT_EVENT_TAIL = 20_000;
|
||||
|
||||
const execHostEnforced = process.env.CLAWDBOT_NODE_EXEC_HOST?.trim().toLowerCase() === "app";
|
||||
const execHostFallbackAllowed =
|
||||
process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim().toLowerCase() !== "0";
|
||||
const execHostFallbackAllowed = process.env.CLAWDBOT_NODE_EXEC_FALLBACK?.trim() === "1";
|
||||
|
||||
const blockedEnvKeys = new Set([
|
||||
"PATH",
|
||||
@@ -560,7 +559,8 @@ async function handleInvoke(
|
||||
const skillAllow =
|
||||
autoAllowSkills && resolution?.executableName ? bins.has(resolution.executableName) : false;
|
||||
|
||||
const useMacAppExec = process.platform === "darwin";
|
||||
const useMacAppExec =
|
||||
process.platform === "darwin" && (execHostEnforced || !execHostFallbackAllowed);
|
||||
if (useMacAppExec) {
|
||||
const approvalDecision =
|
||||
params.approvalDecision === "allow-once" || params.approvalDecision === "allow-always"
|
||||
@@ -579,28 +579,28 @@ async function handleInvoke(
|
||||
};
|
||||
const response = await runViaMacAppExecHost({ approvals, request: execRequest });
|
||||
if (!response) {
|
||||
if (execHostEnforced || !execHostFallbackAllowed) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "companion-unavailable",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!response.ok) {
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.denied",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
reason: "companion-unavailable",
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "UNAVAILABLE",
|
||||
message: "COMPANION_APP_UNAVAILABLE: macOS app exec host unreachable",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const reason = response.error.reason ?? "approval-required";
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
@@ -618,29 +618,29 @@ async function handleInvoke(
|
||||
error: { code: "UNAVAILABLE", message: response.error.message },
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
const result: ExecHostRunResult = response.payload;
|
||||
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.finished",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: combined,
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(result),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result: ExecHostRunResult = response.payload;
|
||||
const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n");
|
||||
await sendNodeEvent(
|
||||
client,
|
||||
"exec.finished",
|
||||
buildExecEventPayload({
|
||||
sessionKey,
|
||||
runId,
|
||||
host: "node",
|
||||
command: cmdText,
|
||||
exitCode: result.exitCode,
|
||||
timedOut: result.timedOut,
|
||||
success: result.success,
|
||||
output: combined,
|
||||
}),
|
||||
);
|
||||
await sendInvokeResult(client, frame, {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify(result),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (security === "deny") {
|
||||
@@ -833,52 +833,19 @@ async function sendInvokeResult(
|
||||
},
|
||||
) {
|
||||
try {
|
||||
await client.request("node.invoke.result", buildNodeInvokeResultParams(frame, result));
|
||||
await client.request("node.invoke.result", {
|
||||
id: frame.id,
|
||||
nodeId: frame.nodeId,
|
||||
ok: result.ok,
|
||||
payload: result.payload,
|
||||
payloadJSON: result.payloadJSON ?? null,
|
||||
error: result.error ?? null,
|
||||
});
|
||||
} catch {
|
||||
// ignore: node invoke responses are best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export function buildNodeInvokeResultParams(
|
||||
frame: NodeInvokeRequestPayload,
|
||||
result: {
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
},
|
||||
): {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string;
|
||||
error?: { code?: string; message?: string };
|
||||
} {
|
||||
const params: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string;
|
||||
error?: { code?: string; message?: string };
|
||||
} = {
|
||||
id: frame.id,
|
||||
nodeId: frame.nodeId,
|
||||
ok: result.ok,
|
||||
};
|
||||
if (result.payload !== undefined) {
|
||||
params.payload = result.payload;
|
||||
}
|
||||
if (typeof result.payloadJSON === "string") {
|
||||
params.payloadJSON = result.payloadJSON;
|
||||
}
|
||||
if (result.error) {
|
||||
params.error = result.error;
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
async function sendNodeEvent(client: GatewayClient, event: string, payload: unknown) {
|
||||
try {
|
||||
await client.request("node.event", {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { execFile, spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { danger, shouldLogVerbose } from "../globals.js";
|
||||
@@ -62,28 +61,12 @@ export async function runCommandWithTimeout(
|
||||
const { windowsVerbatimArguments } = options;
|
||||
const hasInput = input !== undefined;
|
||||
|
||||
const shouldSuppressNpmFund = (() => {
|
||||
const cmd = path.basename(argv[0] ?? "");
|
||||
if (cmd === "npm" || cmd === "npm.cmd" || cmd === "npm.exe") return true;
|
||||
if (cmd === "node" || cmd === "node.exe") {
|
||||
const script = argv[1] ?? "";
|
||||
return script.includes("npm-cli.js");
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
const resolvedEnv = env ? { ...process.env, ...env } : { ...process.env };
|
||||
if (shouldSuppressNpmFund) {
|
||||
if (resolvedEnv.NPM_CONFIG_FUND == null) resolvedEnv.NPM_CONFIG_FUND = "false";
|
||||
if (resolvedEnv.npm_config_fund == null) resolvedEnv.npm_config_fund = "false";
|
||||
}
|
||||
|
||||
// Spawn with inherited stdin (TTY) so tools like `pi` stay interactive when needed.
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(argv[0], argv.slice(1), {
|
||||
stdio: [hasInput ? "pipe" : "inherit", "pipe", "pipe"],
|
||||
cwd,
|
||||
env: resolvedEnv,
|
||||
env: env ? { ...process.env, ...env } : process.env,
|
||||
windowsVerbatimArguments,
|
||||
});
|
||||
let stdout = "";
|
||||
|
||||
@@ -179,8 +179,6 @@ export const registerTelegramHandlers = ({
|
||||
const callback = ctx.callbackQuery;
|
||||
if (!callback) return;
|
||||
if (shouldSkipUpdate(ctx)) return;
|
||||
// Answer immediately to prevent Telegram from retrying while we process
|
||||
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
|
||||
try {
|
||||
const data = (callback.data ?? "").trim();
|
||||
const callbackMessage = callback.message;
|
||||
@@ -325,6 +323,8 @@ export const registerTelegramHandlers = ({
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(danger(`callback handler failed: ${String(err)}`));
|
||||
} finally {
|
||||
await bot.api.answerCallbackQuery(callback.id).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createEditorSubmitHandler } from "./tui.js";
|
||||
|
||||
describe("createEditorSubmitHandler", () => {
|
||||
it("adds submitted messages to editor history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
handler("hello world");
|
||||
|
||||
expect(editor.setText).toHaveBeenCalledWith("");
|
||||
expect(editor.addToHistory).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("trims input before adding to history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
handler(" hi ");
|
||||
|
||||
expect(editor.addToHistory).toHaveBeenCalledWith("hi");
|
||||
});
|
||||
|
||||
it("does not add empty submissions to history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
});
|
||||
|
||||
handler(" ");
|
||||
|
||||
expect(editor.addToHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes slash commands to handleCommand", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleCommand = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
handler("/models");
|
||||
|
||||
expect(editor.addToHistory).toHaveBeenCalledWith("/models");
|
||||
expect(handleCommand).toHaveBeenCalledWith("/models");
|
||||
expect(sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes normal messages to sendMessage", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleCommand = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
});
|
||||
|
||||
handler("hello");
|
||||
|
||||
expect(editor.addToHistory).toHaveBeenCalledWith("hello");
|
||||
expect(sendMessage).toHaveBeenCalledWith("hello");
|
||||
expect(handleCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -36,30 +36,6 @@ import type {
|
||||
export { resolveFinalAssistantText } from "./tui-formatters.js";
|
||||
export type { TuiOptions } from "./tui-types.js";
|
||||
|
||||
export function createEditorSubmitHandler(params: {
|
||||
editor: {
|
||||
setText: (value: string) => void;
|
||||
addToHistory: (value: string) => void;
|
||||
};
|
||||
handleCommand: (value: string) => Promise<void> | void;
|
||||
sendMessage: (value: string) => Promise<void> | void;
|
||||
}) {
|
||||
return (text: string) => {
|
||||
const value = text.trim();
|
||||
params.editor.setText("");
|
||||
if (!value) return;
|
||||
|
||||
// Enable built-in editor prompt history navigation (up/down).
|
||||
params.editor.addToHistory(value);
|
||||
|
||||
if (value.startsWith("/")) {
|
||||
void params.handleCommand(value);
|
||||
return;
|
||||
}
|
||||
void params.sendMessage(value);
|
||||
};
|
||||
}
|
||||
|
||||
export async function runTui(opts: TuiOptions) {
|
||||
const config = loadConfig();
|
||||
const initialSessionInput = (opts.session ?? "").trim();
|
||||
@@ -497,11 +473,16 @@ export async function runTui(opts: TuiOptions) {
|
||||
});
|
||||
|
||||
updateAutocompleteProvider();
|
||||
editor.onSubmit = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
});
|
||||
editor.onSubmit = (text) => {
|
||||
const value = text.trim();
|
||||
editor.setText("");
|
||||
if (!value) return;
|
||||
if (value.startsWith("/")) {
|
||||
void handleCommand(value);
|
||||
return;
|
||||
}
|
||||
void sendMessage(value);
|
||||
};
|
||||
|
||||
editor.onEscape = () => {
|
||||
void abortActive();
|
||||
|
||||
Reference in New Issue
Block a user