feat: implement two-shim CLI install with fallback instructions

This commit is contained in:
Mohamed Boudra
2026-03-08 15:40:02 +07:00
parent 2d5d0dcacd
commit 2a1ef17107
15 changed files with 1176 additions and 244 deletions
+9 -9
View File
@@ -66,7 +66,7 @@ export function createCli(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
program
@@ -86,14 +86,14 @@ export function createCli(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
program
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
program
@@ -104,7 +104,7 @@ export function createCli(): Command {
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--since <time>', 'Show logs since timestamp')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
program
@@ -114,7 +114,7 @@ export function createCli(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
program
@@ -124,7 +124,7 @@ export function createCli(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
program
@@ -135,7 +135,7 @@ export function createCli(): Command {
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
program
@@ -143,7 +143,7 @@ export function createCli(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
program
@@ -152,7 +152,7 @@ export function createCli(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Top-level local daemon shortcuts
+12 -12
View File
@@ -30,7 +30,7 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
agent
@@ -47,14 +47,14 @@ export function createAgentCommand(): Command {
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runRunCommand))
agent
.command('attach')
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runAttachCommand)
agent
@@ -64,7 +64,7 @@ export function createAgentCommand(): Command {
.option('-f, --follow', 'Follow log output (streaming)')
.option('--tail <n>', 'Show last n entries')
.option('--filter <type>', 'Filter by event type (tools, text, errors, permissions)')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(runLogsCommand)
agent
@@ -74,7 +74,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runStopCommand))
agent
@@ -84,7 +84,7 @@ export function createAgentCommand(): Command {
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDeleteCommand))
agent
@@ -94,7 +94,7 @@ export function createAgentCommand(): Command {
.argument('<prompt>', 'The message to send')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runSendCommand))
agent
@@ -102,7 +102,7 @@ export function createAgentCommand(): Command {
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runInspectCommand))
agent
@@ -111,7 +111,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runWaitCommand))
// Advanced agent commands (less common operations)
@@ -122,7 +122,7 @@ export function createAgentCommand(): Command {
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModeCommand))
agent
@@ -131,7 +131,7 @@ export function createAgentCommand(): Command {
.argument('<id>', 'Agent ID (or prefix)')
.option('--force', 'Force archive running agent (interrupts active run first)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
agent
@@ -146,7 +146,7 @@ export function createAgentCommand(): Command {
[]
)
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runUpdateCommand))
return agent
+3 -3
View File
@@ -11,7 +11,7 @@ export function createPermitCommand(): Command {
.command('ls')
.description('List all pending permissions')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
permit
@@ -22,7 +22,7 @@ export function createPermitCommand(): Command {
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runAllowCommand))
permit
@@ -34,7 +34,7 @@ export function createPermitCommand(): Command {
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runDenyCommand))
return permit
+2 -2
View File
@@ -10,7 +10,7 @@ export function createProviderCommand(): Command {
.command('ls')
.description('List available providers and status')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
provider
@@ -19,7 +19,7 @@ export function createProviderCommand(): Command {
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runModelsCommand))
return provider
+2 -2
View File
@@ -14,7 +14,7 @@ export function createSpeechCommand(): Command {
.command("models")
.description("List local speech model download status")
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechModelsCommand));
speech
@@ -22,7 +22,7 @@ export function createSpeechCommand(): Command {
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, [])
.option("--json", "Output in JSON format")
.option("--host <host>", "Daemon host:port (default: localhost:6767)")
.option("--host <host>", "Daemon host target (default: local socket/pipe, then localhost:6767)")
.action(withOutput(runSpeechDownloadCommand));
return speech;
+2 -2
View File
@@ -10,7 +10,7 @@ export function createWorktreeCommand(): Command {
.command('ls')
.description('List Paseo-managed git worktrees')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runLsCommand))
worktree
@@ -18,7 +18,7 @@ export function createWorktreeCommand(): Command {
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
.option('--json', 'Output in JSON format')
.option('--host <host>', 'Daemon host:port (default: localhost:6767)')
.option('--host <host>', 'Daemon host target (default: local socket/pipe, then localhost:6767)')
.action(withOutput(runArchiveCommand))
return worktree
+146 -38
View File
@@ -1,4 +1,6 @@
import { DaemonClient } from '@getpaseo/server'
import { existsSync, readFileSync } from 'node:fs'
import { loadConfig, resolvePaseoHome, DaemonClient } from '@getpaseo/server'
import path from 'node:path'
import WebSocket from 'ws'
import { getOrCreateCliClientId } from './client-id.js'
@@ -9,6 +11,7 @@ export interface ConnectOptions {
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
const PID_FILENAME = 'paseo.pid'
type DaemonTarget =
| {
@@ -25,7 +28,106 @@ type DaemonTarget =
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return options?.host ?? process.env.PASEO_HOST ?? DEFAULT_HOST
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim()
if (!trimmed) {
return null
}
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
) {
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
}
if (path.isAbsolute(trimmed)) {
return `unix://${trimmed}`
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`
}
return trimmed.includes(':') ? trimmed : null
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host)
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME)
if (!existsSync(pidPath)) {
return null
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { sockPath?: unknown }
return typeof parsed.sockPath === 'string' ? parsed.sockPath : null
} catch {
return null
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
if (isIpcDaemonHost(pidHost)) {
return pidHost
}
const config = loadConfig(paseoHome, { env })
const configuredHost = normalizeDaemonHost(config.listen)
return isIpcDaemonHost(configuredHost) ? configuredHost : null
}
function resolveConfiguredTcpDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen)
if (!isTcpDaemonHost(configuredHost)) {
return null
}
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
}
export function resolveDefaultDaemonHosts(env: NodeJS.ProcessEnv = process.env): string[] {
const paseoHome = resolvePaseoHome(env)
const candidates: string[] = []
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome)
if (configuredIpcHost) {
candidates.push(configuredIpcHost)
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
if (configuredTcpHost) {
candidates.push(configuredTcpHost)
}
candidates.push(DEFAULT_HOST)
return Array.from(new Set(candidates))
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST
if (explicitHost) {
return [explicitHost]
}
return resolveDefaultDaemonHosts()
}
export function resolveDaemonTarget(host: string): DaemonTarget {
@@ -86,50 +188,56 @@ function createNodeWebSocketFactory() {
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const host = getDaemonHost(options)
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const target = resolveDaemonTarget(host)
const hosts = resolveDaemonHostCandidates(options)
const nodeWebSocketFactory = createNodeWebSocketFactory()
let lastError: unknown = null
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
for (const host of hosts) {
const target = resolveDaemonTarget(host)
const client = new DaemonClient(
{
url: target.url,
clientId,
clientType: 'cli',
webSocketFactory: (url: string, config?: { headers?: Record<string, string> }) =>
nodeWebSocketFactory(url, {
headers: config?.headers,
...(target.type === 'ipc' ? { socketPath: target.socketPath } : {}),
}),
reconnect: { enabled: false },
} as unknown as ConstructorParameters<typeof DaemonClient>[0]
)
// Connect with timeout
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`))
}, timeout)
})
try {
await Promise.race([connectPromise, timeoutPromise])
// Clear the timeout so it doesn't keep the event loop alive
if (timeoutHandle) {
clearTimeout(timeoutHandle)
try {
await Promise.race([connectPromise, timeoutPromise])
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
return client
} catch (err) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
lastError = err
await client.close().catch(() => {})
}
return client
} catch (err) {
// Clear the timeout on error too
if (timeoutHandle) {
clearTimeout(timeoutHandle)
}
await client.close().catch(() => {})
throw err
}
if (lastError instanceof Error) {
throw lastError
}
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
}
/**