Switch to double quotes and reformat codebase with Biome

This commit is contained in:
Mohamed Boudra
2026-03-21 20:22:15 +07:00
parent ab40786882
commit f6579dbeb4
805 changed files with 38132 additions and 40175 deletions
+157 -134
View File
@@ -1,207 +1,230 @@
import { Command } from 'commander'
import { createRequire } from 'node:module'
import { createAgentCommand } from './commands/agent/index.js'
import { createDaemonCommand } from './commands/daemon/index.js'
import { createPermitCommand } from './commands/permit/index.js'
import { createProviderCommand } from './commands/provider/index.js'
import { createSpeechCommand } from './commands/speech/index.js'
import { createWorktreeCommand } from './commands/worktree/index.js'
import { startCommand as daemonStartCommand } from './commands/daemon/start.js'
import { runStatusCommand as runDaemonStatusCommand } from './commands/daemon/status.js'
import { runRestartCommand as runDaemonRestartCommand } from './commands/daemon/restart.js'
import { runLsCommand } from './commands/agent/ls.js'
import { runRunCommand } from './commands/agent/run.js'
import { runLogsCommand } from './commands/agent/logs.js'
import { runDeleteCommand } from './commands/agent/delete.js'
import { runStopCommand } from './commands/agent/stop.js'
import { runSendCommand } from './commands/agent/send.js'
import { runInspectCommand } from './commands/agent/inspect.js'
import { runWaitCommand } from './commands/agent/wait.js'
import { runAttachCommand } from './commands/agent/attach.js'
import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js'
import { Command } from "commander";
import { createRequire } from "node:module";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createSpeechCommand } from "./commands/speech/index.js";
import { createWorktreeCommand } from "./commands/worktree/index.js";
import { startCommand as daemonStartCommand } from "./commands/daemon/start.js";
import { runStatusCommand as runDaemonStatusCommand } from "./commands/daemon/status.js";
import { runRestartCommand as runDaemonRestartCommand } from "./commands/daemon/restart.js";
import { runLsCommand } from "./commands/agent/ls.js";
import { runRunCommand } from "./commands/agent/run.js";
import { runLogsCommand } from "./commands/agent/logs.js";
import { runDeleteCommand } from "./commands/agent/delete.js";
import { runStopCommand } from "./commands/agent/stop.js";
import { runSendCommand } from "./commands/agent/send.js";
import { runInspectCommand } from "./commands/agent/inspect.js";
import { runWaitCommand } from "./commands/agent/wait.js";
import { runAttachCommand } from "./commands/agent/attach.js";
import { withOutput } from "./output/index.js";
import { onboardCommand } from "./commands/onboard.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
addJsonOption,
collectMultiple,
} from './utils/command-options.js'
} from "./utils/command-options.js";
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
type CliPackageJson = {
version?: unknown
}
version?: unknown;
};
function resolveCliVersion(): string {
const packageJson = require('../package.json') as CliPackageJson
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
return packageJson.version.trim()
const packageJson = require("../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
throw new Error('Unable to resolve @getpaseo/cli version from package.json.')
throw new Error("Unable to resolve @getpaseo/cli version from package.json.");
}
const VERSION = resolveCliVersion()
const VERSION = resolveCliVersion();
export function createCli(): Command {
const program = new Command()
const program = new Command();
program
.name('paseo')
.description('Paseo CLI - control your AI coding agents from the command line')
.version(VERSION, '-v, --version', 'output the version number')
.name("paseo")
.description("Paseo CLI - control your AI coding agents from the command line")
.version(VERSION, "-v, --version", "output the version number")
// Global output options
.option('-o, --format <format>', 'output format: table, json, yaml', 'table')
.option('--json', 'output in JSON format (alias for --format json)')
.option('-q, --quiet', 'minimal output (IDs only)')
.option('--no-headers', 'omit table headers')
.option('--no-color', 'disable colored output')
.option("-o, --format <format>", "output format: table, json, yaml", "table")
.option("--json", "output in JSON format (alias for --format json)")
.option("-q, --quiet", "minimal output (IDs only)")
.option("--no-headers", "omit table headers")
.option("--no-color", "disable colored output");
// Primary agent commands (top-level)
addJsonAndDaemonHostOptions(
program
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
program
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--worktree <name>', 'Create agent in a new git worktree')
.option('--base <branch>', 'Base branch for worktree (default: current branch)')
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
'--image <path>',
'Attach image(s) to the initial prompt (can be used multiple times)',
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[]
[],
)
.option('--cwd <path>', 'Working directory (default: current)')
.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')
).action(withOutput(runRunCommand))
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
program
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--worktree <name>", "Create agent in a new git worktree")
.option("--base <branch>", "Base branch for worktree (default: current branch)")
.option(
"--image <path>",
"Attach image(s) to the initial prompt (can be used multiple times)",
collectMultiple,
[],
)
.option("--cwd <path>", "Working directory (default: current)")
.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",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
program
.command('attach')
.command("attach")
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
program
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.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('--since <time>', 'Show logs since timestamp')
).action(runLogsCommand)
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.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("--since <time>", "Show logs since timestamp"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
program
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
program
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
program
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('[prompt]', 'The message to send')
.option('--prompt <text>', 'Provide the message inline as a flag')
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
.option('--no-wait', 'Return immediately without waiting for completion')
.option('--image <path>', 'Attach image(s) to the message', collectMultiple, [])
).action(withOutput(runSendCommand))
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion")
.option("--image <path>", "Attach image(s) to the message", collectMultiple, []),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
program
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
program
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Top-level local daemon shortcuts
program.addCommand(onboardCommand())
program.addCommand(daemonStartCommand())
program.addCommand(onboardCommand());
program.addCommand(daemonStartCommand());
addJsonOption(
program
.command('status')
.description('Show local daemon status (alias for "paseo daemon status")')
.command("status")
.description('Show local daemon status (alias for "paseo daemon status")'),
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runDaemonStatusCommand))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runDaemonStatusCommand));
addJsonOption(
program
.command('restart')
.description('Restart local daemon (alias for "paseo daemon restart")')
.command("restart")
.description('Restart local daemon (alias for "paseo daemon restart")'),
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <port>', 'Port for restarted daemon listen target')
.option('--no-relay', 'Disable relay on restarted daemon')
.option('--no-mcp', 'Disable Agent MCP on restarted daemon')
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.action(withOutput(runDaemonRestartCommand))
.option("--port <port>", "Port for restarted daemon listen target")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(withOutput(runDaemonRestartCommand));
// Advanced agent commands (less common operations)
program.addCommand(createAgentCommand())
program.addCommand(createAgentCommand());
// Daemon commands
program.addCommand(createDaemonCommand())
program.addCommand(createDaemonCommand());
// Permission commands
program.addCommand(createPermitCommand())
program.addCommand(createPermitCommand());
// Provider commands
program.addCommand(createProviderCommand())
program.addCommand(createProviderCommand());
// Speech model commands
program.addCommand(createSpeechCommand())
program.addCommand(createSpeechCommand());
// Worktree commands
program.addCommand(createWorktreeCommand())
program.addCommand(createWorktreeCommand());
return program
return program;
}
+61 -56
View File
@@ -1,126 +1,131 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost, resolveAgentId } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost, resolveAgentId } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent archive command */
export interface AgentArchiveResult {
agentId: string
status: 'archived'
archivedAt: string
agentId: string;
status: "archived";
archivedAt: string;
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<AgentArchiveResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'STATUS', field: 'status' },
{ header: 'ARCHIVED AT', field: 'archivedAt' },
{ header: "AGENT ID", field: "agentId" },
{ header: "STATUS", field: "status" },
{ header: "ARCHIVED AT", field: "archivedAt" },
],
}
};
export interface AgentArchiveOptions extends CommandOptions {
force?: boolean
host?: string
force?: boolean;
host?: string;
}
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>
export type AgentArchiveCommandResult = SingleResult<AgentArchiveResult>;
export async function runArchiveCommand(
agentIdArg: string,
options: AgentArchiveOptions,
_command: Command
_command: Command,
): Promise<AgentArchiveCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent archive <id-or-name>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent archive <id-or-name>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
const agentId = resolveAgentId(agentIdArg, agents)
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
const agentId = resolveAgentId(agentIdArg, agents);
if (!agentId) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = agents.find((entry) => entry.id === agentId)
const agent = agents.find((entry) => entry.id === agentId);
if (!agent) {
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`)
throw new Error(`Resolved agent missing from fetched agents: ${agentId}`);
}
// Check if agent is already archived
if (agent.archivedAt) {
const error: CommandError = {
code: 'AGENT_ALREADY_ARCHIVED',
code: "AGENT_ALREADY_ARCHIVED",
message: `Agent ${agentId.slice(0, 7)} is already archived`,
details: `Archived at: ${agent.archivedAt}`,
}
throw error
};
throw error;
}
// Check if agent is running and reject unless --force is set
if (agent.status === 'running' && !options.force) {
if (agent.status === "running" && !options.force) {
const error: CommandError = {
code: 'AGENT_RUNNING',
code: "AGENT_RUNNING",
message: `Agent ${agentId.slice(0, 7)} is currently running`,
details:
'Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.',
}
throw error
"Use --force to archive a running agent (it will interrupt the active run), or stop it first with: paseo agent stop. Use paseo agent delete to hard-delete it.",
};
throw error;
}
// Archive the agent
const result = await client.archiveAgent(agentId)
const result = await client.archiveAgent(agentId);
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId,
status: 'archived',
status: "archived",
archivedAt: result.archivedAt,
},
schema: archiveSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'ARCHIVE_FAILED',
code: "ARCHIVE_FAILED",
message: `Failed to archive agent: ${message}`,
}
throw error
};
throw error;
}
}
+90 -90
View File
@@ -1,16 +1,16 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type {
DaemonClient,
AgentStreamMessage,
AgentStreamEventPayload,
AgentTimelineItem,
} from '@getpaseo/server'
} from "@getpaseo/server";
export interface AgentAttachOptions {
host?: string
[key: string]: unknown
host?: string;
[key: string]: unknown;
}
/**
@@ -18,41 +18,41 @@ export interface AgentAttachOptions {
*/
function printTimelineItem(item: AgentTimelineItem): void {
switch (item.type) {
case 'assistant_message':
case "assistant_message":
// Print assistant text directly
process.stdout.write(item.text)
break
process.stdout.write(item.text);
break;
case 'reasoning':
case "reasoning":
// Print reasoning in a muted color if available
console.log(`\n[Reasoning] ${item.text}`)
break
console.log(`\n[Reasoning] ${item.text}`);
break;
case 'tool_call': {
const toolName = item.name
const status = item.status ?? 'started'
console.log(`\n[Tool: ${toolName}] ${status}`)
break
case "tool_call": {
const toolName = item.name;
const status = item.status ?? "started";
console.log(`\n[Tool: ${toolName}] ${status}`);
break;
}
case 'todo': {
const completed = item.items.filter((i) => i.completed).length
const total = item.items.length
console.log(`\n[Todo] ${completed}/${total} completed`)
break
case "todo": {
const completed = item.items.filter((i) => i.completed).length;
const total = item.items.length;
console.log(`\n[Todo] ${completed}/${total} completed`);
break;
}
case 'error':
console.error(`\n[Error] ${item.message}`)
break
case "error":
console.error(`\n[Error] ${item.message}`);
break;
case 'user_message':
console.log(`\n[User] ${item.text}`)
break
case "user_message":
console.log(`\n[User] ${item.text}`);
break;
default:
// Unknown item type, skip
break
break;
}
}
@@ -61,33 +61,33 @@ function printTimelineItem(item: AgentTimelineItem): void {
*/
function printStreamEvent(event: AgentStreamEventPayload): void {
switch (event.type) {
case 'timeline':
case "timeline":
// Print the timeline item
printTimelineItem(event.item)
break
printTimelineItem(event.item);
break;
case 'permission_requested':
console.log(`\n[Permission Required] ${event.request.name}`)
case "permission_requested":
console.log(`\n[Permission Required] ${event.request.name}`);
if (event.request.description) {
console.log(` ${event.request.description}`)
console.log(` ${event.request.description}`);
}
break
break;
case 'permission_resolved':
console.log(`\n[Permission ${event.resolution.behavior}]`)
break
case "permission_resolved":
console.log(`\n[Permission ${event.resolution.behavior}]`);
break;
case 'turn_failed':
console.error(`\n[Turn Failed] ${event.error}`)
break
case "turn_failed":
console.error(`\n[Turn Failed] ${event.error}`);
break;
case 'attention_required':
console.log(`\n[Attention Required: ${event.reason}]`)
break
case "attention_required":
console.log(`\n[Attention Required: ${event.reason}]`);
break;
default:
// Other event types are internal
break
break;
}
}
@@ -97,91 +97,91 @@ function printStreamEvent(event: AgentStreamEventPayload): void {
export async function runAttachCommand(
id: string,
options: AgentAttachOptions,
_command: Command
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo attach <id>')
process.exit(1)
console.error("Error: Agent ID required");
console.error("Usage: paseo attach <id>");
process.exit(1);
}
let client: DaemonClient
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
try {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
await client.close();
process.exit(1);
}
const resolvedId = fetchResult.agent.id
const resolvedId = fetchResult.agent.id;
// Print header
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`)
console.log(`(Press Ctrl+C to detach)\n`)
console.log(`Attaching to agent ${resolvedId.substring(0, 7)}...`);
console.log(`(Press Ctrl+C to detach)\n`);
// Print existing output from timeline fetch.
try {
const timelineItems = await fetchProjectedTimelineItems({
client,
agentId: resolvedId,
})
});
for (const item of timelineItems) {
printTimelineItem(item)
printTimelineItem(item);
}
} catch (error) {
console.warn('Warning: failed to fetch existing timeline', error)
console.warn("Warning: failed to fetch existing timeline", error);
}
// Subscribe to new events
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== resolvedId) return
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
const message = msg as AgentStreamMessage;
if (message.type !== "agent_stream") return;
if (message.payload.agentId !== resolvedId) return;
printStreamEvent(message.payload.event)
})
printStreamEvent(message.payload.event);
});
// Handle Ctrl+C to detach gracefully
let detached = false
let detached = false;
const detach = () => {
if (detached) return
detached = true
if (detached) return;
detached = true;
console.log('\n\nDetaching from agent...')
unsubscribe()
console.log("\n\nDetaching from agent...");
unsubscribe();
client
.close()
.then(() => {
process.exit(0)
process.exit(0);
})
.catch(() => {
process.exit(1)
})
}
process.exit(1);
});
};
process.on('SIGINT', detach)
process.on('SIGTERM', detach)
process.on("SIGINT", detach);
process.on("SIGTERM", detach);
// Keep the process alive
await new Promise(() => {
// Wait indefinitely until interrupted
})
});
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to attach to agent: ${message}`)
process.exit(1)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to attach to agent: ${message}`);
process.exit(1);
}
}
+62 -57
View File
@@ -1,114 +1,119 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
export interface DeleteResult {
deletedCount: number
agentIds: string[]
deletedCount: number;
agentIds: string[];
}
export const deleteSchema: OutputSchema<DeleteResult> = {
idField: (item) => item.agentIds.join('\n'),
columns: [{ header: 'DELETED', field: 'deletedCount' }],
}
idField: (item) => item.agentIds.join("\n"),
columns: [{ header: "DELETED", field: "deletedCount" }],
};
export interface AgentDeleteOptions extends CommandOptions {
all?: boolean
cwd?: string
all?: boolean;
cwd?: string;
}
export type AgentDeleteResult = SingleResult<DeleteResult>
export type AgentDeleteResult = SingleResult<DeleteResult>;
export async function runDeleteCommand(
id: string | undefined,
options: AgentDeleteOptions,
_command: Command
_command: Command,
): Promise<AgentDeleteResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required unless --all or --cwd is specified',
details: 'Usage: paseo agent delete <id> | --all | --cwd <path>',
}
throw error
code: "MISSING_ARGUMENT",
message: "Agent ID required unless --all or --cwd is specified",
details: "Usage: paseo agent delete <id> | --all | --cwd <path>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
let agents = fetchPayload.entries.map((entry) => entry.agent)
const deletedIds: string[] = []
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const deletedIds: string[] = [];
if (options.all) {
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
const filterCwd = options.cwd
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
agents = [fetchResult.agent]
agents = [fetchResult.agent];
}
for (const agent of agents) {
try {
if (agent.status === 'running') {
await client.cancelAgent(agent.id)
if (agent.status === "running") {
await client.cancelAgent(agent.id);
}
await client.deleteAgent(agent.id)
deletedIds.push(agent.id)
await client.deleteAgent(agent.id);
deletedIds.push(agent.id);
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`)
const message = err instanceof Error ? err.message : String(err);
console.error(`Warning: Failed to delete agent ${agent.id.slice(0, 7)}: ${message}`);
}
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
deletedCount: deletedIds.length,
agentIds: deletedIds,
},
schema: deleteSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
if (err && typeof err === 'object' && 'code' in err) {
throw err
await client.close().catch(() => {});
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DELETE_AGENT_FAILED',
code: "DELETE_AGENT_FAILED",
message: `Failed to delete agent(s): ${message}`,
}
throw error
};
throw error;
}
}
+119 -99
View File
@@ -1,149 +1,169 @@
import { Command } from 'commander'
import { runModeCommand } from './mode.js'
import { runArchiveCommand } from './archive.js'
import { runDeleteCommand } from './delete.js'
import { runLsCommand } from './ls.js'
import { runRunCommand } from './run.js'
import { runLogsCommand } from './logs.js'
import { runStopCommand } from './stop.js'
import { runSendCommand } from './send.js'
import { runInspectCommand } from './inspect.js'
import { runWaitCommand } from './wait.js'
import { runAttachCommand } from './attach.js'
import { runUpdateCommand } from './update.js'
import { withOutput } from '../../output/index.js'
import { Command } from "commander";
import { runModeCommand } from "./mode.js";
import { runArchiveCommand } from "./archive.js";
import { runDeleteCommand } from "./delete.js";
import { runLsCommand } from "./ls.js";
import { runRunCommand } from "./run.js";
import { runLogsCommand } from "./logs.js";
import { runStopCommand } from "./stop.js";
import { runSendCommand } from "./send.js";
import { runInspectCommand } from "./inspect.js";
import { runWaitCommand } from "./wait.js";
import { runAttachCommand } from "./attach.js";
import { runUpdateCommand } from "./update.js";
import { withOutput } from "../../output/index.js";
import {
addDaemonHostOption,
addJsonAndDaemonHostOptions,
collectMultiple,
} from '../../utils/command-options.js'
} from "../../utils/command-options.js";
export function createAgentCommand(): Command {
const agent = new Command('agent').description('Manage agents (advanced operations)')
const agent = new Command("agent").description("Manage agents (advanced operations)");
// Primary agent commands (same as top-level)
addJsonAndDaemonHostOptions(
agent
.command('ls')
.description('List agents. By default excludes archived agents.')
.option('-a, --all', 'Include archived agents')
.option('-g, --global', 'Legacy no-op (kept for compatibility)')
.option('--label <key=value>', 'Filter by label (can be used multiple times)', collectMultiple, [])
.option('--thinking <id>', 'Filter by thinking option ID')
).action(withOutput(runLsCommand))
addJsonAndDaemonHostOptions(
agent
.command('run')
.description('Create and start an agent with a task')
.argument('<prompt>', 'The task/prompt for the agent')
.option('-d, --detach', 'Run in background (detached)')
.option('--name <name>', 'Assign a name/title to the agent')
.option('--provider <provider>', 'Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)', 'claude')
.option('--model <model>', 'Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)')
.option('--thinking <id>', 'Thinking option ID to use for this run')
.option('--mode <mode>', 'Provider-specific mode (e.g., plan, default, bypass)')
.option('--cwd <path>', 'Working directory (default: current)')
.option('--label <key=value>', 'Add label(s) to the agent (can be used multiple times)', collectMultiple, [])
.command("ls")
.description("List agents. By default excludes archived agents.")
.option("-a, --all", "Include archived agents")
.option("-g, --global", "Legacy no-op (kept for compatibility)")
.option(
'--wait-timeout <duration>',
'Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit'
"--label <key=value>",
"Filter by label (can be used multiple times)",
collectMultiple,
[],
)
.option('--output-schema <schema>', 'Output JSON matching the provided schema file path or inline JSON schema')
).action(withOutput(runRunCommand))
.option("--thinking <id>", "Filter by thinking option ID"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
agent
.command("run")
.description("Create and start an agent with a task")
.argument("<prompt>", "The task/prompt for the agent")
.option("-d, --detach", "Run in background (detached)")
.option("--name <name>", "Assign a name/title to the agent")
.option(
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
"claude",
)
.option(
"--model <model>",
"Model to use (e.g., claude-sonnet-4-20250514, claude-3-5-haiku-20241022)",
)
.option("--thinking <id>", "Thinking option ID to use for this run")
.option("--mode <mode>", "Provider-specific mode (e.g., plan, default, bypass)")
.option("--cwd <path>", "Working directory (default: current)")
.option(
"--label <key=value>",
"Add label(s) to the agent (can be used multiple times)",
collectMultiple,
[],
)
.option(
"--wait-timeout <duration>",
"Maximum time to wait for agent to finish (e.g., 30s, 5m, 1h). Default: no limit",
)
.option(
"--output-schema <schema>",
"Output JSON matching the provided schema file path or inline JSON schema",
),
).action(withOutput(runRunCommand));
addDaemonHostOption(
agent
.command('attach')
.command("attach")
.description("Attach to a running agent's output stream")
.argument('<id>', 'Agent ID (or prefix)')
).action(runAttachCommand)
.argument("<id>", "Agent ID (or prefix)"),
).action(runAttachCommand);
addDaemonHostOption(
agent
.command('logs')
.description('View agent activity/timeline')
.argument('<id>', 'Agent ID (or prefix)')
.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)')
).action(runLogsCommand)
.command("logs")
.description("View agent activity/timeline")
.argument("<id>", "Agent ID (or prefix)")
.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)"),
).action(runLogsCommand);
addJsonAndDaemonHostOptions(
agent
.command('stop')
.description('Interrupt an agent if it is running (no-op for idle agents)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Stop all agents')
.option('--cwd <path>', 'Stop all agents in directory')
).action(withOutput(runStopCommand))
.command("stop")
.description("Interrupt an agent if it is running (no-op for idle agents)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Stop all agents")
.option("--cwd <path>", "Stop all agents in directory"),
).action(withOutput(runStopCommand));
addJsonAndDaemonHostOptions(
agent
.command('delete')
.description('Delete an agent (interrupt if running, then hard-delete)')
.argument('[id]', 'Agent ID (or prefix) - optional if --all or --cwd specified')
.option('--all', 'Delete all agents')
.option('--cwd <path>', 'Delete all agents in directory')
).action(withOutput(runDeleteCommand))
.command("delete")
.description("Delete an agent (interrupt if running, then hard-delete)")
.argument("[id]", "Agent ID (or prefix) - optional if --all or --cwd specified")
.option("--all", "Delete all agents")
.option("--cwd <path>", "Delete all agents in directory"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
agent
.command('send')
.description('Send a message/task to an existing agent')
.argument('<id>', 'Agent ID (or prefix)')
.argument('[prompt]', 'The message to send')
.option('--prompt <text>', 'Provide the message inline as a flag')
.option('--prompt-file <path>', 'Read the message from a UTF-8 text file')
.option('--no-wait', 'Return immediately without waiting for completion')
).action(withOutput(runSendCommand))
.command("send")
.description("Send a message/task to an existing agent")
.argument("<id>", "Agent ID (or prefix)")
.argument("[prompt]", "The message to send")
.option("--prompt <text>", "Provide the message inline as a flag")
.option("--prompt-file <path>", "Read the message from a UTF-8 text file")
.option("--no-wait", "Return immediately without waiting for completion"),
).action(withOutput(runSendCommand));
addJsonAndDaemonHostOptions(
agent
.command('inspect')
.description('Show detailed information about an agent')
.argument('<id>', 'Agent ID (or prefix)')
).action(withOutput(runInspectCommand))
.command("inspect")
.description("Show detailed information about an agent")
.argument("<id>", "Agent ID (or prefix)"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
agent
.command('wait')
.description('Wait for an agent to become idle')
.argument('<id>', 'Agent ID (or prefix)')
.option('--timeout <seconds>', 'Maximum wait time (default: no limit)')
).action(withOutput(runWaitCommand))
.command("wait")
.description("Wait for an agent to become idle")
.argument("<id>", "Agent ID (or prefix)")
.option("--timeout <seconds>", "Maximum wait time (default: no limit)"),
).action(withOutput(runWaitCommand));
// Advanced agent commands (less common operations)
addJsonAndDaemonHostOptions(
agent
.command('mode')
.command("mode")
.description("Change an agent's operational mode")
.argument('<id>', 'Agent ID (or prefix)')
.argument('[mode]', 'Mode to set (required unless --list)')
.option('--list', 'List available modes for this agent')
).action(withOutput(runModeCommand))
.argument("<id>", "Agent ID (or prefix)")
.argument("[mode]", "Mode to set (required unless --list)")
.option("--list", "List available modes for this agent"),
).action(withOutput(runModeCommand));
addJsonAndDaemonHostOptions(
agent
.command('archive')
.description('Archive an agent (soft-delete)')
.argument('<id>', 'Agent ID, prefix, or name')
.option('--force', 'Force archive running agent (interrupts active run first)')
).action(withOutput(runArchiveCommand))
.command("archive")
.description("Archive an agent (soft-delete)")
.argument("<id>", "Agent ID, prefix, or name")
.option("--force", "Force archive running agent (interrupts active run first)"),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(
agent
.command('update')
.command("update")
.description("Update an agent's metadata")
.argument('<id>', 'Agent ID (or prefix)')
.option('--name <name>', "Update the agent's display name")
.argument("<id>", "Agent ID (or prefix)")
.option("--name <name>", "Update the agent's display name")
.option(
'--label <label>',
'Add/set label(s) on the agent (can be used multiple times or comma-separated)',
"--label <label>",
"Add/set label(s) on the agent (can be used multiple times or comma-separated)",
collectMultiple,
[]
)
).action(withOutput(runUpdateCommand))
[],
),
).action(withOutput(runUpdateCommand));
return agent
return agent;
}
+127 -126
View File
@@ -1,101 +1,101 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Agent inspect data for display (matches CLI spec format) */
interface AgentInspect {
Id: string
Name: string
Provider: string
Model: string
Thinking: string
Status: string
Archived: boolean
ArchivedAt: string | null
Mode: string
Cwd: string
CreatedAt: string
UpdatedAt: string
Id: string;
Name: string;
Provider: string;
Model: string;
Thinking: string;
Status: string;
Archived: boolean;
ArchivedAt: string | null;
Mode: string;
Cwd: string;
CreatedAt: string;
UpdatedAt: string;
LastUsage: {
InputTokens: number
OutputTokens: number
CachedTokens: number
CostUsd: number
} | null
InputTokens: number;
OutputTokens: number;
CachedTokens: number;
CostUsd: number;
} | null;
Capabilities: {
Streaming: boolean
Persistence: boolean
DynamicModes: boolean
McpServers: boolean
} | null
Streaming: boolean;
Persistence: boolean;
DynamicModes: boolean;
McpServers: boolean;
} | null;
AvailableModes: Array<{
id: string
label: string
}> | null
id: string;
label: string;
}> | null;
PendingPermissions: Array<{
id: string
tool: string
}>
Worktree: string | null
ParentAgentId: string | null
id: string;
tool: string;
}>;
Worktree: string | null;
ParentAgentId: string | null;
}
/** Key-value row for table display */
interface InspectRow {
key: string
value: string
key: string;
value: string;
}
/** Schema for key-value display with custom serialization for JSON/YAML */
function createInspectSchema(agent: AgentInspect): OutputSchema<InspectRow> {
return {
idField: 'key',
idField: "key",
columns: [
{ header: 'KEY', field: 'key' },
{ header: "KEY", field: "key" },
{
header: 'VALUE',
field: 'value',
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'idle') return 'yellow'
if (item.value === 'error') return 'red'
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "idle") return "yellow";
if (item.value === "error") return "red";
}
return undefined
return undefined;
},
},
],
// For JSON/YAML, return the structured agent object
serialize: (_item) => agent,
}
};
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
/** Format cost in USD */
function formatCost(costUsd: number): string {
if (costUsd === 0) return '$0.00'
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`
return `$${costUsd.toFixed(2)}`
if (costUsd === 0) return "$0.00";
if (costUsd < 0.01) return `$${costUsd.toFixed(4)}`;
return `$${costUsd.toFixed(2)}`;
}
function normalizeModelId(value: string | null | undefined): string | null {
if (typeof value !== 'string') return null
const normalized = value.trim()
if (!normalized || normalized.toLowerCase() === 'default') return null
return normalized
if (typeof value !== "string") return null;
const normalized = value.trim();
if (!normalized || normalized.toLowerCase() === "default") return null;
return normalized;
}
function resolveModel(snapshot: AgentSnapshotPayload): string | null {
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model)
return normalizeModelId(snapshot.runtimeInfo?.model) ?? normalizeModelId(snapshot.model);
}
/** Convert agent snapshot to inspection data */
@@ -107,7 +107,7 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
CachedTokens: snapshot.lastUsage.cachedInputTokens ?? 0,
CostUsd: snapshot.lastUsage.totalCostUsd ?? 0,
}
: null
: null;
const capabilities = snapshot.capabilities
? {
@@ -116,22 +116,22 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
DynamicModes: snapshot.capabilities.supportsDynamicModes ?? false,
McpServers: snapshot.capabilities.supportsMcpServers ?? false,
}
: null
: null;
// Extract worktree and parentAgentId from labels if they exist
const worktree = snapshot.labels?.['paseo.worktree'] ?? null
const parentAgentId = snapshot.labels?.['paseo.parent-agent-id'] ?? null
const worktree = snapshot.labels?.["paseo.worktree"] ?? null;
const parentAgentId = snapshot.labels?.["paseo.parent-agent-id"] ?? null;
return {
Id: snapshot.id,
Name: snapshot.title ?? '-',
Name: snapshot.title ?? "-",
Provider: snapshot.provider,
Model: resolveModel(snapshot) ?? '-',
Thinking: snapshot.effectiveThinkingOptionId ?? 'auto',
Model: resolveModel(snapshot) ?? "-",
Thinking: snapshot.effectiveThinkingOptionId ?? "auto",
Status: snapshot.status,
Archived: snapshot.archivedAt != null,
ArchivedAt: snapshot.archivedAt ?? null,
Mode: snapshot.currentModeId ?? 'default',
Mode: snapshot.currentModeId ?? "default",
Cwd: snapshot.cwd,
CreatedAt: snapshot.createdAt,
UpdatedAt: snapshot.updatedAt,
@@ -142,133 +142,134 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect {
: null,
PendingPermissions: (snapshot.pendingPermissions ?? []).map((p) => ({
id: p.id,
tool: p.name ?? 'unknown',
tool: p.name ?? "unknown",
})),
Worktree: worktree,
ParentAgentId: parentAgentId,
}
};
}
/** Convert agent to key-value rows for table display */
function toInspectRows(agent: AgentInspect): InspectRow[] {
const rows: InspectRow[] = [
{ key: 'Id', value: agent.Id },
{ key: 'Name', value: agent.Name },
{ key: 'Provider', value: agent.Provider },
{ key: 'Model', value: agent.Model },
{ key: 'Thinking', value: agent.Thinking },
{ key: 'Status', value: agent.Status },
{ key: 'Archived', value: String(agent.Archived) },
{ key: 'ArchivedAt', value: agent.ArchivedAt ?? 'null' },
{ key: 'Mode', value: agent.Mode },
{ key: 'Cwd', value: shortenPath(agent.Cwd) },
{ key: 'CreatedAt', value: agent.CreatedAt },
{ key: 'UpdatedAt', value: agent.UpdatedAt },
]
{ key: "Id", value: agent.Id },
{ key: "Name", value: agent.Name },
{ key: "Provider", value: agent.Provider },
{ key: "Model", value: agent.Model },
{ key: "Thinking", value: agent.Thinking },
{ key: "Status", value: agent.Status },
{ key: "Archived", value: String(agent.Archived) },
{ key: "ArchivedAt", value: agent.ArchivedAt ?? "null" },
{ key: "Mode", value: agent.Mode },
{ key: "Cwd", value: shortenPath(agent.Cwd) },
{ key: "CreatedAt", value: agent.CreatedAt },
{ key: "UpdatedAt", value: agent.UpdatedAt },
];
if (agent.LastUsage) {
rows.push({
key: 'LastUsage',
key: "LastUsage",
value: `InputTokens: ${agent.LastUsage.InputTokens}, OutputTokens: ${agent.LastUsage.OutputTokens}, CachedTokens: ${agent.LastUsage.CachedTokens}, CostUsd: ${formatCost(agent.LastUsage.CostUsd)}`,
})
});
}
if (agent.Capabilities) {
rows.push({
key: 'Capabilities',
key: "Capabilities",
value: `Streaming: ${agent.Capabilities.Streaming}, Persistence: ${agent.Capabilities.Persistence}, DynamicModes: ${agent.Capabilities.DynamicModes}, McpServers: ${agent.Capabilities.McpServers}`,
})
});
}
if (agent.AvailableModes && agent.AvailableModes.length > 0) {
rows.push({
key: 'AvailableModes',
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(', '),
})
key: "AvailableModes",
value: agent.AvailableModes.map((m) => `${m.id} (${m.label})`).join(", "),
});
}
rows.push({
key: 'PendingPermissions',
value: agent.PendingPermissions.length > 0
? agent.PendingPermissions.map(p => `${p.id} (${p.tool})`).join(', ')
: '[]'
})
key: "PendingPermissions",
value:
agent.PendingPermissions.length > 0
? agent.PendingPermissions.map((p) => `${p.id} (${p.tool})`).join(", ")
: "[]",
});
rows.push({ key: 'Worktree', value: agent.Worktree ?? 'null' })
rows.push({ key: 'ParentAgentId', value: agent.ParentAgentId ?? 'null' })
rows.push({ key: "Worktree", value: agent.Worktree ?? "null" });
rows.push({ key: "ParentAgentId", value: agent.ParentAgentId ?? "null" });
return rows
return rows;
}
export type AgentInspectResult = ListResult<InspectRow>
export type AgentInspectResult = ListResult<InspectRow>;
export interface AgentInspectOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runInspectCommand(
agentIdArg: string,
options: AgentInspectOptions,
_command: Command
_command: Command,
): Promise<AgentInspectResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent inspect <id>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent inspect <id>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdArg)
const fetchResult = await client.fetchAgent(agentIdArg);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
await client.close()
await client.close();
const inspectData = toInspectData(fetchResult.agent)
const inspectData = toInspectData(fetchResult.agent);
return {
type: 'list',
type: "list",
data: toInspectRows(inspectData),
schema: createInspectSchema(inspectData),
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INSPECT_FAILED',
code: "INSPECT_FAILED",
message: `Failed to inspect agent: ${message}`,
}
throw error
};
throw error;
}
}
+99 -102
View File
@@ -1,155 +1,151 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions } from '../../output/index.js'
import { fetchProjectedTimelineItems } from '../../utils/timeline.js'
import type {
DaemonClient,
AgentStreamMessage,
AgentTimelineItem,
} from '@getpaseo/server'
import { curateAgentActivity } from '@getpaseo/server'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions } from "../../output/index.js";
import { fetchProjectedTimelineItems } from "../../utils/timeline.js";
import type { DaemonClient, AgentStreamMessage, AgentTimelineItem } from "@getpaseo/server";
import { curateAgentActivity } from "@getpaseo/server";
export interface AgentLogsOptions extends CommandOptions {
follow?: boolean
tail?: string
filter?: string
since?: string
follow?: boolean;
tail?: string;
filter?: string;
since?: string;
}
// Logs command returns void - it outputs directly to console
export type AgentLogsResult = void
export type AgentLogsResult = void;
export const NO_ACTIVITY_MESSAGE = 'No activity to display.'
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
export async function fetchAgentTimelineItems(
client: DaemonClient,
agentId: string
agentId: string,
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({ client, agentId })
return fetchProjectedTimelineItems({ client, agentId });
}
export function formatAgentActivityTranscript(
timelineItems: AgentTimelineItem[],
tailCount?: number
tailCount?: number,
): string {
if (tailCount === 0) {
return ''
return "";
}
return curateAgentActivity(
timelineItems,
tailCount !== undefined ? { maxItems: tailCount } : undefined
)
tailCount !== undefined ? { maxItems: tailCount } : undefined,
);
}
function parseTailCount(raw: string | undefined): number | undefined {
if (raw === undefined) return undefined
const parsed = Number.parseInt(raw, 10)
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
if (Number.isNaN(parsed) || parsed < 0) {
return undefined
return undefined;
}
return parsed
return parsed;
}
/**
* Check if a timeline item matches the filter type
*/
function matchesFilter(item: AgentTimelineItem, filter?: string): boolean {
if (!filter) return true
if (!filter) return true;
const filterLower = filter.toLowerCase()
const type = item.type.toLowerCase()
const filterLower = filter.toLowerCase();
const type = item.type.toLowerCase();
switch (filterLower) {
case 'tools':
return type === 'tool_call'
case 'text':
return type === 'user_message' || type === 'assistant_message' || type === 'reasoning'
case 'errors':
return type === 'error'
case 'permissions':
case "tools":
return type === "tool_call";
case "text":
return type === "user_message" || type === "assistant_message" || type === "reasoning";
case "errors":
return type === "error";
case "permissions":
// Permissions might be in tool_call status or a separate event type
return type.includes('permission')
return type.includes("permission");
default:
// If filter doesn't match predefined types, match against the actual type
return type.includes(filterLower)
return type.includes(filterLower);
}
}
export async function runLogsCommand(
id: string,
options: AgentLogsOptions,
_command: Command
_command: Command,
): Promise<AgentLogsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
if (!id) {
console.error('Error: Agent ID required')
console.error('Usage: paseo agent logs <id>')
process.exit(1)
console.error("Error: Agent ID required");
console.error("Usage: paseo agent logs <id>");
process.exit(1);
}
let client: DaemonClient
let client: DaemonClient;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`)
console.error('Start the daemon with: paseo daemon start')
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
try {
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
console.error(`Error: No agent found matching: ${id}`)
console.error('Use `paseo ls` to list available agents')
await client.close()
process.exit(1)
console.error(`Error: No agent found matching: ${id}`);
console.error("Use `paseo ls` to list available agents");
await client.close();
process.exit(1);
}
const resolvedId = fetchResult.agent.id
const resolvedId = fetchResult.agent.id;
// For follow mode, we stream events continuously
if (options.follow) {
if (options.tail !== undefined && parseTailCount(options.tail) === undefined) {
console.error(`Error: Invalid --tail value: ${options.tail}`)
console.error('Usage: --tail <n> (where n is >= 0)')
await client.close().catch(() => {})
process.exit(1)
console.error(`Error: Invalid --tail value: ${options.tail}`);
console.error("Usage: --tail <n> (where n is >= 0)");
await client.close().catch(() => {});
process.exit(1);
}
await runFollowMode(client, resolvedId, options)
return
await runFollowMode(client, resolvedId, options);
return;
}
// Fetch timeline directly via cursor RPC.
let timelineItems = await fetchAgentTimelineItems(client, resolvedId)
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
// Apply filter
if (options.filter) {
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter))
timelineItems = timelineItems.filter((item) => matchesFilter(item, options.filter));
}
const tailCount = parseTailCount(options.tail)
const tailCount = parseTailCount(options.tail);
if (options.tail !== undefined && tailCount === undefined) {
console.error(`Error: Invalid --tail value: ${options.tail}`)
console.error('Usage: --tail <n> (where n is >= 0)')
await client.close().catch(() => {})
process.exit(1)
console.error(`Error: Invalid --tail value: ${options.tail}`);
console.error("Usage: --tail <n> (where n is >= 0)");
await client.close().catch(() => {});
process.exit(1);
}
await client.close()
await client.close();
// Use curateAgentActivity to format the transcript
if (tailCount === 0) {
return
return;
}
const transcript = formatAgentActivityTranscript(timelineItems, tailCount)
console.log(transcript)
const transcript = formatAgentActivityTranscript(timelineItems, tailCount);
console.log(transcript);
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`Error: Failed to get logs: ${message}`)
await client.close().catch(() => {})
process.exit(1)
const message = err instanceof Error ? err.message : String(err);
console.error(`Error: Failed to get logs: ${message}`);
await client.close().catch(() => {});
process.exit(1);
}
}
@@ -159,60 +155,61 @@ export async function runLogsCommand(
async function runFollowMode(
client: DaemonClient,
agentId: string,
options: AgentLogsOptions
options: AgentLogsOptions,
): Promise<void> {
const DEFAULT_FOLLOW_TAIL = 10
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
// First, get existing timeline.
let existingItems = await fetchAgentTimelineItems(client, agentId)
let existingItems = await fetchAgentTimelineItems(client, agentId);
// Apply filter to existing items
if (options.filter) {
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter))
existingItems = existingItems.filter((item) => matchesFilter(item, options.filter));
}
// Print existing transcript (tail-like behavior)
if (tailCount > 0) {
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount)
const existingTranscript = formatAgentActivityTranscript(existingItems, tailCount);
if (existingTranscript !== NO_ACTIVITY_MESSAGE) {
console.log(existingTranscript)
console.log(existingTranscript);
}
}
// Subscribe to new events
const tailLabel = tailCount === 0 ? 'no history' : `last ${tailCount} entr${tailCount === 1 ? 'y' : 'ies'}`
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`)
const tailLabel =
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
const unsubscribe = client.on('agent_stream', (msg: unknown) => {
const message = msg as AgentStreamMessage
if (message.type !== 'agent_stream') return
if (message.payload.agentId !== agentId) return
const unsubscribe = client.on("agent_stream", (msg: unknown) => {
const message = msg as AgentStreamMessage;
if (message.type !== "agent_stream") return;
if (message.payload.agentId !== agentId) return;
if (message.payload.event.type === 'timeline') {
const item = message.payload.event.item
if (message.payload.event.type === "timeline") {
const item = message.payload.event.item;
// Apply filter
if (options.filter && !matchesFilter(item, options.filter)) {
return
return;
}
// Print each timeline item as it arrives using the curator format
const transcript = formatAgentActivityTranscript([item])
const transcript = formatAgentActivityTranscript([item]);
if (transcript !== NO_ACTIVITY_MESSAGE) {
console.log(transcript)
console.log(transcript);
}
}
})
});
// Wait for interrupt
await new Promise<void>((resolve) => {
const cleanup = () => {
unsubscribe()
resolve()
}
unsubscribe();
resolve();
};
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
})
process.on("SIGINT", cleanup);
process.on("SIGTERM", cleanup);
});
await client.close()
await client.close();
}
+106 -106
View File
@@ -1,102 +1,102 @@
import type { Command } from 'commander'
import type { AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Agent list item for display */
export interface AgentListItem {
id: string
shortId: string
name: string
provider: string
thinking: string
status: string
cwd: string
created: string
id: string;
shortId: string;
name: string;
provider: string;
thinking: string;
status: string;
cwd: string;
created: string;
}
/** Helper to get relative time string */
function relativeTime(date: Date | string): string {
const now = Date.now()
const then = new Date(date).getTime()
const seconds = Math.floor((now - then) / 1000)
const now = Date.now();
const then = new Date(date).getTime();
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return 'just now'
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`
return `${Math.floor(seconds / 86400)} days ago`
if (seconds < 60) return "just now";
if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`;
return `${Math.floor(seconds / 86400)} days ago`;
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
function normalizeModelId(modelId: string | null | undefined): string | null {
if (typeof modelId !== 'string') return null
const normalized = modelId.trim()
if (!normalized || normalized.toLowerCase() === 'default') return null
return normalized
if (typeof modelId !== "string") return null;
const normalized = modelId.trim();
if (!normalized || normalized.toLowerCase() === "default") return null;
return normalized;
}
/** Schema for agent ls output */
export const agentLsSchema: OutputSchema<AgentListItem> = {
idField: 'shortId',
idField: "shortId",
columns: [
{ header: 'AGENT ID', field: 'shortId', width: 12 },
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'PROVIDER', field: 'provider', width: 15 },
{ header: 'THINKING', field: 'thinking', width: 12 },
{ header: "AGENT ID", field: "shortId", width: 12 },
{ header: "NAME", field: "name", width: 20 },
{ header: "PROVIDER", field: "provider", width: 15 },
{ header: "THINKING", field: "thinking", width: 12 },
{
header: 'STATUS',
field: 'status',
header: "STATUS",
field: "status",
width: 10,
color: (value) => {
if (value === 'running') return 'green'
if (value === 'idle') return 'yellow'
if (value === 'error') return 'red'
return undefined
if (value === "running") return "green";
if (value === "idle") return "yellow";
if (value === "error") return "red";
return undefined;
},
},
{ header: 'CWD', field: 'cwd', width: 30 },
{ header: 'CREATED', field: 'created', width: 15 },
{ header: "CWD", field: "cwd", width: 30 },
{ header: "CREATED", field: "created", width: 15 },
],
}
};
/** Transform agent snapshot to AgentListItem */
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model)
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
return {
id: agent.id,
shortId: agent.id.slice(0, 7),
name: agent.title ?? '-',
name: agent.title ?? "-",
provider: model ? `${agent.provider}/${model}` : agent.provider,
thinking: agent.effectiveThinkingOptionId ?? 'auto',
thinking: agent.effectiveThinkingOptionId ?? "auto",
status: agent.status,
cwd: shortenPath(agent.cwd),
created: relativeTime(agent.createdAt),
}
};
}
export type AgentLsResult = ListResult<AgentListItem>
export type AgentLsResult = ListResult<AgentListItem>;
export interface AgentLsOptions extends CommandOptions {
/** -a: Include archived agents */
all?: boolean
all?: boolean;
/** Legacy flag retained for CLI compatibility */
global?: boolean
global?: boolean;
/** Filter by specific status */
status?: string
status?: string;
/** Filter by specific cwd */
cwd?: string
cwd?: string;
/** Filter by labels (key=value format) */
label?: string[]
label?: string[];
/** Filter by thinking option ID */
thinking?: string
thinking?: string;
}
/**
@@ -106,128 +106,128 @@ export interface AgentLsOptions extends CommandOptions {
*/
export async function runLsCommand(
options: AgentLsOptions,
_command: Command
_command: Command,
): Promise<AgentLsResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const normalizedThinkingOptionId = options.thinking?.trim()
const normalizedThinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !normalizedThinkingOptionId) {
const error: CommandError = {
code: 'INVALID_THINKING_OPTION',
message: '--thinking cannot be empty',
}
throw error
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
};
throw error;
}
// Parse --label filters (key=value).
const labelFilters: Record<string, string> = {}
const labelFilters: Record<string, string> = {};
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
const eqIndex = labelStr.indexOf("=");
if (eqIndex !== -1) {
const key = labelStr.slice(0, eqIndex)
const value = labelStr.slice(eqIndex + 1)
labelFilters[key] = value
const key = labelStr.slice(0, eqIndex);
const value = labelStr.slice(eqIndex + 1);
labelFilters[key] = value;
}
}
}
const daemonFilter: {
includeArchived?: boolean
labels?: Record<string, string>
thinkingOptionId?: string
} = {}
includeArchived?: boolean;
labels?: Record<string, string>;
thinkingOptionId?: string;
} = {};
if (options.all) {
daemonFilter.includeArchived = true
daemonFilter.includeArchived = true;
}
if (Object.keys(labelFilters).length > 0) {
daemonFilter.labels = labelFilters
daemonFilter.labels = labelFilters;
}
if (normalizedThinkingOptionId) {
daemonFilter.thinkingOptionId = normalizedThinkingOptionId
daemonFilter.thinkingOptionId = normalizedThinkingOptionId;
}
const fetchPayload = await client.fetchAgents({
filter: Object.keys(daemonFilter).length > 0 ? daemonFilter : undefined,
})
let agents = fetchPayload.entries.map((entry) => entry.agent)
});
let agents = fetchPayload.entries.map((entry) => entry.agent);
// By default, exclude archived agents. `-a` includes them.
if (!options.all) {
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
}
// If explicit status filter is provided, apply it.
if (options.status) {
agents = agents.filter((a) => a.status === options.status)
agents = agents.filter((a) => a.status === options.status);
}
// Optional cwd filter.
if (options.cwd) {
const targetCwd = options.cwd.replace(/\/$/, '')
const targetCwd = options.cwd.replace(/\/$/, "");
agents = agents.filter((a) => {
const agentCwd = a.cwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
const agentCwd = a.cwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
}
// Apply label filtering only when explicitly requested.
if (Object.keys(labelFilters).length > 0) {
agents = agents.filter((a) => {
const agentLabels = a.labels
const agentLabels = a.labels;
for (const [key, value] of Object.entries(labelFilters)) {
if (agentLabels[key] !== value) {
return false
return false;
}
}
return true
})
return true;
});
}
await client.close()
await client.close();
// Sort agents: running first, then idle, then others; within each group, most recent first
const statusOrder = { running: 0, idle: 1 } as Record<string, number>
const statusOrder = { running: 0, idle: 1 } as Record<string, number>;
agents.sort((a, b) => {
// Primary sort: by status
const aOrder = statusOrder[a.status] ?? 999
const bOrder = statusOrder[b.status] ?? 999
if (aOrder !== bOrder) return aOrder - bOrder
const aOrder = statusOrder[a.status] ?? 999;
const bOrder = statusOrder[b.status] ?? 999;
if (aOrder !== bOrder) return aOrder - bOrder;
// Secondary sort: by creation time (most recent first)
const aTime = new Date(a.createdAt).getTime()
const bTime = new Date(b.createdAt).getTime()
return bTime - aTime
})
const aTime = new Date(a.createdAt).getTime();
const bTime = new Date(b.createdAt).getTime();
return bTime - aTime;
});
const items = agents.map(toListItem)
const items = agents.map(toListItem);
return {
type: 'list',
type: "list",
data: items,
schema: agentLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'LIST_AGENTS_FAILED',
code: "LIST_AGENTS_FAILED",
message: `Failed to list agents: ${message}`,
}
throw error
};
throw error;
}
}
+55 -55
View File
@@ -1,139 +1,139 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
OutputSchema,
CommandError,
AnyCommandResult,
} from '../../output/index.js'
import type { AgentMode } from '@getpaseo/server'
} from "../../output/index.js";
import type { AgentMode } from "@getpaseo/server";
/** Result for setting mode */
export interface SetModeResult {
agentId: string
mode: string
agentId: string;
mode: string;
}
/** Schema for mode list output */
export const modeListSchema: OutputSchema<AgentMode> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'MODE', field: 'id', width: 15 },
{ header: 'LABEL', field: 'label', width: 25 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
{ header: "MODE", field: "id", width: 15 },
{ header: "LABEL", field: "label", width: 25 },
{ header: "DESCRIPTION", field: "description", width: 40 },
],
}
};
/** Schema for set mode output */
export const setModeSchema: OutputSchema<SetModeResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'MODE', field: 'mode', width: 20 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "MODE", field: "mode", width: 20 },
],
}
};
export interface AgentModeOptions extends CommandOptions {
list?: boolean
list?: boolean;
}
const missingModeError = (): CommandError => ({
code: 'MISSING_ARGUMENT',
message: 'Mode argument required unless --list is specified',
details: 'Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>',
})
code: "MISSING_ARGUMENT",
message: "Mode argument required unless --list is specified",
details: "Usage: paseo agent mode <id> <mode> | paseo agent mode --list <id>",
});
// This command returns two different data shapes (set result vs mode list).
// Keep `any` here to match the existing output wrapper generic contract.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type AgentModeResult = AnyCommandResult<any>
export type AgentModeResult = AnyCommandResult<any>;
export async function runModeCommand(
id: string,
mode: string | undefined,
options: AgentModeOptions,
_command: Command
_command: Command,
): Promise<AgentModeResult> {
const normalizedMode = mode?.trim()
const host = getDaemonHost({ host: options.host as string | undefined })
const normalizedMode = mode?.trim();
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!options.list && !normalizedMode) {
throw missingModeError()
throw missingModeError();
}
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
const fetchResult = await client.fetchAgent(id)
client = await connectToDaemon({ host: options.host as string | undefined });
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
const agent = fetchResult.agent
const resolvedId = agent.id
const agent = fetchResult.agent;
const resolvedId = agent.id;
if (options.list) {
// List available modes for this agent
const availableModes = agent.availableModes ?? []
const availableModes = agent.availableModes ?? [];
const items: AgentMode[] = availableModes.map((m) => ({
id: m.id,
label: m.label,
description: m.description,
}))
}));
return {
type: 'list',
type: "list",
data: items,
schema: modeListSchema,
}
};
}
if (!normalizedMode) {
throw missingModeError()
throw missingModeError();
}
// Set the agent mode
await client.setAgentMode(resolvedId, normalizedMode)
await client.setAgentMode(resolvedId, normalizedMode);
return {
type: 'single',
type: "single",
data: {
agentId: resolvedId.slice(0, 7),
mode: normalizedMode,
},
schema: setModeSchema,
}
};
} catch (err) {
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
if (!client) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'MODE_OPERATION_FAILED',
message: `Failed to ${options.list ? 'list modes' : 'set mode'}: ${message}`,
}
throw error
code: "MODE_OPERATION_FAILED",
message: `Failed to ${options.list ? "list modes" : "set mode"}: ${message}`,
};
throw error;
} finally {
if (client) {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
}
}
+235 -229
View File
@@ -1,331 +1,338 @@
import type { Command } from 'commander'
import type { Command } from "commander";
import {
getStructuredAgentResponse,
StructuredAgentResponseError,
type AgentSnapshotPayload,
} from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { lookup } from 'mime-types'
import { parseDuration } from '../../utils/duration.js'
} from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { lookup } from "mime-types";
import { parseDuration } from "../../utils/duration.js";
/** Result type for agent run command */
export interface AgentRunResult {
agentId: string
status: 'created' | 'running' | 'completed' | 'timeout' | 'permission' | 'error'
provider: string
cwd: string
title: string | null
agentId: string;
status: "created" | "running" | "completed" | "timeout" | "permission" | "error";
provider: string;
cwd: string;
title: string | null;
}
/** Schema for agent run output */
export const agentRunSchema: OutputSchema<AgentRunResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 10 },
{ header: 'PROVIDER', field: 'provider', width: 10 },
{ header: 'CWD', field: 'cwd', width: 30 },
{ header: 'TITLE', field: 'title', width: 20 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "PROVIDER", field: "provider", width: 10 },
{ header: "CWD", field: "cwd", width: 30 },
{ header: "TITLE", field: "title", width: 20 },
],
}
};
export interface AgentRunOptions extends CommandOptions {
detach?: boolean
name?: string
provider?: string
model?: string
thinking?: string
mode?: string
worktree?: string
base?: string
image?: string[]
cwd?: string
label?: string[]
waitTimeout?: string
outputSchema?: string
detach?: boolean;
name?: string;
provider?: string;
model?: string;
thinking?: string;
mode?: string;
worktree?: string;
base?: string;
image?: string[];
cwd?: string;
label?: string[];
waitTimeout?: string;
outputSchema?: string;
}
interface ResolvedProviderModel {
provider: string
model: string | undefined
provider: string;
model: string | undefined;
}
function toRunResult(
agent: AgentSnapshotPayload,
statusOverride?: AgentRunResult['status']
statusOverride?: AgentRunResult["status"],
): AgentRunResult {
return {
agentId: agent.id,
status: statusOverride ?? (agent.status === 'running' ? 'running' : 'created'),
status: statusOverride ?? (agent.status === "running" ? "running" : "created"),
provider: agent.provider,
cwd: agent.cwd,
title: agent.title,
}
};
}
function loadOutputSchema(value: string): Record<string, unknown> {
const trimmed = value.trim()
const trimmed = value.trim();
if (!trimmed) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: '--output-schema cannot be empty',
details: 'Provide a JSON schema file path or inline JSON object',
}
throw error
code: "INVALID_OUTPUT_SCHEMA",
message: "--output-schema cannot be empty",
details: "Provide a JSON schema file path or inline JSON object",
};
throw error;
}
let source = trimmed
if (!trimmed.startsWith('{')) {
let source = trimmed;
if (!trimmed.startsWith("{")) {
try {
source = readFileSync(resolve(trimmed), 'utf8')
source = readFileSync(resolve(trimmed), "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
code: "INVALID_OUTPUT_SCHEMA",
message: `Failed to read output schema file: ${trimmed}`,
details: message,
}
throw error
};
throw error;
}
}
let parsed: unknown
let parsed: unknown;
try {
parsed = JSON.parse(source)
parsed = JSON.parse(source);
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Failed to parse output schema JSON',
code: "INVALID_OUTPUT_SCHEMA",
message: "Failed to parse output schema JSON",
details: message,
}
throw error
};
throw error;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
const error: CommandError = {
code: 'INVALID_OUTPUT_SCHEMA',
message: 'Output schema must be a JSON object',
}
throw error
code: "INVALID_OUTPUT_SCHEMA",
message: "Output schema must be a JSON object",
};
throw error;
}
return parsed as Record<string, unknown>
return parsed as Record<string, unknown>;
}
class StructuredRunStatusError extends Error {
readonly kind: 'timeout' | 'permission' | 'error' | 'empty'
readonly kind: "timeout" | "permission" | "error" | "empty";
constructor(kind: 'timeout' | 'permission' | 'error' | 'empty', message: string) {
super(message)
this.name = 'StructuredRunStatusError'
this.kind = kind
constructor(kind: "timeout" | "permission" | "error" | "empty", message: string) {
super(message);
this.name = "StructuredRunStatusError";
this.kind = kind;
}
}
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>
type ConnectedDaemonClient = Awaited<ReturnType<typeof connectToDaemon>>;
export interface StructuredResponseTimelineClient {
fetchAgentTimeline: ConnectedDaemonClient['fetchAgentTimeline']
fetchAgentTimeline: ConnectedDaemonClient["fetchAgentTimeline"];
}
export async function resolveStructuredResponseMessage(options: {
client: StructuredResponseTimelineClient
agentId: string
lastMessage: string | null
client: StructuredResponseTimelineClient;
agentId: string;
lastMessage: string | null;
}): Promise<string | null> {
const direct = options.lastMessage?.trim()
const direct = options.lastMessage?.trim();
if (direct) {
return direct
return direct;
}
try {
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
direction: 'tail',
projection: 'projected',
direction: "tail",
projection: "projected",
limit: 200,
})
});
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
const entry = timeline.entries[index]
if (!entry || entry.item.type !== 'assistant_message') {
continue
const entry = timeline.entries[index];
if (!entry || entry.item.type !== "assistant_message") {
continue;
}
const text = entry.item.text.trim()
const text = entry.item.text.trim();
if (text.length > 0) {
return text
return text;
}
}
} catch {
// Leave empty; caller will surface a consistent structured-output failure message.
}
return null
return null;
}
function structuredRunSchema(output: Record<string, unknown>): OutputSchema<AgentRunResult> {
return {
...agentRunSchema,
serialize: () => output,
}
};
}
export function resolveProviderAndModel(options: Pick<AgentRunOptions, 'provider' | 'model'>): ResolvedProviderModel {
const providerInput = options.provider?.trim() || 'claude'
const modelInput = options.model?.trim()
export function resolveProviderAndModel(
options: Pick<AgentRunOptions, "provider" | "model">,
): ResolvedProviderModel {
const providerInput = options.provider?.trim() || "claude";
const modelInput = options.model?.trim();
if (options.model !== undefined && !modelInput) {
const error: CommandError = {
code: 'INVALID_MODEL',
message: '--model cannot be empty',
}
throw error
code: "INVALID_MODEL",
message: "--model cannot be empty",
};
throw error;
}
const slashIndex = providerInput.indexOf('/')
const slashIndex = providerInput.indexOf("/");
if (slashIndex === -1) {
return {
provider: providerInput,
model: modelInput,
}
};
}
const provider = providerInput.slice(0, slashIndex).trim()
const modelFromProvider = providerInput.slice(slashIndex + 1).trim()
const provider = providerInput.slice(0, slashIndex).trim();
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
if (!provider || !modelFromProvider) {
const error: CommandError = {
code: 'INVALID_PROVIDER',
message: 'Invalid --provider value',
details: 'Use --provider <provider> or --provider <provider>/<model>',
}
throw error
code: "INVALID_PROVIDER",
message: "Invalid --provider value",
details: "Use --provider <provider> or --provider <provider>/<model>",
};
throw error;
}
if (modelInput && modelInput !== modelFromProvider) {
const error: CommandError = {
code: 'CONFLICTING_MODEL_OPTIONS',
message: 'Conflicting model values provided',
code: "CONFLICTING_MODEL_OPTIONS",
message: "Conflicting model values provided",
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
}
throw error
};
throw error;
}
return {
provider,
model: modelInput ?? modelFromProvider,
}
};
}
export async function runRunCommand(
prompt: string,
options: AgentRunOptions,
_command: Command
_command: Command,
): Promise<SingleResult<AgentRunResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined
let waitTimeoutMs = 0
const host = getDaemonHost({ host: options.host as string | undefined });
const outputSchema = options.outputSchema ? loadOutputSchema(options.outputSchema) : undefined;
let waitTimeoutMs = 0;
// Validate prompt is provided
if (!prompt || prompt.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_PROMPT',
message: 'A prompt is required',
details: 'Usage: paseo agent run [options] <prompt>',
}
throw error
code: "MISSING_PROMPT",
message: "A prompt is required",
details: "Usage: paseo agent run [options] <prompt>",
};
throw error;
}
// Validate --base is only used with --worktree
if (options.base && !options.worktree) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--base can only be used with --worktree',
details: 'Usage: paseo agent run --worktree <name> --base <branch> <prompt>',
}
throw error
code: "INVALID_OPTIONS",
message: "--base can only be used with --worktree",
details: "Usage: paseo agent run --worktree <name> --base <branch> <prompt>",
};
throw error;
}
// --output-schema always runs in attached/wait mode
if (outputSchema && options.detach) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: '--output-schema cannot be used with --detach',
details: 'Structured output requires waiting for the agent to finish',
}
throw error
code: "INVALID_OPTIONS",
message: "--output-schema cannot be used with --detach",
details: "Structured output requires waiting for the agent to finish",
};
throw error;
}
if (options.waitTimeout) {
try {
waitTimeoutMs = parseDuration(options.waitTimeout)
waitTimeoutMs = parseDuration(options.waitTimeout);
if (waitTimeoutMs <= 0) {
throw new Error('Timeout must be positive')
throw new Error("Timeout must be positive");
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid wait timeout value',
code: "INVALID_TIMEOUT",
message: "Invalid wait timeout value",
details: message,
}
throw error
};
throw error;
}
}
const resolvedProviderModel = resolveProviderAndModel(options)
const resolvedProviderModel = resolveProviderAndModel(options);
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Resolve working directory
const cwd = options.cwd ?? process.cwd()
const thinkingOptionId = options.thinking?.trim()
const cwd = options.cwd ?? process.cwd();
const thinkingOptionId = options.thinking?.trim();
if (options.thinking !== undefined && !thinkingOptionId) {
const error: CommandError = {
code: 'INVALID_THINKING_OPTION',
message: '--thinking cannot be empty',
code: "INVALID_THINKING_OPTION",
message: "--thinking cannot be empty",
details:
'Provide a thinking option ID. Use "paseo provider models <provider> --thinking" to list valid IDs.',
}
throw error
};
throw error;
}
// Process images if provided
let images: Array<{ data: string; mimeType: string }> | undefined
let images: Array<{ data: string; mimeType: string }> | undefined;
if (options.image && options.image.length > 0) {
images = options.image.map((imagePath) => {
const resolvedPath = resolve(imagePath)
const resolvedPath = resolve(imagePath);
try {
const imageData = readFileSync(resolvedPath)
const mimeType = lookup(resolvedPath) || 'application/octet-stream'
const imageData = readFileSync(resolvedPath);
const mimeType = lookup(resolvedPath) || "application/octet-stream";
// Verify it's an image MIME type
if (!mimeType.startsWith('image/')) {
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`)
if (!mimeType.startsWith("image/")) {
throw new Error(`File is not an image: ${imagePath} (detected type: ${mimeType})`);
}
return {
data: imageData.toString('base64'),
data: imageData.toString("base64"),
mimeType,
}
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
throw new Error(`Failed to read image ${imagePath}: ${message}`)
const message = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to read image ${imagePath}: ${message}`);
}
})
});
}
// Build git options if worktree is specified
@@ -335,34 +342,34 @@ export async function runRunCommand(
worktreeSlug: options.worktree,
baseBranch: options.base,
}
: undefined
: undefined;
// Build labels from --label flags
const labels: Record<string, string> = {}
const labels: Record<string, string> = {};
if (options.label) {
for (const labelStr of options.label) {
const eqIndex = labelStr.indexOf('=')
const eqIndex = labelStr.indexOf("=");
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${labelStr}`,
details: 'Labels must be in key=value format',
}
throw error
details: "Labels must be in key=value format",
};
throw error;
}
const key = labelStr.slice(0, eqIndex)
const value = labelStr.slice(eqIndex + 1)
labels[key] = value
const key = labelStr.slice(0, eqIndex);
const value = labelStr.slice(eqIndex + 1);
labels[key] = value;
}
}
if (outputSchema) {
let structuredAgent: AgentSnapshotPayload | null = null
let structuredAgent: AgentSnapshotPayload | null = null;
const callStructuredTurn = async (structuredPrompt: string): Promise<string> => {
if (!structuredAgent) {
structuredAgent = await client.createAgent({
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
cwd,
title: options.name,
modeId: options.mode,
@@ -374,94 +381,94 @@ export async function runRunCommand(
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
});
} else {
await client.sendMessage(structuredAgent.id, structuredPrompt)
await client.sendMessage(structuredAgent.id, structuredPrompt);
}
const state = await client.waitForFinish(structuredAgent.id, waitTimeoutMs)
if (state.status === 'timeout') {
throw new StructuredRunStatusError('timeout', 'Timed out waiting for structured output')
const state = await client.waitForFinish(structuredAgent.id, waitTimeoutMs);
if (state.status === "timeout") {
throw new StructuredRunStatusError("timeout", "Timed out waiting for structured output");
}
if (state.status === 'permission') {
if (state.status === "permission") {
throw new StructuredRunStatusError(
'permission',
'Agent is waiting for permission before producing structured output'
)
"permission",
"Agent is waiting for permission before producing structured output",
);
}
if (state.status === 'error') {
if (state.status === "error") {
throw new StructuredRunStatusError(
'error',
state.error ?? 'Agent failed before producing structured output'
)
"error",
state.error ?? "Agent failed before producing structured output",
);
}
const lastMessage = await resolveStructuredResponseMessage({
client,
agentId: structuredAgent.id,
lastMessage: state.lastMessage,
})
});
if (!lastMessage) {
throw new StructuredRunStatusError(
'empty',
'Agent finished without a structured output message'
)
"empty",
"Agent finished without a structured output message",
);
}
return lastMessage
}
return lastMessage;
};
let output: Record<string, unknown>
let output: Record<string, unknown>;
try {
output = await getStructuredAgentResponse<Record<string, unknown>>({
caller: callStructuredTurn,
prompt,
schema: outputSchema,
schemaName: 'RunOutput',
schemaName: "RunOutput",
maxRetries: 2,
})
});
} catch (err) {
if (err instanceof StructuredRunStatusError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
code: "OUTPUT_SCHEMA_FAILED",
message: err.message,
}
throw error
};
throw error;
}
if (err instanceof StructuredAgentResponseError) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent response did not match the required output schema',
code: "OUTPUT_SCHEMA_FAILED",
message: "Agent response did not match the required output schema",
details:
err.validationErrors.length > 0
? err.validationErrors.join('\n')
: err.lastResponse || 'No response',
}
throw error
? err.validationErrors.join("\n")
: err.lastResponse || "No response",
};
throw error;
}
throw err
throw err;
}
if (!structuredAgent) {
const error: CommandError = {
code: 'OUTPUT_SCHEMA_FAILED',
message: 'Agent finished without a structured output message',
}
throw error
code: "OUTPUT_SCHEMA_FAILED",
message: "Agent finished without a structured output message",
};
throw error;
}
await client.close()
await client.close();
return {
type: 'single',
data: toRunResult(structuredAgent, 'completed'),
type: "single",
data: toRunResult(structuredAgent, "completed"),
schema: structuredRunSchema(output),
}
};
}
// Create the agent
const agent = await client.createAgent({
provider: resolvedProviderModel.provider as 'claude' | 'codex' | 'opencode',
provider: resolvedProviderModel.provider as "claude" | "codex" | "opencode",
cwd,
title: options.name,
modeId: options.mode,
@@ -472,43 +479,42 @@ export async function runRunCommand(
git,
worktreeName: options.worktree,
labels: Object.keys(labels).length > 0 ? labels : undefined,
})
});
// Default run behavior is foreground: wait for completion unless --detach is set.
if (!options.detach) {
const state = await client.waitForFinish(agent.id, waitTimeoutMs)
await client.close()
const state = await client.waitForFinish(agent.id, waitTimeoutMs);
await client.close();
const finalAgent = state.final ?? agent
const status: AgentRunResult['status'] =
state.status === 'idle' ? 'completed' : state.status
const finalAgent = state.final ?? agent;
const status: AgentRunResult["status"] = state.status === "idle" ? "completed" : state.status;
return {
type: 'single',
type: "single",
data: toRunResult(finalAgent, status),
schema: agentRunSchema,
}
};
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: toRunResult(agent),
schema: agentRunSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'AGENT_CREATE_FAILED',
code: "AGENT_CREATE_FAILED",
message: `Failed to create agent: ${message}`,
}
throw error
};
throw error;
}
}
+125 -118
View File
@@ -1,129 +1,137 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { readFile } from 'node:fs/promises'
import { extname, resolve } from 'node:path'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { readFile } from "node:fs/promises";
import { extname, resolve } from "node:path";
/** Result type for agent send command */
export interface AgentSendResult {
agentId: string
status: 'sent' | 'completed' | 'timeout' | 'permission' | 'error'
message: string
agentId: string;
status: "sent" | "completed" | "timeout" | "permission" | "error";
message: string;
}
/** Schema for agent send output */
export const agentSendSchema: OutputSchema<AgentSendResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "MESSAGE", field: "message", width: 40 },
],
}
};
export interface AgentSendOptions extends CommandOptions {
noWait?: boolean
image?: string[]
prompt?: string
promptFile?: string
noWait?: boolean;
image?: string[];
prompt?: string;
promptFile?: string;
}
/**
* Read image files and convert them to base64 data URIs
*/
async function readImageFiles(imagePaths: string[]): Promise<Array<{ data: string; mimeType: string }>> {
const images: Array<{ data: string; mimeType: string }> = []
async function readImageFiles(
imagePaths: string[],
): Promise<Array<{ data: string; mimeType: string }>> {
const images: Array<{ data: string; mimeType: string }> = [];
for (const path of imagePaths) {
try {
const buffer = await readFile(path)
const ext = extname(path).toLowerCase()
const buffer = await readFile(path);
const ext = extname(path).toLowerCase();
// Determine media type from extension
let mimeType = 'image/jpeg'
let mimeType = "image/jpeg";
switch (ext) {
case '.png':
mimeType = 'image/png'
break
case '.jpg':
case '.jpeg':
mimeType = 'image/jpeg'
break
case '.gif':
mimeType = 'image/gif'
break
case '.webp':
mimeType = 'image/webp'
break
case ".png":
mimeType = "image/png";
break;
case ".jpg":
case ".jpeg":
mimeType = "image/jpeg";
break;
case ".gif":
mimeType = "image/gif";
break;
case ".webp":
mimeType = "image/webp";
break;
default:
// Default to jpeg for unknown types
mimeType = 'image/jpeg'
mimeType = "image/jpeg";
}
const data = buffer.toString('base64')
const data = buffer.toString("base64");
images.push({
data,
mimeType,
})
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'IMAGE_READ_ERROR',
code: "IMAGE_READ_ERROR",
message: `Failed to read image file: ${path}`,
details: message,
}
throw error
};
throw error;
}
}
return images
return images;
}
async function resolvePromptInput(options: {
promptArgument: string | undefined
promptOption: string | undefined
promptFile: string | undefined
promptArgument: string | undefined;
promptOption: string | undefined;
promptFile: string | undefined;
}): Promise<string> {
const promptText = options.promptArgument?.trim()
const promptOptionText = options.promptOption?.trim()
const promptFilePath = options.promptFile?.trim()
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length
const promptText = options.promptArgument?.trim();
const promptOptionText = options.promptOption?.trim();
const promptFilePath = options.promptFile?.trim();
const providedSourceCount = [promptText, promptOptionText, promptFilePath].filter(Boolean).length;
if (providedSourceCount > 1) {
const error: CommandError = {
code: 'CONFLICTING_PROMPT_INPUT',
message: 'Provide exactly one of prompt argument, --prompt, or --prompt-file',
}
throw error
code: "CONFLICTING_PROMPT_INPUT",
message: "Provide exactly one of prompt argument, --prompt, or --prompt-file",
};
throw error;
}
if (promptText) {
return options.promptArgument as string
return options.promptArgument as string;
}
if (promptOptionText) {
return options.promptOption as string
return options.promptOption as string;
}
if (!promptFilePath) {
const error: CommandError = {
code: 'MISSING_PROMPT',
message: 'A prompt is required',
details: 'Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>',
}
throw error
code: "MISSING_PROMPT",
message: "A prompt is required",
details:
"Usage: paseo agent send [options] <id> [prompt] | --prompt <text> | --prompt-file <path>",
};
throw error;
}
try {
return await readFile(resolve(promptFilePath), 'utf8')
return await readFile(resolve(promptFilePath), "utf8");
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'PROMPT_FILE_READ_ERROR',
code: "PROMPT_FILE_READ_ERROR",
message: `Failed to read prompt file: ${promptFilePath}`,
details: message,
}
throw error
};
throw error;
}
}
@@ -131,126 +139,125 @@ export async function runSendCommand(
agentIdArg: string,
prompt: string | undefined,
options: AgentSendOptions,
_command: Command
_command: Command,
): Promise<SingleResult<AgentSendResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent send [options] <id> [prompt]',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent send [options] <id> [prompt]",
};
throw error;
}
const promptInput = await resolvePromptInput({
promptArgument: prompt,
promptOption: options.prompt,
promptFile: options.promptFile,
})
});
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Read image files if provided
const images = options.image && options.image.length > 0
? await readImageFiles(options.image)
: undefined
const images =
options.image && options.image.length > 0 ? await readImageFiles(options.image) : undefined;
// Send the message
await client.sendAgentMessage(agentIdArg, promptInput, { images })
await client.sendAgentMessage(agentIdArg, promptInput, { images });
// If --no-wait, return immediately
if (options.noWait) {
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId: agentIdArg,
status: 'sent',
message: 'Message sent, not waiting for completion',
status: "sent",
message: "Message sent, not waiting for completion",
},
schema: agentSendSchema,
}
};
}
// Wait for agent to finish
const state = await client.waitForFinish(agentIdArg, 600000) // 10 minute timeout
const state = await client.waitForFinish(agentIdArg, 600000); // 10 minute timeout
await client.close()
await client.close();
if (state.status === 'timeout') {
if (state.status === "timeout") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'timeout',
message: 'Timed out waiting for agent to finish',
status: "timeout",
message: "Timed out waiting for agent to finish",
},
schema: agentSendSchema,
}
};
}
if (state.status === 'permission') {
if (state.status === "permission") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'permission',
message: 'Agent is waiting for permission',
status: "permission",
message: "Agent is waiting for permission",
},
schema: agentSendSchema,
}
};
}
if (state.status === 'error') {
if (state.status === "error") {
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'error',
message: state.error ?? 'Agent finished with error',
status: "error",
message: state.error ?? "Agent finished with error",
},
schema: agentSendSchema,
}
};
}
return {
type: 'single',
type: "single",
data: {
agentId: state.final?.id ?? agentIdArg,
status: 'completed',
message: 'Agent completed processing the message',
status: "completed",
message: "Agent completed processing the message",
},
schema: agentSendSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'SEND_FAILED',
code: "SEND_FAILED",
message: `Failed to send message: ${message}`,
}
throw error
};
throw error;
}
}
+61 -56
View File
@@ -1,123 +1,128 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent stop command */
export interface StopResult {
stoppedCount: number
agentIds: string[]
stoppedCount: number;
agentIds: string[];
}
/** Schema for stop command output */
export const stopSchema: OutputSchema<StopResult> = {
// For quiet mode, output the stopped agent IDs (one per line)
idField: (item) => item.agentIds.join('\n'),
columns: [{ header: 'INTERRUPTED', field: 'stoppedCount' }],
}
idField: (item) => item.agentIds.join("\n"),
columns: [{ header: "INTERRUPTED", field: "stoppedCount" }],
};
export interface AgentStopOptions extends CommandOptions {
all?: boolean
cwd?: string
all?: boolean;
cwd?: string;
}
export type AgentStopResult = SingleResult<StopResult>
export type AgentStopResult = SingleResult<StopResult>;
export async function runStopCommand(
id: string | undefined,
options: AgentStopOptions,
_command: Command
_command: Command,
): Promise<AgentStopResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments - need either an id, --all, or --cwd
if (!id && !options.all && !options.cwd) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Agent ID required unless --all or --cwd is specified',
details: 'Usage: paseo agent stop <id> | --all | --cwd <path>',
}
throw error
code: "MISSING_ARGUMENT",
message: "Agent ID required unless --all or --cwd is specified",
details: "Usage: paseo agent stop <id> | --all | --cwd <path>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } })
let agents = fetchPayload.entries.map((entry) => entry.agent)
const stoppedIds: string[] = []
const fetchPayload = await client.fetchAgents({ filter: { includeArchived: true } });
let agents = fetchPayload.entries.map((entry) => entry.agent);
const stoppedIds: string[] = [];
if (options.all) {
// Stop all agents (not archived)
agents = agents.filter((a) => !a.archivedAt)
agents = agents.filter((a) => !a.archivedAt);
} else if (options.cwd) {
// Stop agents in directory
const filterCwd = options.cwd
const filterCwd = options.cwd;
agents = agents.filter((a) => {
if (a.archivedAt) return false
const agentCwd = a.cwd.replace(/\/$/, '')
const targetCwd = filterCwd.replace(/\/$/, '')
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + '/')
})
if (a.archivedAt) return false;
const agentCwd = a.cwd.replace(/\/$/, "");
const targetCwd = filterCwd.replace(/\/$/, "");
return agentCwd === targetCwd || agentCwd.startsWith(targetCwd + "/");
});
} else if (id) {
// Stop specific agent
const fetchResult = await client.fetchAgent(id)
const fetchResult = await client.fetchAgent(id);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `No agent found matching: ${id}`,
details: 'Use `paseo ls` to list available agents',
}
throw error
details: "Use `paseo ls` to list available agents",
};
throw error;
}
agents = [fetchResult.agent]
agents = [fetchResult.agent];
}
// Interrupt each running agent. Idle agents are a no-op.
for (const agent of agents) {
try {
if (agent.status === 'running') {
await client.cancelAgent(agent.id)
stoppedIds.push(agent.id)
if (agent.status === "running") {
await client.cancelAgent(agent.id);
stoppedIds.push(agent.id);
}
} catch (err) {
// Continue interrupting other agents even if one fails
const message = err instanceof Error ? err.message : String(err)
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`)
const message = err instanceof Error ? err.message : String(err);
console.error(`Warning: Failed to stop agent ${agent.id.slice(0, 7)}: ${message}`);
}
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
stoppedCount: stoppedIds.length,
agentIds: stoppedIds,
},
schema: stopSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw if it's already a CommandError
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'STOP_AGENT_FAILED',
code: "STOP_AGENT_FAILED",
message: `Failed to stop agent(s): ${message}`,
}
throw error
};
throw error;
}
}
+85 -80
View File
@@ -1,177 +1,182 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for agent update command */
export interface AgentUpdateResult {
agentId: string
name: string | null
labels: string
agentId: string;
name: string | null;
labels: string;
}
/** Schema for update command output */
export const updateSchema: OutputSchema<AgentUpdateResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId' },
{ header: 'NAME', field: 'name' },
{ header: 'LABELS', field: 'labels' },
{ header: "AGENT ID", field: "agentId" },
{ header: "NAME", field: "name" },
{ header: "LABELS", field: "labels" },
],
}
};
export interface AgentUpdateOptions extends CommandOptions {
name?: string
label?: string[]
host?: string
name?: string;
label?: string[];
host?: string;
}
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>
export type AgentUpdateCommandResult = SingleResult<AgentUpdateResult>;
function parseLabelOptions(labels: string[] | undefined): Record<string, string> {
const parsed: Record<string, string> = {}
const parsed: Record<string, string> = {};
if (!labels) {
return parsed
return parsed;
}
for (const rawLabel of labels) {
for (const segment of rawLabel.split(',')) {
const label = segment.trim()
for (const segment of rawLabel.split(",")) {
const label = segment.trim();
if (!label) {
continue
continue;
}
const eqIndex = label.indexOf('=')
const eqIndex = label.indexOf("=");
if (eqIndex === -1) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${label}`,
details: 'Labels must be in key=value format',
}
throw error
details: "Labels must be in key=value format",
};
throw error;
}
const key = label.slice(0, eqIndex).trim()
const value = label.slice(eqIndex + 1)
const key = label.slice(0, eqIndex).trim();
const value = label.slice(eqIndex + 1);
if (!key) {
const error: CommandError = {
code: 'INVALID_LABEL',
code: "INVALID_LABEL",
message: `Invalid label format: ${label}`,
details: 'Labels must include a non-empty key in key=value format',
}
throw error
details: "Labels must include a non-empty key in key=value format",
};
throw error;
}
parsed[key] = value
parsed[key] = value;
}
}
return parsed
return parsed;
}
function formatLabels(labels: Record<string, string>): string {
const entries = Object.entries(labels)
const entries = Object.entries(labels);
if (entries.length === 0) {
return '-'
return "-";
}
return entries.map(([key, value]) => `${key}=${value}`).join(',')
return entries.map(([key, value]) => `${key}=${value}`).join(",");
}
export async function runUpdateCommand(
agentIdArg: string,
options: AgentUpdateOptions,
_command: Command
_command: Command,
): Promise<AgentUpdateCommandResult> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent update <id> [--name <name>] [--label <key=value>]',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent update <id> [--name <name>] [--label <key=value>]",
};
throw error;
}
const name = options.name?.trim()
const name = options.name?.trim();
if (options.name !== undefined && !name) {
const error: CommandError = {
code: 'INVALID_NAME',
message: 'Name cannot be empty',
details: 'Use --name <name> with a non-empty value',
}
throw error
code: "INVALID_NAME",
message: "Name cannot be empty",
details: "Use --name <name> with a non-empty value",
};
throw error;
}
const labels = parseLabelOptions(options.label)
const labels = parseLabelOptions(options.label);
if (!name && Object.keys(labels).length === 0) {
const error: CommandError = {
code: 'NO_CHANGES_PROVIDED',
message: 'Nothing to update',
details: 'Provide at least one of: --name <name>, --label <key=value>',
}
throw error
code: "NO_CHANGES_PROVIDED",
message: "Nothing to update",
details: "Provide at least one of: --name <name>, --label <key=value>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdArg)
const fetchResult = await client.fetchAgent(agentIdArg);
if (!fetchResult) {
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdArg}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agentId = fetchResult.agent.id
const agentId = fetchResult.agent.id;
await client.updateAgent(agentId, {
...(name ? { name } : {}),
...(Object.keys(labels).length > 0 ? { labels } : {}),
})
});
const updatedResult = await client.fetchAgent(agentId)
const updatedResult = await client.fetchAgent(agentId);
if (!updatedResult) {
throw new Error(`Agent not found after update: ${agentId}`)
throw new Error(`Agent not found after update: ${agentId}`);
}
await client.close()
await client.close();
return {
type: 'single',
type: "single",
data: {
agentId,
name: updatedResult.agent.title,
labels: formatLabels(updatedResult.agent.labels),
},
schema: updateSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'UPDATE_FAILED',
code: "UPDATE_FAILED",
message: `Failed to update agent: ${message}`,
}
throw error
};
throw error;
}
}
+89 -84
View File
@@ -1,193 +1,198 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from './logs.js'
import { parseDuration } from '../../utils/duration.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
import { fetchAgentTimelineItems, formatAgentActivityTranscript } from "./logs.js";
import { parseDuration } from "../../utils/duration.js";
/** Result type for agent wait command */
export interface AgentWaitResult {
agentId: string
status: 'idle' | 'timeout' | 'permission' | 'error'
message: string
agentId: string;
status: "idle" | "timeout" | "permission" | "error";
message: string;
}
/** Schema for agent wait output */
export const agentWaitSchema: OutputSchema<AgentWaitResult> = {
idField: 'agentId',
idField: "agentId",
columns: [
{ header: 'AGENT ID', field: 'agentId', width: 12 },
{ header: 'STATUS', field: 'status', width: 12 },
{ header: 'MESSAGE', field: 'message', width: 40 },
{ header: "AGENT ID", field: "agentId", width: 12 },
{ header: "STATUS", field: "status", width: 12 },
{ header: "MESSAGE", field: "message", width: 40 },
],
}
};
export interface AgentWaitOptions extends CommandOptions {
timeout?: string
host?: string
timeout?: string;
host?: string;
}
const WAIT_ACTIVITY_PREVIEW_COUNT = 5
const WAIT_ACTIVITY_PREVIEW_COUNT = 5;
function appendRecentActivity(message: string, transcript: string | null): string {
if (!transcript || transcript.trim().length === 0) {
return message
return message;
}
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`
return `${message}\nLast ${WAIT_ACTIVITY_PREVIEW_COUNT} activity items:\n${transcript}`;
}
async function getRecentActivityTranscript(
client: Awaited<ReturnType<typeof connectToDaemon>>,
agentId: string
agentId: string,
): Promise<string | null> {
try {
const timelineItems = await fetchAgentTimelineItems(client, agentId)
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT)
const timelineItems = await fetchAgentTimelineItems(client, agentId);
return formatAgentActivityTranscript(timelineItems, WAIT_ACTIVITY_PREVIEW_COUNT);
} catch {
return null
return null;
}
}
export async function runWaitCommand(
agentIdArg: string,
options: AgentWaitOptions,
_command: Command
_command: Command,
): Promise<SingleResult<AgentWaitResult>> {
const host = getDaemonHost({ host: options.host as string | undefined })
const host = getDaemonHost({ host: options.host as string | undefined });
// Validate arguments
if (!agentIdArg || agentIdArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_AGENT_ID',
message: 'Agent ID is required',
details: 'Usage: paseo agent wait <id>',
}
throw error
code: "MISSING_AGENT_ID",
message: "Agent ID is required",
details: "Usage: paseo agent wait <id>",
};
throw error;
}
// Parse timeout (no limit unless explicitly provided)
let timeoutMs = 0
let timeoutLabel: string | null = null
let timeoutMs = 0;
let timeoutLabel: string | null = null;
if (options.timeout) {
try {
timeoutMs = parseDuration(options.timeout)
timeoutMs = parseDuration(options.timeout);
if (timeoutMs <= 0) {
throw new Error('Timeout must be positive')
throw new Error("Timeout must be positive");
}
const timeoutSeconds = Math.floor(timeoutMs / 1000)
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? '' : 's'}`
const timeoutSeconds = Math.floor(timeoutMs / 1000);
timeoutLabel = `${timeoutSeconds} second${timeoutSeconds === 1 ? "" : "s"}`;
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'INVALID_TIMEOUT',
message: 'Invalid timeout value',
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
}
throw error
};
throw error;
}
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host as string | undefined })
client = await connectToDaemon({ host: options.host as string | undefined });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
try {
const state = await client.waitForFinish(agentIdArg, timeoutMs)
const resolvedAgentId = state.final?.id ?? agentIdArg
const state = await client.waitForFinish(agentIdArg, timeoutMs);
const resolvedAgentId = state.final?.id ?? agentIdArg;
const recentActivity =
state.status === 'timeout' || state.status === 'idle'
state.status === "timeout" || state.status === "idle"
? await getRecentActivityTranscript(client, resolvedAgentId)
: null
: null;
await client.close()
await client.close();
if (state.status === 'timeout') {
if (state.status === "timeout") {
const timeoutMessage = timeoutLabel
? `Agent did not finish within ${timeoutLabel}. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`
: `Agent wait timed out. Run \`paseo wait ${resolvedAgentId}\` again to keep waiting.`;
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'timeout',
status: "timeout",
message: appendRecentActivity(timeoutMessage, recentActivity),
},
schema: agentWaitSchema,
}
};
}
if (state.status === 'permission') {
const permission = state.final?.pendingPermissions?.[0]
if (state.status === "permission") {
const permission = state.final?.pendingPermissions?.[0];
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'permission',
status: "permission",
message: permission
? `Agent is waiting for permission: ${permission.kind}`
: 'Agent is waiting for permission',
: "Agent is waiting for permission",
},
schema: agentWaitSchema,
}
};
}
if (state.status === 'error') {
if (state.status === "error") {
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'error',
message: state.error ?? 'Agent finished with error',
status: "error",
message: state.error ?? "Agent finished with error",
},
schema: agentWaitSchema,
}
};
}
// Agent is idle
return {
type: 'single',
type: "single",
data: {
agentId: resolvedAgentId,
status: 'idle',
message: appendRecentActivity('Agent is idle.', recentActivity),
status: "idle",
message: appendRecentActivity("Agent is idle.", recentActivity),
},
schema: agentWaitSchema,
}
};
} catch (waitErr) {
await client.close().catch(() => {})
await client.close().catch(() => {});
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr)
const waitMessage = waitErr instanceof Error ? waitErr.message : String(waitErr);
// Other errors
const error: CommandError = {
code: 'WAIT_FAILED',
code: "WAIT_FAILED",
message: `Failed to wait for agent: ${waitMessage}`,
}
throw error
};
throw error;
}
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WAIT_FAILED',
code: "WAIT_FAILED",
message: `Failed to wait for agent: ${message}`,
}
throw error
};
throw error;
}
}
+34 -43
View File
@@ -1,53 +1,44 @@
import { Command } from 'commander'
import { startCommand } from './start.js'
import { runStatusCommand } from './status.js'
import { runStopCommand } from './stop.js'
import { runRestartCommand } from './restart.js'
import { pairCommand } from './pair.js'
import { withOutput } from '../../output/index.js'
import { addJsonOption } from '../../utils/command-options.js'
import { Command } from "commander";
import { startCommand } from "./start.js";
import { runStatusCommand } from "./status.js";
import { runStopCommand } from "./stop.js";
import { runRestartCommand } from "./restart.js";
import { pairCommand } from "./pair.js";
import { withOutput } from "../../output/index.js";
import { addJsonOption } from "../../utils/command-options.js";
export function createDaemonCommand(): Command {
const daemon = new Command('daemon').description('Manage the Paseo daemon')
const daemon = new Command("daemon").description("Manage the Paseo daemon");
daemon.addCommand(startCommand())
daemon.addCommand(pairCommand())
daemon.addCommand(startCommand());
daemon.addCommand(pairCommand());
addJsonOption(
daemon
.command('status')
.description('Show local daemon status')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.action(withOutput(runStatusCommand))
addJsonOption(daemon.command("status").description("Show local daemon status"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(withOutput(runStatusCommand));
addJsonOption(
daemon
.command('stop')
.description('Stop the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before failing (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.action(withOutput(runStopCommand))
addJsonOption(daemon.command("stop").description("Stop the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before failing (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.action(withOutput(runStopCommand));
addJsonOption(
daemon
.command('restart')
.description('Restart the local daemon')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--timeout <seconds>', 'Wait timeout before force step (default: 15)')
.option('--force', 'Send SIGKILL if graceful stop times out')
.option('--listen <listen>', 'Listen target for restarted daemon (host:port, port, or unix socket)')
.option('--port <port>', 'Port for restarted daemon listen target')
.option('--no-relay', 'Disable relay on restarted daemon')
.option('--no-mcp', 'Disable Agent MCP on restarted daemon')
addJsonOption(daemon.command("restart").description("Restart the local daemon"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--timeout <seconds>", "Wait timeout before force step (default: 15)")
.option("--force", "Send SIGKILL if graceful stop times out")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--listen <listen>",
"Listen target for restarted daemon (host:port, port, or unix socket)",
)
.action(withOutput(runRestartCommand))
.option("--port <port>", "Port for restarted daemon listen target")
.option("--no-relay", "Disable relay on restarted daemon")
.option("--no-mcp", "Disable Agent MCP on restarted daemon")
.option(
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(withOutput(runRestartCommand));
return daemon
return daemon;
}
+230 -231
View File
@@ -1,312 +1,313 @@
import { spawn, spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import { spawn, spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
export interface DaemonStartOptions {
port?: string
listen?: string
home?: string
foreground?: boolean
relay?: boolean
mcp?: boolean
allowedHosts?: string
port?: string;
listen?: string;
home?: string;
foreground?: boolean;
relay?: boolean;
mcp?: boolean;
allowedHosts?: string;
}
export interface LocalDaemonPidInfo {
pid: number
startedAt?: string
hostname?: string
uid?: number
listen?: string
pid: number;
startedAt?: string;
hostname?: string;
uid?: number;
listen?: string;
}
export interface LocalDaemonState {
home: string
listen: string
logPath: string
pidPath: string
pidInfo: LocalDaemonPidInfo | null
running: boolean
stalePidFile: boolean
home: string;
listen: string;
logPath: string;
pidPath: string;
pidInfo: LocalDaemonPidInfo | null;
running: boolean;
stalePidFile: boolean;
}
export interface DetachedStartResult {
pid: number | null
logPath: string
pid: number | null;
logPath: string;
}
export interface StopLocalDaemonOptions {
home?: string
timeoutMs?: number
force?: boolean
home?: string;
timeoutMs?: number;
force?: boolean;
}
export interface StopLocalDaemonResult {
action: 'stopped' | 'not_running'
home: string
pid: number | null
forced: boolean
message: string
action: "stopped" | "not_running";
home: string;
pid: number | null;
forced: boolean;
message: string;
}
type ProcessExitDetails = {
code: number | null
signal: NodeJS.Signals | null
error?: Error
}
code: number | null;
signal: NodeJS.Signals | null;
error?: Error;
};
type DetachedStartupResult =
| { exitedEarly: false }
| ({ exitedEarly: true } & ProcessExitDetails)
type DetachedStartupResult = { exitedEarly: false } | ({ exitedEarly: true } & ProcessExitDetails);
const DETACHED_STARTUP_GRACE_MS = 1200
const PID_POLL_INTERVAL_MS = 100
const KILL_TIMEOUT_MS = 3000
const DAEMON_LOG_FILENAME = 'daemon.log'
const DAEMON_PID_FILENAME = 'paseo.pid'
const DETACHED_STARTUP_GRACE_MS = 1200;
const PID_POLL_INTERVAL_MS = 100;
const KILL_TIMEOUT_MS = 3000;
const DAEMON_LOG_FILENAME = "daemon.log";
const DAEMON_PID_FILENAME = "paseo.pid";
export const DEFAULT_STOP_TIMEOUT_MS = 15_000
export const DEFAULT_STOP_TIMEOUT_MS = 15_000;
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false })
const startupReady = (): DetachedStartupResult => ({ exitedEarly: false });
const startupExited = (details: ProcessExitDetails): DetachedStartupResult => ({
exitedEarly: true,
...details,
})
});
function envWithHome(home?: string): NodeJS.ProcessEnv {
if (!home) {
return process.env
return process.env;
}
return { ...process.env, PASEO_HOME: home }
return { ...process.env, PASEO_HOME: home };
}
function buildRunnerArgs(options: DaemonStartOptions): string[] {
const args: string[] = []
const args: string[] = [];
if (options.relay === false) {
args.push('--no-relay')
args.push("--no-relay");
}
if (options.mcp === false) {
args.push('--no-mcp')
args.push("--no-mcp");
}
return args
return args;
}
function buildChildEnv(options: DaemonStartOptions): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = { ...process.env }
const childEnv: NodeJS.ProcessEnv = { ...process.env };
if (options.home) {
childEnv.PASEO_HOME = options.home
childEnv.PASEO_HOME = options.home;
}
if (options.listen) {
childEnv.PASEO_LISTEN = options.listen
childEnv.PASEO_LISTEN = options.listen;
} else if (options.port) {
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`
childEnv.PASEO_LISTEN = `127.0.0.1:${options.port}`;
}
if (options.allowedHosts) {
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts
childEnv.PASEO_ALLOWED_HOSTS = options.allowedHosts;
}
return childEnv
return childEnv;
}
function resolveDaemonRunnerEntry(): string {
const serverExportPath = require.resolve('@getpaseo/server')
let currentDir = path.dirname(serverExportPath)
const serverExportPath = require.resolve("@getpaseo/server");
let currentDir = path.dirname(serverExportPath);
while (true) {
const packageJsonPath = path.join(currentDir, 'package.json')
const packageJsonPath = path.join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { name?: string }
if (packageJson.name === '@getpaseo/server') {
const distRunner = path.join(currentDir, 'dist', 'scripts', 'daemon-runner.js')
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { name?: string };
if (packageJson.name === "@getpaseo/server") {
const distRunner = path.join(currentDir, "dist", "scripts", "daemon-runner.js");
if (existsSync(distRunner)) {
return distRunner
return distRunner;
}
return path.join(currentDir, 'scripts', 'daemon-runner.ts')
return path.join(currentDir, "scripts", "daemon-runner.ts");
}
} catch {
// Continue searching up if package.json exists but is invalid.
}
}
const parentDir = path.dirname(currentDir)
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break
break;
}
currentDir = parentDir
currentDir = parentDir;
}
throw new Error('Unable to resolve @getpaseo/server package root for daemon runner')
throw new Error("Unable to resolve @getpaseo/server package root for daemon runner");
}
function pidFilePath(paseoHome: string): string {
return path.join(paseoHome, DAEMON_PID_FILENAME)
return path.join(paseoHome, DAEMON_PID_FILENAME);
}
function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as Record<string, unknown>
const pidValue = parsed.pid
if (typeof pidValue !== 'number' || !Number.isInteger(pidValue) || pidValue <= 0) {
return null
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as Record<string, unknown>;
const pidValue = parsed.pid;
if (typeof pidValue !== "number" || !Number.isInteger(pidValue) || pidValue <= 0) {
return null;
}
return {
pid: pidValue,
startedAt: typeof parsed.startedAt === 'string' ? parsed.startedAt : undefined,
hostname: typeof parsed.hostname === 'string' ? parsed.hostname : undefined,
uid: typeof parsed.uid === 'number' ? parsed.uid : undefined,
listen: typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : undefined,
}
startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen:
typeof parsed.listen === "string"
? parsed.listen
: typeof parsed.sockPath === "string"
? parsed.sockPath
: undefined,
};
} catch {
return null
return null;
}
}
function tailFile(filePath: string, lines = 30): string | null {
try {
const content = readFileSync(filePath, 'utf-8')
return content.split('\n').filter(Boolean).slice(-lines).join('\n')
const content = readFileSync(filePath, "utf-8");
return content.split("\n").filter(Boolean).slice(-lines).join("\n");
} catch {
return null
return null;
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
setTimeout(resolve, ms);
});
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof error.code === 'string' ? error.code : undefined
return typeof error.code === "string" ? error.code : undefined;
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
return false
return false;
}
}
function signalProcess(pid: number, signal: NodeJS.Signals): boolean {
try {
process.kill(pid, signal)
return true
process.kill(pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return false
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return false;
}
throw err
throw err;
}
}
function signalProcessSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
return false;
}
try {
return signalProcess(pid, signal)
return signalProcess(pid, signal);
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'EPERM') {
return true
const code = readNodeErrnoCode(err);
if (code === "EPERM") {
return true;
}
throw err
throw err;
}
}
function signalProcessGroupSafely(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) {
return false
return false;
}
if (process.platform === 'win32') {
return signalProcessSafely(pid, signal)
if (process.platform === "win32") {
return signalProcessSafely(pid, signal);
}
try {
process.kill(-pid, signal)
return true
process.kill(-pid, signal);
return true;
} catch (err) {
const code = readNodeErrnoCode(err)
if (code === 'ESRCH') {
return signalProcessSafely(pid, signal)
const code = readNodeErrnoCode(err);
if (code === "ESRCH") {
return signalProcessSafely(pid, signal);
}
if (code === 'EPERM') {
return true
if (code === "EPERM") {
return true;
}
throw err
throw err;
}
}
async function waitForPidExit(pid: number, timeoutMs: number): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return true
return true;
}
await sleep(PID_POLL_INTERVAL_MS)
await sleep(PID_POLL_INTERVAL_MS);
}
return !isProcessRunning(pid)
return !isProcessRunning(pid);
}
type LifecycleShutdownAttempt =
| { requested: true }
| { requested: false; reason: string }
type LifecycleShutdownAttempt = { requested: true } | { requested: false; reason: string };
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
return error instanceof Error ? error.message : String(error);
}
export function resolveLocalPaseoHome(home?: string): string {
return resolvePaseoHome(envWithHome(home))
return resolvePaseoHome(envWithHome(home));
}
export function resolveTcpHostFromListen(listen: string): string | null {
const normalized = listen.trim()
const normalized = listen.trim();
if (!normalized) {
return null
return null;
}
if (
normalized.startsWith('/') ||
normalized.startsWith('unix://') ||
normalized.startsWith('pipe://') ||
normalized.startsWith('\\\\.\\pipe\\')
normalized.startsWith("/") ||
normalized.startsWith("unix://") ||
normalized.startsWith("pipe://") ||
normalized.startsWith("\\\\.\\pipe\\")
) {
return null
return null;
}
if (/^\d+$/.test(normalized)) {
return `127.0.0.1:${normalized}`
return `127.0.0.1:${normalized}`;
}
if (normalized.includes(':')) {
return normalized
if (normalized.includes(":")) {
return normalized;
}
return null
return null;
}
export function resolveLocalDaemonState(options: { home?: string } = {}): LocalDaemonState {
@@ -315,14 +316,14 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
// Status should reflect local persisted config + pid file, not inherited daemon env overrides.
PASEO_LISTEN: undefined,
PASEO_ALLOWED_HOSTS: undefined,
}
const home = resolvePaseoHome(env)
const config = loadConfig(home, { env })
const pidPath = pidFilePath(home)
const logPath = path.join(home, DAEMON_LOG_FILENAME)
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false
const listen = pidInfo?.listen ?? config.listen
};
const home = resolvePaseoHome(env);
const config = loadConfig(home, { env });
const pidPath = pidFilePath(home);
const logPath = path.join(home, DAEMON_LOG_FILENAME);
const pidInfo = existsSync(pidPath) ? readPidFile(pidPath) : null;
const running = pidInfo ? isProcessRunning(pidInfo.pid) : false;
const listen = pidInfo?.listen ?? config.listen;
return {
home,
@@ -332,199 +333,197 @@ export function resolveLocalDaemonState(options: { home?: string } = {}): LocalD
pidInfo,
running,
stalePidFile: Boolean(pidInfo) && !running,
}
};
}
export function tailDaemonLog(home?: string, lines = 30): string | null {
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME)
return tailFile(logPath, lines)
const logPath = path.join(resolveLocalPaseoHome(home), DAEMON_LOG_FILENAME);
return tailFile(logPath, lines);
}
export async function startLocalDaemonDetached(
options: DaemonStartOptions
options: DaemonStartOptions,
): Promise<DetachedStartResult> {
if (options.listen && options.port) {
throw new Error('Cannot use --listen and --port together')
throw new Error("Cannot use --listen and --port together");
}
const childEnv = buildChildEnv(options)
const childEnv = buildChildEnv(options);
const paseoHome = resolvePaseoHome(childEnv)
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME)
const daemonRunnerEntry = resolveDaemonRunnerEntry()
const paseoHome = resolvePaseoHome(childEnv);
const logPath = path.join(paseoHome, DAEMON_LOG_FILENAME);
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const child = spawn(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
detached: true,
env: childEnv,
stdio: ['ignore', 'ignore', 'ignore'],
}
)
stdio: ["ignore", "ignore", "ignore"],
},
);
child.unref()
child.unref();
const startup = await new Promise<DetachedStartupResult>((resolve) => {
let settled = false
let settled = false;
const finish = (value: DetachedStartupResult) => {
if (settled) return
settled = true
resolve(value)
}
if (settled) return;
settled = true;
resolve(value);
};
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS)
const timer = setTimeout(() => finish(startupReady()), DETACHED_STARTUP_GRACE_MS);
child.once('error', (error) => {
clearTimeout(timer)
finish(startupExited({ code: null, signal: null, error }))
})
child.once("error", (error) => {
clearTimeout(timer);
finish(startupExited({ code: null, signal: null, error }));
});
child.once('exit', (code, signal) => {
clearTimeout(timer)
finish(startupExited({ code, signal }))
})
})
child.once("exit", (code, signal) => {
clearTimeout(timer);
finish(startupExited({ code, signal }));
});
});
if (startup.exitedEarly) {
const reason = startup.error
? startup.error.message
: `exit code ${startup.code ?? 'unknown'}${startup.signal ? ` (${startup.signal})` : ''}`
const recentLogs = tailFile(logPath)
: `exit code ${startup.code ?? "unknown"}${startup.signal ? ` (${startup.signal})` : ""}`;
const recentLogs = tailFile(logPath);
throw new Error(
[
`Daemon failed to start in background (${reason}).`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
.join("\n\n"),
);
}
return {
pid: child.pid ?? null,
logPath,
}
};
}
export function startLocalDaemonForeground(options: DaemonStartOptions): number {
if (options.listen && options.port) {
throw new Error('Cannot use --listen and --port together')
throw new Error("Cannot use --listen and --port together");
}
const childEnv = buildChildEnv(options)
const daemonRunnerEntry = resolveDaemonRunnerEntry()
const childEnv = buildChildEnv(options);
const daemonRunnerEntry = resolveDaemonRunnerEntry();
const result = spawnSync(
process.execPath,
[...process.execArgv, daemonRunnerEntry, ...buildRunnerArgs(options)],
{
env: childEnv,
stdio: 'inherit',
}
)
stdio: "inherit",
},
);
if (result.error) {
throw result.error
throw result.error;
}
return result.status ?? 1
return result.status ?? 1;
}
async function requestLifecycleShutdown(
state: LocalDaemonState,
timeoutMs: number
timeoutMs: number,
): Promise<LifecycleShutdownAttempt> {
const host = resolveTcpHostFromListen(state.listen)
const host = resolveTcpHostFromListen(state.listen);
if (!host) {
return {
requested: false,
reason: 'daemon listen target is not TCP, falling back to owner PID signal',
}
reason: "daemon listen target is not TCP, falling back to owner PID signal",
};
}
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) })
const client = await tryConnectToDaemon({ host, timeout: Math.min(timeoutMs, 5000) });
if (!client) {
return {
requested: false,
reason: `daemon websocket at ${host} is not reachable, falling back to owner PID signal`,
}
};
}
try {
await client.shutdownServer()
return { requested: true }
await client.shutdownServer();
return { requested: true };
} catch (error) {
return {
requested: false,
reason: `daemon lifecycle shutdown request failed (${getErrorMessage(
error
error,
)}), falling back to owner PID signal`,
}
};
} finally {
await client.close().catch(() => undefined)
await client.close().catch(() => undefined);
}
}
export async function stopLocalDaemon(
options: StopLocalDaemonOptions = {}
options: StopLocalDaemonOptions = {},
): Promise<StopLocalDaemonResult> {
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS
const state = resolveLocalDaemonState({ home: options.home })
const timeoutMs = options.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS;
const state = resolveLocalDaemonState({ home: options.home });
if (!state.pidInfo || !state.running) {
const staleSuffix =
state.stalePidFile && state.pidInfo
? ` (stale PID file for ${state.pidInfo.pid})`
: ''
state.stalePidFile && state.pidInfo ? ` (stale PID file for ${state.pidInfo.pid})` : "";
return {
action: 'not_running',
action: "not_running",
home: state.home,
pid: state.pidInfo?.pid ?? null,
forced: false,
message: `Daemon is not running${staleSuffix}`,
}
};
}
const pid = state.pidInfo.pid
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs)
const lifecycleRequested = shutdownAttempt.requested
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason
let forced = false
const pid = state.pidInfo.pid;
const shutdownAttempt = await requestLifecycleShutdown(state, timeoutMs);
const lifecycleRequested = shutdownAttempt.requested;
const fallbackMessage = shutdownAttempt.requested ? null : shutdownAttempt.reason;
let forced = false;
if (!lifecycleRequested) {
const signaled = signalProcessSafely(pid, 'SIGTERM')
const signaled = signalProcessSafely(pid, "SIGTERM");
if (!signaled) {
return {
action: 'not_running',
action: "not_running",
home: state.home,
pid,
forced: false,
message: 'Daemon process was already stopped',
}
message: "Daemon process was already stopped",
};
}
}
let stopped = await waitForPidExit(pid, timeoutMs)
let stopped = await waitForPidExit(pid, timeoutMs);
if (!stopped && options.force) {
forced = true
signalProcessGroupSafely(pid, 'SIGKILL')
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS)
forced = true;
signalProcessGroupSafely(pid, "SIGKILL");
stopped = await waitForPidExit(pid, KILL_TIMEOUT_MS);
}
if (!stopped) {
throw new Error(
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`
)
`Timed out waiting for daemon PID ${pid} to stop after ${Math.ceil(timeoutMs / 1000)}s`,
);
}
return {
action: 'stopped',
action: "stopped",
home: state.home,
pid,
forced,
message: forced
? 'Daemon owner process was force-stopped'
? "Daemon owner process was force-stopped"
: lifecycleRequested
? 'Daemon stopped gracefully'
: fallbackMessage ?? 'Daemon stopped via owner PID signal',
}
? "Daemon stopped gracefully"
: (fallbackMessage ?? "Daemon stopped via owner PID signal"),
};
}
+23 -26
View File
@@ -1,31 +1,28 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from '@getpaseo/server'
import { addJsonOption } from '../../utils/command-options.js'
import { Command } from "commander";
import chalk from "chalk";
import { generateLocalPairingOffer, loadConfig, resolvePaseoHome } from "@getpaseo/server";
import { addJsonOption } from "../../utils/command-options.js";
interface PairOptions {
home?: string
json?: boolean
home?: string;
json?: boolean;
}
export function pairCommand(): Command {
return addJsonOption(
new Command('pair')
.description('Print the daemon pairing QR code and link')
)
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
return addJsonOption(new Command("pair").description("Print the daemon pairing QR code and link"))
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.action(async (_options: PairOptions, command: Command) => {
await runPairCommand(command.optsWithGlobals() as PairOptions)
})
await runPairCommand(command.optsWithGlobals() as PairOptions);
});
}
export async function runPairCommand(options: PairOptions): Promise<void> {
if (options.home) {
process.env.PASEO_HOME = options.home
process.env.PASEO_HOME = options.home;
}
const paseoHome = resolvePaseoHome()
const config = loadConfig(paseoHome)
const paseoHome = resolvePaseoHome();
const config = loadConfig(paseoHome);
const pairing = await generateLocalPairingOffer({
paseoHome,
relayEnabled: config.relayEnabled,
@@ -33,12 +30,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
});
if (!pairing.relayEnabled || !pairing.url) {
console.error(chalk.red('Relay pairing is disabled for this daemon config.'))
console.error(chalk.yellow('Enable relay and run this command again.'))
process.exit(1)
console.error(chalk.red("Relay pairing is disabled for this daemon config."));
console.error(chalk.yellow("Enable relay and run this command again."));
process.exit(1);
}
if (options.json) {
@@ -50,12 +47,12 @@ export async function runPairCommand(options: PairOptions): Promise<void> {
qr: pairing.qr,
},
null,
2
)}\n`
)
return
2,
)}\n`,
);
return;
}
const qrBlock = pairing.qr ? `${pairing.qr}\n` : ''
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`)
const qrBlock = pairing.qr ? `${pairing.qr}\n` : "";
process.stdout.write(`\nScan to pair:\n${qrBlock}${pairing.url}\n`);
}
+62 -56
View File
@@ -1,123 +1,129 @@
import type { Command } from 'commander'
import type { Command } from "commander";
import {
startLocalDaemonDetached,
stopLocalDaemon,
DEFAULT_STOP_TIMEOUT_MS,
type DaemonStartOptions,
} from './local-daemon.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
} from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
interface RestartResult {
action: 'restarted'
home: string
pid: string
message: string
action: "restarted";
home: string;
pid: string;
message: string;
}
const restartResultSchema: OutputSchema<RestartResult> = {
idField: 'action',
idField: "action",
columns: [
{
header: 'STATUS',
field: 'action',
color: () => 'green',
header: "STATUS",
field: "action",
color: () => "green",
},
{ header: 'HOME', field: 'home' },
{ header: 'PID', field: 'pid' },
{ header: 'MESSAGE', field: 'message' },
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
}
};
export type RestartCommandResult = SingleResult<RestartResult>
export type RestartCommandResult = SingleResult<RestartResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== 'string' || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: 'INVALID_TIMEOUT',
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: 'Timeout must be a positive number of seconds',
}
throw error
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
function toStartOptions(options: CommandOptions): DaemonStartOptions {
const startOptions: DaemonStartOptions = {
home: typeof options.home === 'string' ? options.home : undefined,
listen: typeof options.listen === 'string' ? options.listen : undefined,
port: typeof options.port === 'string' ? options.port : undefined,
relay: typeof options.relay === 'boolean' ? options.relay : undefined,
mcp: typeof options.mcp === 'boolean' ? options.mcp : undefined,
allowedHosts: typeof options.allowedHosts === 'string' ? options.allowedHosts : undefined,
}
home: typeof options.home === "string" ? options.home : undefined,
listen: typeof options.listen === "string" ? options.listen : undefined,
port: typeof options.port === "string" ? options.port : undefined,
relay: typeof options.relay === "boolean" ? options.relay : undefined,
mcp: typeof options.mcp === "boolean" ? options.mcp : undefined,
allowedHosts: typeof options.allowedHosts === "string" ? options.allowedHosts : undefined,
};
if (startOptions.listen && startOptions.port) {
const error: CommandError = {
code: 'INVALID_OPTIONS',
message: 'Cannot use --listen and --port together',
}
throw error
code: "INVALID_OPTIONS",
message: "Cannot use --listen and --port together",
};
throw error;
}
return startOptions
return startOptions;
}
export async function runRestartCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<RestartCommandResult> {
const timeoutMs = parseTimeoutMs(options.timeout)
const force = options.force === true
const startOptions = toStartOptions(options)
const timeoutMs = parseTimeoutMs(options.timeout);
const force = options.force === true;
const startOptions = toStartOptions(options);
try {
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>
let stopResult: Awaited<ReturnType<typeof stopLocalDaemon>>;
try {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force,
})
});
} catch (err) {
const isTimeout = err instanceof Error && err.message.includes('Timed out waiting for daemon PID')
const isTimeout =
err instanceof Error && err.message.includes("Timed out waiting for daemon PID");
if (!force && isTimeout) {
stopResult = await stopLocalDaemon({
home: startOptions.home,
timeoutMs,
force: true,
})
});
} else {
throw err
throw err;
}
}
const startup = await startLocalDaemonDetached(startOptions)
const before = stopResult.pid === null ? 'not running' : `PID ${stopResult.pid}`
const after = startup.pid === null ? 'unknown PID' : `PID ${startup.pid}`
const startup = await startLocalDaemonDetached(startOptions);
const before = stopResult.pid === null ? "not running" : `PID ${stopResult.pid}`;
const after = startup.pid === null ? "unknown PID" : `PID ${startup.pid}`;
return {
type: 'single',
type: "single",
data: {
action: 'restarted',
action: "restarted",
home: stopResult.home,
pid: startup.pid === null ? '-' : String(startup.pid),
pid: startup.pid === null ? "-" : String(startup.pid),
message: `Local daemon restarted (${before} -> ${after})`,
},
schema: restartResultSchema,
}
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'RESTART_FAILED',
code: "RESTART_FAILED",
message: `Failed to restart local daemon: ${message}`,
}
throw error
};
throw error;
}
}
@@ -1,46 +1,45 @@
import { spawnSync } from 'node:child_process'
import { spawnSync } from "node:child_process";
export interface NodePathFromPidResult {
nodePath: string | null
error?: string
nodePath: string | null;
error?: string;
}
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}
export function resolveNodePathFromPid(pid: number): NodePathFromPidResult {
const result = spawnSync('ps', ['-o', 'comm=', '-p', String(pid)], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
const result = spawnSync("ps", ["-o", "comm=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.error) {
return {
nodePath: null,
error: `ps failed: ${normalizeError(result.error)}`,
}
};
}
if ((result.status ?? 1) !== 0) {
const details = result.stderr?.trim()
const details = result.stderr?.trim();
return {
nodePath: null,
error: details ? `ps failed: ${details}` : `ps exited with code ${result.status ?? 1}`,
}
};
}
const resolved = result.stdout.trim()
const resolved = result.stdout.trim();
if (!resolved) {
return {
nodePath: null,
error: 'ps returned an empty command path',
}
error: "ps returned an empty command path",
};
}
return { nodePath: resolved }
return { nodePath: resolved };
}
+30 -30
View File
@@ -1,58 +1,58 @@
import { Command } from 'commander'
import chalk from 'chalk'
import { Command } from "commander";
import chalk from "chalk";
import {
startLocalDaemonForeground,
startLocalDaemonDetached,
type DaemonStartOptions as StartOptions,
} from './local-daemon.js'
import { getErrorMessage } from '../../utils/errors.js'
} from "./local-daemon.js";
import { getErrorMessage } from "../../utils/errors.js";
export type { DaemonStartOptions as StartOptions } from './local-daemon.js'
export type { DaemonStartOptions as StartOptions } from "./local-daemon.js";
export function startCommand(): Command {
return new Command('start')
.description('Start the local Paseo daemon')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--foreground', 'Run in foreground (don\'t daemonize)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
return new Command("start")
.description("Start the local Paseo daemon")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--foreground", "Run in foreground (don't daemonize)")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.action(async (options: StartOptions) => {
await runStart(options)
})
await runStart(options);
});
}
export async function runStart(options: StartOptions): Promise<void> {
if (options.listen && options.port) {
console.error(chalk.red('Cannot use --listen and --port together'))
process.exit(1)
console.error(chalk.red("Cannot use --listen and --port together"));
process.exit(1);
}
if (!options.foreground) {
try {
const startup = await startLocalDaemonDetached(options)
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? 'unknown'}).`))
console.log(chalk.dim(`Logs: ${startup.logPath}`))
const startup = await startLocalDaemonDetached(options);
console.log(chalk.green(`Daemon starting in background (PID ${startup.pid ?? "unknown"}).`));
console.log(chalk.dim(`Logs: ${startup.logPath}`));
} catch (err) {
exitWithError(getErrorMessage(err))
exitWithError(getErrorMessage(err));
}
return
return;
}
try {
const status = startLocalDaemonForeground(options)
process.exit(status)
const status = startLocalDaemonForeground(options);
process.exit(status);
} catch (err) {
const message = getErrorMessage(err)
exitWithError(`Failed to start daemon: ${message}`)
const message = getErrorMessage(err);
exitWithError(`Failed to start daemon: ${message}`);
}
}
function exitWithError(message: string): never {
console.error(chalk.red(message))
process.exit(1)
console.error(chalk.red(message));
process.exit(1);
}
+139 -139
View File
@@ -1,260 +1,260 @@
import type { Command } from 'commander'
import { execFileSync } from 'node:child_process'
import { createRequire } from 'node:module'
import { getOrCreateServerId, findExecutable, applyProviderEnv } from '@getpaseo/server'
import { tryConnectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import { resolveLocalDaemonState, resolveTcpHostFromListen } from './local-daemon.js'
import { resolveNodePathFromPid } from './runtime-toolchain.js'
import type { Command } from "commander";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
import { getOrCreateServerId, findExecutable, applyProviderEnv } from "@getpaseo/server";
import { tryConnectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js";
import { resolveNodePathFromPid } from "./runtime-toolchain.js";
interface ProviderBinaryStatus {
label: string
path: string | null
version: string | null
label: string;
path: string | null;
version: string | null;
}
interface DaemonStatus {
serverId: string | null
status: 'running' | 'stopped' | 'unresponsive'
home: string
listen: string
hostname: string | null
pid: number | null
startedAt: string | null
owner: string | null
logPath: string
runningAgents: number | null
idleAgents: number | null
daemonNode: string
cliNode: string
cliVersion: string
providers: ProviderBinaryStatus[]
note?: string
serverId: string | null;
status: "running" | "stopped" | "unresponsive";
home: string;
listen: string;
hostname: string | null;
pid: number | null;
startedAt: string | null;
owner: string | null;
logPath: string;
runningAgents: number | null;
idleAgents: number | null;
daemonNode: string;
cliNode: string;
cliVersion: string;
providers: ProviderBinaryStatus[];
note?: string;
}
interface StatusRow {
key: string
value: string
key: string;
value: string;
}
type CliPackageJson = {
version?: unknown
}
version?: unknown;
};
const require = createRequire(import.meta.url)
const require = createRequire(import.meta.url);
function normalizeError(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}
function shortenMessage(message: string, max = 120): string {
const normalized = message.replace(/\s+/g, ' ').trim()
const normalized = message.replace(/\s+/g, " ").trim();
if (normalized.length <= max) {
return normalized
return normalized;
}
return `${normalized.slice(0, max - 3)}...`
return `${normalized.slice(0, max - 3)}...`;
}
function appendNote(current: string | undefined, next: string | undefined): string | undefined {
if (!next) return current
if (!current) return next
return `${current}; ${next}`
if (!next) return current;
if (!current) return next;
return `${current}; ${next}`;
}
function resolveCliVersion(): string {
try {
const packageJson = require('../../../package.json') as CliPackageJson
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
return packageJson.version.trim()
const packageJson = require("../../../package.json") as CliPackageJson;
if (typeof packageJson.version === "string" && packageJson.version.trim().length > 0) {
return packageJson.version.trim();
}
} catch {
// Fall through.
}
return 'unknown'
return "unknown";
}
function createStatusSchema(status: DaemonStatus): OutputSchema<StatusRow> {
return {
idField: 'key',
idField: "key",
columns: [
{ header: 'KEY', field: 'key' },
{ header: "KEY", field: "key" },
{
header: 'VALUE',
field: 'value',
header: "VALUE",
field: "value",
color: (_, item) => {
if (item.key === 'Status') {
if (item.value === 'running') return 'green'
if (item.value === 'unresponsive') return 'yellow'
return 'red'
if (item.key === "Status") {
if (item.value === "running") return "green";
if (item.value === "unresponsive") return "yellow";
return "red";
}
if (item.key.startsWith(' ')) {
if (item.value === 'not found') return 'red'
if (item.value.endsWith('(--version failed)')) return 'yellow'
return 'green'
if (item.key.startsWith(" ")) {
if (item.value === "not found") return "red";
if (item.value.endsWith("(--version failed)")) return "yellow";
return "green";
}
return undefined
return undefined;
},
},
],
serialize: () => status,
}
};
}
function toStatusRows(status: DaemonStatus): StatusRow[] {
const rows: StatusRow[] = [
{ key: 'Server ID', value: status.serverId ?? '-' },
{ key: 'Status', value: status.status },
{ key: 'Home', value: status.home },
{ key: 'Listen', value: status.listen },
{ key: 'Hostname', value: status.hostname ?? '-' },
{ key: 'PID', value: status.pid === null ? '-' : String(status.pid) },
{ key: 'Started', value: status.startedAt ?? '-' },
{ key: 'Owner', value: status.owner ?? '-' },
{ key: 'Logs', value: status.logPath },
{ key: 'Daemon Node', value: status.daemonNode },
{ key: 'CLI Node', value: status.cliNode },
{ key: 'CLI', value: status.cliVersion },
]
{ key: "Server ID", value: status.serverId ?? "-" },
{ key: "Status", value: status.status },
{ key: "Home", value: status.home },
{ key: "Listen", value: status.listen },
{ key: "Hostname", value: status.hostname ?? "-" },
{ key: "PID", value: status.pid === null ? "-" : String(status.pid) },
{ key: "Started", value: status.startedAt ?? "-" },
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
];
if (status.runningAgents !== null && status.idleAgents !== null) {
rows.push({
key: 'Agents',
key: "Agents",
value: `${status.runningAgents} running, ${status.idleAgents} idle`,
})
});
} else {
rows.push({
key: 'Agents',
value: 'Unavailable (daemon API not reachable)',
})
key: "Agents",
value: "Unavailable (daemon API not reachable)",
});
}
if (status.note) {
rows.push({ key: 'Note', value: status.note })
rows.push({ key: "Note", value: status.note });
}
rows.push({ key: '', value: '' })
rows.push({ key: 'Providers', value: '' })
rows.push({ key: "", value: "" });
rows.push({ key: "Providers", value: "" });
for (const provider of status.providers) {
if (!provider.path) {
rows.push({ key: ` ${provider.label}`, value: 'not found' })
rows.push({ key: ` ${provider.label}`, value: "not found" });
} else if (!provider.version) {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` })
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (--version failed)` });
} else {
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` })
rows.push({ key: ` ${provider.label}`, value: `${provider.path} (${provider.version})` });
}
}
return rows
return rows;
}
const PROVIDER_BINARIES: { label: string; binary: string }[] = [
{ label: 'Claude', binary: 'claude' },
{ label: 'Codex', binary: 'codex' },
{ label: 'OpenCode', binary: 'opencode' },
]
{ label: "Claude", binary: "claude" },
{ label: "Codex", binary: "codex" },
{ label: "OpenCode", binary: "opencode" },
];
function checkProviderBinary(binary: string): { path: string | null; version: string | null } {
const binaryPath = findExecutable(binary)
const binaryPath = findExecutable(binary);
if (!binaryPath) {
return { path: null, version: null }
return { path: null, version: null };
}
const env = applyProviderEnv(process.env)
const env = applyProviderEnv(process.env);
try {
const output = execFileSync(binaryPath, ['--version'], {
encoding: 'utf8',
const output = execFileSync(binaryPath, ["--version"], {
encoding: "utf8",
timeout: 5000,
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ["ignore", "pipe", "pipe"],
env,
}).trim()
return { path: binaryPath, version: output || null }
}).trim();
return { path: binaryPath, version: output || null };
} catch {
return { path: binaryPath, version: null }
return { path: binaryPath, version: null };
}
}
function checkProviderBinaries(): ProviderBinaryStatus[] {
return PROVIDER_BINARIES.map(({ label, binary }) => {
const result = checkProviderBinary(binary)
return { label, ...result }
})
const result = checkProviderBinary(binary);
return { label, ...result };
});
}
function resolveOwnerLabel(uid: number | undefined, hostname: string | undefined): string | null {
if (uid === undefined && !hostname) {
return null
return null;
}
const uidPart = uid === undefined ? '?' : String(uid)
const hostPart = hostname ?? 'unknown-host'
return `${uidPart}@${hostPart}`
const uidPart = uid === undefined ? "?" : String(uid);
const hostPart = hostname ?? "unknown-host";
return `${uidPart}@${hostPart}`;
}
export type StatusResult = ListResult<StatusRow>
export type StatusResult = ListResult<StatusRow>;
export async function runStatusCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<StatusResult> {
const home = typeof options.home === 'string' ? options.home : undefined
const state = resolveLocalDaemonState({ home })
const home = typeof options.home === "string" ? options.home : undefined;
const state = resolveLocalDaemonState({ home });
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname)
let daemonNode: string
const owner = resolveOwnerLabel(state.pidInfo?.uid, state.pidInfo?.hostname);
let daemonNode: string;
if (!state.running) {
daemonNode = '-'
daemonNode = "-";
} else if (state.pidInfo?.pid) {
const fromPid = resolveNodePathFromPid(state.pidInfo.pid)
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? 'could not resolve from PID'})`
const fromPid = resolveNodePathFromPid(state.pidInfo.pid);
daemonNode = fromPid.nodePath ?? `unknown (${fromPid.error ?? "could not resolve from PID"})`;
} else {
daemonNode = 'unknown (no PID available)'
daemonNode = "unknown (no PID available)";
}
const cliNode = process.execPath
let status: DaemonStatus['status'] = state.running ? 'running' : 'stopped'
let runningAgents: number | null = null
let idleAgents: number | null = null
let note: string | undefined
const cliNode = process.execPath;
let status: DaemonStatus["status"] = state.running ? "running" : "stopped";
let runningAgents: number | null = null;
let idleAgents: number | null = null;
let note: string | undefined;
if (!state.running && state.stalePidFile && state.pidInfo) {
note = `Stale PID file found for PID ${state.pidInfo.pid}`
note = `Stale PID file found for PID ${state.pidInfo.pid}`;
}
if (state.running) {
const host = resolveTcpHostFromListen(state.listen)
const host = resolveTcpHostFromListen(state.listen);
if (host) {
const client = await tryConnectToDaemon({ host, timeout: 1500 })
const client = await tryConnectToDaemon({ host, timeout: 1500 });
if (client) {
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
runningAgents = agents.filter(a => a.status === 'running').length
idleAgents = agents.filter(a => a.status === 'idle').length
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
runningAgents = agents.filter((a) => a.status === "running").length;
idleAgents = agents.filter((a) => a.status === "idle").length;
} catch {
status = 'unresponsive'
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`)
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but API requests to ${host} failed`);
} finally {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
} else {
status = 'unresponsive'
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`)
status = "unresponsive";
note = appendNote(note, `Daemon PID is running but websocket at ${host} is not reachable`);
}
} else {
note = appendNote(note, 'Daemon is configured for unix socket listen; API probe skipped')
note = appendNote(note, "Daemon is configured for unix socket listen; API probe skipped");
}
}
const cliVersion = resolveCliVersion()
const cliVersion = resolveCliVersion();
let serverId: string | null = null
let serverId: string | null = null;
try {
serverId = getOrCreateServerId(state.home)
serverId = getOrCreateServerId(state.home);
} catch (error) {
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`)
note = appendNote(note, `serverId unavailable: ${shortenMessage(normalizeError(error))}`);
}
const providers = checkProviderBinaries()
const providers = checkProviderBinaries();
const daemonStatus: DaemonStatus = {
serverId,
@@ -273,11 +273,11 @@ export async function runStatusCommand(
cliVersion,
providers,
note,
}
};
return {
type: 'list',
type: "list",
data: toStatusRows(daemonStatus),
schema: createStatusSchema(daemonStatus),
}
};
}
+41 -36
View File
@@ -1,74 +1,79 @@
import type { Command } from 'commander'
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from './local-daemon.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { stopLocalDaemon, DEFAULT_STOP_TIMEOUT_MS } from "./local-daemon.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
interface StopResult {
action: 'stopped' | 'not_running'
home: string
pid: string
message: string
action: "stopped" | "not_running";
home: string;
pid: string;
message: string;
}
const stopResultSchema: OutputSchema<StopResult> = {
idField: 'action',
idField: "action",
columns: [
{
header: 'STATUS',
field: 'action',
color: (value) => (value === 'stopped' ? 'green' : 'yellow'),
header: "STATUS",
field: "action",
color: (value) => (value === "stopped" ? "green" : "yellow"),
},
{ header: 'HOME', field: 'home' },
{ header: 'PID', field: 'pid' },
{ header: 'MESSAGE', field: 'message' },
{ header: "HOME", field: "home" },
{ header: "PID", field: "pid" },
{ header: "MESSAGE", field: "message" },
],
}
};
export type StopCommandResult = SingleResult<StopResult>
export type StopCommandResult = SingleResult<StopResult>;
function parseTimeoutMs(raw: unknown): number {
if (typeof raw !== 'string' || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS
if (typeof raw !== "string" || raw.trim().length === 0) {
return DEFAULT_STOP_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
const error: CommandError = {
code: 'INVALID_TIMEOUT',
code: "INVALID_TIMEOUT",
message: `Invalid timeout value: ${raw}`,
details: 'Timeout must be a positive number of seconds',
}
throw error
details: "Timeout must be a positive number of seconds",
};
throw error;
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
export async function runStopCommand(
options: CommandOptions,
_command: Command
_command: Command,
): Promise<StopCommandResult> {
const home = typeof options.home === 'string' ? options.home : undefined
const force = options.force === true
const timeoutMs = parseTimeoutMs(options.timeout)
const home = typeof options.home === "string" ? options.home : undefined;
const force = options.force === true;
const timeoutMs = parseTimeoutMs(options.timeout);
try {
const result = await stopLocalDaemon({ home, force, timeoutMs })
const result = await stopLocalDaemon({ home, force, timeoutMs });
return {
type: 'single',
type: "single",
data: {
action: result.action,
home: result.home,
pid: result.pid === null ? '-' : String(result.pid),
pid: result.pid === null ? "-" : String(result.pid),
message: result.message,
},
schema: stopResultSchema,
}
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'STOP_FAILED',
code: "STOP_FAILED",
message: `Failed to stop local daemon: ${message}`,
}
throw error
};
throw error;
}
}
+203 -199
View File
@@ -1,14 +1,14 @@
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from '@clack/prompts'
import { Command } from 'commander'
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { cancel, confirm, intro, isCancel, log, note, outro, spinner } from "@clack/prompts";
import { Command } from "commander";
import { writeFileSync } from "node:fs";
import path from "node:path";
import {
generateLocalPairingOffer,
loadConfig,
loadPersistedConfig,
type CliConfigOverrides,
type PersistedConfig,
} from '@getpaseo/server'
} from "@getpaseo/server";
import {
resolveLocalPaseoHome,
resolveLocalDaemonState,
@@ -16,88 +16,94 @@ import {
startLocalDaemonDetached,
tailDaemonLog,
type DaemonStartOptions,
} from './daemon/local-daemon.js'
import { tryConnectToDaemon } from '../utils/client.js'
} from "./daemon/local-daemon.js";
import { tryConnectToDaemon } from "../utils/client.js";
interface OnboardOptions extends DaemonStartOptions {
timeout?: string
voice?: 'ask' | 'enable' | 'disable'
timeout?: string;
voice?: "ask" | "enable" | "disable";
}
type OnboardPersistedConfig = PersistedConfig & {
features?: PersistedConfig['features'] & {
dictation?: PersistedConfig['features'] extends { dictation?: infer T }
features?: PersistedConfig["features"] & {
dictation?: PersistedConfig["features"] extends { dictation?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
voiceMode?: PersistedConfig['features'] extends { voiceMode?: infer T }
: { enabled?: boolean };
voiceMode?: PersistedConfig["features"] extends { voiceMode?: infer T }
? T & { enabled?: boolean }
: { enabled?: boolean }
}
}
: { enabled?: boolean };
};
};
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000
const DEFAULT_READY_TIMEOUT_MS = 10 * 60 * 1000;
class OnboardCancelledError extends Error {}
const plainNoteFormat = (line: string): string => line
const plainNoteFormat = (line: string): string => line;
function renderNote(message: string, title: string): void {
note(message, title, { format: plainNoteFormat })
note(message, title, { format: plainNoteFormat });
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
setTimeout(resolve, ms);
});
}
function parseTimeoutMs(raw: string | undefined): number {
if (!raw || raw.trim().length === 0) {
return DEFAULT_READY_TIMEOUT_MS
return DEFAULT_READY_TIMEOUT_MS;
}
const seconds = Number(raw)
const seconds = Number(raw);
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new Error(`Invalid timeout value: ${raw}`)
throw new Error(`Invalid timeout value: ${raw}`);
}
return Math.ceil(seconds * 1000)
return Math.ceil(seconds * 1000);
}
function toCliOverrides(options: DaemonStartOptions): CliConfigOverrides {
const cliOverrides: CliConfigOverrides = {}
const cliOverrides: CliConfigOverrides = {};
if (options.listen) {
cliOverrides.listen = options.listen
cliOverrides.listen = options.listen;
} else if (options.port) {
cliOverrides.listen = `127.0.0.1:${options.port}`
cliOverrides.listen = `127.0.0.1:${options.port}`;
}
if (options.relay === false) {
cliOverrides.relayEnabled = false
cliOverrides.relayEnabled = false;
}
if (options.allowedHosts) {
const raw = options.allowedHosts.trim()
const raw = options.allowedHosts.trim();
cliOverrides.allowedHosts =
raw.toLowerCase() === 'true'
raw.toLowerCase() === "true"
? true
: raw.split(',').map(host => host.trim()).filter(Boolean)
: raw
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
if (options.mcp === false) {
cliOverrides.mcpEnabled = false
cliOverrides.mcpEnabled = false;
}
return cliOverrides
return cliOverrides;
}
function savePersistedConfig(paseoHome: string, config: OnboardPersistedConfig): void {
const configPath = path.join(paseoHome, 'config.json')
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`)
const configPath = path.join(paseoHome, "config.json");
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
}
function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean): OnboardPersistedConfig {
function applyVoiceSelection(
config: OnboardPersistedConfig,
enabled: boolean,
): OnboardPersistedConfig {
return {
...config,
features: {
@@ -111,321 +117,319 @@ function applyVoiceSelection(config: OnboardPersistedConfig, enabled: boolean):
enabled,
},
},
}
};
}
function resolvePersistedVoiceSelection(config: OnboardPersistedConfig): boolean | null {
const voiceModeEnabled = config.features?.voiceMode?.enabled
if (typeof voiceModeEnabled === 'boolean') {
return voiceModeEnabled
const voiceModeEnabled = config.features?.voiceMode?.enabled;
if (typeof voiceModeEnabled === "boolean") {
return voiceModeEnabled;
}
const dictationEnabled = config.features?.dictation?.enabled
if (typeof dictationEnabled === 'boolean') {
return dictationEnabled
const dictationEnabled = config.features?.dictation?.enabled;
if (typeof dictationEnabled === "boolean") {
return dictationEnabled;
}
return null
return null;
}
async function resolveVoiceSelection(mode: OnboardOptions['voice']): Promise<boolean> {
if (mode === 'enable') {
return true
async function resolveVoiceSelection(mode: OnboardOptions["voice"]): Promise<boolean> {
if (mode === "enable") {
return true;
}
if (mode === 'disable') {
return false
if (mode === "disable") {
return false;
}
if (!process.stdin.isTTY || !process.stdout.isTTY) {
log.message('Non-interactive terminal detected; voice setup defaults to disabled.')
return false
log.message("Non-interactive terminal detected; voice setup defaults to disabled.");
return false;
}
const answer = await confirm({
message: 'Enable voice features? (downloads local STT/TTS models in background)',
active: 'Yes',
inactive: 'No',
message: "Enable voice features? (downloads local STT/TTS models in background)",
active: "Yes",
inactive: "No",
initialValue: false,
})
});
if (isCancel(answer)) {
throw new OnboardCancelledError('Onboarding cancelled by user.')
throw new OnboardCancelledError("Onboarding cancelled by user.");
}
return answer
return answer;
}
type DownloadProgress = {
modelId: string | null
pct: number | null
}
modelId: string | null;
pct: number | null;
};
function parseDownloadProgress(logTail: string): DownloadProgress | null {
const lines = logTail.split('\n').filter(Boolean)
const lines = logTail.split("\n").filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
const line = lines[index]
if (!line || !line.includes('Downloading model artifact')) {
continue
const line = lines[index];
if (!line || !line.includes("Downloading model artifact")) {
continue;
}
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/)
const modelMatch = line.match(
/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/
)
const pctMatch = line.match(/"pct"\s*:\s*(\d{1,3})|\bpct[=:]\s*(\d{1,3})/);
const modelMatch = line.match(/"modelId"\s*:\s*"([^"]+)"|\bmodelId[=:]\s*"?([^\s",}]+)/);
return {
modelId: modelMatch?.[1] ?? modelMatch?.[2] ?? null,
pct: pctMatch ? Number(pctMatch[1] ?? pctMatch[2]) : null,
}
};
}
return null
return null;
}
function renderProgressLine(progress: DownloadProgress): string {
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : ''
const modelSuffix = progress.modelId ? ` (${progress.modelId})` : "";
if (progress.pct === null) {
return `Downloading speech model${modelSuffix}...`
return `Downloading speech model${modelSuffix}...`;
}
return `Downloading speech model${modelSuffix}: ${progress.pct}%`
return `Downloading speech model${modelSuffix}: ${progress.pct}%`;
}
async function waitForDaemonReady(args: {
home: string
timeoutMs: number
onStatus?: (message: string) => void
home: string;
timeoutMs: number;
onStatus?: (message: string) => void;
}): Promise<{ listen: string; host: string | null }> {
const deadline = Date.now() + args.timeoutMs
let lastStatus = ''
let lastPrintedAt = 0
const deadline = Date.now() + args.timeoutMs;
let lastStatus = "";
let lastPrintedAt = 0;
while (Date.now() < deadline) {
const state = resolveLocalDaemonState({ home: args.home })
const host = resolveTcpHostFromListen(state.listen)
const state = resolveLocalDaemonState({ home: args.home });
const host = resolveTcpHostFromListen(state.listen);
if (state.running && host) {
const client = await tryConnectToDaemon({ host, timeout: 1200 })
const client = await tryConnectToDaemon({ host, timeout: 1200 });
if (client) {
try {
await client.fetchAgents()
return { listen: state.listen, host }
await client.fetchAgents();
return { listen: state.listen, host };
} catch {
// Daemon process is alive but not API-ready yet.
} finally {
await client.close().catch(() => {})
await client.close().catch(() => {});
}
}
} else if (state.running && !host) {
return { listen: state.listen, host: null }
return { listen: state.listen, host: null };
}
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? '')
const progressLine = progress ? renderProgressLine(progress) : null
const statusMessage = progressLine ?? 'Waiting for daemon to become ready...'
const progress = parseDownloadProgress(tailDaemonLog(args.home, 120) ?? "");
const progressLine = progress ? renderProgressLine(progress) : null;
const statusMessage = progressLine ?? "Waiting for daemon to become ready...";
if (statusMessage !== lastStatus) {
args.onStatus?.(statusMessage)
lastStatus = statusMessage
lastPrintedAt = Date.now()
args.onStatus?.(statusMessage);
lastStatus = statusMessage;
lastPrintedAt = Date.now();
} else if (!args.onStatus && Date.now() - lastPrintedAt >= 3000) {
console.log(statusMessage)
lastPrintedAt = Date.now()
console.log(statusMessage);
lastPrintedAt = Date.now();
}
await sleep(200)
await sleep(200);
}
const recentLogs = tailDaemonLog(args.home, 60)
const recentLogs = tailDaemonLog(args.home, 60);
throw new Error(
[
`Timed out after ${Math.ceil(args.timeoutMs / 1000)}s waiting for daemon readiness.`,
recentLogs ? `Recent daemon logs:\n${recentLogs}` : null,
]
.filter(Boolean)
.join('\n\n')
)
.join("\n\n"),
);
}
function printNextSteps(pairingUrl: string | null, paseoHome: string, richUi: boolean): void {
const daemonLogPath = path.join(paseoHome, 'daemon.log')
const daemonLogPath = path.join(paseoHome, "daemon.log");
const nextStepsLines = [
pairingUrl
? '1. Open Paseo and scan the QR code above, or paste the pairing link.'
: '1. Open Paseo and connect to your daemon.',
'2. Web app: https://app.paseo.sh',
'3. Desktop app: https://github.com/getpaseo/paseo/releases/latest',
'4. Docs: https://paseo.sh/docs',
? "1. Open Paseo and scan the QR code above, or paste the pairing link."
: "1. Open Paseo and connect to your daemon.",
"2. Web app: https://app.paseo.sh",
"3. Desktop app: https://github.com/getpaseo/paseo/releases/latest",
"4. Docs: https://paseo.sh/docs",
'5. Example: paseo run --output-schema schema.json "extract fields"',
]
];
const quickReferenceLines = [
'1. paseo --help',
'2. paseo ls',
"1. paseo --help",
"2. paseo ls",
'3. paseo run "your prompt"',
'4. paseo status',
"4. paseo status",
`5. Daemon logs: ${daemonLogPath}`,
]
];
if (!richUi) {
console.log('')
console.log('Next steps:')
console.log("");
console.log("Next steps:");
for (const line of nextStepsLines) {
console.log(line)
console.log(line);
}
console.log('')
console.log('CLI quick reference:')
console.log("");
console.log("CLI quick reference:");
for (const line of quickReferenceLines) {
console.log(line)
console.log(line);
}
return
return;
}
renderNote(nextStepsLines.join('\n'), 'Next steps')
renderNote(quickReferenceLines.join('\n'), 'CLI quick reference')
renderNote(nextStepsLines.join("\n"), "Next steps");
renderNote(quickReferenceLines.join("\n"), "CLI quick reference");
}
export function onboardCommand(): Command {
return new Command('onboard')
.description('Run first-time setup, start daemon, and print pairing instructions')
.option('--listen <listen>', 'Listen target (host:port, port, or unix socket path)')
.option('--port <port>', 'Port to listen on (default: 6767)')
.option('--home <path>', 'Paseo home directory (default: ~/.paseo)')
.option('--no-relay', 'Disable relay connection')
.option('--no-mcp', 'Disable the Agent MCP HTTP endpoint')
return new Command("onboard")
.description("Run first-time setup, start daemon, and print pairing instructions")
.option("--listen <listen>", "Listen target (host:port, port, or unix socket path)")
.option("--port <port>", "Port to listen on (default: 6767)")
.option("--home <path>", "Paseo home directory (default: ~/.paseo)")
.option("--no-relay", "Disable relay connection")
.option("--no-mcp", "Disable the Agent MCP HTTP endpoint")
.option(
'--allowed-hosts <hosts>',
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")'
"--allowed-hosts <hosts>",
'Comma-separated Host allowlist values (example: "localhost,.example.com" or "true")',
)
.option('--timeout <seconds>', 'Max time to wait for daemon readiness (default: 600)')
.option('--voice <mode>', 'Voice setup mode: ask, enable, disable', 'ask')
.option("--timeout <seconds>", "Max time to wait for daemon readiness (default: 600)")
.option("--voice <mode>", "Voice setup mode: ask, enable, disable", "ask")
.action(async (options: OnboardOptions) => {
await runOnboard(options)
})
await runOnboard(options);
});
}
export async function runOnboard(options: OnboardOptions): Promise<void> {
const richUi = process.stdin.isTTY && process.stdout.isTTY
const richUi = process.stdin.isTTY && process.stdout.isTTY;
if (richUi) {
intro('Welcome to Paseo')
intro("Welcome to Paseo");
}
if (options.listen && options.port) {
cancel('Cannot use --listen and --port together')
process.exit(1)
cancel("Cannot use --listen and --port together");
process.exit(1);
}
let timeoutMs = DEFAULT_READY_TIMEOUT_MS
let timeoutMs = DEFAULT_READY_TIMEOUT_MS;
try {
timeoutMs = parseTimeoutMs(options.timeout)
timeoutMs = parseTimeoutMs(options.timeout);
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
cancel(message)
process.exit(1)
const message = error instanceof Error ? error.message : String(error);
cancel(message);
process.exit(1);
}
const paseoHome = resolveLocalPaseoHome(options.home)
const paseoHome = resolveLocalPaseoHome(options.home);
if (richUi) {
renderNote(paseoHome, 'Paseo home')
renderNote(paseoHome, "Paseo home");
}
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted)
const shouldPrompt = options.voice === 'ask' || options.voice === undefined
let voiceEnabled: boolean
let persisted = loadPersistedConfig(paseoHome) as OnboardPersistedConfig;
const persistedVoiceSelection = resolvePersistedVoiceSelection(persisted);
const shouldPrompt = options.voice === "ask" || options.voice === undefined;
let voiceEnabled: boolean;
try {
voiceEnabled =
shouldPrompt && persistedVoiceSelection !== null
? persistedVoiceSelection
: await resolveVoiceSelection(options.voice)
: await resolveVoiceSelection(options.voice);
} catch (error) {
if (error instanceof OnboardCancelledError) {
cancel('Onboarding cancelled.')
process.exit(0)
return
cancel("Onboarding cancelled.");
process.exit(0);
return;
}
throw error
throw error;
}
if (shouldPrompt && persistedVoiceSelection !== null) {
log.message(`Using saved voice setup from config (${voiceEnabled ? 'enabled' : 'disabled'}).`)
log.message(`Using saved voice setup from config (${voiceEnabled ? "enabled" : "disabled"}).`);
}
persisted = applyVoiceSelection(persisted, voiceEnabled)
savePersistedConfig(paseoHome, persisted)
persisted = applyVoiceSelection(persisted, voiceEnabled);
savePersistedConfig(paseoHome, persisted);
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) })
const config = loadConfig(paseoHome, { cli: toCliOverrides(options) });
const voiceStatus = voiceEnabled
? 'Voice features enabled. Local speech models will be downloaded automatically if missing.'
: 'Voice features disabled. Local speech models will not be downloaded.'
log.message(voiceStatus)
? "Voice features enabled. Local speech models will be downloaded automatically if missing."
: "Voice features disabled. Local speech models will not be downloaded.";
log.message(voiceStatus);
const stateBeforeStart = resolveLocalDaemonState({ home: options.home })
const startSpinner = richUi ? spinner() : null
const stateBeforeStart = resolveLocalDaemonState({ home: options.home });
const startSpinner = richUi ? spinner() : null;
if (!stateBeforeStart.running) {
try {
if (startSpinner) {
startSpinner.start('Starting daemon...')
startSpinner.start("Starting daemon...");
} else {
log.message('Starting daemon...')
log.message("Starting daemon...");
}
const startup = await startLocalDaemonDetached(options)
const startup = await startLocalDaemonDetached(options);
if (startSpinner) {
startSpinner.stop(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
startSpinner.stop(`Daemon started (PID ${startup.pid ?? "unknown"})`);
} else {
log.message(`Daemon started (PID ${startup.pid ?? 'unknown'})`)
log.message(`Daemon started (PID ${startup.pid ?? "unknown"})`);
}
log.message(`Logs: ${startup.logPath}`)
log.message(`Logs: ${startup.logPath}`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const message = error instanceof Error ? error.message : String(error);
if (startSpinner) {
startSpinner.error(message)
startSpinner.error(message);
} else {
log.error(message)
log.error(message);
}
process.exit(1)
process.exit(1);
}
} else {
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? 'unknown'}).`)
log.message(`Daemon already running (PID ${stateBeforeStart.pidInfo?.pid ?? "unknown"}).`);
}
let readyState: { listen: string; host: string | null }
const readySpinner = richUi ? spinner() : null
let readyState: { listen: string; host: string | null };
const readySpinner = richUi ? spinner() : null;
try {
if (readySpinner) {
readySpinner.start('Waiting for daemon to become ready...')
readySpinner.start("Waiting for daemon to become ready...");
} else {
log.message('Waiting for daemon to become ready...')
log.message("Waiting for daemon to become ready...");
}
readyState = await waitForDaemonReady({
home: options.home ?? paseoHome,
timeoutMs,
onStatus: readySpinner ? (message) => readySpinner.message(message) : undefined,
})
});
if (readySpinner) {
readySpinner.stop(`Daemon ready on ${readyState.listen}`)
readySpinner.stop(`Daemon ready on ${readyState.listen}`);
} else {
log.message(`Daemon ready on ${readyState.listen}`)
log.message(`Daemon ready on ${readyState.listen}`);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const message = error instanceof Error ? error.message : String(error);
if (readySpinner) {
readySpinner.error(message)
readySpinner.error(message);
} else {
log.error(message)
log.error(message);
}
process.exit(1)
return
process.exit(1);
return;
}
if (config.relayEnabled === false) {
log.warn('Relay is disabled; pairing offer is unavailable for this daemon.')
printNextSteps(null, paseoHome, richUi)
log.warn("Relay is disabled; pairing offer is unavailable for this daemon.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro('Paseo daemon is running.')
outro("Paseo daemon is running.");
}
return
return;
}
const pairing = await generateLocalPairingOffer({
@@ -435,24 +439,24 @@ export async function runOnboard(options: OnboardOptions): Promise<void> {
relayPublicEndpoint: config.relayPublicEndpoint,
appBaseUrl: config.appBaseUrl,
includeQr: true,
})
});
if (!pairing.url) {
log.warn('Relay pairing URL is unavailable for this daemon configuration.')
printNextSteps(null, paseoHome, richUi)
log.warn("Relay pairing URL is unavailable for this daemon configuration.");
printNextSteps(null, paseoHome, richUi);
if (richUi) {
outro('Paseo daemon is running.')
outro("Paseo daemon is running.");
}
return
return;
}
renderNote(
pairing.qr ?? 'QR is unavailable in this terminal. Use the pairing link below.',
'Scan to pair'
)
renderNote(pairing.url, 'Pairing link')
printNextSteps(pairing.url, paseoHome, richUi)
pairing.qr ?? "QR is unavailable in this terminal. Use the pairing link below.",
"Scan to pair",
);
renderNote(pairing.url, "Pairing link");
printNextSteps(pairing.url, paseoHome, richUi);
if (richUi) {
outro('Paseo is ready!')
outro("Paseo is ready!");
}
}
+73 -75
View File
@@ -1,165 +1,163 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission response item for display */
export interface PermissionResponseItem {
requestId: string
agentId: string
agentShortId: string
name: string
result: string
requestId: string;
agentId: string;
agentShortId: string;
name: string;
result: string;
}
/** Schema for permit allow/deny output */
export const permitResponseSchema: OutputSchema<PermissionResponseItem> = {
idField: 'requestId',
idField: "requestId",
columns: [
{ header: 'REQUEST ID', field: 'requestId', width: 12 },
{ header: 'AGENT', field: 'agentShortId', width: 10 },
{ header: 'TOOL', field: 'name', width: 20 },
{ header: "REQUEST ID", field: "requestId", width: 12 },
{ header: "AGENT", field: "agentShortId", width: 10 },
{ header: "TOOL", field: "name", width: 20 },
{
header: 'RESULT',
field: 'result',
header: "RESULT",
field: "result",
width: 10,
color: (value) => {
if (value === 'allowed') return 'green'
if (value === 'denied') return 'red'
return undefined
if (value === "allowed") return "green";
if (value === "denied") return "red";
return undefined;
},
},
],
}
};
export type PermitAllowResult = ListResult<PermissionResponseItem>
export type PermitAllowResult = ListResult<PermissionResponseItem>;
export interface PermitAllowOptions extends CommandOptions {
all?: boolean
input?: string
host?: string
all?: boolean;
input?: string;
host?: string;
}
export async function runAllowCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitAllowOptions,
_command: Command
_command: Command,
): Promise<PermitAllowResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// No validation needed - if no reqId provided, allow all by default
// Parse input JSON if provided
let updatedInput: Record<string, unknown> | undefined
let updatedInput: Record<string, unknown> | undefined;
if (options.input) {
try {
updatedInput = JSON.parse(options.input)
updatedInput = JSON.parse(options.input);
} catch (err) {
const error: CommandError = {
code: 'INVALID_JSON',
code: "INVALID_JSON",
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
}
throw error
};
throw error;
}
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
if (!fetchResult) {
await client.close()
await client.close();
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = fetchResult.agent
const resolvedAgentId = agent.id
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close()
await client.close();
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
};
throw error;
}
// Determine which permissions to allow
let permissionsToAllow: AgentPermissionRequest[]
let permissionsToAllow: AgentPermissionRequest[];
if (!reqId || options.all) {
// Default: allow all pending permissions if no req_id specified
// --all flag is kept as an explicit alias for clarity
permissionsToAllow = pendingPermissions
permissionsToAllow = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
await client.close()
await client.close();
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToAllow = [permission]
permissionsToAllow = [permission];
}
// Allow permissions
const results: PermissionResponseItem[] = []
const results: PermissionResponseItem[] = [];
for (const permission of permissionsToAllow) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'allow',
behavior: "allow",
...(updatedInput ? { updatedInput } : {}),
})
});
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'allowed',
})
result: "allowed",
});
}
await client.close()
await client.close();
return {
type: 'list',
type: "list",
data: results,
schema: permitResponseSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'ALLOW_PERMISSION_FAILED',
code: "ALLOW_PERMISSION_FAILED",
message: `Failed to allow permission: ${message}`,
}
throw error
};
throw error;
}
}
+60 -62
View File
@@ -1,130 +1,128 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, CommandError } from '../../output/index.js'
import { permitResponseSchema, type PermissionResponseItem } from './allow.js'
import type { Command } from "commander";
import type { AgentPermissionRequest } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, CommandError } from "../../output/index.js";
import { permitResponseSchema, type PermissionResponseItem } from "./allow.js";
export type PermitDenyResult = ListResult<PermissionResponseItem>
export type PermitDenyResult = ListResult<PermissionResponseItem>;
export interface PermitDenyOptions extends CommandOptions {
all?: boolean
message?: string
interrupt?: boolean
host?: string
all?: boolean;
message?: string;
interrupt?: boolean;
host?: string;
}
export async function runDenyCommand(
agentIdOrPrefix: string,
reqId: string | undefined,
options: PermitDenyOptions,
_command: Command
_command: Command,
): Promise<PermitDenyResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!options.all && !reqId) {
const error: CommandError = {
code: 'MISSING_ARGUMENT',
message: 'Request ID is required unless --all is specified',
details: 'Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all',
}
throw error
code: "MISSING_ARGUMENT",
message: "Request ID is required unless --all is specified",
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const fetchResult = await client.fetchAgent(agentIdOrPrefix)
const fetchResult = await client.fetchAgent(agentIdOrPrefix);
if (!fetchResult) {
await client.close()
await client.close();
const error: CommandError = {
code: 'AGENT_NOT_FOUND',
code: "AGENT_NOT_FOUND",
message: `Agent not found: ${agentIdOrPrefix}`,
details: 'Use "paseo ls" to list available agents',
}
throw error
};
throw error;
}
const agent = fetchResult.agent
const resolvedAgentId = agent.id
const agent = fetchResult.agent;
const resolvedAgentId = agent.id;
// Get pending permissions for this agent
const pendingPermissions = agent.pendingPermissions || []
const pendingPermissions = agent.pendingPermissions || [];
if (pendingPermissions.length === 0) {
await client.close()
await client.close();
const error: CommandError = {
code: 'NO_PENDING_PERMISSIONS',
code: "NO_PENDING_PERMISSIONS",
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
}
throw error
};
throw error;
}
// Determine which permissions to deny
let permissionsToDeny: AgentPermissionRequest[]
let permissionsToDeny: AgentPermissionRequest[];
if (options.all) {
permissionsToDeny = pendingPermissions
permissionsToDeny = pendingPermissions;
} else {
// Find permission by ID prefix
const permission = pendingPermissions.find(
(p) => p.id === reqId || p.id.startsWith(reqId!)
)
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
if (!permission) {
await client.close()
await client.close();
const error: CommandError = {
code: 'PERMISSION_NOT_FOUND',
code: "PERMISSION_NOT_FOUND",
message: `Permission request not found: ${reqId}`,
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(', ')}`,
}
throw error
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
};
throw error;
}
permissionsToDeny = [permission]
permissionsToDeny = [permission];
}
// Deny permissions
const results: PermissionResponseItem[] = []
const results: PermissionResponseItem[] = [];
for (const permission of permissionsToDeny) {
await client.respondToPermission(resolvedAgentId, permission.id, {
behavior: 'deny',
behavior: "deny",
...(options.message ? { message: options.message } : {}),
...(options.interrupt ? { interrupt: true } : {}),
})
});
results.push({
requestId: permission.id.slice(0, 8),
agentId: resolvedAgentId,
agentShortId: resolvedAgentId.slice(0, 7),
name: permission.name,
result: 'denied',
})
result: "denied",
});
}
await client.close()
await client.close();
return {
type: 'list',
type: "list",
data: results,
schema: permitResponseSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandErrors
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DENY_PERMISSION_FAILED',
code: "DENY_PERMISSION_FAILED",
message: `Failed to deny permission: ${message}`,
}
throw error
};
throw error;
}
}
+27 -29
View File
@@ -1,39 +1,37 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runAllowCommand } from './allow.js'
import { runDenyCommand } from './deny.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runAllowCommand } from "./allow.js";
import { runDenyCommand } from "./deny.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createPermitCommand(): Command {
const permit = new Command('permit').description('Manage permission requests')
const permit = new Command("permit").description("Manage permission requests");
addJsonAndDaemonHostOptions(
permit.command("ls").description("List all pending permissions"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
permit
.command('ls')
.description('List all pending permissions')
).action(withOutput(runLsCommand))
.command("allow")
.description("Allow a permission request")
.argument("<agent>", "Agent ID (or prefix)")
.argument("[req_id]", "Permission request ID (optional if --all)")
.option("--all", "Allow all pending permissions for this agent")
.option("--input <json>", "Modified input parameters (JSON)"),
).action(withOutput(runAllowCommand));
addJsonAndDaemonHostOptions(
permit
.command('allow')
.description('Allow a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Allow all pending permissions for this agent')
.option('--input <json>', 'Modified input parameters (JSON)')
).action(withOutput(runAllowCommand))
.command("deny")
.description("Deny a permission request")
.argument("<agent>", "Agent ID (or prefix)")
.argument("[req_id]", "Permission request ID (optional if --all)")
.option("--all", "Deny all pending permissions for this agent")
.option("--message <msg>", "Denial reason message")
.option("--interrupt", "Stop agent after denial"),
).action(withOutput(runDenyCommand));
addJsonAndDaemonHostOptions(
permit
.command('deny')
.description('Deny a permission request')
.argument('<agent>', 'Agent ID (or prefix)')
.argument('[req_id]', 'Permission request ID (optional if --all)')
.option('--all', 'Deny all pending permissions for this agent')
.option('--message <msg>', 'Denial reason message')
.option('--interrupt', 'Stop agent after denial')
).action(withOutput(runDenyCommand))
return permit
return permit;
}
+47 -41
View File
@@ -1,88 +1,94 @@
import type { Command } from 'commander'
import type { AgentPermissionRequest, AgentSnapshotPayload } from '@getpaseo/server'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import type { AgentPermissionRequest, AgentSnapshotPayload } from "@getpaseo/server";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Permission list item for display */
export interface PermissionListItem {
id: string
agentId: string
agentShortId: string
name: string
description: string
id: string;
agentId: string;
agentShortId: string;
name: string;
description: string;
}
/** Schema for permit ls output */
export const permitLsSchema: OutputSchema<PermissionListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'AGENT', field: 'agentShortId', width: 12 },
{ header: 'REQ_ID', field: 'id', width: 12 },
{ header: 'TOOL', field: 'name', width: 20 },
{ header: 'DESCRIPTION', field: 'description', width: 50 },
{ header: "AGENT", field: "agentShortId", width: 12 },
{ header: "REQ_ID", field: "id", width: 12 },
{ header: "TOOL", field: "name", width: 20 },
{ header: "DESCRIPTION", field: "description", width: 50 },
],
}
};
/** Transform agent snapshot + permission to list item */
function toListItem(agent: AgentSnapshotPayload, permission: AgentPermissionRequest): PermissionListItem {
function toListItem(
agent: AgentSnapshotPayload,
permission: AgentPermissionRequest,
): PermissionListItem {
return {
id: permission.id.slice(0, 8),
agentId: agent.id,
agentShortId: agent.id.slice(0, 7),
name: permission.name,
description: permission.description ?? '-',
}
description: permission.description ?? "-",
};
}
export type PermitLsResult = ListResult<PermissionListItem>
export type PermitLsResult = ListResult<PermissionListItem>;
export interface PermitLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(options: PermitLsOptions, _command: Command): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host })
export async function runLsCommand(
options: PermitLsOptions,
_command: Command,
): Promise<PermitLsResult> {
const host = getDaemonHost({ host: options.host });
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
await client.close()
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
await client.close();
// Collect all pending permissions from all agents
const items: PermissionListItem[] = []
const items: PermissionListItem[] = [];
for (const agent of agents) {
if (agent.pendingPermissions && agent.pendingPermissions.length > 0) {
for (const permission of agent.pendingPermissions) {
items.push(toListItem(agent, permission))
items.push(toListItem(agent, permission));
}
}
}
return {
type: 'list',
type: "list",
data: items,
schema: permitLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
const message = err instanceof Error ? err.message : String(err)
await client.close().catch(() => {});
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'LIST_PERMISSIONS_FAILED',
code: "LIST_PERMISSIONS_FAILED",
message: `Failed to list permissions: ${message}`,
}
throw error
};
throw error;
}
}
+16 -18
View File
@@ -1,25 +1,23 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runModelsCommand } from './models.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runModelsCommand } from "./models.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createProviderCommand(): Command {
const provider = new Command('provider').description('Manage agent providers')
const provider = new Command("provider").description("Manage agent providers");
addJsonAndDaemonHostOptions(
provider.command("ls").description("List available providers and status"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
provider
.command('ls')
.description('List available providers and status')
).action(withOutput(runLsCommand))
.command("models")
.description("List models for a provider")
.argument("<provider>", "Provider name (claude, codex, opencode)")
.option("--thinking", "Include thinking option IDs for each model"),
).action(withOutput(runModelsCommand));
addJsonAndDaemonHostOptions(
provider
.command('models')
.description('List models for a provider')
.argument('<provider>', 'Provider name (claude, codex, opencode)')
.option('--thinking', 'Include thinking option IDs for each model')
).action(withOutput(runModelsCommand))
return provider
return provider;
}
+34 -34
View File
@@ -1,70 +1,70 @@
import type { Command } from 'commander'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import type { Command } from "commander";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
/** Provider list item for display */
export interface ProviderListItem {
provider: string
status: string
defaultMode: string
modes: string
provider: string;
status: string;
defaultMode: string;
modes: string;
}
/** Static provider data - providers are built-in and don't require daemon */
const PROVIDERS: ProviderListItem[] = [
{
provider: 'claude',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
provider: "claude",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
{
provider: 'codex',
status: 'available',
defaultMode: 'auto',
modes: 'read-only, auto, full-access',
provider: "codex",
status: "available",
defaultMode: "auto",
modes: "read-only, auto, full-access",
},
{
provider: 'opencode',
status: 'available',
defaultMode: 'default',
modes: 'plan, default, bypass',
provider: "opencode",
status: "available",
defaultMode: "default",
modes: "plan, default, bypass",
},
]
];
/** Schema for provider ls output */
export const providerLsSchema: OutputSchema<ProviderListItem> = {
idField: 'provider',
idField: "provider",
columns: [
{ header: 'PROVIDER', field: 'provider', width: 12 },
{ header: "PROVIDER", field: "provider", width: 12 },
{
header: 'STATUS',
field: 'status',
header: "STATUS",
field: "status",
width: 12,
color: (value) => {
if (value === 'available') return 'green'
if (value === 'unavailable') return 'red'
return undefined
if (value === "available") return "green";
if (value === "unavailable") return "red";
return undefined;
},
},
{ header: 'DEFAULT MODE', field: 'defaultMode', width: 14 },
{ header: 'MODES', field: 'modes', width: 30 },
{ header: "DEFAULT MODE", field: "defaultMode", width: 14 },
{ header: "MODES", field: "modes", width: 30 },
],
}
};
export type ProviderLsResult = ListResult<ProviderListItem>
export type ProviderLsResult = ListResult<ProviderListItem>;
export interface ProviderLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(
_options: ProviderLsOptions,
_command: Command
_command: Command,
): Promise<ProviderLsResult> {
// Provider data is static - no daemon connection needed
return {
type: 'list',
type: "list",
data: PROVIDERS,
schema: providerLsSchema,
}
};
}
+36 -37
View File
@@ -1,82 +1,81 @@
import type { Command } from 'commander'
import { connectToDaemon } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
/** Model list item for display */
export interface ModelListItem {
model: string
id: string
description: string
thinkingOptionIds: string[]
defaultThinkingOptionId: string | null
thinkingOptions: string
model: string;
id: string;
description: string;
thinkingOptionIds: string[];
defaultThinkingOptionId: string | null;
thinkingOptions: string;
}
/** Schema for provider models output */
export const providerModelsSchema: OutputSchema<ModelListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'ID', field: 'id', width: 30 },
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'DESCRIPTION', field: 'description', width: 40 },
{ header: "ID", field: "id", width: 30 },
{ header: "MODEL", field: "model", width: 30 },
{ header: "DESCRIPTION", field: "description", width: 40 },
],
}
};
const providerModelsWithThinkingSchema: OutputSchema<ModelListItem> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'ID', field: 'id', width: 30 },
{ header: 'MODEL', field: 'model', width: 30 },
{ header: 'THINKING IDS', field: 'thinkingOptions', width: 40 },
{ header: "ID", field: "id", width: 30 },
{ header: "MODEL", field: "model", width: 30 },
{ header: "THINKING IDS", field: "thinkingOptions", width: 40 },
{
header: 'DEFAULT THINKING',
field: (item) => item.defaultThinkingOptionId ?? 'auto',
header: "DEFAULT THINKING",
field: (item) => item.defaultThinkingOptionId ?? "auto",
width: 18,
},
],
}
};
export type ProviderModelsResult = ListResult<ModelListItem>
export type ProviderModelsResult = ListResult<ModelListItem>;
export interface ProviderModelsOptions extends CommandOptions {
host?: string
thinking?: boolean
host?: string;
thinking?: boolean;
}
export async function runModelsCommand(
provider: string,
options: ProviderModelsOptions,
_command: Command
_command: Command,
): Promise<ProviderModelsResult> {
const normalizedProvider = provider.toLowerCase()
const normalizedProvider = provider.toLowerCase();
const client = await connectToDaemon({ host: options.host })
const client = await connectToDaemon({ host: options.host });
try {
const result = await client.listProviderModels(normalizedProvider)
const result = await client.listProviderModels(normalizedProvider);
if (result.error) {
throw {
code: 'PROVIDER_ERROR',
code: "PROVIDER_ERROR",
message: `Failed to fetch models for ${provider}: ${result.error}`,
}
};
}
const models: ModelListItem[] = (result.models ?? []).map((m) => ({
model: m.label,
id: m.id,
description: m.description ?? '',
description: m.description ?? "",
thinkingOptionIds: (m.thinkingOptions ?? []).map((option) => option.id),
defaultThinkingOptionId: m.defaultThinkingOptionId ?? null,
thinkingOptions:
(m.thinkingOptions ?? []).map((option) => option.id).join(', ') || 'none',
}))
thinkingOptions: (m.thinkingOptions ?? []).map((option) => option.id).join(", ") || "none",
}));
return {
type: 'list',
type: "list",
data: models,
schema: options.thinking ? providerModelsWithThinkingSchema : providerModelsSchema,
}
};
} finally {
await client.close()
await client.close();
}
}
+2 -7
View File
@@ -1,10 +1,5 @@
import type { Command } from "commander";
import type {
CommandError,
CommandOptions,
ListResult,
OutputSchema,
} from "../../output/index.js";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechDownloadRow {
@@ -29,7 +24,7 @@ export interface SpeechDownloadOptions extends CommandOptions {
export async function runSpeechDownloadCommand(
options: SpeechDownloadOptions,
_command: Command
_command: Command,
): Promise<SpeechDownloadResult> {
const client = await connectToDaemon({ host: options.host });
try {
+15 -17
View File
@@ -1,24 +1,22 @@
import { Command } from 'commander'
import { withOutput } from '../../output/index.js'
import { runSpeechModelsCommand } from './models.js'
import { runSpeechDownloadCommand } from './download.js'
import { addJsonAndDaemonHostOptions, collectMultiple } from '../../utils/command-options.js'
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { runSpeechModelsCommand } from "./models.js";
import { runSpeechDownloadCommand } from "./download.js";
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
export function createSpeechCommand(): Command {
const speech = new Command('speech').description('Manage local speech models')
const speech = new Command("speech").description("Manage local speech models");
addJsonAndDaemonHostOptions(
speech.command("models").description("List local speech model download status"),
).action(withOutput(runSpeechModelsCommand));
addJsonAndDaemonHostOptions(
speech
.command('models')
.description('List local speech model download status')
).action(withOutput(runSpeechModelsCommand))
.command("download")
.description("Download local speech models")
.option("--model <id>", "Model ID to download (repeatable)", collectMultiple, []),
).action(withOutput(runSpeechDownloadCommand));
addJsonAndDaemonHostOptions(
speech
.command('download')
.description('Download local speech models')
.option('--model <id>', 'Model ID to download (repeatable)', collectMultiple, [])
).action(withOutput(runSpeechDownloadCommand))
return speech
return speech;
}
+2 -7
View File
@@ -1,10 +1,5 @@
import type { Command } from "commander";
import type {
CommandError,
CommandOptions,
ListResult,
OutputSchema,
} from "../../output/index.js";
import type { CommandError, CommandOptions, ListResult, OutputSchema } from "../../output/index.js";
import { connectToDaemon } from "../../utils/client.js";
interface SpeechModelListItem {
@@ -39,7 +34,7 @@ export interface SpeechModelsOptions extends CommandOptions {
export async function runSpeechModelsCommand(
options: SpeechModelsOptions,
_command: Command
_command: Command,
): Promise<SpeechModelsResult> {
const client = await connectToDaemon({ host: options.host });
try {
+59 -54
View File
@@ -1,129 +1,134 @@
import type { Command } from 'commander'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, SingleResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
SingleResult,
OutputSchema,
CommandError,
} from "../../output/index.js";
/** Result type for worktree archive command */
export interface WorktreeArchiveResult {
name: string
status: 'archived'
removedAgents: string[]
name: string;
status: "archived";
removedAgents: string[];
}
/** Schema for archive command output */
export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
idField: 'name',
idField: "name",
columns: [
{ header: 'NAME', field: 'name' },
{ header: 'STATUS', field: 'status' },
{ header: "NAME", field: "name" },
{ header: "STATUS", field: "status" },
{
header: 'REMOVED AGENTS',
field: (item) => item.removedAgents.length > 0 ? item.removedAgents.join(', ') : '-',
header: "REMOVED AGENTS",
field: (item) => (item.removedAgents.length > 0 ? item.removedAgents.join(", ") : "-"),
},
],
}
};
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string
host?: string;
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
export async function runArchiveCommand(
nameArg: string,
options: WorktreeArchiveOptions,
_command: Command
_command: Command,
): Promise<WorktreeArchiveCommandResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
// Validate arguments
if (!nameArg || nameArg.trim().length === 0) {
const error: CommandError = {
code: 'MISSING_WORKTREE_NAME',
message: 'Worktree name is required',
details: 'Usage: paseo worktree archive <name>',
}
throw error
code: "MISSING_WORKTREE_NAME",
message: "Worktree name is required",
details: "Usage: paseo worktree archive <name>",
};
throw error;
}
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({})
const listResponse = await client.getPaseoWorktreeList({});
if (listResponse.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${listResponse.error.message}`,
}
throw error
};
throw error;
}
// Find the worktree by name or branch
const worktree = listResponse.worktrees.find((wt) => {
const name = wt.worktreePath.split('/').pop()
return name === nameArg || wt.branchName === nameArg
})
const name = wt.worktreePath.split("/").pop();
return name === nameArg || wt.branchName === nameArg;
});
if (!worktree) {
const error: CommandError = {
code: 'WORKTREE_NOT_FOUND',
code: "WORKTREE_NOT_FOUND",
message: `Worktree not found: ${nameArg}`,
details: 'Use "paseo worktree ls" to list available worktrees',
}
throw error
};
throw error;
}
// Archive the worktree
const response = await client.archivePaseoWorktree({
worktreePath: worktree.worktreePath,
})
});
await client.close()
await client.close();
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${response.error.message}`,
}
throw error
};
throw error;
}
const worktreeName = worktree.worktreePath.split('/').pop() ?? nameArg
const worktreeName = worktree.worktreePath.split("/").pop() ?? nameArg;
return {
type: 'single',
type: "single",
data: {
name: worktreeName,
status: 'archived',
status: "archived",
removedAgents: response.removedAgents ?? [],
},
schema: archiveSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WORKTREE_ARCHIVE_FAILED',
code: "WORKTREE_ARCHIVE_FAILED",
message: `Failed to archive worktree: ${message}`,
}
throw error
};
throw error;
}
}
+15 -17
View File
@@ -1,24 +1,22 @@
import { Command } from 'commander'
import { runLsCommand } from './ls.js'
import { runArchiveCommand } from './archive.js'
import { withOutput } from '../../output/index.js'
import { addJsonAndDaemonHostOptions } from '../../utils/command-options.js'
import { Command } from "commander";
import { runLsCommand } from "./ls.js";
import { runArchiveCommand } from "./archive.js";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
export function createWorktreeCommand(): Command {
const worktree = new Command('worktree').description('Manage Paseo-managed git worktrees')
const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees");
addJsonAndDaemonHostOptions(
worktree.command("ls").description("List Paseo-managed git worktrees"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
worktree
.command('ls')
.description('List Paseo-managed git worktrees')
).action(withOutput(runLsCommand))
.command("archive")
.description("Archive a worktree (removes worktree and associated branch)")
.argument("<name>", "Worktree name or branch name"),
).action(withOutput(runArchiveCommand));
addJsonAndDaemonHostOptions(
worktree
.command('archive')
.description('Archive a worktree (removes worktree and associated branch)')
.argument('<name>', 'Worktree name or branch name')
).action(withOutput(runArchiveCommand))
return worktree
return worktree;
}
+55 -55
View File
@@ -1,130 +1,130 @@
import type { Command } from 'commander'
import { homedir } from 'node:os'
import { basename, join, sep } from 'node:path'
import { connectToDaemon, getDaemonHost } from '../../utils/client.js'
import type { CommandOptions, ListResult, OutputSchema, CommandError } from '../../output/index.js'
import type { Command } from "commander";
import { homedir } from "node:os";
import { basename, join, sep } from "node:path";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
/** Worktree list item for display */
export interface WorktreeListItem {
name: string
branch: string
cwd: string
agent: string
name: string;
branch: string;
cwd: string;
agent: string;
}
/** Shorten home directory in path */
function shortenPath(path: string): string {
const home = process.env.HOME
const home = process.env.HOME;
if (home && path.startsWith(home)) {
return '~' + path.slice(home.length)
return "~" + path.slice(home.length);
}
return path
return path;
}
/** Extract worktree name from path */
function extractWorktreeName(path: string): string {
return basename(path)
return basename(path);
}
export function resolvePaseoHomePath(): string {
return process.env.PASEO_HOME ?? join(homedir(), '.paseo')
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
}
export function resolvePaseoWorktreesDir(): string {
return join(resolvePaseoHomePath(), 'worktrees')
return join(resolvePaseoHomePath(), "worktrees");
}
function isAgentInManagedWorktree(agentCwd: string): boolean {
const worktreesDir = resolvePaseoWorktreesDir()
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep)
const worktreesDir = resolvePaseoWorktreesDir();
return agentCwd === worktreesDir || agentCwd.startsWith(worktreesDir + sep);
}
/** Schema for worktree ls output */
export const worktreeLsSchema: OutputSchema<WorktreeListItem> = {
idField: 'name',
idField: "name",
columns: [
{ header: 'NAME', field: 'name', width: 20 },
{ header: 'BRANCH', field: 'branch', width: 25 },
{ header: 'CWD', field: 'cwd', width: 45 },
{ header: 'AGENT', field: 'agent', width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "BRANCH", field: "branch", width: 25 },
{ header: "CWD", field: "cwd", width: 45 },
{ header: "AGENT", field: "agent", width: 10 },
],
}
};
export type WorktreeLsResult = ListResult<WorktreeListItem>
export type WorktreeLsResult = ListResult<WorktreeListItem>;
export interface WorktreeLsOptions extends CommandOptions {
host?: string
host?: string;
}
export async function runLsCommand(
options: WorktreeLsOptions,
_command: Command
_command: Command,
): Promise<WorktreeLsResult> {
const host = getDaemonHost({ host: options.host })
const host = getDaemonHost({ host: options.host });
let client
let client;
try {
client = await connectToDaemon({ host: options.host })
client = await connectToDaemon({ host: options.host });
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'DAEMON_NOT_RUNNING',
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: 'Start the daemon with: paseo daemon start',
}
throw error
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
try {
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } })
const agents = agentsPayload.entries.map((entry) => entry.agent)
const agentsPayload = await client.fetchAgents({ filter: { includeArchived: true } });
const agents = agentsPayload.entries.map((entry) => entry.agent);
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({})
const response = await client.getPaseoWorktreeList({});
await client.close()
await client.close();
if (response.error) {
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${response.error.message}`,
}
throw error
};
throw error;
}
// Build a map of worktree paths to agent IDs
const worktreeAgentMap = new Map<string, string>()
const worktreeAgentMap = new Map<string, string>();
for (const agent of agents) {
if (isAgentInManagedWorktree(agent.cwd)) {
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7))
worktreeAgentMap.set(agent.cwd, agent.id.slice(0, 7));
}
}
const items: WorktreeListItem[] = response.worktrees.map((wt) => ({
name: extractWorktreeName(wt.worktreePath),
branch: wt.branchName ?? '-',
branch: wt.branchName ?? "-",
cwd: shortenPath(wt.worktreePath),
agent: worktreeAgentMap.get(wt.worktreePath) ?? '-',
}))
agent: worktreeAgentMap.get(wt.worktreePath) ?? "-",
}));
return {
type: 'list',
type: "list",
data: items,
schema: worktreeLsSchema,
}
};
} catch (err) {
await client.close().catch(() => {})
await client.close().catch(() => {});
// Re-throw CommandError as-is
if (err && typeof err === 'object' && 'code' in err) {
throw err
if (err && typeof err === "object" && "code" in err) {
throw err;
}
const message = err instanceof Error ? err.message : String(err)
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: 'WORKTREE_LIST_FAILED',
code: "WORKTREE_LIST_FAILED",
message: `Failed to list worktrees: ${message}`,
}
throw error
};
throw error;
}
}
+4 -4
View File
@@ -1,7 +1,7 @@
import { createCli } from './cli.js'
import { createCli } from "./cli.js";
const program = createCli()
const program = createCli();
if (process.argv.length <= 2) {
process.argv.push('onboard')
process.argv.push("onboard");
}
program.parse()
program.parse();
+7 -7
View File
@@ -47,16 +47,16 @@ export type {
ListResult,
AnyCommandResult,
CommandError,
} from './types.js'
} from "./types.js";
// Renderers
export { renderTable, renderTableHeader, renderTableRow } from './table.js'
export { renderJson, renderJsonLine } from './json.js'
export { renderYaml, renderYamlDoc } from './yaml.js'
export { renderQuiet } from './quiet.js'
export { renderTable, renderTableHeader, renderTableRow } from "./table.js";
export { renderJson, renderJsonLine } from "./json.js";
export { renderYaml, renderYamlDoc } from "./yaml.js";
export { renderQuiet } from "./quiet.js";
// Main render function
export { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
export { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
// Command wrapper
export { withOutput, createOutputOptions, type CommandOptions } from './with-output.js'
export { withOutput, createOutputOptions, type CommandOptions } from "./with-output.js";
+14 -17
View File
@@ -4,41 +4,38 @@
* Renders structured data as formatted JSON for machine consumption.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Render command result as JSON */
export function renderJson<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
export function renderJson<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
const { schema } = result;
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
if (result.type === "list") {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
const serialized = result.data.map((item) => schema.serialize!(item));
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
const first = JSON.stringify(serialized[0]);
const allSame = serialized.every((s) => JSON.stringify(s) === first);
if (allSame) {
return JSON.stringify(serialized[0], null, 2)
return JSON.stringify(serialized[0], null, 2);
}
}
return JSON.stringify(serialized, null, 2)
return JSON.stringify(serialized, null, 2);
} else {
const serialized = schema.serialize(result.data)
return JSON.stringify(serialized, null, 2)
const serialized = schema.serialize(result.data);
return JSON.stringify(serialized, null, 2);
}
}
return JSON.stringify(result.data, null, 2)
return JSON.stringify(result.data, null, 2);
}
/** Render a single item as JSON line (for NDJSON streaming) */
export function renderJsonLine<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return JSON.stringify(output)
const output = serialize ? serialize(item) : item;
return JSON.stringify(output);
}
+8 -11
View File
@@ -4,24 +4,21 @@
* Outputs only ID fields, one per line. Useful for scripting and pipelines.
*/
import type { AnyCommandResult, OutputOptions } from './types.js'
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Extract ID from item using schema definition */
function getId<T>(item: T, idField: keyof T | ((item: T) => string)): string {
if (typeof idField === 'function') {
return idField(item)
if (typeof idField === "function") {
return idField(item);
}
return String(item[idField])
return String(item[idField]);
}
/** Render command result in quiet mode (IDs only) */
export function renderQuiet<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
if (result.type === 'single') {
return getId(result.data, result.schema.idField)
export function renderQuiet<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
if (result.type === "single") {
return getId(result.data, result.schema.idField);
} else {
return result.data.map((item) => getId(item, result.schema.idField)).join('\n')
return result.data.map((item) => getId(item, result.schema.idField)).join("\n");
}
}
+40 -43
View File
@@ -4,100 +4,97 @@
* Selects the appropriate renderer based on output options.
*/
import chalk from 'chalk'
import YAML from 'yaml'
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
import { renderTable } from './table.js'
import { renderJson } from './json.js'
import { renderYaml } from './yaml.js'
import { renderQuiet } from './quiet.js'
import chalk from "chalk";
import YAML from "yaml";
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
import { renderTable } from "./table.js";
import { renderJson } from "./json.js";
import { renderYaml } from "./yaml.js";
import { renderQuiet } from "./quiet.js";
/** Default output options */
export const defaultOutputOptions: OutputOptions = {
format: 'table',
format: "table",
quiet: false,
noHeaders: false,
noColor: false,
}
};
/** Render command result to string based on output options */
export function render<T>(
result: AnyCommandResult<T>,
options: Partial<OutputOptions> = {}
options: Partial<OutputOptions> = {},
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
// Quiet mode takes precedence
if (opts.quiet) {
return renderQuiet(result, opts)
return renderQuiet(result, opts);
}
// Dispatch to format-specific renderer
switch (opts.format) {
case 'json':
return renderJson(result, opts)
case 'yaml':
return renderYaml(result, opts)
case 'table':
case "json":
return renderJson(result, opts);
case "yaml":
return renderYaml(result, opts);
case "table":
default:
return renderTable(result, opts)
return renderTable(result, opts);
}
}
/** Convert an unknown error to a CommandError */
export function toCommandError(error: unknown): CommandError {
if (isCommandError(error)) {
return error
return error;
}
if (error instanceof Error) {
return {
code: 'UNKNOWN_ERROR',
code: "UNKNOWN_ERROR",
message: error.message,
details: error.stack,
}
};
}
return {
code: 'UNKNOWN_ERROR',
code: "UNKNOWN_ERROR",
message: String(error),
}
};
}
/** Type guard for CommandError */
function isCommandError(error: unknown): error is CommandError {
return (
typeof error === 'object' &&
typeof error === "object" &&
error !== null &&
'code' in error &&
'message' in error &&
typeof (error as CommandError).code === 'string' &&
typeof (error as CommandError).message === 'string'
)
"code" in error &&
"message" in error &&
typeof (error as CommandError).code === "string" &&
typeof (error as CommandError).message === "string"
);
}
/** Render an error to string based on output options */
export function renderError(
error: CommandError,
options: Partial<OutputOptions> = {}
): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options }
export function renderError(error: CommandError, options: Partial<OutputOptions> = {}): string {
const opts: OutputOptions = { ...defaultOutputOptions, ...options };
if (opts.format === 'json') {
return JSON.stringify({ error }, null, 2)
if (opts.format === "json") {
return JSON.stringify({ error }, null, 2);
}
if (opts.format === 'yaml') {
return YAML.stringify({ error })
if (opts.format === "yaml") {
return YAML.stringify({ error });
}
// Table/default format: human-readable error
const prefix = opts.noColor ? 'Error: ' : chalk.red('Error: ')
const message = error.message
const prefix = opts.noColor ? "Error: " : chalk.red("Error: ");
const message = error.message;
if (error.details && typeof error.details === 'string') {
return `${prefix}${message}\n${error.details}`
if (error.details && typeof error.details === "string") {
return `${prefix}${message}\n${error.details}`;
}
return `${prefix}${message}`
return `${prefix}${message}`;
}
+59 -75
View File
@@ -4,49 +4,40 @@
* Renders structured data as aligned ASCII tables with optional color support.
*/
import chalk, { type ChalkInstance } from 'chalk'
import type {
AnyCommandResult,
ColumnDef,
OutputOptions,
OutputSchema,
} from './types.js'
import chalk, { type ChalkInstance } from "chalk";
import type { AnyCommandResult, ColumnDef, OutputOptions, OutputSchema } from "./types.js";
// ANSI escape code regex for stripping colors when measuring width
const ANSI_REGEX =
// eslint-disable-next-line no-control-regex
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
/** Strip ANSI escape codes from a string */
function stripAnsi(str: string): string {
return str.replace(ANSI_REGEX, '')
return str.replace(ANSI_REGEX, "");
}
/** Get visible string length (excluding ANSI codes) */
function visibleLength(str: string): number {
return stripAnsi(str).length
return stripAnsi(str).length;
}
/** Pad a cell to the specified width with alignment */
function padCell(
cell: string,
width: number,
align: 'left' | 'right' | 'center'
): string {
const visible = visibleLength(cell)
const padding = Math.max(0, width - visible)
function padCell(cell: string, width: number, align: "left" | "right" | "center"): string {
const visible = visibleLength(cell);
const padding = Math.max(0, width - visible);
switch (align) {
case 'right':
return ' '.repeat(padding) + cell
case 'center': {
const left = Math.floor(padding / 2)
const right = padding - left
return ' '.repeat(left) + cell + ' '.repeat(right)
case "right":
return " ".repeat(padding) + cell;
case "center": {
const left = Math.floor(padding / 2);
const right = padding - left;
return " ".repeat(left) + cell + " ".repeat(right);
}
case 'left':
case "left":
default:
return cell + ' '.repeat(padding)
return cell + " ".repeat(padding);
}
}
@@ -65,15 +56,15 @@ function applyColor(str: string, colorName: string): string {
grey: chalk.grey,
dim: chalk.dim,
bold: chalk.bold,
}
};
const colorFn = colorMap[colorName]
return colorFn ? colorFn(str) : str
const colorFn = colorMap[colorName];
return colorFn ? colorFn(str) : str;
}
/** Extract value from item using field definition */
function getValue<T>(item: T, field: keyof T | ((item: T) => unknown)): unknown {
return typeof field === 'function' ? field(item) : item[field]
return typeof field === "function" ? field(item) : item[field];
}
/** Render a single table row */
@@ -81,106 +72,99 @@ function renderRow<T>(
item: T,
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
options: OutputOptions,
): string {
return columns
.map((col, colIndex) => {
const value = getValue(item, col.field)
let cell = String(value ?? '')
const width = widths[colIndex]
const value = getValue(item, col.field);
let cell = String(value ?? "");
const width = widths[colIndex];
// Apply color if enabled
if (col.color && !options.noColor) {
const colorName = col.color(value, item)
const colorName = col.color(value, item);
if (colorName) {
cell = applyColor(cell, colorName)
cell = applyColor(cell, colorName);
}
}
return padCell(cell, width ?? 0, col.align ?? 'left')
return padCell(cell, width ?? 0, col.align ?? "left");
})
.join(' ')
.join(" ");
}
/** Render header row */
function renderHeader<T>(
columns: ColumnDef<T>[],
widths: number[],
options: OutputOptions
options: OutputOptions,
): string {
const headerRow = columns
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? 'left'))
.join(' ')
.map((col, i) => padCell(col.header, widths[i] ?? 0, col.align ?? "left"))
.join(" ");
return options.noColor ? headerRow : chalk.bold(headerRow)
return options.noColor ? headerRow : chalk.bold(headerRow);
}
/** Calculate column widths based on content and hints */
function calculateWidths<T>(
data: T[],
columns: ColumnDef<T>[],
includeHeaders: boolean
): number[] {
function calculateWidths<T>(data: T[], columns: ColumnDef<T>[], includeHeaders: boolean): number[] {
return columns.map((col) => {
// Start with header width if including headers
let maxWidth = includeHeaders ? col.header.length : 0
let maxWidth = includeHeaders ? col.header.length : 0;
// Check all data values
for (const item of data) {
const value = getValue(item, col.field)
const str = String(value ?? '')
maxWidth = Math.max(maxWidth, visibleLength(str))
const value = getValue(item, col.field);
const str = String(value ?? "");
maxWidth = Math.max(maxWidth, visibleLength(str));
}
// Apply width hint if specified (minimum width)
if (col.width) {
maxWidth = Math.max(maxWidth, col.width)
maxWidth = Math.max(maxWidth, col.width);
}
return maxWidth
})
return maxWidth;
});
}
/** Render a list result as a table */
export function renderTable<T>(
result: AnyCommandResult<T>,
options: OutputOptions
): string {
const { schema } = result
const data = result.type === 'list' ? result.data : [result.data]
export function renderTable<T>(result: AnyCommandResult<T>, options: OutputOptions): string {
const { schema } = result;
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return ''
return "";
}
const columns = schema.columns as ColumnDef<T>[]
const includeHeaders = !options.noHeaders
const widths = calculateWidths(data, columns, includeHeaders)
const columns = schema.columns as ColumnDef<T>[];
const includeHeaders = !options.noHeaders;
const widths = calculateWidths(data, columns, includeHeaders);
const lines: string[] = []
const lines: string[] = [];
// Add header row
if (includeHeaders) {
lines.push(renderHeader(columns, widths, options))
lines.push(renderHeader(columns, widths, options));
}
// Add data rows
for (const item of data) {
lines.push(renderRow(item, columns, widths, options))
lines.push(renderRow(item, columns, widths, options));
}
return lines.join('\n')
return lines.join("\n");
}
/** Render just a table header (for streaming) */
export function renderTableHeader<T>(
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
widths?: number[],
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderHeader(columns, actualWidths, options)
const columns = schema.columns;
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
return renderHeader(columns, actualWidths, options);
}
/** Render just a table row (for streaming) */
@@ -188,9 +172,9 @@ export function renderTableRow<T>(
item: T,
schema: OutputSchema<T>,
options: OutputOptions,
widths?: number[]
widths?: number[],
): string {
const columns = schema.columns
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length)
return renderRow(item, columns, actualWidths, options)
const columns = schema.columns;
const actualWidths = widths ?? columns.map((col) => col.width ?? col.header.length);
return renderRow(item, columns, actualWidths, options);
}
+24 -24
View File
@@ -6,74 +6,74 @@
*/
/** Supported output formats */
export type OutputFormat = 'table' | 'json' | 'yaml'
export type OutputFormat = "table" | "json" | "yaml";
/** Options controlling output rendering */
export interface OutputOptions {
/** Output format (table, json, yaml) */
format: OutputFormat
format: OutputFormat;
/** Minimal output - IDs only */
quiet: boolean
quiet: boolean;
/** Omit table headers */
noHeaders: boolean
noHeaders: boolean;
/** Disable color output */
noColor: boolean
noColor: boolean;
}
/** Column definition for table output */
export interface ColumnDef<T> {
/** Header text for the column */
header: string
header: string;
/** Field key or accessor function */
field: keyof T | ((item: T) => unknown)
field: keyof T | ((item: T) => unknown);
/** Optional width hint (characters) */
width?: number
width?: number;
/** Optional alignment */
align?: 'left' | 'right' | 'center'
align?: "left" | "right" | "center";
/** Optional color function - returns chalk color name */
color?: (value: unknown, item: T) => string | undefined
color?: (value: unknown, item: T) => string | undefined;
}
/** Schema describing how to render command output */
export interface OutputSchema<T> {
/** Field to use for quiet mode (--quiet outputs just this) */
idField: keyof T | ((item: T) => string)
idField: keyof T | ((item: T) => string);
/** Column definitions for table output */
columns: ColumnDef<T>[]
columns: ColumnDef<T>[];
/** Optional: transform data before JSON/YAML output */
serialize?: (data: T) => unknown
serialize?: (data: T) => unknown;
}
/** Result type for commands returning a single item */
export interface SingleResult<T> {
type: 'single'
type: "single";
/** The structured data to render */
data: T
data: T;
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
schema: OutputSchema<T>;
}
/** Result type for commands returning a list */
export interface ListResult<T> {
type: 'list'
type: "list";
/** The structured data to render */
data: T[]
data: T[];
/** Schema describing how to render this data (for item type T) */
schema: OutputSchema<T>
schema: OutputSchema<T>;
}
/** Union type for all command results */
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>
export type AnyCommandResult<T> = SingleResult<T> | ListResult<T>;
/** Base interface for command results (deprecated, use SingleResult or ListResult) */
export type CommandResult<T> = SingleResult<T> | ListResult<T>
export type CommandResult<T> = SingleResult<T> | ListResult<T>;
/** Structured error for command failures */
export interface CommandError {
/** Machine-readable error code */
code: string
code: string;
/** Human-readable message */
message: string
message: string;
/** Additional context */
details?: unknown
details?: unknown;
}
+31 -33
View File
@@ -4,51 +4,51 @@
* Wraps command handlers to automatically render results and handle errors.
*/
import type { Command } from 'commander'
import type { AnyCommandResult, CommandError, OutputOptions } from './types.js'
import { render, renderError, toCommandError, defaultOutputOptions } from './render.js'
import type { Command } from "commander";
import type { AnyCommandResult, CommandError, OutputOptions } from "./types.js";
import { render, renderError, toCommandError, defaultOutputOptions } from "./render.js";
/** Options that include output settings from global options */
export interface CommandOptions extends Partial<OutputOptions> {
[key: string]: unknown
[key: string]: unknown;
}
function normalizeFormat(raw: unknown): OutputOptions['format'] {
const value = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
function normalizeFormat(raw: unknown): OutputOptions["format"] {
const value = typeof raw === "string" ? raw.trim().toLowerCase() : "";
// Common user expectation: "cli" means "table/human"
if (value === 'cli') return 'table'
if (value === "cli") return "table";
if (value === 'table' || value === 'json' || value === 'yaml') return value
if (value === "table" || value === "json" || value === "yaml") return value;
const error: CommandError = {
code: 'INVALID_FORMAT',
code: "INVALID_FORMAT",
message: `Unsupported output format: ${String(raw)}`,
details: 'Supported formats: table, json, yaml',
}
throw error
details: "Supported formats: table, json, yaml",
};
throw error;
}
/** Extract output options from command options */
function extractOutputOptions(options: CommandOptions): OutputOptions {
const hasStructuredOutputSchema =
typeof options.outputSchema === 'string' && options.outputSchema.trim().length > 0
typeof options.outputSchema === "string" && options.outputSchema.trim().length > 0;
if (hasStructuredOutputSchema) {
return {
format: 'json',
format: "json",
quiet: false,
noHeaders: options.headers === false,
noColor: options.color === false,
}
};
}
return {
format: options.json ? 'json' : normalizeFormat(options.format ?? defaultOutputOptions.format),
format: options.json ? "json" : normalizeFormat(options.format ?? defaultOutputOptions.format),
quiet: options.quiet ?? defaultOutputOptions.quiet,
noHeaders: options.headers === false, // Commander uses --no-headers -> headers: false
noColor: options.color === false, // Commander uses --no-color -> color: false
}
};
}
/**
@@ -71,37 +71,35 @@ function extractOutputOptions(options: CommandOptions): OutputOptions {
* ```
*/
export function withOutput<T, Args extends unknown[]>(
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>
handler: (...args: [...Args, CommandOptions, Command]) => Promise<AnyCommandResult<T>>,
): (...args: [...Args, CommandOptions, Command]) => Promise<void> {
return async (...args) => {
// Last two args are options and command
const command = args[args.length - 1] as Command
const command = args[args.length - 1] as Command;
// Use optsWithGlobals() to get both local and global options
const options = command.optsWithGlobals() as CommandOptions
const outputOptions = extractOutputOptions(options)
const options = command.optsWithGlobals() as CommandOptions;
const outputOptions = extractOutputOptions(options);
try {
const result = await handler(...args)
const output = render(result, outputOptions)
const result = await handler(...args);
const output = render(result, outputOptions);
if (output) {
process.stdout.write(output + '\n')
process.stdout.write(output + "\n");
}
} catch (error) {
const commandError = toCommandError(error)
const errorOutput = renderError(commandError, outputOptions)
process.stderr.write(errorOutput + '\n')
process.exit(1)
const commandError = toCommandError(error);
const errorOutput = renderError(commandError, outputOptions);
process.stderr.write(errorOutput + "\n");
process.exit(1);
}
}
};
}
/**
* Helper to create output options from partial input.
* Useful for testing or manual rendering.
*/
export function createOutputOptions(
partial: Partial<OutputOptions> = {}
): OutputOptions {
return { ...defaultOutputOptions, ...partial }
export function createOutputOptions(partial: Partial<OutputOptions> = {}): OutputOptions {
return { ...defaultOutputOptions, ...partial };
}
+15 -18
View File
@@ -4,42 +4,39 @@
* Renders structured data as YAML for machine consumption and human readability.
*/
import YAML from 'yaml'
import type { AnyCommandResult, OutputOptions } from './types.js'
import YAML from "yaml";
import type { AnyCommandResult, OutputOptions } from "./types.js";
/** Render command result as YAML */
export function renderYaml<T>(
result: AnyCommandResult<T>,
_options: OutputOptions
): string {
const { schema } = result
export function renderYaml<T>(result: AnyCommandResult<T>, _options: OutputOptions): string {
const { schema } = result;
// Apply custom serializer if provided
if (schema.serialize) {
if (result.type === 'list') {
if (result.type === "list") {
// If all items serialize to the same object, return just one
// This handles the case where a list of key-value rows should serialize
// to a single structured object
const serialized = result.data.map((item) => schema.serialize!(item))
const serialized = result.data.map((item) => schema.serialize!(item));
if (serialized.length > 0) {
const first = JSON.stringify(serialized[0])
const allSame = serialized.every((s) => JSON.stringify(s) === first)
const first = JSON.stringify(serialized[0]);
const allSame = serialized.every((s) => JSON.stringify(s) === first);
if (allSame) {
return YAML.stringify(serialized[0])
return YAML.stringify(serialized[0]);
}
}
return YAML.stringify(serialized)
return YAML.stringify(serialized);
} else {
const serialized = schema.serialize(result.data)
return YAML.stringify(serialized)
const serialized = schema.serialize(result.data);
return YAML.stringify(serialized);
}
}
return YAML.stringify(result.data)
return YAML.stringify(result.data);
}
/** Render a single item as YAML document (for streaming) */
export function renderYamlDoc<T>(item: T, serialize?: (data: T) => unknown): string {
const output = serialize ? serialize(item) : item
return YAML.stringify(output)
const output = serialize ? serialize(item) : item;
return YAML.stringify(output);
}
+2 -4
View File
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
const CLIENT_SESSION_KEY_FILE = join(
process.env.PASEO_HOME ?? join(homedir(), ".paseo"),
"cli-client-id"
"cli-client-id",
);
let cachedClientId: string | null = null;
@@ -25,9 +25,7 @@ export async function getOrCreateCliClientId(): Promise<string> {
}
try {
const existing = normalizeClientId(
await readFile(CLIENT_SESSION_KEY_FILE, "utf8")
);
const existing = normalizeClientId(await readFile(CLIENT_SESSION_KEY_FILE, "utf8"));
if (existing) {
cachedClientId = existing;
return existing;
+134 -134
View File
@@ -1,186 +1,188 @@
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'
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";
export interface ConnectOptions {
host?: string
timeout?: number
host?: string;
timeout?: number;
}
const DEFAULT_HOST = 'localhost:6767'
const DEFAULT_TIMEOUT = 5000
const PID_FILENAME = 'paseo.pid'
const DEFAULT_HOST = "localhost:6767";
const DEFAULT_TIMEOUT = 5000;
const PID_FILENAME = "paseo.pid";
type DaemonTarget =
| {
type: 'tcp'
url: string
type: "tcp";
url: string;
}
| {
type: 'ipc'
url: string
socketPath: string
}
type: "ipc";
url: string;
socketPath: string;
};
/**
* Get the daemon host from environment or options
*/
export function getDaemonHost(options?: ConnectOptions): string {
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST
return resolveDaemonHostCandidates(options)[0] ?? DEFAULT_HOST;
}
export function normalizeDaemonHost(raw: string): string | null {
const trimmed = raw.trim()
const trimmed = raw.trim();
if (!trimmed) {
return null
return null;
}
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
trimmed.startsWith("\\\\.\\pipe\\")
) {
return trimmed.startsWith('\\\\.\\pipe\\') ? `pipe://${trimmed}` : trimmed
return trimmed.startsWith("\\\\.\\pipe\\") ? `pipe://${trimmed}` : trimmed;
}
if (path.isAbsolute(trimmed)) {
return `unix://${trimmed}`
return `unix://${trimmed}`;
}
if (/^\d+$/.test(trimmed)) {
return `127.0.0.1:${trimmed}`
return `127.0.0.1:${trimmed}`;
}
return trimmed.includes(':') ? trimmed : null
return trimmed.includes(":") ? trimmed : null;
}
export function resolveDefaultDaemonHost(env: NodeJS.ProcessEnv = process.env): string {
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST
return resolveDefaultDaemonHosts(env)[0] ?? DEFAULT_HOST;
}
function isIpcDaemonHost(host: string | null): host is string {
return host !== null && (host.startsWith('unix://') || host.startsWith('pipe://'))
return host !== null && (host.startsWith("unix://") || host.startsWith("pipe://"));
}
function isTcpDaemonHost(host: string | null): host is string {
return host !== null && !isIpcDaemonHost(host)
return host !== null && !isIpcDaemonHost(host);
}
function readPidSocketTarget(paseoHome: string): string | null {
const pidPath = path.join(paseoHome, PID_FILENAME)
const pidPath = path.join(paseoHome, PID_FILENAME);
if (!existsSync(pidPath)) {
return null
return null;
}
try {
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8')) as { listen?: unknown; sockPath?: unknown }
return typeof parsed.listen === 'string' ? parsed.listen : typeof parsed.sockPath === 'string' ? parsed.sockPath : null
const parsed = JSON.parse(readFileSync(pidPath, "utf-8")) as {
listen?: unknown;
sockPath?: unknown;
};
return typeof parsed.listen === "string"
? parsed.listen
: typeof parsed.sockPath === "string"
? parsed.sockPath
: null;
} catch {
return null
return null;
}
}
function resolveConfiguredIpcDaemonHost(env: NodeJS.ProcessEnv, paseoHome: string): string | null {
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? '')
const directEnvHost = normalizeDaemonHost(env.PASEO_LISTEN ?? "");
if (isIpcDaemonHost(directEnvHost)) {
return directEnvHost
return directEnvHost;
}
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? '')
const pidHost = normalizeDaemonHost(readPidSocketTarget(paseoHome) ?? "");
if (isIpcDaemonHost(pidHost)) {
return pidHost
return pidHost;
}
const config = loadConfig(paseoHome, { env })
const configuredHost = normalizeDaemonHost(config.listen)
return isIpcDaemonHost(configuredHost) ? configuredHost : null
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)
const configuredHost = normalizeDaemonHost(loadConfig(paseoHome, { env }).listen);
if (!isTcpDaemonHost(configuredHost)) {
return null
return null;
}
return configuredHost === '127.0.0.1:6767' ? null : configuredHost
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)
const paseoHome = resolvePaseoHome(env);
const candidates: string[] = [];
const configuredIpcHost = resolveConfiguredIpcDaemonHost(env, paseoHome);
if (configuredIpcHost) {
candidates.push(configuredIpcHost)
candidates.push(configuredIpcHost);
}
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome)
const configuredTcpHost = resolveConfiguredTcpDaemonHost(env, paseoHome);
if (configuredTcpHost) {
candidates.push(configuredTcpHost)
candidates.push(configuredTcpHost);
}
candidates.push(DEFAULT_HOST)
return Array.from(new Set(candidates))
candidates.push(DEFAULT_HOST);
return Array.from(new Set(candidates));
}
function resolveDaemonHostCandidates(options?: ConnectOptions): string[] {
const explicitHost = options?.host ?? process.env.PASEO_HOST
const explicitHost = options?.host ?? process.env.PASEO_HOST;
if (explicitHost) {
return [explicitHost]
return [explicitHost];
}
return resolveDefaultDaemonHosts()
return resolveDefaultDaemonHosts();
}
export function resolveDaemonTarget(host: string): DaemonTarget {
const trimmed = host.trim()
const trimmed = host.trim();
if (
trimmed.startsWith('unix://') ||
trimmed.startsWith('pipe://') ||
trimmed.startsWith('\\\\.\\pipe\\')
trimmed.startsWith("unix://") ||
trimmed.startsWith("pipe://") ||
trimmed.startsWith("\\\\.\\pipe\\")
) {
const socketPath = trimmed.startsWith('unix://')
? trimmed.slice('unix://'.length).trim()
: trimmed.startsWith('pipe://')
? trimmed.slice('pipe://'.length).trim()
: trimmed
const socketPath = trimmed.startsWith("unix://")
? trimmed.slice("unix://".length).trim()
: trimmed.startsWith("pipe://")
? trimmed.slice("pipe://".length).trim()
: trimmed;
if (!socketPath) {
throw new Error('Invalid IPC daemon target: missing socket path')
throw new Error("Invalid IPC daemon target: missing socket path");
}
const isUnixSocket = trimmed.startsWith('unix://')
const isUnixSocket = trimmed.startsWith("unix://");
return {
type: 'ipc',
url: isUnixSocket
? `ws+unix://${socketPath}:/ws`
: 'ws://localhost/ws',
type: "ipc",
url: isUnixSocket ? `ws+unix://${socketPath}:/ws` : "ws://localhost/ws",
socketPath,
}
};
}
return {
type: 'tcp',
type: "tcp",
url: `ws://${trimmed}/ws`,
}
};
}
/**
* Create a WebSocket factory that works in Node.js
*/
function createNodeWebSocketFactory() {
return (
url: string,
options?: { headers?: Record<string, string>; socketPath?: string }
) => {
return (url: string, options?: { headers?: Record<string, string>; socketPath?: string }) => {
return new WebSocket(url, {
headers: options?.headers,
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
}) as unknown as {
readyState: number
send: (data: string | Uint8Array | ArrayBuffer) => void
close: (code?: number, reason?: string) => void
binaryType?: string
on: (event: string, listener: (...args: unknown[]) => void) => void
off: (event: string, listener: (...args: unknown[]) => void) => void
}
}
readyState: number;
send: (data: string | Uint8Array | ArrayBuffer) => void;
close: (code?: number, reason?: string) => void;
binaryType?: string;
on: (event: string, listener: (...args: unknown[]) => void) => void;
off: (event: string, listener: (...args: unknown[]) => void) => void;
};
};
}
/**
@@ -188,56 +190,54 @@ function createNodeWebSocketFactory() {
* Returns the connected client or throws if connection fails
*/
export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonClient> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const clientId = await getOrCreateCliClientId()
const hosts = resolveDaemonHostCandidates(options)
const nodeWebSocketFactory = createNodeWebSocketFactory()
let lastError: unknown = null
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
const clientId = await getOrCreateCliClientId();
const hosts = resolveDaemonHostCandidates(options);
const nodeWebSocketFactory = createNodeWebSocketFactory();
let lastError: unknown = null;
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]
)
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]);
const connectPromise = client.connect()
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
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)
})
reject(new Error(`Connection timeout after ${timeout}ms`));
}, timeout);
});
try {
await Promise.race([connectPromise, timeoutPromise])
await Promise.race([connectPromise, timeoutPromise]);
if (timeoutHandle) {
clearTimeout(timeoutHandle)
clearTimeout(timeoutHandle);
}
return client
return client;
} catch (err) {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
clearTimeout(timeoutHandle);
}
lastError = err
await client.close().catch(() => {})
lastError = err;
await client.close().catch(() => {});
}
}
if (lastError instanceof Error) {
throw lastError
throw lastError;
}
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(', ')}`)
throw new Error(`Unable to connect to Paseo daemon via ${hosts.join(", ")}`);
}
/**
@@ -245,16 +245,16 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
*/
export async function tryConnectToDaemon(options?: ConnectOptions): Promise<DaemonClient | null> {
try {
return await connectToDaemon(options)
return await connectToDaemon(options);
} catch {
return null
return null;
}
}
/** Minimal agent type for ID resolution */
interface AgentLike {
id: string
title?: string | null
id: string;
title?: string | null;
}
/**
@@ -268,40 +268,40 @@ interface AgentLike {
*/
export function resolveAgentId(idOrName: string, agents: AgentLike[]): string | null {
if (!idOrName || agents.length === 0) {
return null
return null;
}
const query = idOrName.toLowerCase()
const query = idOrName.toLowerCase();
// Try exact ID match first
const exactMatch = agents.find((a) => a.id === idOrName)
const exactMatch = agents.find((a) => a.id === idOrName);
if (exactMatch) {
return exactMatch.id
return exactMatch.id;
}
// Try ID prefix match
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query))
const prefixMatches = agents.filter((a) => a.id.toLowerCase().startsWith(query));
if (prefixMatches.length === 1 && prefixMatches[0]) {
return prefixMatches[0].id
return prefixMatches[0].id;
}
// Try title/name match (case-insensitive)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query)
const titleMatches = agents.filter((a) => a.title?.toLowerCase() === query);
if (titleMatches.length === 1 && titleMatches[0]) {
return titleMatches[0].id
return titleMatches[0].id;
}
// Try partial title match
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query))
const partialTitleMatches = agents.filter((a) => a.title?.toLowerCase().includes(query));
if (partialTitleMatches.length === 1 && partialTitleMatches[0]) {
return partialTitleMatches[0].id
return partialTitleMatches[0].id;
}
// If we have multiple prefix matches and no unique title match, return first prefix match
const firstPrefixMatch = prefixMatches[0]
const firstPrefixMatch = prefixMatches[0];
if (firstPrefixMatch) {
return firstPrefixMatch.id
return firstPrefixMatch.id;
}
return null
return null;
}
+9 -9
View File
@@ -1,23 +1,23 @@
import type { Command } from 'commander'
import type { Command } from "commander";
const JSON_OPTION_DESCRIPTION = 'Output in JSON format'
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
const DAEMON_HOST_OPTION_DESCRIPTION =
'Daemon host target (default: local socket/pipe, then localhost:6767)'
"Daemon host target (default: local socket/pipe, then localhost:6767)";
export function collectMultiple(value: string, previous: string[]): string[] {
return previous.concat([value])
return previous.concat([value]);
}
export function addJsonOption<T extends Command>(command: T): T {
command.option('--json', JSON_OPTION_DESCRIPTION)
return command
command.option("--json", JSON_OPTION_DESCRIPTION);
return command;
}
export function addDaemonHostOption<T extends Command>(command: T): T {
command.option('--host <host>', DAEMON_HOST_OPTION_DESCRIPTION)
return command
command.option("--host <host>", DAEMON_HOST_OPTION_DESCRIPTION);
return command;
}
export function addJsonAndDaemonHostOptions<T extends Command>(command: T): T {
return addDaemonHostOption(addJsonOption(command))
return addDaemonHostOption(addJsonOption(command));
}
+20 -20
View File
@@ -4,40 +4,40 @@
* If no unit is specified, assumes seconds.
*/
export function parseDuration(input: string): number {
const trimmed = input.trim()
const trimmed = input.trim();
// If it's just a number, treat as seconds
if (/^\d+$/.test(trimmed)) {
return parseInt(trimmed, 10) * 1000
return parseInt(trimmed, 10) * 1000;
}
// Parse duration with units
let totalMs = 0
const regex = /(\d+)([smh])/g
let match
let hasMatch = false
let totalMs = 0;
const regex = /(\d+)([smh])/g;
let match;
let hasMatch = false;
while ((match = regex.exec(trimmed)) !== null) {
hasMatch = true
const value = parseInt(match[1], 10)
const unit = match[2]
hasMatch = true;
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's':
totalMs += value * 1000
break
case 'm':
totalMs += value * 60 * 1000
break
case 'h':
totalMs += value * 60 * 60 * 1000
break
case "s":
totalMs += value * 1000;
break;
case "m":
totalMs += value * 60 * 1000;
break;
case "h":
totalMs += value * 60 * 60 * 1000;
break;
}
}
if (!hasMatch) {
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`)
throw new Error(`Invalid duration format: ${input}. Use formats like: 5m, 30s, 1h, 2h30m`);
}
return totalMs
return totalMs;
}
+2 -2
View File
@@ -3,8 +3,8 @@
*/
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message
return error.message;
}
return String(error)
return String(error);
}
+9 -9
View File
@@ -1,17 +1,17 @@
import type { AgentTimelineItem, DaemonClient } from '@getpaseo/server'
import type { AgentTimelineItem, DaemonClient } from "@getpaseo/server";
type FetchProjectedTimelineItemsInput = {
client: DaemonClient
agentId: string
}
client: DaemonClient;
agentId: string;
};
export async function fetchProjectedTimelineItems(
input: FetchProjectedTimelineItemsInput
input: FetchProjectedTimelineItemsInput,
): Promise<AgentTimelineItem[]> {
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
direction: 'tail',
direction: "tail",
limit: 0,
projection: 'projected',
})
return timeline.entries.map((entry) => entry.item)
projection: "projected",
});
return timeline.entries.map((entry) => entry.item);
}
+26 -26
View File
@@ -8,46 +8,46 @@
* - paseo --help shows commands
*/
import { $ } from 'zx'
import { $ } from "zx";
$.verbose = false
$.verbose = false;
console.log('📋 Phase 1: Foundation Tests\n')
console.log("📋 Phase 1: Foundation Tests\n");
// Test 1.1: --version outputs version
console.log(' Testing paseo --version...')
const versionResult = await $`paseo --version`.nothrow()
console.log(" Testing paseo --version...");
const versionResult = await $`paseo --version`.nothrow();
if (versionResult.exitCode !== 0) {
console.error(' ❌ paseo --version failed with exit code', versionResult.exitCode)
console.error(' stderr:', versionResult.stderr)
process.exit(1)
console.error(" ❌ paseo --version failed with exit code", versionResult.exitCode);
console.error(" stderr:", versionResult.stderr);
process.exit(1);
}
const versionOutput = versionResult.stdout.trim()
const versionOutput = versionResult.stdout.trim();
if (!versionOutput.match(/\d+\.\d+\.\d+/)) {
console.error(' ❌ paseo --version output does not contain version number')
console.error(' output:', versionOutput)
process.exit(1)
console.error(" ❌ paseo --version output does not contain version number");
console.error(" output:", versionOutput);
process.exit(1);
}
console.log(' ✅ paseo --version outputs:', versionOutput)
console.log(" ✅ paseo --version outputs:", versionOutput);
// Test 1.2: --help shows commands
console.log(' Testing paseo --help...')
const helpResult = await $`paseo --help`.nothrow()
console.log(" Testing paseo --help...");
const helpResult = await $`paseo --help`.nothrow();
if (helpResult.exitCode !== 0) {
console.error(' ❌ paseo --help failed with exit code', helpResult.exitCode)
console.error(' stderr:', helpResult.stderr)
process.exit(1)
console.error(" ❌ paseo --help failed with exit code", helpResult.exitCode);
console.error(" stderr:", helpResult.stderr);
process.exit(1);
}
const helpOutput = helpResult.stdout
const helpOutput = helpResult.stdout;
// Check for expected sections in help output
const expectedTerms = ['agent', 'daemon', 'Usage', 'Options', 'Commands']
const missingTerms = expectedTerms.filter(term => !helpOutput.includes(term))
const expectedTerms = ["agent", "daemon", "Usage", "Options", "Commands"];
const missingTerms = expectedTerms.filter((term) => !helpOutput.includes(term));
if (missingTerms.length > 0) {
console.error(' ❌ paseo --help missing expected terms:', missingTerms.join(', '))
console.error(' output:', helpOutput)
process.exit(1)
console.error(" ❌ paseo --help missing expected terms:", missingTerms.join(", "));
console.error(" output:", helpOutput);
process.exit(1);
}
console.log(' ✅ paseo --help shows commands')
console.log(" ✅ paseo --help shows commands");
console.log('\n✅ Phase 1: Foundation Tests PASSED')
console.log("\n✅ Phase 1: Foundation Tests PASSED");
+172 -172
View File
@@ -5,7 +5,7 @@
* Verifies that renderers correctly format structured data.
*/
import assert from 'node:assert'
import assert from "node:assert";
import {
render,
renderTable,
@@ -19,261 +19,261 @@ import {
type SingleResult,
type OutputSchema,
type CommandError,
} from '../src/output/index.js'
} from "../src/output/index.js";
// Test data types
interface Agent {
id: string
title: string
status: 'running' | 'idle' | 'error'
provider: string
id: string;
title: string;
status: "running" | "idle" | "error";
provider: string;
}
// Schema for agents
const agentSchema: OutputSchema<Agent> = {
idField: 'id',
idField: "id",
columns: [
{ header: 'ID', field: 'id', width: 8 },
{ header: 'TITLE', field: 'title', width: 20 },
{ header: "ID", field: "id", width: 8 },
{ header: "TITLE", field: "title", width: 20 },
{
header: 'STATUS',
field: 'status',
header: "STATUS",
field: "status",
color: (value) => {
switch (value) {
case 'running':
return 'green'
case 'idle':
return 'dim'
case 'error':
return 'red'
case "running":
return "green";
case "idle":
return "dim";
case "error":
return "red";
default:
return undefined
return undefined;
}
},
},
{ header: 'PROVIDER', field: 'provider' },
{ header: "PROVIDER", field: "provider" },
],
}
};
// Test data
const testAgents: Agent[] = [
{ id: 'abc123', title: 'Feature Implementation', status: 'running', provider: 'claude' },
{ id: 'def456', title: 'Bug Fix', status: 'idle', provider: 'codex' },
{ id: 'ghi789', title: 'Failed Task', status: 'error', provider: 'claude' },
]
{ id: "abc123", title: "Feature Implementation", status: "running", provider: "claude" },
{ id: "def456", title: "Bug Fix", status: "idle", provider: "codex" },
{ id: "ghi789", title: "Failed Task", status: "error", provider: "claude" },
];
const listResult: ListResult<Agent> = {
type: 'list',
type: "list",
data: testAgents,
schema: agentSchema,
}
};
const singleResult: SingleResult<Agent> = {
type: 'single',
type: "single",
data: testAgents[0],
schema: agentSchema,
}
};
// Test utilities
let passed = 0
let failed = 0
let passed = 0;
let failed = 0;
function test(name: string, fn: () => void): void {
try {
fn()
console.log(`✓ ${name}`)
passed++
fn();
console.log(`✓ ${name}`);
passed++;
} catch (error) {
console.error(`✗ ${name}`)
console.error(` ${error instanceof Error ? error.message : error}`)
failed++
console.error(`✗ ${name}`);
console.error(` ${error instanceof Error ? error.message : error}`);
failed++;
}
}
// Table renderer tests
console.log('\n=== Table Renderer ===\n')
console.log("\n=== Table Renderer ===\n");
test('renderTable formats list data with headers', () => {
const output = renderTable(listResult, createOutputOptions({ noColor: true }))
assert.ok(output.includes('ID'), 'Should include ID header')
assert.ok(output.includes('TITLE'), 'Should include TITLE header')
assert.ok(output.includes('STATUS'), 'Should include STATUS header')
assert.ok(output.includes('abc123'), 'Should include first agent ID')
assert.ok(output.includes('Feature Implementation'), 'Should include first agent title')
})
test("renderTable formats list data with headers", () => {
const output = renderTable(listResult, createOutputOptions({ noColor: true }));
assert.ok(output.includes("ID"), "Should include ID header");
assert.ok(output.includes("TITLE"), "Should include TITLE header");
assert.ok(output.includes("STATUS"), "Should include STATUS header");
assert.ok(output.includes("abc123"), "Should include first agent ID");
assert.ok(output.includes("Feature Implementation"), "Should include first agent title");
});
test('renderTable omits headers when noHeaders is true', () => {
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }))
assert.ok(!output.includes('ID '), 'Should not include header row')
assert.ok(output.includes('abc123'), 'Should still include data')
})
test("renderTable omits headers when noHeaders is true", () => {
const output = renderTable(listResult, createOutputOptions({ noHeaders: true, noColor: true }));
assert.ok(!output.includes("ID "), "Should not include header row");
assert.ok(output.includes("abc123"), "Should still include data");
});
test('renderTable handles empty data', () => {
test("renderTable handles empty data", () => {
const emptyResult: ListResult<Agent> = {
type: 'list',
type: "list",
data: [],
schema: agentSchema,
}
const output = renderTable(emptyResult, createOutputOptions())
assert.strictEqual(output, '', 'Should return empty string for empty data')
})
};
const output = renderTable(emptyResult, createOutputOptions());
assert.strictEqual(output, "", "Should return empty string for empty data");
});
test('renderTable handles single result', () => {
const output = renderTable(singleResult, createOutputOptions({ noColor: true }))
assert.ok(output.includes('abc123'), 'Should include agent ID')
assert.ok(output.includes('Feature Implementation'), 'Should include agent title')
})
test("renderTable handles single result", () => {
const output = renderTable(singleResult, createOutputOptions({ noColor: true }));
assert.ok(output.includes("abc123"), "Should include agent ID");
assert.ok(output.includes("Feature Implementation"), "Should include agent title");
});
// JSON renderer tests
console.log('\n=== JSON Renderer ===\n')
console.log("\n=== JSON Renderer ===\n");
test('renderJson outputs valid JSON for list', () => {
const output = renderJson(listResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.ok(Array.isArray(parsed), 'Should be an array')
assert.strictEqual(parsed.length, 3, 'Should have 3 items')
assert.strictEqual(parsed[0].id, 'abc123', 'Should include correct data')
})
test("renderJson outputs valid JSON for list", () => {
const output = renderJson(listResult, createOutputOptions());
const parsed = JSON.parse(output);
assert.ok(Array.isArray(parsed), "Should be an array");
assert.strictEqual(parsed.length, 3, "Should have 3 items");
assert.strictEqual(parsed[0].id, "abc123", "Should include correct data");
});
test('renderJson outputs valid JSON for single item', () => {
const output = renderJson(singleResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.strictEqual(parsed.id, 'abc123', 'Should include correct data')
assert.strictEqual(parsed.title, 'Feature Implementation', 'Should include correct title')
})
test("renderJson outputs valid JSON for single item", () => {
const output = renderJson(singleResult, createOutputOptions());
const parsed = JSON.parse(output);
assert.strictEqual(parsed.id, "abc123", "Should include correct data");
assert.strictEqual(parsed.title, "Feature Implementation", "Should include correct title");
});
test('renderJson uses custom serializer when provided', () => {
test("renderJson uses custom serializer when provided", () => {
const customSchema: OutputSchema<Agent> = {
...agentSchema,
serialize: (agent) => ({ agentId: agent.id, name: agent.title }),
}
};
const customResult: SingleResult<Agent> = {
type: 'single',
type: "single",
data: testAgents[0],
schema: customSchema,
}
const output = renderJson(customResult, createOutputOptions())
const parsed = JSON.parse(output)
assert.ok('agentId' in parsed, 'Should use custom serialization')
assert.ok('name' in parsed, 'Should use custom field names')
})
};
const output = renderJson(customResult, createOutputOptions());
const parsed = JSON.parse(output);
assert.ok("agentId" in parsed, "Should use custom serialization");
assert.ok("name" in parsed, "Should use custom field names");
});
// YAML renderer tests
console.log('\n=== YAML Renderer ===\n')
console.log("\n=== YAML Renderer ===\n");
test('renderYaml outputs valid YAML for list', () => {
const output = renderYaml(listResult, createOutputOptions())
assert.ok(output.includes('- id: abc123'), 'Should format as YAML list')
assert.ok(output.includes('title: Feature Implementation'), 'Should include title')
})
test("renderYaml outputs valid YAML for list", () => {
const output = renderYaml(listResult, createOutputOptions());
assert.ok(output.includes("- id: abc123"), "Should format as YAML list");
assert.ok(output.includes("title: Feature Implementation"), "Should include title");
});
test('renderYaml outputs valid YAML for single item', () => {
const output = renderYaml(singleResult, createOutputOptions())
assert.ok(output.includes('id: abc123'), 'Should include ID')
assert.ok(!output.startsWith('-'), 'Should not be a list')
})
test("renderYaml outputs valid YAML for single item", () => {
const output = renderYaml(singleResult, createOutputOptions());
assert.ok(output.includes("id: abc123"), "Should include ID");
assert.ok(!output.startsWith("-"), "Should not be a list");
});
// Quiet renderer tests
console.log('\n=== Quiet Renderer ===\n')
console.log("\n=== Quiet Renderer ===\n");
test('renderQuiet outputs only IDs for list', () => {
const output = renderQuiet(listResult, createOutputOptions())
const lines = output.split('\n')
assert.strictEqual(lines.length, 3, 'Should have 3 lines')
assert.strictEqual(lines[0], 'abc123', 'First line should be first ID')
assert.strictEqual(lines[1], 'def456', 'Second line should be second ID')
})
test("renderQuiet outputs only IDs for list", () => {
const output = renderQuiet(listResult, createOutputOptions());
const lines = output.split("\n");
assert.strictEqual(lines.length, 3, "Should have 3 lines");
assert.strictEqual(lines[0], "abc123", "First line should be first ID");
assert.strictEqual(lines[1], "def456", "Second line should be second ID");
});
test('renderQuiet outputs only ID for single item', () => {
const output = renderQuiet(singleResult, createOutputOptions())
assert.strictEqual(output, 'abc123', 'Should output only the ID')
})
test("renderQuiet outputs only ID for single item", () => {
const output = renderQuiet(singleResult, createOutputOptions());
assert.strictEqual(output, "abc123", "Should output only the ID");
});
test('renderQuiet supports function idField', () => {
test("renderQuiet supports function idField", () => {
const customSchema: OutputSchema<Agent> = {
...agentSchema,
idField: (agent) => `${agent.provider}/${agent.id}`,
}
};
const customResult: SingleResult<Agent> = {
type: 'single',
type: "single",
data: testAgents[0],
schema: customSchema,
}
const output = renderQuiet(customResult, createOutputOptions())
assert.strictEqual(output, 'claude/abc123', 'Should use function to extract ID')
})
};
const output = renderQuiet(customResult, createOutputOptions());
assert.strictEqual(output, "claude/abc123", "Should use function to extract ID");
});
// Main render dispatcher tests
console.log('\n=== Render Dispatcher ===\n')
console.log("\n=== Render Dispatcher ===\n");
test('render uses table format by default', () => {
const output = render(listResult, { noColor: true })
assert.ok(output.includes('ID'), 'Should use table format with headers')
})
test("render uses table format by default", () => {
const output = render(listResult, { noColor: true });
assert.ok(output.includes("ID"), "Should use table format with headers");
});
test('render uses json format when specified', () => {
const output = render(listResult, { format: 'json' })
const parsed = JSON.parse(output)
assert.ok(Array.isArray(parsed), 'Should be valid JSON array')
})
test("render uses json format when specified", () => {
const output = render(listResult, { format: "json" });
const parsed = JSON.parse(output);
assert.ok(Array.isArray(parsed), "Should be valid JSON array");
});
test('render uses yaml format when specified', () => {
const output = render(listResult, { format: 'yaml' })
assert.ok(output.includes('- id:'), 'Should be valid YAML')
})
test("render uses yaml format when specified", () => {
const output = render(listResult, { format: "yaml" });
assert.ok(output.includes("- id:"), "Should be valid YAML");
});
test('render uses quiet mode when quiet is true', () => {
const output = render(listResult, { quiet: true })
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Should output only IDs')
})
test("render uses quiet mode when quiet is true", () => {
const output = render(listResult, { quiet: true });
assert.strictEqual(output, "abc123\ndef456\nghi789", "Should output only IDs");
});
test('quiet mode takes precedence over format', () => {
const output = render(listResult, { format: 'json', quiet: true })
assert.strictEqual(output, 'abc123\ndef456\nghi789', 'Quiet should override format')
})
test("quiet mode takes precedence over format", () => {
const output = render(listResult, { format: "json", quiet: true });
assert.strictEqual(output, "abc123\ndef456\nghi789", "Quiet should override format");
});
// Error rendering tests
console.log('\n=== Error Rendering ===\n')
console.log("\n=== Error Rendering ===\n");
test('renderError formats error for table format', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { noColor: true })
assert.ok(output.includes('Error:'), 'Should include Error prefix')
assert.ok(output.includes('Agent not found'), 'Should include message')
})
test("renderError formats error for table format", () => {
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
const output = renderError(error, { noColor: true });
assert.ok(output.includes("Error:"), "Should include Error prefix");
assert.ok(output.includes("Agent not found"), "Should include message");
});
test('renderError formats error as JSON', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { format: 'json' })
const parsed = JSON.parse(output)
assert.ok('error' in parsed, 'Should have error property')
assert.strictEqual(parsed.error.code, 'NOT_FOUND', 'Should include error code')
})
test("renderError formats error as JSON", () => {
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
const output = renderError(error, { format: "json" });
const parsed = JSON.parse(output);
assert.ok("error" in parsed, "Should have error property");
assert.strictEqual(parsed.error.code, "NOT_FOUND", "Should include error code");
});
test('renderError formats error as YAML', () => {
const error: CommandError = { code: 'NOT_FOUND', message: 'Agent not found' }
const output = renderError(error, { format: 'yaml' })
assert.ok(output.includes('error:'), 'Should have error key')
assert.ok(output.includes('code: NOT_FOUND'), 'Should include error code')
})
test("renderError formats error as YAML", () => {
const error: CommandError = { code: "NOT_FOUND", message: "Agent not found" };
const output = renderError(error, { format: "yaml" });
assert.ok(output.includes("error:"), "Should have error key");
assert.ok(output.includes("code: NOT_FOUND"), "Should include error code");
});
test('toCommandError converts Error to CommandError', () => {
const error = new Error('Something went wrong')
const commandError = toCommandError(error)
assert.strictEqual(commandError.code, 'UNKNOWN_ERROR', 'Should use UNKNOWN_ERROR code')
assert.strictEqual(commandError.message, 'Something went wrong', 'Should preserve message')
})
test("toCommandError converts Error to CommandError", () => {
const error = new Error("Something went wrong");
const commandError = toCommandError(error);
assert.strictEqual(commandError.code, "UNKNOWN_ERROR", "Should use UNKNOWN_ERROR code");
assert.strictEqual(commandError.message, "Something went wrong", "Should preserve message");
});
test('toCommandError passes through CommandError', () => {
const error: CommandError = { code: 'CUSTOM', message: 'Custom error' }
const commandError = toCommandError(error)
assert.strictEqual(commandError.code, 'CUSTOM', 'Should preserve code')
assert.strictEqual(commandError.message, 'Custom error', 'Should preserve message')
})
test("toCommandError passes through CommandError", () => {
const error: CommandError = { code: "CUSTOM", message: "Custom error" };
const commandError = toCommandError(error);
assert.strictEqual(commandError.code, "CUSTOM", "Should preserve code");
assert.strictEqual(commandError.message, "Custom error", "Should preserve message");
});
// Summary
console.log('\n=== Summary ===\n')
console.log(`Passed: ${passed}`)
console.log(`Failed: ${failed}`)
console.log("\n=== Summary ===\n");
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0)
process.exit(failed > 0 ? 1 : 0);
+70 -66
View File
@@ -14,112 +14,116 @@
* - daemon restart starts the daemon and can be cleaned up
*/
import assert from 'node:assert'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { runLocalPaseo } from './helpers/local-cli.ts'
import assert from "node:assert";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log('=== Daemon Commands ===\n')
console.log("=== Daemon Commands ===\n");
// Keep restart off default 6767 to avoid collisions with any existing daemon.
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
function daemonCommand(args: string[]) {
return runLocalPaseo(['daemon', ...args], { PASEO_HOME: paseoHome })
return runLocalPaseo(["daemon", ...args], { PASEO_HOME: paseoHome });
}
try {
// Test 1: daemon --help shows subcommands
{
console.log('Test 1: daemon --help shows subcommands')
const result = await runLocalPaseo(['daemon', '--help'])
assert.strictEqual(result.exitCode, 0, 'daemon --help should exit 0')
assert(result.stdout.includes('start'), 'help should mention start')
assert(result.stdout.includes('status'), 'help should mention status')
assert(result.stdout.includes('stop'), 'help should mention stop')
assert(result.stdout.includes('restart'), 'help should mention restart')
assert(result.stdout.includes('pair'), 'help should mention pair')
console.log('✓ daemon --help shows subcommands\n')
console.log("Test 1: daemon --help shows subcommands");
const result = await runLocalPaseo(["daemon", "--help"]);
assert.strictEqual(result.exitCode, 0, "daemon --help should exit 0");
assert(result.stdout.includes("start"), "help should mention start");
assert(result.stdout.includes("status"), "help should mention status");
assert(result.stdout.includes("stop"), "help should mention stop");
assert(result.stdout.includes("restart"), "help should mention restart");
assert(result.stdout.includes("pair"), "help should mention pair");
console.log("✓ daemon --help shows subcommands\n");
}
// Test 2: daemon pair works without daemon process
{
console.log('Test 2: daemon pair prints local pairing URL')
const result = await daemonCommand(['pair'])
assert.strictEqual(result.exitCode, 0, 'daemon pair should succeed')
assert(result.stdout.includes('Scan to pair:'), 'output should include scan header')
assert(result.stdout.includes('#offer='), 'output should include pairing offer fragment')
console.log('✓ daemon pair prints local pairing URL\n')
console.log("Test 2: daemon pair prints local pairing URL");
const result = await daemonCommand(["pair"]);
assert.strictEqual(result.exitCode, 0, "daemon pair should succeed");
assert(result.stdout.includes("Scan to pair:"), "output should include scan header");
assert(result.stdout.includes("#offer="), "output should include pairing offer fragment");
console.log("✓ daemon pair prints local pairing URL\n");
}
// Test 3: daemon status reports stopped when daemon not running
{
console.log('Test 3: daemon status reports stopped when not running')
const result = await daemonCommand(['status'])
assert.strictEqual(result.exitCode, 0, 'status should succeed when daemon is stopped')
const output = result.stdout.toLowerCase()
assert(output.includes('status'), 'status table should include Status row')
assert(output.includes('stopped'), 'status should report stopped')
console.log('✓ daemon status reports stopped when not running\n')
console.log("Test 3: daemon status reports stopped when not running");
const result = await daemonCommand(["status"]);
assert.strictEqual(result.exitCode, 0, "status should succeed when daemon is stopped");
const output = result.stdout.toLowerCase();
assert(output.includes("status"), "status table should include Status row");
assert(output.includes("stopped"), "status should report stopped");
console.log("✓ daemon status reports stopped when not running\n");
}
// Test 4: daemon pair --json outputs valid JSON
{
console.log('Test 4: daemon pair --json outputs JSON')
const result = await daemonCommand(['pair', '--json'])
assert.strictEqual(result.exitCode, 0, 'daemon pair --json should succeed')
const pairing = JSON.parse(result.stdout)
assert.strictEqual(pairing.relayEnabled, true, 'pairing should report relay enabled')
assert.match(pairing.url, /#offer=/, 'pairing URL should include offer fragment')
assert.strictEqual(typeof pairing.qr, 'string', 'pairing should include QR content')
console.log('✓ daemon pair --json outputs valid JSON\n')
console.log("Test 4: daemon pair --json outputs JSON");
const result = await daemonCommand(["pair", "--json"]);
assert.strictEqual(result.exitCode, 0, "daemon pair --json should succeed");
const pairing = JSON.parse(result.stdout);
assert.strictEqual(pairing.relayEnabled, true, "pairing should report relay enabled");
assert.match(pairing.url, /#offer=/, "pairing URL should include offer fragment");
assert.strictEqual(typeof pairing.qr, "string", "pairing should include QR content");
console.log("✓ daemon pair --json outputs valid JSON\n");
}
// Test 5: daemon status --json outputs valid JSON
{
console.log('Test 5: daemon status --json outputs JSON')
const result = await daemonCommand(['status', '--json'])
assert.strictEqual(result.exitCode, 0, '--json status should succeed')
const status = JSON.parse(result.stdout)
assert.strictEqual(typeof status.serverId, 'string', 'json status should include serverId')
assert.strictEqual(status.status, 'stopped', 'json status should report stopped')
assert.strictEqual(status.home, paseoHome, 'json status should reflect the isolated home')
assert.strictEqual(status.hostname, null, 'json status should include hostname when unavailable')
console.log('✓ daemon status --json outputs valid JSON\n')
console.log("Test 5: daemon status --json outputs JSON");
const result = await daemonCommand(["status", "--json"]);
assert.strictEqual(result.exitCode, 0, "--json status should succeed");
const status = JSON.parse(result.stdout);
assert.strictEqual(typeof status.serverId, "string", "json status should include serverId");
assert.strictEqual(status.status, "stopped", "json status should report stopped");
assert.strictEqual(status.home, paseoHome, "json status should reflect the isolated home");
assert.strictEqual(
status.hostname,
null,
"json status should include hostname when unavailable",
);
console.log("✓ daemon status --json outputs valid JSON\n");
}
// Test 6: daemon stop handles daemon not running gracefully
{
console.log('Test 6: daemon stop handles daemon not running')
const result = await daemonCommand(['stop'])
console.log("Test 6: daemon stop handles daemon not running");
const result = await daemonCommand(["stop"]);
// Stop should succeed even if daemon is not running (idempotent).
assert.strictEqual(result.exitCode, 0, 'stop should succeed when daemon not running')
const output = result.stdout + result.stderr
assert.strictEqual(result.exitCode, 0, "stop should succeed when daemon not running");
const output = result.stdout + result.stderr;
const mentionsNotRunning =
output.toLowerCase().includes('not running') ||
output.toLowerCase().includes('was not running')
assert(mentionsNotRunning, 'output should mention daemon was not running')
console.log('✓ daemon stop succeeds gracefully when daemon not running\n')
output.toLowerCase().includes("not running") ||
output.toLowerCase().includes("was not running");
assert(mentionsNotRunning, "output should mention daemon was not running");
console.log("✓ daemon stop succeeds gracefully when daemon not running\n");
}
// Test 7: daemon restart starts daemon and can be stopped
{
console.log('Test 7: daemon restart starts daemon and can be stopped')
const result = await daemonCommand(['restart', '--port', String(port)])
assert.strictEqual(result.exitCode, 0, 'restart should succeed even when previously stopped')
assert(result.stdout.toLowerCase().includes('restarted'), 'output should report restart')
console.log("Test 7: daemon restart starts daemon and can be stopped");
const result = await daemonCommand(["restart", "--port", String(port)]);
assert.strictEqual(result.exitCode, 0, "restart should succeed even when previously stopped");
assert(result.stdout.toLowerCase().includes("restarted"), "output should report restart");
const cleanup = await daemonCommand(['stop', '--force'])
assert.strictEqual(cleanup.exitCode, 0, 'cleanup stop should succeed after restart')
console.log('✓ daemon restart starts and stop cleanup succeeds\n')
const cleanup = await daemonCommand(["stop", "--force"]);
assert.strictEqual(cleanup.exitCode, 0, "cleanup stop should succeed after restart");
console.log("✓ daemon restart starts and stop cleanup succeeds\n");
}
} finally {
// Best-effort daemon cleanup in case assertions fail before explicit stop.
await daemonCommand(['stop', '--force'])
await daemonCommand(["stop", "--force"]);
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All daemon tests passed ===')
console.log("=== All daemon tests passed ===");
+79 -79
View File
@@ -19,153 +19,153 @@
* - paseo ls does not support --ui
*/
import assert from 'node:assert'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { runLocalPaseo } from './helpers/local-cli.ts'
import assert from "node:assert";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
console.log('=== LS Command Tests ===\n')
console.log("=== LS Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: paseo --help shows ls command
{
console.log('Test 1: paseo --help shows ls command')
const result = await runLocalPaseo(['--help'])
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls command')
console.log('✓ paseo --help shows ls command\n')
console.log("Test 1: paseo --help shows ls command");
const result = await runLocalPaseo(["--help"]);
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls command");
console.log("✓ paseo --help shows ls command\n");
}
// Test 2: paseo ls --help shows options
{
console.log('Test 2: paseo ls --help shows options')
const result = await runLocalPaseo(['ls', '--help'])
assert.strictEqual(result.exitCode, 0, 'paseo ls --help should exit 0')
assert(result.stdout.includes('-a'), 'help should mention -a flag')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('-g'), 'help should mention -g flag')
assert(result.stdout.includes('--global'), 'help should mention --global flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(!result.stdout.includes('--ui'), 'help should not mention --ui')
console.log('✓ paseo ls --help shows options\n')
console.log("Test 2: paseo ls --help shows options");
const result = await runLocalPaseo(["ls", "--help"]);
assert.strictEqual(result.exitCode, 0, "paseo ls --help should exit 0");
assert(result.stdout.includes("-a"), "help should mention -a flag");
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("-g"), "help should mention -g flag");
assert(result.stdout.includes("--global"), "help should mention --global flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(!result.stdout.includes("--ui"), "help should not mention --ui");
console.log("✓ paseo ls --help shows options\n");
}
// Test 3: paseo ls returns error when no daemon running
{
console.log('Test 3: paseo ls handles daemon not running')
const result = await runLocalPaseo(['ls'], {
console.log("Test 3: paseo ls handles daemon not running");
const result = await runLocalPaseo(["ls"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
});
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ paseo ls handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ paseo ls handles daemon not running\n");
}
// Test 4: paseo ls --json returns valid JSON error
{
console.log('Test 4: paseo ls --json handles errors')
const result = await runLocalPaseo(['ls', '--json'], {
console.log("Test 4: paseo ls --json handles errors");
const result = await runLocalPaseo(["ls", "--json"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
});
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
// But output should be valid JSON if present
const output = result.stdout.trim()
const output = result.stdout.trim();
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ paseo ls --json outputs valid JSON error\n')
JSON.parse(output);
console.log("✓ paseo ls --json outputs valid JSON error\n");
} catch {
// Empty or stderr-only output is acceptable
console.log('✓ paseo ls --json handled error (output may be in stderr)\n')
console.log("✓ paseo ls --json handled error (output may be in stderr)\n");
}
} else {
console.log('✓ paseo ls --json handled error gracefully\n')
console.log("✓ paseo ls --json handled error gracefully\n");
}
}
// Test 5: paseo ls -a flag is accepted
{
console.log('Test 5: paseo ls -a flag is accepted')
const result = await runLocalPaseo(['ls', '-a'], {
console.log("Test 5: paseo ls -a flag is accepted");
const result = await runLocalPaseo(["ls", "-a"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
});
// Will fail due to no daemon, but flag should be parsed without error
// (no "unknown option" error)
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -a flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -a flag is accepted\n')
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -a flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ paseo ls -a flag is accepted\n");
}
// Test 6: paseo ls -g flag is accepted
{
console.log('Test 6: paseo ls -g flag is accepted')
const result = await runLocalPaseo(['ls', '-g'], {
console.log("Test 6: paseo ls -g flag is accepted");
const result = await runLocalPaseo(["ls", "-g"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -g flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -g flag is accepted\n')
});
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -g flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ paseo ls -g flag is accepted\n");
}
// Test 7: paseo ls -ag combined flags are accepted
{
console.log('Test 7: paseo ls -ag combined flags are accepted')
const result = await runLocalPaseo(['ls', '-ag'], {
console.log("Test 7: paseo ls -ag combined flags are accepted");
const result = await runLocalPaseo(["ls", "-ag"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -ag flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ paseo ls -ag combined flags are accepted\n')
});
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -ag flags");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ paseo ls -ag combined flags are accepted\n");
}
// Test 8: -q (quiet) flag is accepted globally
{
console.log('Test 8: -q (quiet) flag is accepted')
const result = await runLocalPaseo(['-q', 'ls'], {
console.log("Test 8: -q (quiet) flag is accepted");
const result = await runLocalPaseo(["-q", "ls"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted\n')
});
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted\n");
}
// Test 9: paseo ls --ui is rejected (flag removed)
{
console.log('Test 9: paseo ls --ui is rejected')
const result = await runLocalPaseo(['ls', '--ui'], {
console.log("Test 9: paseo ls --ui is rejected");
const result = await runLocalPaseo(["ls", "--ui"], {
PASEO_HOST: `localhost:${port}`,
PASEO_HOME: paseoHome,
})
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
const output = result.stdout + result.stderr
assert(output.includes('unknown option'), 'should report unknown option for --ui')
console.log('✓ paseo ls --ui is rejected\n')
});
assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag");
const output = result.stdout + result.stderr;
assert(output.includes("unknown option"), "should report unknown option for --ui");
console.log("✓ paseo ls --ui is rejected\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All ls tests passed ===')
console.log("=== All ls tests passed ===");
+131 -128
View File
@@ -20,229 +20,232 @@
* - run --cwd flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Run Command Tests ===\n')
console.log("=== Run Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const schemaPath = join(paseoHome, 'run-output-schema.json')
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
const schemaPath = join(paseoHome, "run-output-schema.json");
await writeFile(
schemaPath,
JSON.stringify({
type: 'object',
type: "object",
properties: {
status: { type: 'string' },
status: { type: "string" },
},
required: ['status'],
required: ["status"],
additionalProperties: false,
})
)
}),
);
try {
// Test 1: run --help shows options
{
console.log('Test 1: run --help shows options')
const result = await $`npx paseo run --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'run --help should exit 0')
assert(result.stdout.includes('-d'), 'help should mention -d flag')
assert(result.stdout.includes('--detach'), 'help should mention --detach flag')
assert(result.stdout.includes('--name'), 'help should mention --name option')
assert(result.stdout.includes('--provider'), 'help should mention --provider option')
assert(result.stdout.includes('--mode'), 'help should mention --mode option')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--output-schema'), 'help should mention --output-schema option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<prompt>'), 'help should mention prompt argument')
assert(!result.stdout.includes('--ui'), 'help should not mention removed --ui option')
console.log('✓ run --help shows options\n')
console.log("Test 1: run --help shows options");
const result = await $`npx paseo run --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "run --help should exit 0");
assert(result.stdout.includes("-d"), "help should mention -d flag");
assert(result.stdout.includes("--detach"), "help should mention --detach flag");
assert(result.stdout.includes("--name"), "help should mention --name option");
assert(result.stdout.includes("--provider"), "help should mention --provider option");
assert(result.stdout.includes("--mode"), "help should mention --mode option");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
assert(result.stdout.includes("--output-schema"), "help should mention --output-schema option");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<prompt>"), "help should mention prompt argument");
assert(!result.stdout.includes("--ui"), "help should not mention removed --ui option");
console.log("✓ run --help shows options\n");
}
// Test 2: run requires prompt argument
{
console.log('Test 2: run requires prompt argument')
console.log("Test 2: run requires prompt argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without prompt");
const output = result.stdout + result.stderr;
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ run requires prompt argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasMissingArg, "error should mention missing argument");
console.log("✓ run requires prompt argument\n");
}
// Test 3: run handles daemon not running
{
console.log('Test 3: run handles daemon not running')
console.log("Test 3: run handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run "test prompt"`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ run handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ run handles daemon not running\n");
}
// Test 4: run -d flag is accepted
{
console.log('Test 4: run -d flag is accepted')
console.log("Test 4: run -d flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -d flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run -d flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -d flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run -d flag is accepted\n");
}
// Test 5: run --name flag is accepted
{
console.log('Test 5: run --name flag is accepted')
console.log("Test 5: run --name flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --name flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --name flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --name "test-agent" "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --name flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --name flag is accepted\n");
}
// Test 6: run --provider flag is accepted
{
console.log('Test 6: run --provider flag is accepted')
console.log("Test 6: run --provider flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --provider flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --provider flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --provider flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --provider flag is accepted\n");
}
// Test 6b: run --provider provider/model syntax is accepted
{
console.log('Test 6b: run --provider provider/model syntax is accepted')
console.log("Test 6b: run --provider provider/model syntax is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept provider/model syntax')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --provider provider/model syntax is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept provider/model syntax");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --provider provider/model syntax is accepted\n");
}
// Test 7: run --mode flag is accepted
{
console.log('Test 7: run --mode flag is accepted')
console.log("Test 7: run --mode flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --mode flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --mode flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --mode bypass "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --mode flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --mode flag is accepted\n");
}
// Test 8: run --cwd flag is accepted
{
console.log('Test 8: run --cwd flag is accepted')
console.log("Test 8: run --cwd flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --cwd flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --cwd flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --cwd /tmp "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --cwd flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --cwd flag is accepted\n");
}
// Test 9: run --output-schema flag is accepted
{
console.log('Test 9: run --output-schema flag is accepted')
console.log("Test 9: run --output-schema flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --output-schema flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ run --output-schema flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --output-schema ${schemaPath} "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --output-schema flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ run --output-schema flag is accepted\n");
}
// Test 10: run --output-schema cannot be used with --detach
{
console.log('Test 10: run --output-schema cannot be used with --detach')
console.log("Test 10: run --output-schema cannot be used with --detach");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail with --detach and --output-schema')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run -d --output-schema ${schemaPath} "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail with --detach and --output-schema");
const output = result.stdout + result.stderr;
assert(
output.includes('--output-schema cannot be used with --detach'),
'error should explain detach incompatibility'
)
console.log('✓ run --output-schema cannot be used with --detach\n')
output.includes("--output-schema cannot be used with --detach"),
"error should explain detach incompatibility",
);
console.log("✓ run --output-schema cannot be used with --detach\n");
}
// Test 11: -q (quiet) flag is accepted with run
{
console.log('Test 11: -q (quiet) flag is accepted with run')
console.log("Test 11: -q (quiet) flag is accepted with run");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with run\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with run\n");
}
// Test 12: Combined flags work together
{
console.log('Test 12: Combined flags work together')
console.log("Test 12: Combined flags work together");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept all combined flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ Combined flags work together\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q run -d --name "test-fixer" --provider claude --mode bypass --cwd /tmp "Fix the tests"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept all combined flags");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ Combined flags work together\n");
}
// Test 12b: conflicting provider/model syntax is rejected before connect
{
console.log('Test 12b: conflicting provider/model syntax is rejected')
console.log("Test 12b: conflicting provider/model syntax is rejected");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for conflicting model inputs')
const output = result.stdout + result.stderr
assert(output.includes('Conflicting model values provided'), 'should explain conflicting model inputs')
console.log('✓ conflicting provider/model syntax is rejected\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --provider codex/gpt-5.4 --model gpt-5.5 "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting model inputs");
const output = result.stdout + result.stderr;
assert(
output.includes("Conflicting model values provided"),
"should explain conflicting model inputs",
);
console.log("✓ conflicting provider/model syntax is rejected\n");
}
// Test 13: paseo --help shows run command
{
console.log('Test 13: paseo --help shows run command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('run'), 'help should mention run command')
console.log('✓ paseo --help shows run command\n')
console.log("Test 13: paseo --help shows run command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("run"), "help should mention run command");
console.log("✓ paseo --help shows run command\n");
}
// Test 14: run --ui is rejected (flag removed)
{
console.log('Test 14: run --ui is rejected')
console.log("Test 14: run --ui is rejected");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for removed --ui flag')
const output = result.stdout + result.stderr
assert(output.includes('unknown option'), 'should report unknown option for --ui')
console.log('✓ run --ui is rejected\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo run --ui "test prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail for removed --ui flag");
const output = result.stdout + result.stderr;
assert(output.includes("unknown option"), "should report unknown option for --ui");
console.log("✓ run --ui is rejected\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All run tests passed ===')
console.log("=== All run tests passed ===");
+111 -112
View File
@@ -17,194 +17,193 @@
* - agent shows send in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Send Command Tests ===\n')
console.log("=== Send Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const promptFilePath = join(paseoHome, 'send-prompt.txt')
await writeFile(promptFilePath, 'prompt from file')
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
const promptFilePath = join(paseoHome, "send-prompt.txt");
await writeFile(promptFilePath, "prompt from file");
try {
// Test 1: send --help shows options
{
console.log('Test 1: send --help shows options')
const result = await $`npx paseo send --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
assert(result.stdout.includes('--prompt'), 'help should mention --prompt option')
assert(result.stdout.includes('--prompt-file'), 'help should mention --prompt-file option')
assert(result.stdout.includes('--no-wait'), 'help should mention --no-wait flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
assert(result.stdout.includes('[prompt]'), 'help should mention optional prompt argument')
console.log(' help should mention --prompt option')
console.log(' help should mention --prompt-file option')
console.log(' help should mention --no-wait flag')
console.log(' help should mention --host option')
console.log(' help should mention <id> argument')
console.log(' help should mention [prompt] argument')
console.log('✓ send --help shows options\n')
console.log("Test 1: send --help shows options");
const result = await $`npx paseo send --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "send --help should exit 0");
assert(result.stdout.includes("--prompt"), "help should mention --prompt option");
assert(result.stdout.includes("--prompt-file"), "help should mention --prompt-file option");
assert(result.stdout.includes("--no-wait"), "help should mention --no-wait flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention id argument");
assert(result.stdout.includes("[prompt]"), "help should mention optional prompt argument");
console.log(" help should mention --prompt option");
console.log(" help should mention --prompt-file option");
console.log(" help should mention --no-wait flag");
console.log(" help should mention --host option");
console.log(" help should mention <id> argument");
console.log(" help should mention [prompt] argument");
console.log("✓ send --help shows options\n");
}
// Test 2: send requires id argument
{
console.log('Test 2: send requires id argument')
console.log("Test 2: send requires id argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ send requires id argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasMissingArg, "error should mention missing argument");
console.log("✓ send requires id argument\n");
}
// Test 3: send requires prompt argument
{
console.log('Test 3: send requires prompt argument')
console.log("Test 3: send requires prompt argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without prompt')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without prompt");
const output = result.stdout + result.stderr;
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('✓ send requires prompt argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasMissingArg, "error should mention missing argument");
console.log("✓ send requires prompt argument\n");
}
// Test 4: send handles daemon not running
{
console.log('Test 4: send handles daemon not running')
console.log("Test 4: send handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "test prompt"`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ send handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ send handles daemon not running\n");
}
// Test 5: send --no-wait flag is accepted
{
console.log('Test 5: send --no-wait flag is accepted')
console.log("Test 5: send --no-wait flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --no-wait flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --no-wait flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --no-wait abc123 "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --no-wait flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ send --no-wait flag is accepted\n");
}
// Test 5b: send --prompt flag is accepted
{
console.log('Test 5b: send --prompt flag is accepted')
console.log("Test 5b: send --prompt flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --prompt flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --prompt flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt "test prompt" abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --prompt flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ send --prompt flag is accepted\n");
}
// Test 5c: send --prompt-file flag is accepted
{
console.log('Test 5c: send --prompt-file flag is accepted')
console.log("Test 5c: send --prompt-file flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --prompt-file flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --prompt-file flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send --prompt-file ${promptFilePath} abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --prompt-file flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ send --prompt-file flag is accepted\n");
}
// Test 6: send --host flag is accepted
{
console.log('Test 6: send --host flag is accepted')
console.log("Test 6: send --host flag is accepted");
const result =
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ send --host flag is accepted\n')
await $`PASEO_HOME=${paseoHome} npx paseo send --host localhost:${port} abc123 "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ send --host flag is accepted\n");
}
// Test 7: -q (quiet) flag is accepted with send
{
console.log('Test 7: -q (quiet) flag is accepted with send')
console.log("Test 7: -q (quiet) flag is accepted with send");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with send\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "test prompt"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with send\n");
}
// Test 8: Combined flags work together
{
console.log('Test 8: Combined flags work together')
console.log("Test 8: Combined flags work together");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept all combined flags')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ Combined flags work together\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q send --no-wait abc123 "Run the linter"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept all combined flags");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ Combined flags work together\n");
}
// Test 8b: conflicting prompt sources are rejected
{
console.log('Test 8b: conflicting prompt sources are rejected')
console.log("Test 8b: conflicting prompt sources are rejected");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail for conflicting prompt sources')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo send abc123 "positional prompt" --prompt "flag prompt"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail for conflicting prompt sources");
const output = result.stdout + result.stderr;
assert(
output.includes('Provide exactly one of prompt argument, --prompt, or --prompt-file'),
'should explain conflicting prompt sources'
)
console.log('✓ conflicting prompt sources are rejected\n')
output.includes("Provide exactly one of prompt argument, --prompt, or --prompt-file"),
"should explain conflicting prompt sources",
);
console.log("✓ conflicting prompt sources are rejected\n");
}
// Test 9: paseo --help shows send command
{
console.log('Test 9: paseo --help shows send command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('send'), 'help should mention send command')
console.log('✓ paseo --help shows send command\n')
console.log("Test 9: paseo --help shows send command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("send"), "help should mention send command");
console.log("✓ paseo --help shows send command\n");
}
// Test 10: ID prefix syntax is mentioned in help
{
console.log('Test 10: send command description mentions ID')
const result = await $`npx paseo send --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'send --help should exit 0')
console.log("Test 10: send command description mentions ID");
const result = await $`npx paseo send --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "send --help should exit 0");
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('✓ send command description mentions ID\n')
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
assert(hasIdMention, "help should mention ID or prefix");
console.log("✓ send command description mentions ID\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All send tests passed ===')
console.log("=== All send tests passed ===");
+67 -67
View File
@@ -17,120 +17,120 @@
* - stop --cwd flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Stop Command Tests ===\n')
console.log("=== Stop Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: stop --help shows options
{
console.log('Test 1: stop --help shows options')
const result = await $`npx paseo stop --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'stop --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
console.log('✓ stop --help shows options\n')
console.log("Test 1: stop --help shows options");
const result = await $`npx paseo stop --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "stop --help should exit 0");
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("[id]"), "help should mention optional id argument");
console.log("✓ stop --help shows options\n");
}
// Test 2: stop requires ID, --all, or --cwd
{
console.log('Test 2: stop requires ID, --all, or --cwd')
console.log("Test 2: stop requires ID, --all, or --cwd");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ stop requires ID, --all, or --cwd\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument") ||
output.toLowerCase().includes("id");
assert(hasError, "error should mention missing argument");
console.log("✓ stop requires ID, --all, or --cwd\n");
}
// Test 3: stop handles daemon not running
{
console.log('Test 3: stop handles daemon not running')
console.log("Test 3: stop handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ stop handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ stop handles daemon not running\n");
}
// Test 4: stop --all flag is accepted
{
console.log('Test 4: stop --all flag is accepted')
console.log("Test 4: stop --all flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop --all flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --all`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --all flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ stop --all flag is accepted\n");
}
// Test 5: stop --cwd flag is accepted
{
console.log('Test 5: stop --cwd flag is accepted')
console.log("Test 5: stop --cwd flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --cwd flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop --cwd flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop --cwd /tmp`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --cwd flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ stop --cwd flag is accepted\n");
}
// Test 6: stop with ID and --host flag is accepted
{
console.log('Test 6: stop with ID and --host flag is accepted')
console.log("Test 6: stop with ID and --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ stop with ID and --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo stop abc123 --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ stop with ID and --host flag is accepted\n");
}
// Test 7: paseo --help shows stop command
{
console.log('Test 7: paseo --help shows stop command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('stop'), 'help should mention stop command')
console.log('✓ paseo --help shows stop command\n')
console.log("Test 7: paseo --help shows stop command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("stop"), "help should mention stop command");
console.log("✓ paseo --help shows stop command\n");
}
// Test 8: -q (quiet) flag is accepted with stop
{
console.log('Test 8: -q (quiet) flag is accepted with stop')
console.log("Test 8: -q (quiet) flag is accepted with stop");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with stop\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q stop abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with stop\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All stop tests passed ===')
console.log("=== All stop tests passed ===");
+76 -73
View File
@@ -17,132 +17,135 @@
* - logs --tail flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Logs Command Tests ===\n')
console.log("=== Logs Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: logs --help shows options
{
console.log('Test 1: logs --help shows options')
const result = await $`npx paseo logs --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'logs --help should exit 0')
assert(result.stdout.includes('-f') || result.stdout.includes('--follow'), 'help should mention -f/--follow flag')
assert(result.stdout.includes('--tail'), 'help should mention --tail option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ logs --help shows options\n')
console.log("Test 1: logs --help shows options");
const result = await $`npx paseo logs --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "logs --help should exit 0");
assert(
result.stdout.includes("-f") || result.stdout.includes("--follow"),
"help should mention -f/--follow flag",
);
assert(result.stdout.includes("--tail"), "help should mention --tail option");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ logs --help shows options\n");
}
// Test 2: logs requires ID argument
{
console.log('Test 2: logs requires ID argument')
console.log("Test 2: logs requires ID argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ logs requires ID argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument") ||
output.toLowerCase().includes("id");
assert(hasError, "error should mention missing argument");
console.log("✓ logs requires ID argument\n");
}
// Test 3: logs handles daemon not running
{
console.log('Test 3: logs handles daemon not running')
console.log("Test 3: logs handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ logs handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ logs handles daemon not running\n");
}
// Test 4: logs -f (follow) flag is accepted
{
console.log('Test 4: logs -f (follow) flag is accepted')
console.log("Test 4: logs -f (follow) flag is accepted");
// Use timeout to avoid hanging on follow mode
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -f flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs -f (follow) flag is accepted\n')
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs -f abc123' || true`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -f flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ logs -f (follow) flag is accepted\n");
}
// Test 5: logs --follow flag is accepted
{
console.log('Test 5: logs --follow flag is accepted')
console.log("Test 5: logs --follow flag is accepted");
const result =
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --follow flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs --follow flag is accepted\n')
await $`timeout 1 bash -c 'PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --follow abc123' || true`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --follow flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ logs --follow flag is accepted\n");
}
// Test 6: logs --tail flag is accepted
{
console.log('Test 6: logs --tail flag is accepted')
console.log("Test 6: logs --tail flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --tail flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs --tail flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs --tail 50 abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --tail flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ logs --tail flag is accepted\n");
}
// Test 7: logs with ID and --host flag is accepted
{
console.log('Test 7: logs with ID and --host flag is accepted')
console.log("Test 7: logs with ID and --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ logs with ID and --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo logs abc123 --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ logs with ID and --host flag is accepted\n");
}
// Test 8: paseo --help shows logs command
{
console.log('Test 8: paseo --help shows logs command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('logs'), 'help should mention logs command')
console.log('✓ paseo --help shows logs command\n')
console.log("Test 8: paseo --help shows logs command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("logs"), "help should mention logs command");
console.log("✓ paseo --help shows logs command\n");
}
// Test 9: -q (quiet) flag is accepted with logs
{
console.log('Test 9: -q (quiet) flag is accepted with logs')
console.log("Test 9: -q (quiet) flag is accepted with logs");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with logs\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q logs abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with logs\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All logs tests passed ===')
console.log("=== All logs tests passed ===");
+80 -81
View File
@@ -17,145 +17,144 @@
* - agent shows inspect in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Inspect Command Tests ===\n')
console.log("=== Inspect Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: inspect --help shows options
{
console.log('Test 1: inspect --help shows options')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
console.log(' help should mention --host option')
console.log(' help should mention <id> argument')
console.log('inspect --help shows options\n')
console.log("Test 1: inspect --help shows options");
const result = await $`npx paseo inspect --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention id argument");
console.log(" help should mention --host option");
console.log(" help should mention <id> argument");
console.log("inspect --help shows options\n");
}
// Test 2: inspect requires id argument
{
console.log('Test 2: inspect requires id argument')
console.log("Test 2: inspect requires id argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('inspect requires id argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasMissingArg, "error should mention missing argument");
console.log("inspect requires id argument\n");
}
// Test 3: inspect handles daemon not running
{
console.log('Test 3: inspect handles daemon not running')
console.log("Test 3: inspect handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('inspect handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("inspect handles daemon not running\n");
}
// Test 4: inspect --host flag is accepted
{
console.log('Test 4: inspect --host flag is accepted')
console.log("Test 4: inspect --host flag is accepted");
const result =
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('inspect --host flag is accepted\n')
await $`PASEO_HOME=${paseoHome} npx paseo inspect --host localhost:${port} abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("inspect --host flag is accepted\n");
}
// Test 5: -q (quiet) flag is accepted with inspect
{
console.log('Test 5: -q (quiet) flag is accepted with inspect')
console.log("Test 5: -q (quiet) flag is accepted with inspect");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('-q (quiet) flag is accepted with inspect\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q inspect abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("-q (quiet) flag is accepted with inspect\n");
}
// Test 6: --json flag is accepted with inspect
{
console.log('Test 6: --json flag is accepted with inspect')
console.log("Test 6: --json flag is accepted with inspect");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--json flag is accepted with inspect\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo inspect abc123 --json`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --json flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("--json flag is accepted with inspect\n");
}
// Test 7: --format yaml flag is accepted with inspect
{
console.log('Test 7: --format yaml flag is accepted with inspect')
console.log("Test 7: --format yaml flag is accepted with inspect");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format yaml flag is accepted with inspect\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml inspect abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --format yaml flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("--format yaml flag is accepted with inspect\n");
}
// Test 8: paseo --help shows inspect command
{
console.log('Test 8: paseo --help shows inspect command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('inspect'), 'help should mention inspect command')
console.log('paseo --help shows inspect command\n')
console.log("Test 8: paseo --help shows inspect command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("inspect"), "help should mention inspect command");
console.log("paseo --help shows inspect command\n");
}
// Test 9: inspect command description is helpful
{
console.log('Test 9: inspect command description is helpful')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
console.log("Test 9: inspect command description is helpful");
const result = await $`npx paseo inspect --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
const hasDescription =
result.stdout.toLowerCase().includes('detail') ||
result.stdout.toLowerCase().includes('information') ||
result.stdout.toLowerCase().includes('show')
assert(hasDescription, 'help should describe what inspect does')
console.log('inspect command description is helpful\n')
result.stdout.toLowerCase().includes("detail") ||
result.stdout.toLowerCase().includes("information") ||
result.stdout.toLowerCase().includes("show");
assert(hasDescription, "help should describe what inspect does");
console.log("inspect command description is helpful\n");
}
// Test 10: ID prefix syntax is mentioned in help
{
console.log('Test 10: inspect command mentions ID')
const result = await $`npx paseo inspect --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'inspect --help should exit 0')
console.log("Test 10: inspect command mentions ID");
const result = await $`npx paseo inspect --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "inspect --help should exit 0");
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('inspect command mentions ID\n')
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
assert(hasIdMention, "help should mention ID or prefix");
console.log("inspect command mentions ID\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All inspect tests passed ===')
console.log("=== All inspect tests passed ===");
+69 -69
View File
@@ -16,124 +16,124 @@
* - agent mode --list flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Agent Mode Command Tests ===\n')
console.log("=== Agent Mode Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: agent mode --help shows options
{
console.log('Test 1: agent mode --help shows options')
const result = await $`npx paseo agent mode --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent mode --help should exit 0')
assert(result.stdout.includes('--list'), 'help should mention --list flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
assert(result.stdout.includes('[mode]'), 'help should mention optional mode argument')
console.log('✓ agent mode --help shows options\n')
console.log("Test 1: agent mode --help shows options");
const result = await $`npx paseo agent mode --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent mode --help should exit 0");
assert(result.stdout.includes("--list"), "help should mention --list flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention id argument");
assert(result.stdout.includes("[mode]"), "help should mention optional mode argument");
console.log("✓ agent mode --help shows options\n");
}
// Test 2: agent mode requires id argument
{
console.log('Test 2: agent mode requires id argument')
console.log("Test 2: agent mode requires id argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent mode requires id argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument") ||
output.toLowerCase().includes("id");
assert(hasError, "error should mention missing argument");
console.log("✓ agent mode requires id argument\n");
}
// Test 3: agent mode handles daemon not running
{
console.log('Test 3: agent mode handles daemon not running')
console.log("Test 3: agent mode handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 bypass`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ agent mode handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ agent mode handles daemon not running\n");
}
// Test 4: agent mode --list flag is accepted
{
console.log('Test 4: agent mode --list flag is accepted')
console.log("Test 4: agent mode --list flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --list flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent mode --list flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode --list abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --list flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ agent mode --list flag is accepted\n");
}
// Test 5: agent mode with ID and --host flag is accepted
{
console.log('Test 5: agent mode with ID and --host flag is accepted')
console.log("Test 5: agent mode with ID and --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent mode with ID and --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123 plan --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ agent mode with ID and --host flag is accepted\n");
}
// Test 6: agent shows mode in subcommands
{
console.log('Test 6: agent --help shows mode subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('mode'), 'help should mention mode subcommand')
console.log('✓ agent --help shows mode subcommand\n')
console.log("Test 6: agent --help shows mode subcommand");
const result = await $`npx paseo agent --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
assert(result.stdout.includes("mode"), "help should mention mode subcommand");
console.log("✓ agent --help shows mode subcommand\n");
}
// Test 7: -q (quiet) flag is accepted with agent mode
{
console.log('Test 7: -q (quiet) flag is accepted with agent mode')
console.log("Test 7: -q (quiet) flag is accepted with agent mode");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with agent mode\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent mode abc123 bypass`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with agent mode\n");
}
// Test 8: agent mode requires mode argument when not using --list
{
console.log('Test 8: agent mode requires mode argument when not using --list')
console.log("Test 8: agent mode requires mode argument when not using --list");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent mode abc123`.nothrow();
// Should fail because mode is required unless --list is specified
assert.notStrictEqual(result.exitCode, 0, 'should fail without mode argument')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail without mode argument");
const output = result.stdout + result.stderr;
assert(
output.includes('Mode argument required unless --list is specified'),
'error should mention missing mode argument'
)
console.log('✓ agent mode requires mode argument when not using --list\n')
output.includes("Mode argument required unless --list is specified"),
"error should mention missing mode argument",
);
console.log("✓ agent mode requires mode argument when not using --list\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All agent mode tests passed ===')
console.log("=== All agent mode tests passed ===");
+59 -59
View File
@@ -16,107 +16,107 @@
* - agent archive --force flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Agent Archive Command Tests ===\n')
console.log("=== Agent Archive Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: agent archive --help shows options
{
console.log('Test 1: agent archive --help shows options')
const result = await $`npx paseo agent archive --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent archive --help should exit 0')
assert(result.stdout.includes('--force'), 'help should mention --force flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent archive --help shows options\n')
console.log("Test 1: agent archive --help shows options");
const result = await $`npx paseo agent archive --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent archive --help should exit 0");
assert(result.stdout.includes("--force"), "help should mention --force flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ agent archive --help shows options\n");
}
// Test 2: agent archive requires ID argument
{
console.log('Test 2: agent archive requires ID argument')
console.log("Test 2: agent archive requires ID argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent archive requires ID argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasError, "error should mention missing argument");
console.log("✓ agent archive requires ID argument\n");
}
// Test 3: agent archive handles daemon not running
{
console.log('Test 3: agent archive handles daemon not running')
console.log("Test 3: agent archive handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ agent archive handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ agent archive handles daemon not running\n");
}
// Test 4: agent archive --force flag is accepted
{
console.log('Test 4: agent archive --force flag is accepted')
console.log("Test 4: agent archive --force flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --force flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent archive --force flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --force`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --force flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ agent archive --force flag is accepted\n");
}
// Test 5: agent archive with ID and --host flag is accepted
{
console.log('Test 5: agent archive with ID and --host flag is accepted')
console.log("Test 5: agent archive with ID and --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent archive with ID and --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent archive abc123 --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ agent archive with ID and --host flag is accepted\n");
}
// Test 6: agent shows archive in subcommands
{
console.log('Test 6: agent --help shows archive subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
console.log('✓ agent --help shows archive subcommand\n')
console.log("Test 6: agent --help shows archive subcommand");
const result = await $`npx paseo agent --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
assert(result.stdout.includes("archive"), "help should mention archive subcommand");
console.log("✓ agent --help shows archive subcommand\n");
}
// Test 7: -q (quiet) flag is accepted with agent archive
{
console.log('Test 7: -q (quiet) flag is accepted with agent archive')
console.log("Test 7: -q (quiet) flag is accepted with agent archive");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with agent archive\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q agent archive abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with agent archive\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All agent archive tests passed ===')
console.log("=== All agent archive tests passed ===");
+93 -95
View File
@@ -18,169 +18,167 @@
* - agent shows wait in subcommands
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Wait Command Tests ===\n')
console.log("=== Wait Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: wait --help shows options
{
console.log('Test 1: wait --help shows options')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('--timeout'), 'help should mention --timeout option')
assert(result.stdout.includes('<id>'), 'help should mention id argument')
console.log(' help should mention --host option')
console.log(' help should mention --timeout option')
console.log(' help should mention <id> argument')
console.log('wait --help shows options\n')
console.log("Test 1: wait --help shows options");
const result = await $`npx paseo wait --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("--timeout"), "help should mention --timeout option");
assert(result.stdout.includes("<id>"), "help should mention id argument");
console.log(" help should mention --host option");
console.log(" help should mention --timeout option");
console.log(" help should mention <id> argument");
console.log("wait --help shows options\n");
}
// Test 2: wait requires id argument
{
console.log('Test 2: wait requires id argument')
console.log("Test 2: wait requires id argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
// Commander should complain about missing argument
const hasMissingArg =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasMissingArg, 'error should mention missing argument')
console.log('wait requires id argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasMissingArg, "error should mention missing argument");
console.log("wait requires id argument\n");
}
// Test 3: wait handles daemon not running
{
console.log('Test 3: wait handles daemon not running')
console.log("Test 3: wait handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('wait handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("wait handles daemon not running\n");
}
// Test 4: wait --timeout flag is accepted
{
console.log('Test 4: wait --timeout flag is accepted')
console.log("Test 4: wait --timeout flag is accepted");
const result =
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --timeout flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('wait --timeout flag is accepted\n')
await $`PASEO_HOME=${paseoHome} npx paseo wait --timeout 30 --host localhost:${port} abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --timeout flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("wait --timeout flag is accepted\n");
}
// Test 5: wait --host flag is accepted
{
console.log('Test 5: wait --host flag is accepted')
console.log("Test 5: wait --host flag is accepted");
const result =
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('wait --host flag is accepted\n')
await $`PASEO_HOME=${paseoHome} npx paseo wait --host localhost:${port} abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("wait --host flag is accepted\n");
}
// Test 6: -q (quiet) flag is accepted with wait
{
console.log('Test 6: -q (quiet) flag is accepted with wait')
console.log("Test 6: -q (quiet) flag is accepted with wait");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('-q (quiet) flag is accepted with wait\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q wait abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("-q (quiet) flag is accepted with wait\n");
}
// Test 7: --json flag is accepted with wait
{
console.log('Test 7: --json flag is accepted with wait')
console.log("Test 7: --json flag is accepted with wait");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--json flag is accepted with wait\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo wait abc123 --json`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --json flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("--json flag is accepted with wait\n");
}
// Test 8: --format yaml flag is accepted with wait
{
console.log('Test 8: --format yaml flag is accepted with wait')
console.log("Test 8: --format yaml flag is accepted with wait");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --format yaml flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('--format yaml flag is accepted with wait\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo --format yaml wait abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --format yaml flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("--format yaml flag is accepted with wait\n");
}
// Test 9: paseo --help shows wait command
{
console.log('Test 9: paseo --help shows wait command')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('wait'), 'help should mention wait command')
console.log('paseo --help shows wait command\n')
console.log("Test 9: paseo --help shows wait command");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("wait"), "help should mention wait command");
console.log("paseo --help shows wait command\n");
}
// Test 10: wait command description is helpful
{
console.log('Test 10: wait command description is helpful')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
console.log("Test 10: wait command description is helpful");
const result = await $`npx paseo wait --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
const hasDescription =
result.stdout.toLowerCase().includes('wait') ||
result.stdout.toLowerCase().includes('idle')
assert(hasDescription, 'help should describe what wait does')
console.log('wait command description is helpful\n')
result.stdout.toLowerCase().includes("wait") || result.stdout.toLowerCase().includes("idle");
assert(hasDescription, "help should describe what wait does");
console.log("wait command description is helpful\n");
}
// Test 11: ID prefix syntax is mentioned in help
{
console.log('Test 11: wait command mentions ID')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
console.log("Test 11: wait command mentions ID");
const result = await $`npx paseo wait --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
const hasIdMention =
result.stdout.toLowerCase().includes('id') ||
result.stdout.toLowerCase().includes('prefix')
assert(hasIdMention, 'help should mention ID or prefix')
console.log('wait command mentions ID\n')
result.stdout.toLowerCase().includes("id") || result.stdout.toLowerCase().includes("prefix");
assert(hasIdMention, "help should mention ID or prefix");
console.log("wait command mentions ID\n");
}
// Test 12: timeout option documents no default limit
{
console.log('Test 12: timeout option documents no default limit')
const result = await $`npx paseo wait --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'wait --help should exit 0')
console.log("Test 12: timeout option documents no default limit");
const result = await $`npx paseo wait --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "wait --help should exit 0");
assert(
result.stdout.toLowerCase().includes('default: no limit'),
'help should mention timeout default is no limit'
)
console.log('timeout option documents no default limit\n')
result.stdout.toLowerCase().includes("default: no limit"),
"help should mention timeout default is no limit",
);
console.log("timeout option documents no default limit\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All wait tests passed ===')
console.log("=== All wait tests passed ===");
+46 -46
View File
@@ -16,92 +16,92 @@
* - permit ls --json handles errors
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Permit LS Command Tests ===\n')
console.log("=== Permit LS Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: permit --help shows subcommands
{
console.log('Test 1: permit --help shows subcommands')
const result = await $`npx paseo permit --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
assert(result.stdout.includes('allow'), 'help should mention allow subcommand')
assert(result.stdout.includes('deny'), 'help should mention deny subcommand')
console.log('✓ permit --help shows subcommands\n')
console.log("Test 1: permit --help shows subcommands");
const result = await $`npx paseo permit --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "permit --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls subcommand");
assert(result.stdout.includes("allow"), "help should mention allow subcommand");
assert(result.stdout.includes("deny"), "help should mention deny subcommand");
console.log("✓ permit --help shows subcommands\n");
}
// Test 2: permit ls --help shows options
{
console.log('Test 2: permit ls --help shows options')
const result = await $`npx paseo permit ls --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit ls --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
console.log('✓ permit ls --help shows options\n')
console.log("Test 2: permit ls --help shows options");
const result = await $`npx paseo permit ls --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "permit ls --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
console.log("✓ permit ls --help shows options\n");
}
// Test 3: permit ls returns error when no daemon running
{
console.log('Test 3: permit ls handles daemon not running')
console.log("Test 3: permit ls handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit ls handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ permit ls handles daemon not running\n");
}
// Test 4: permit ls --json handles errors
{
console.log('Test 4: permit ls --json handles errors')
console.log("Test 4: permit ls --json handles errors");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit ls --json`.nothrow();
// Should still fail (daemon not running)
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
// But output should be valid JSON if present
const output = result.stdout.trim()
const output = result.stdout.trim();
if (output.length > 0) {
try {
JSON.parse(output)
console.log('✓ permit ls --json outputs valid JSON error\n')
JSON.parse(output);
console.log("✓ permit ls --json outputs valid JSON error\n");
} catch {
// Empty or stderr-only output is acceptable
console.log('✓ permit ls --json handled error (output may be in stderr)\n')
console.log("✓ permit ls --json handled error (output may be in stderr)\n");
}
} else {
console.log('✓ permit ls --json handled error gracefully\n')
console.log("✓ permit ls --json handled error gracefully\n");
}
}
// Test 5: -q (quiet) flag is accepted globally
{
console.log('Test 5: -q (quiet) flag is accepted')
console.log("Test 5: -q (quiet) flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q permit ls`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All permit ls tests passed ===')
console.log("=== All permit ls tests passed ===");
+84 -84
View File
@@ -21,155 +21,155 @@
* - permit allow --input flag is accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Permit Allow/Deny Command Tests ===\n')
console.log("=== Permit Allow/Deny Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: permit allow --help shows options
{
console.log('Test 1: permit allow --help shows options')
const result = await $`npx paseo permit allow --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit allow --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--input'), 'help should mention --input option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
console.log('✓ permit allow --help shows options\n')
console.log("Test 1: permit allow --help shows options");
const result = await $`npx paseo permit allow --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "permit allow --help should exit 0");
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("--input"), "help should mention --input option");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<agent>"), "help should mention agent argument");
console.log("✓ permit allow --help shows options\n");
}
// Test 2: permit deny --help shows options
{
console.log('Test 2: permit deny --help shows options')
const result = await $`npx paseo permit deny --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'permit deny --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--message'), 'help should mention --message option')
assert(result.stdout.includes('--interrupt'), 'help should mention --interrupt flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<agent>'), 'help should mention agent argument')
console.log('✓ permit deny --help shows options\n')
console.log("Test 2: permit deny --help shows options");
const result = await $`npx paseo permit deny --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "permit deny --help should exit 0");
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("--message"), "help should mention --message option");
assert(result.stdout.includes("--interrupt"), "help should mention --interrupt flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<agent>"), "help should mention agent argument");
console.log("✓ permit deny --help shows options\n");
}
// Test 3: permit allow handles daemon not running
{
console.log('Test 3: permit allow handles daemon not running')
console.log("Test 3: permit allow handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit allow handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ permit allow handles daemon not running\n");
}
// Test 4: permit deny handles daemon not running
{
console.log('Test 4: permit deny handles daemon not running')
console.log("Test 4: permit deny handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ permit deny handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ permit deny handles daemon not running\n");
}
// Test 5: permit allow --all flag is accepted
{
console.log('Test 5: permit allow --all flag is accepted')
console.log("Test 5: permit allow --all flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit allow --all flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 --all`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --all flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ permit allow --all flag is accepted\n");
}
// Test 6: permit deny --all flag is accepted
{
console.log('Test 6: permit deny --all flag is accepted')
console.log("Test 6: permit deny --all flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --all flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 --all`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --all flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ permit deny --all flag is accepted\n");
}
// Test 7: permit deny --message flag is accepted
{
console.log('Test 7: permit deny --message flag is accepted')
console.log("Test 7: permit deny --message flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --message flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --message flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --message "Not allowed"`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --message flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ permit deny --message flag is accepted\n");
}
// Test 8: permit deny --interrupt flag is accepted
{
console.log('Test 8: permit deny --interrupt flag is accepted')
console.log("Test 8: permit deny --interrupt flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --interrupt flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit deny --interrupt flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123 req456 --interrupt`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --interrupt flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ permit deny --interrupt flag is accepted\n");
}
// Test 9: permit allow --input flag is accepted
{
console.log('Test 9: permit allow --input flag is accepted')
console.log("Test 9: permit allow --input flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --input flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ permit allow --input flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123 req456 --input '{"key":"value"}'`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --input flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ permit allow --input flag is accepted\n");
}
// Test 10: permit allow without req_id and without --all fails gracefully
{
console.log('Test 10: permit allow requires req_id or --all')
console.log("Test 10: permit allow requires req_id or --all");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit allow abc123`.nothrow();
// This might fail due to daemon not running first, or due to missing argument
// The important thing is it doesn't crash with an unhandled error
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
console.log('✓ permit allow requires req_id or --all\n')
assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all");
console.log("✓ permit allow requires req_id or --all\n");
}
// Test 11: permit deny without req_id and without --all fails gracefully
{
console.log('Test 11: permit deny requires req_id or --all')
console.log("Test 11: permit deny requires req_id or --all");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without req_id or --all')
console.log('✓ permit deny requires req_id or --all\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo permit deny abc123`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without req_id or --all");
console.log("✓ permit deny requires req_id or --all\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All permit allow/deny tests passed ===')
console.log("=== All permit allow/deny tests passed ===");
+84 -84
View File
@@ -18,152 +18,152 @@
* - worktree archive handles daemon not running
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
$.verbose = false
$.verbose = false;
console.log('=== Worktree Command Tests ===\n')
console.log("=== Worktree Command Tests ===\n");
// Get random port that's definitely not in use (never 6767)
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: worktree --help shows subcommands
{
console.log('Test 1: worktree --help shows subcommands')
const result = await $`npx paseo worktree --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls subcommand')
assert(result.stdout.includes('archive'), 'help should mention archive subcommand')
console.log('✓ worktree --help shows subcommands\n')
console.log("Test 1: worktree --help shows subcommands");
const result = await $`npx paseo worktree --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "worktree --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls subcommand");
assert(result.stdout.includes("archive"), "help should mention archive subcommand");
console.log("✓ worktree --help shows subcommands\n");
}
// Test 2: worktree ls --help shows options
{
console.log('Test 2: worktree ls --help shows options')
const result = await $`npx paseo worktree ls --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree ls --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
console.log('✓ worktree ls --help shows options\n')
console.log("Test 2: worktree ls --help shows options");
const result = await $`npx paseo worktree ls --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
console.log("✓ worktree ls --help shows options\n");
}
// Test 3: worktree ls handles daemon not running
{
console.log('Test 3: worktree ls handles daemon not running')
console.log("Test 3: worktree ls handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ worktree ls handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ worktree ls handles daemon not running\n");
}
// Test 4: worktree ls with --host flag is accepted
{
console.log('Test 4: worktree ls with --host flag is accepted')
console.log("Test 4: worktree ls with --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ worktree ls with --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ worktree ls with --host flag is accepted\n");
}
// Test 5: worktree archive --help shows options
{
console.log('Test 5: worktree archive --help shows options')
const result = await $`npx paseo worktree archive --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'worktree archive --help should exit 0')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<name>'), 'help should mention required name argument')
console.log('✓ worktree archive --help shows options\n')
console.log("Test 5: worktree archive --help shows options");
const result = await $`npx paseo worktree archive --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<name>"), "help should mention required name argument");
console.log("✓ worktree archive --help shows options\n");
}
// Test 6: worktree archive requires name argument
{
console.log('Test 6: worktree archive requires name argument')
console.log("Test 6: worktree archive requires name argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without name')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without name");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument')
assert(hasError, 'error should mention missing argument')
console.log('✓ worktree archive requires name argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument");
assert(hasError, "error should mention missing argument");
console.log("✓ worktree archive requires name argument\n");
}
// Test 7: worktree archive handles daemon not running
{
console.log('Test 7: worktree archive handles daemon not running')
console.log("Test 7: worktree archive handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow()
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree`.nothrow();
// Should fail because daemon not running
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ worktree archive handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ worktree archive handles daemon not running\n");
}
// Test 8: worktree archive with name and --host flag is accepted
{
console.log('Test 8: worktree archive with name and --host flag is accepted')
console.log("Test 8: worktree archive with name and --host flag is accepted");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ worktree archive with name and --host flag is accepted\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree archive test-worktree --host localhost:${port}`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ worktree archive with name and --host flag is accepted\n");
}
// Test 9: -q (quiet) flag is accepted with worktree ls
{
console.log('Test 9: -q (quiet) flag is accepted with worktree ls')
console.log("Test 9: -q (quiet) flag is accepted with worktree ls");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with worktree ls\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo -q worktree ls`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with worktree ls\n");
}
// Test 10: --json flag is accepted with worktree ls
{
console.log('Test 10: --json flag is accepted with worktree ls')
console.log("Test 10: --json flag is accepted with worktree ls");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --json flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ --json flag is accepted with worktree ls\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo worktree ls --json`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --json flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ --json flag is accepted with worktree ls\n");
}
// Test 11: paseo --help shows worktree subcommand
{
console.log('Test 11: paseo --help shows worktree subcommand')
const result = await $`npx paseo --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('worktree'), 'help should mention worktree subcommand')
console.log('✓ paseo --help shows worktree subcommand\n')
console.log("Test 11: paseo --help shows worktree subcommand");
const result = await $`npx paseo --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("worktree"), "help should mention worktree subcommand");
console.log("✓ paseo --help shows worktree subcommand\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All worktree tests passed ===')
console.log("=== All worktree tests passed ===");
+168 -135
View File
@@ -19,230 +19,263 @@
* - provider models --json outputs valid JSON
*/
import assert from 'node:assert'
import { createE2ETestContext } from './helpers/test-daemon.ts'
import assert from "node:assert";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log('=== Provider Commands ===\n')
console.log("=== Provider Commands ===\n");
type ProviderModel = {
model: string
id: string
description?: string
}
model: string;
id: string;
description?: string;
};
const EXPECTED_CLAUDE_MODELS = [
{
id: 'claude-sonnet-4-5-20250929',
model: 'Sonnet 4.5',
descriptionFragment: 'Best for everyday tasks',
id: "claude-sonnet-4-5-20250929",
model: "Sonnet 4.5",
descriptionFragment: "Best for everyday tasks",
},
{
id: 'claude-sonnet-4-6',
model: 'Sonnet 4.6',
descriptionFragment: 'Best for everyday tasks',
id: "claude-sonnet-4-6",
model: "Sonnet 4.6",
descriptionFragment: "Best for everyday tasks",
},
{
id: 'claude-opus-4-6',
model: 'Opus 4.6',
descriptionFragment: 'Most capable',
id: "claude-opus-4-6",
model: "Opus 4.6",
descriptionFragment: "Most capable",
},
{
id: 'claude-haiku-4-5-20251001',
model: 'Haiku 4.5',
descriptionFragment: 'Fastest',
id: "claude-haiku-4-5-20251001",
model: "Haiku 4.5",
descriptionFragment: "Fastest",
},
] as const
] as const;
let claudeModelIdsFromJson: string[] = []
let claudeModelsFromJson: ProviderModel[] = []
let claudeModelIdsFromJson: string[] = [];
let claudeModelsFromJson: ProviderModel[] = [];
const ctx = await createE2ETestContext({ timeout: 120000 })
const ctx = await createE2ETestContext({ timeout: 120000 });
async function runProviderModelsJson(
provider: 'claude' | 'codex' | 'opencode'
provider: "claude" | "codex" | "opencode",
): Promise<ProviderModel[]> {
const transientNeedles = ['transport closed', 'timed out', 'timeout', 'socket', 'econn']
const transientNeedles = ["transport closed", "timed out", "timeout", "socket", "econn"];
for (let attempt = 1; attempt <= 3; attempt++) {
const result = await ctx.paseo(['provider', 'models', provider, '--json'])
const result = await ctx.paseo(["provider", "models", provider, "--json"]);
if (result.exitCode === 0) {
return JSON.parse(result.stdout.trim()) as ProviderModel[]
return JSON.parse(result.stdout.trim()) as ProviderModel[];
}
const combined = `${result.stdout}\n${result.stderr}`
const normalized = combined.toLowerCase()
const isTransient = transientNeedles.some((needle) => normalized.includes(needle))
const combined = `${result.stdout}\n${result.stderr}`;
const normalized = combined.toLowerCase();
const isTransient = transientNeedles.some((needle) => normalized.includes(needle));
if (!isTransient || attempt === 3) {
assert.fail(`provider models ${provider} should exit 0\n${combined}`)
assert.fail(`provider models ${provider} should exit 0\n${combined}`);
}
await new Promise((resolve) => setTimeout(resolve, 250 * attempt))
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
}
assert.fail(`provider models ${provider} exhausted retries`)
assert.fail(`provider models ${provider} exhausted retries`);
}
function assertClaudeModels(data: ProviderModel[]): void {
assert.strictEqual(data.length, EXPECTED_CLAUDE_MODELS.length, 'claude output should match the current catalog size')
assert.strictEqual(
data.length,
EXPECTED_CLAUDE_MODELS.length,
"claude output should match the current catalog size",
);
const byId = new Map(data.map((model) => [model.id, model]))
const ids = [...byId.keys()].sort()
const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort()
const byId = new Map(data.map((model) => [model.id, model]));
const ids = [...byId.keys()].sort();
const expectedIds = EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort();
assert.strictEqual(byId.size, data.length, 'claude model IDs should be unique')
assert.deepStrictEqual(ids, expectedIds, 'claude IDs should match the current catalog')
assert.strictEqual(byId.size, data.length, "claude model IDs should be unique");
assert.deepStrictEqual(ids, expectedIds, "claude IDs should match the current catalog");
for (const expectedModel of EXPECTED_CLAUDE_MODELS) {
const actualModel = byId.get(expectedModel.id)
assert(actualModel, `claude output should include ${expectedModel.id}`)
assert.strictEqual(actualModel.model, expectedModel.model, `${expectedModel.id} should keep its display name`)
const actualModel = byId.get(expectedModel.id);
assert(actualModel, `claude output should include ${expectedModel.id}`);
assert.strictEqual(
actualModel.model,
expectedModel.model,
`${expectedModel.id} should keep its display name`,
);
assert(
(actualModel.description ?? '').includes(expectedModel.descriptionFragment),
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`
)
(actualModel.description ?? "").includes(expectedModel.descriptionFragment),
`${expectedModel.id} description should mention ${expectedModel.descriptionFragment}`,
);
}
}
try {
// Test 1: provider --help shows subcommands
{
console.log('Test 1: provider --help shows subcommands')
const result = await ctx.paseo(['provider', '--help'])
assert.strictEqual(result.exitCode, 0, 'provider --help should exit 0')
assert(result.stdout.includes('ls'), 'help should mention ls')
assert(result.stdout.includes('models'), 'help should mention models')
console.log('✓ provider --help shows subcommands\n')
console.log("Test 1: provider --help shows subcommands");
const result = await ctx.paseo(["provider", "--help"]);
assert.strictEqual(result.exitCode, 0, "provider --help should exit 0");
assert(result.stdout.includes("ls"), "help should mention ls");
assert(result.stdout.includes("models"), "help should mention models");
console.log("✓ provider --help shows subcommands\n");
}
// Test 2: provider ls lists all providers
{
console.log('Test 2: provider ls lists all providers')
const result = await ctx.paseo(['provider', 'ls'])
assert.strictEqual(result.exitCode, 0, 'provider ls should exit 0')
assert(result.stdout.includes('claude'), 'output should include claude')
assert(result.stdout.includes('codex'), 'output should include codex')
assert(result.stdout.includes('opencode'), 'output should include opencode')
assert(result.stdout.includes('available'), 'output should show available status')
console.log('✓ provider ls lists all providers\n')
console.log("Test 2: provider ls lists all providers");
const result = await ctx.paseo(["provider", "ls"]);
assert.strictEqual(result.exitCode, 0, "provider ls should exit 0");
assert(result.stdout.includes("claude"), "output should include claude");
assert(result.stdout.includes("codex"), "output should include codex");
assert(result.stdout.includes("opencode"), "output should include opencode");
assert(result.stdout.includes("available"), "output should show available status");
console.log("✓ provider ls lists all providers\n");
}
// Test 3: provider ls --json outputs valid JSON
{
console.log('Test 3: provider ls --json outputs valid JSON')
const result = await ctx.paseo(['provider', 'ls', '--json'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const data = JSON.parse(result.stdout.trim())
assert(Array.isArray(data), 'output should be an array')
assert.strictEqual(data.length, 3, 'should have 3 providers')
assert(data.some((p: { provider: string }) => p.provider === 'claude'), 'should include claude')
assert(data.some((p: { provider: string }) => p.provider === 'codex'), 'should include codex')
assert(data.some((p: { provider: string }) => p.provider === 'opencode'), 'should include opencode')
console.log('✓ provider ls --json outputs valid JSON\n')
console.log("Test 3: provider ls --json outputs valid JSON");
const result = await ctx.paseo(["provider", "ls", "--json"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const data = JSON.parse(result.stdout.trim());
assert(Array.isArray(data), "output should be an array");
assert.strictEqual(data.length, 3, "should have 3 providers");
assert(
data.some((p: { provider: string }) => p.provider === "claude"),
"should include claude",
);
assert(
data.some((p: { provider: string }) => p.provider === "codex"),
"should include codex",
);
assert(
data.some((p: { provider: string }) => p.provider === "opencode"),
"should include opencode",
);
console.log("✓ provider ls --json outputs valid JSON\n");
}
// Test 4: provider ls --quiet outputs provider names only
{
console.log('Test 4: provider ls --quiet outputs provider names only')
const result = await ctx.paseo(['provider', 'ls', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n')
assert.strictEqual(lines.length, 3, 'should have 3 lines')
assert(lines.includes('claude'), 'should include claude')
assert(lines.includes('codex'), 'should include codex')
assert(lines.includes('opencode'), 'should include opencode')
console.log('✓ provider ls --quiet outputs provider names only\n')
console.log("Test 4: provider ls --quiet outputs provider names only");
const result = await ctx.paseo(["provider", "ls", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n");
assert.strictEqual(lines.length, 3, "should have 3 lines");
assert(lines.includes("claude"), "should include claude");
assert(lines.includes("codex"), "should include codex");
assert(lines.includes("opencode"), "should include opencode");
console.log("✓ provider ls --quiet outputs provider names only\n");
}
// Test 5: provider models claude lists canonical model aliases
{
console.log('Test 5: provider models claude lists canonical model aliases')
const data = await runProviderModelsJson('claude')
assertClaudeModels(data)
console.log('✓ provider models claude lists canonical model aliases\n')
console.log("Test 5: provider models claude lists canonical model aliases");
const data = await runProviderModelsJson("claude");
assertClaudeModels(data);
console.log("✓ provider models claude lists canonical model aliases\n");
}
// Test 6: provider models codex includes concrete codex model IDs
{
console.log('Test 6: provider models codex includes concrete codex model IDs')
const data = await runProviderModelsJson('codex')
assert(data.length >= 6, 'codex model list should include current codex lineup')
const ids = data.map((m) => m.id)
assert.strictEqual(new Set(ids).size, ids.length, 'codex model IDs should be unique')
assert(ids.includes('gpt-5.3-codex'), 'codex output should include gpt-5.3-codex')
assert(ids.includes('gpt-5.3-codex-spark'), 'codex output should include gpt-5.3-codex-spark')
assert(ids.includes('gpt-5.1-codex-max'), 'codex output should include gpt-5.1-codex-max')
assert(ids.includes('gpt-5.1-codex-mini'), 'codex output should include gpt-5.1-codex-mini')
console.log('✓ provider models codex includes concrete codex model IDs\n')
console.log("Test 6: provider models codex includes concrete codex model IDs");
const data = await runProviderModelsJson("codex");
assert(data.length >= 6, "codex model list should include current codex lineup");
const ids = data.map((m) => m.id);
assert.strictEqual(new Set(ids).size, ids.length, "codex model IDs should be unique");
assert(ids.includes("gpt-5.3-codex"), "codex output should include gpt-5.3-codex");
assert(ids.includes("gpt-5.3-codex-spark"), "codex output should include gpt-5.3-codex-spark");
assert(ids.includes("gpt-5.1-codex-max"), "codex output should include gpt-5.1-codex-max");
assert(ids.includes("gpt-5.1-codex-mini"), "codex output should include gpt-5.1-codex-mini");
console.log("✓ provider models codex includes concrete codex model IDs\n");
}
// Test 7: provider models opencode returns namespaced model IDs
{
console.log('Test 7: provider models opencode returns namespaced model IDs')
const data = await runProviderModelsJson('opencode')
assert(data.length >= 3, 'opencode model list should not be empty')
const ids = data.map((m) => m.id)
assert(data.every((m) => m.id.includes('/')), 'opencode model IDs should be provider-namespaced')
assert(ids.includes('opencode/gpt-5-nano'), 'opencode output should include opencode/gpt-5-nano')
assert(ids.includes('openai/o3-mini'), 'opencode output should include openai/o3-mini')
console.log("Test 7: provider models opencode returns namespaced model IDs");
const data = await runProviderModelsJson("opencode");
assert(data.length >= 3, "opencode model list should not be empty");
const ids = data.map((m) => m.id);
assert(
ids.includes('openai/gpt-5.3-codex-spark'),
'opencode output should include openai/gpt-5.3-codex-spark'
)
console.log('✓ provider models opencode returns namespaced model IDs\n')
data.every((m) => m.id.includes("/")),
"opencode model IDs should be provider-namespaced",
);
assert(
ids.includes("opencode/gpt-5-nano"),
"opencode output should include opencode/gpt-5-nano",
);
assert(ids.includes("openai/o3-mini"), "opencode output should include openai/o3-mini");
assert(
ids.includes("openai/gpt-5.3-codex-spark"),
"opencode output should include openai/gpt-5.3-codex-spark",
);
console.log("✓ provider models opencode returns namespaced model IDs\n");
}
// Test 8: provider models unknown fails with error
{
console.log('Test 8: provider models unknown fails with error')
const result = await ctx.paseo(['provider', 'models', 'unknown'])
assert.notStrictEqual(result.exitCode, 0, 'should fail for unknown provider')
const output = result.stdout + result.stderr
console.log("Test 8: provider models unknown fails with error");
const result = await ctx.paseo(["provider", "models", "unknown"]);
assert.notStrictEqual(result.exitCode, 0, "should fail for unknown provider");
const output = result.stdout + result.stderr;
assert(
output.toLowerCase().includes('unknown') || output.toLowerCase().includes('provider'),
'error should mention unknown provider'
)
console.log('✓ provider models unknown fails with error\n')
output.toLowerCase().includes("unknown") || output.toLowerCase().includes("provider"),
"error should mention unknown provider",
);
console.log("✓ provider models unknown fails with error\n");
}
// Test 9: provider models --json outputs valid JSON
{
console.log('Test 9: provider models --json outputs valid JSON')
const data = await runProviderModelsJson('claude')
assert(Array.isArray(data), 'output should be an array')
assert(data.every((m) => m.model && m.id), 'each model should have name and id')
assertClaudeModels(data)
claudeModelIdsFromJson = data.map((m) => m.id)
claudeModelsFromJson = data
console.log('✓ provider models --json outputs valid JSON\n')
console.log("Test 9: provider models --json outputs valid JSON");
const data = await runProviderModelsJson("claude");
assert(Array.isArray(data), "output should be an array");
assert(
data.every((m) => m.model && m.id),
"each model should have name and id",
);
assertClaudeModels(data);
claudeModelIdsFromJson = data.map((m) => m.id);
claudeModelsFromJson = data;
console.log("✓ provider models --json outputs valid JSON\n");
}
// Test 10: provider models --quiet outputs model IDs only
{
console.log('Test 10: provider models --quiet outputs model IDs only')
assert(claudeModelIdsFromJson.length > 0, 'claude model IDs should be captured from --json output')
const result = await ctx.paseo(['provider', 'models', 'claude', '--quiet'])
assert.strictEqual(result.exitCode, 0, 'should exit 0')
const lines = result.stdout.trim().split('\n').filter(Boolean)
assert.strictEqual(lines.length, EXPECTED_CLAUDE_MODELS.length, 'should have one line per Claude catalog model')
console.log("Test 10: provider models --quiet outputs model IDs only");
assert(
claudeModelIdsFromJson.length > 0,
"claude model IDs should be captured from --json output",
);
const result = await ctx.paseo(["provider", "models", "claude", "--quiet"]);
assert.strictEqual(result.exitCode, 0, "should exit 0");
const lines = result.stdout.trim().split("\n").filter(Boolean);
assert.strictEqual(
lines.length,
EXPECTED_CLAUDE_MODELS.length,
"should have one line per Claude catalog model",
);
assert.deepStrictEqual(
[...lines].sort(),
[...claudeModelIdsFromJson].sort(),
'--quiet should print the same model IDs returned by --json'
)
"--quiet should print the same model IDs returned by --json",
);
assert.deepStrictEqual(
[...lines].sort(),
EXPECTED_CLAUDE_MODELS.map((model) => model.id).sort(),
'--quiet should print the current Claude catalog IDs'
)
"--quiet should print the current Claude catalog IDs",
);
assert(
claudeModelsFromJson.some((m) => m.id === 'claude-sonnet-4-5-20250929'),
'captured --json output should still include the Claude default model id'
)
console.log('✓ provider models --quiet outputs model IDs only\n')
claudeModelsFromJson.some((m) => m.id === "claude-sonnet-4-5-20250929"),
"captured --json output should still include the Claude default model id",
);
console.log("✓ provider models --quiet outputs model IDs only\n");
}
} finally {
await ctx.stop()
await ctx.stop();
}
console.log('=== All provider tests passed ===')
console.log("=== All provider tests passed ===");
+59 -60
View File
@@ -10,103 +10,102 @@
* - Graceful daemon connection errors
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
console.log('=== Agent Update Command Tests ===\n')
console.log("=== Agent Update Command Tests ===\n");
// Resolve an available local port so daemon-not-running checks stay deterministic.
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
try {
// Test 1: agent update --help shows options
{
console.log('Test 1: agent update --help shows options')
const result = await $`npx paseo agent update --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent update --help should exit 0')
assert(result.stdout.includes('--name'), 'help should mention --name flag')
assert(result.stdout.includes('--label'), 'help should mention --label flag')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('<id>'), 'help should mention required id argument')
console.log('✓ agent update --help shows options\n')
console.log("Test 1: agent update --help shows options");
const result = await $`npx paseo agent update --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent update --help should exit 0");
assert(result.stdout.includes("--name"), "help should mention --name flag");
assert(result.stdout.includes("--label"), "help should mention --label flag");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("<id>"), "help should mention required id argument");
console.log("✓ agent update --help shows options\n");
}
// Test 2: agent update requires ID argument
{
console.log('Test 2: agent update requires ID argument')
console.log("Test 2: agent update requires ID argument");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without id')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update --name "New Name"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without id");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ agent update requires ID argument\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument") ||
output.toLowerCase().includes("id");
assert(hasError, "error should mention missing argument");
console.log("✓ agent update requires ID argument\n");
}
// Test 3: agent update requires at least one update field
{
console.log('Test 3: agent update requires update field')
console.log("Test 3: agent update requires update field");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail without --name/--label')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail without --name/--label");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('nothing to update') ||
output.toLowerCase().includes('name') ||
output.toLowerCase().includes('label')
assert(hasError, 'error should mention missing update fields')
console.log('✓ agent update requires update field\n')
output.toLowerCase().includes("nothing to update") ||
output.toLowerCase().includes("name") ||
output.toLowerCase().includes("label");
assert(hasError, "error should mention missing update fields");
console.log("✓ agent update requires update field\n");
}
// Test 4: agent update handles daemon not running
{
console.log('Test 4: agent update handles daemon not running')
console.log("Test 4: agent update handles daemon not running");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow()
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --name "Renamed Agent"`.nothrow();
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error should mention connection issue')
console.log('✓ agent update handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error should mention connection issue");
console.log("✓ agent update handles daemon not running\n");
}
// Test 5: agent update accepts multiple/comma-separated labels
{
console.log('Test 5: agent update accepts multi-label syntax')
console.log("Test 5: agent update accepts multi-label syntax");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --label flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ agent update accepts multi-label syntax\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npx paseo agent update abc123 --label surface=workspace,area=frontend --label priority=high`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --label flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ agent update accepts multi-label syntax\n");
}
// Test 6: agent --help shows update subcommand
{
console.log('Test 6: agent --help shows update subcommand')
const result = await $`npx paseo agent --help`.nothrow()
assert.strictEqual(result.exitCode, 0, 'agent --help should exit 0')
assert(result.stdout.includes('update'), 'help should mention update subcommand')
console.log('✓ agent --help shows update subcommand\n')
console.log("Test 6: agent --help shows update subcommand");
const result = await $`npx paseo agent --help`.nothrow();
assert.strictEqual(result.exitCode, 0, "agent --help should exit 0");
assert(result.stdout.includes("update"), "help should mention update subcommand");
console.log("✓ agent --help shows update subcommand\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All agent update tests passed ===')
console.log("=== All agent update tests passed ===");
+63 -43
View File
@@ -1,65 +1,85 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { readFile, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { readFile, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
console.log('=== Onboarding Command ===\n')
console.log("=== Onboarding Command ===\n");
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-onboard-home-'))
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-onboard-home-"));
const port = await getAvailablePort();
try {
console.log('Test 1: `paseo` runs blocking onboarding and prints pairing info')
console.log("Test 1: `paseo` runs blocking onboarding and prints pairing info");
const onboard =
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_PAIRING_QR=0 npx paseo`.nothrow();
assert.strictEqual(
onboard.exitCode,
0,
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`
)
assert(onboard.stdout.includes('Scan to pair'), 'onboard output should include scan header')
assert(onboard.stdout.includes('Pairing link'), 'onboard output should include pairing link header')
assert(onboard.stdout.includes('#offer='), 'onboard output should include pairing offer URL')
assert(onboard.stdout.includes('CLI quick reference'), 'onboard output should include CLI quick reference')
assert(onboard.stdout.includes('paseo --help'), 'onboard output should include --help shortcut')
assert(onboard.stdout.includes('paseo ls'), 'onboard output should include ls shortcut')
assert(onboard.stdout.includes('paseo run "your prompt"'), 'onboard output should include run shortcut')
assert(onboard.stdout.includes('paseo status'), 'onboard output should include status shortcut')
assert(onboard.stdout.includes(join(paseoHome, 'daemon.log')), 'onboard output should include daemon log path')
`onboard should succeed:\nstdout:\n${onboard.stdout}\nstderr:\n${onboard.stderr}`,
);
assert(onboard.stdout.includes("Scan to pair"), "onboard output should include scan header");
assert(
onboard.stdout.includes("Pairing link"),
"onboard output should include pairing link header",
);
assert(onboard.stdout.includes("#offer="), "onboard output should include pairing offer URL");
assert(
onboard.stdout.includes("CLI quick reference"),
"onboard output should include CLI quick reference",
);
assert(onboard.stdout.includes("paseo --help"), "onboard output should include --help shortcut");
assert(onboard.stdout.includes("paseo ls"), "onboard output should include ls shortcut");
assert(
onboard.stdout.includes('paseo run "your prompt"'),
"onboard output should include run shortcut",
);
assert(onboard.stdout.includes("paseo status"), "onboard output should include status shortcut");
assert(
onboard.stdout.includes(join(paseoHome, "daemon.log")),
"onboard output should include daemon log path",
);
const status =
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow()
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`)
assert(status.stdout.includes('running'), 'daemon should be running when onboarding exits')
console.log('✓ onboarding prints pairing info and waits for daemon readiness\n')
await $`PASEO_HOME=${paseoHome} npx paseo daemon status --home ${paseoHome}`.nothrow();
assert.strictEqual(status.exitCode, 0, `daemon status should succeed: ${status.stderr}`);
assert(status.stdout.includes("running"), "daemon should be running when onboarding exits");
console.log("✓ onboarding prints pairing info and waits for daemon readiness\n");
console.log('Test 2: non-interactive onboarding persists voice disabled config')
const configRaw = await readFile(join(paseoHome, 'config.json'), 'utf-8')
console.log("Test 2: non-interactive onboarding persists voice disabled config");
const configRaw = await readFile(join(paseoHome, "config.json"), "utf-8");
const config = JSON.parse(configRaw) as {
features?: {
dictation?: { enabled?: boolean }
voiceMode?: { enabled?: boolean }
}
}
dictation?: { enabled?: boolean };
voiceMode?: { enabled?: boolean };
};
};
assert.strictEqual(config.features?.dictation?.enabled, false, 'dictation.enabled should be false')
assert.strictEqual(config.features?.voiceMode?.enabled, false, 'voiceMode.enabled should be false')
const daemonLog = await readFile(join(paseoHome, 'daemon.log'), 'utf-8')
assert.strictEqual(
config.features?.dictation?.enabled,
false,
"dictation.enabled should be false",
);
assert.strictEqual(
config.features?.voiceMode?.enabled,
false,
"voiceMode.enabled should be false",
);
const daemonLog = await readFile(join(paseoHome, "daemon.log"), "utf-8");
assert(
!daemonLog.includes('Ensuring local speech models'),
'daemon should not attempt local speech model setup when voice is disabled'
)
console.log('✓ non-interactive run persisted voice disabled choices\n')
!daemonLog.includes("Ensuring local speech models"),
"daemon should not attempt local speech model setup when voice is disabled",
);
console.log("✓ non-interactive run persisted voice disabled choices\n");
} finally {
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== Onboarding tests passed ===')
console.log("=== Onboarding tests passed ===");
@@ -6,40 +6,40 @@
* Tests pure helpers that do not require a running daemon.
*/
import assert from 'node:assert'
import { resolveTcpHostFromListen } from '../src/commands/daemon/local-daemon.js'
import assert from "node:assert";
import { resolveTcpHostFromListen } from "../src/commands/daemon/local-daemon.js";
console.log('=== Local Daemon Utility Helpers ===\n')
console.log("=== Local Daemon Utility Helpers ===\n");
{
console.log('Test 1: resolves numeric listen values to localhost host:port')
assert.strictEqual(resolveTcpHostFromListen('6767'), '127.0.0.1:6767')
assert.strictEqual(resolveTcpHostFromListen(' 7777 '), '127.0.0.1:7777')
console.log('✓ resolves numeric listen values\n')
console.log("Test 1: resolves numeric listen values to localhost host:port");
assert.strictEqual(resolveTcpHostFromListen("6767"), "127.0.0.1:6767");
assert.strictEqual(resolveTcpHostFromListen(" 7777 "), "127.0.0.1:7777");
console.log("✓ resolves numeric listen values\n");
}
{
console.log('Test 2: preserves explicit host:port listen values')
assert.strictEqual(resolveTcpHostFromListen('localhost:6767'), 'localhost:6767')
assert.strictEqual(resolveTcpHostFromListen('0.0.0.0:8080'), '0.0.0.0:8080')
console.log('✓ preserves explicit host:port values\n')
console.log("Test 2: preserves explicit host:port listen values");
assert.strictEqual(resolveTcpHostFromListen("localhost:6767"), "localhost:6767");
assert.strictEqual(resolveTcpHostFromListen("0.0.0.0:8080"), "0.0.0.0:8080");
console.log("✓ preserves explicit host:port values\n");
}
{
console.log('Test 3: rejects unix socket listen values')
assert.strictEqual(resolveTcpHostFromListen('/tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('unix:///tmp/paseo.sock'), null)
assert.strictEqual(resolveTcpHostFromListen('pipe://\\\\.\\pipe\\paseo-managed-test'), null)
assert.strictEqual(resolveTcpHostFromListen('\\\\.\\pipe\\paseo-managed-test'), null)
console.log('✓ rejects unix socket listen values\n')
console.log("Test 3: rejects unix socket listen values");
assert.strictEqual(resolveTcpHostFromListen("/tmp/paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("unix:///tmp/paseo.sock"), null);
assert.strictEqual(resolveTcpHostFromListen("pipe://\\\\.\\pipe\\paseo-managed-test"), null);
assert.strictEqual(resolveTcpHostFromListen("\\\\.\\pipe\\paseo-managed-test"), null);
console.log("✓ rejects unix socket listen values\n");
}
{
console.log('Test 4: rejects empty and non-host listen values')
assert.strictEqual(resolveTcpHostFromListen(''), null)
assert.strictEqual(resolveTcpHostFromListen(' '), null)
assert.strictEqual(resolveTcpHostFromListen('localhost'), null)
console.log('✓ rejects empty and non-host listen values\n')
console.log("Test 4: rejects empty and non-host listen values");
assert.strictEqual(resolveTcpHostFromListen(""), null);
assert.strictEqual(resolveTcpHostFromListen(" "), null);
assert.strictEqual(resolveTcpHostFromListen("localhost"), null);
console.log("✓ rejects empty and non-host listen values\n");
}
console.log('=== All local daemon utility tests passed ===')
console.log("=== All local daemon utility tests passed ===");
+12 -12
View File
@@ -1,22 +1,22 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { getErrorMessage } from '../src/utils/errors.js'
import assert from "node:assert";
import { getErrorMessage } from "../src/utils/errors.js";
console.log('=== Error Utils ===\n')
console.log("=== Error Utils ===\n");
{
console.log('Test 1: returns Error.message for Error instances')
assert.strictEqual(getErrorMessage(new Error('boom')), 'boom')
console.log('✓ returns Error.message\n')
console.log("Test 1: returns Error.message for Error instances");
assert.strictEqual(getErrorMessage(new Error("boom")), "boom");
console.log("✓ returns Error.message\n");
}
{
console.log('Test 2: stringifies non-Error values')
assert.strictEqual(getErrorMessage('plain string'), 'plain string')
assert.strictEqual(getErrorMessage(42), '42')
assert.strictEqual(getErrorMessage(null), 'null')
console.log('✓ stringifies non-Error values\n')
console.log("Test 2: stringifies non-Error values");
assert.strictEqual(getErrorMessage("plain string"), "plain string");
assert.strictEqual(getErrorMessage(42), "42");
assert.strictEqual(getErrorMessage(null), "null");
console.log("✓ stringifies non-Error values\n");
}
console.log('=== All error utility tests passed ===')
console.log("=== All error utility tests passed ===");
+19 -19
View File
@@ -1,38 +1,38 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from '../src/commands/worktree/ls.js'
import assert from "node:assert";
import { homedir } from "node:os";
import { join } from "node:path";
import { resolvePaseoHomePath, resolvePaseoWorktreesDir } from "../src/commands/worktree/ls.js";
console.log('=== Worktree LS Path Helper Tests ===\n')
console.log("=== Worktree LS Path Helper Tests ===\n");
const originalPaseoHome = process.env.PASEO_HOME
const originalPaseoHome = process.env.PASEO_HOME;
try {
{
console.log('Test 1: resolves explicit PASEO_HOME when set')
process.env.PASEO_HOME = '/tmp/paseo-explicit-home'
console.log("Test 1: resolves explicit PASEO_HOME when set");
process.env.PASEO_HOME = "/tmp/paseo-explicit-home";
assert.strictEqual(resolvePaseoHomePath(), '/tmp/paseo-explicit-home')
assert.strictEqual(resolvePaseoWorktreesDir(), '/tmp/paseo-explicit-home/worktrees')
console.log('\u2713 explicit PASEO_HOME is respected\n')
assert.strictEqual(resolvePaseoHomePath(), "/tmp/paseo-explicit-home");
assert.strictEqual(resolvePaseoWorktreesDir(), "/tmp/paseo-explicit-home/worktrees");
console.log("\u2713 explicit PASEO_HOME is respected\n");
}
{
console.log('Test 2: falls back to homedir/.paseo when PASEO_HOME is unset')
delete process.env.PASEO_HOME
console.log("Test 2: falls back to homedir/.paseo when PASEO_HOME is unset");
delete process.env.PASEO_HOME;
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), '.paseo'))
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), '.paseo', 'worktrees'))
console.log('\u2713 fallback home path is derived from os.homedir()\n')
assert.strictEqual(resolvePaseoHomePath(), join(homedir(), ".paseo"));
assert.strictEqual(resolvePaseoWorktreesDir(), join(homedir(), ".paseo", "worktrees"));
console.log("\u2713 fallback home path is derived from os.homedir()\n");
}
} finally {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome
process.env.PASEO_HOME = originalPaseoHome;
}
}
console.log('=== All worktree ls path helper tests passed ===')
console.log("=== All worktree ls path helper tests passed ===");
@@ -1,126 +1,126 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import assert from "node:assert";
import {
resolveProviderAndModel,
resolveStructuredResponseMessage,
type StructuredResponseTimelineClient,
} from '../src/commands/agent/run.ts'
} from "../src/commands/agent/run.ts";
type TimelineEntry = {
item: {
type: string
text?: string
}
}
type: string;
text?: string;
};
};
function createClient(options: {
entries?: TimelineEntry[]
throwOnFetch?: boolean
onFetch?: () => void
entries?: TimelineEntry[];
throwOnFetch?: boolean;
onFetch?: () => void;
}): StructuredResponseTimelineClient {
return {
fetchAgentTimeline: async () => {
options.onFetch?.()
options.onFetch?.();
if (options.throwOnFetch) {
throw new Error('timeline unavailable')
throw new Error("timeline unavailable");
}
return {
entries: options.entries ?? [],
} as Awaited<ReturnType<StructuredResponseTimelineClient['fetchAgentTimeline']>>
} as Awaited<ReturnType<StructuredResponseTimelineClient["fetchAgentTimeline"]>>;
},
}
};
}
console.log('=== Run Output Schema Helper Tests ===\n')
console.log("=== Run Output Schema Helper Tests ===\n");
// Test 1: Direct lastMessage is returned without timeline fetch.
{
let fetchCount = 0
const client = createClient({ onFetch: () => fetchCount += 1 })
let fetchCount = 0;
const client = createClient({ onFetch: () => (fetchCount += 1) });
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-1',
agentId: "agent-1",
lastMessage: ' {"summary":"ok"} ',
})
assert.strictEqual(result, '{"summary":"ok"}')
assert.strictEqual(fetchCount, 0, 'should not fetch timeline when lastMessage exists')
console.log('✓ returns direct lastMessage when present')
});
assert.strictEqual(result, '{"summary":"ok"}');
assert.strictEqual(fetchCount, 0, "should not fetch timeline when lastMessage exists");
console.log("✓ returns direct lastMessage when present");
}
// Test 2: Falls back to latest assistant message from timeline.
{
const client = createClient({
entries: [
{ item: { type: 'user_message', text: 'prompt' } },
{ item: { type: 'assistant_message', text: '' } },
{ item: { type: 'assistant_message', text: ' {"summary":"from timeline"} ' } },
{ item: { type: "user_message", text: "prompt" } },
{ item: { type: "assistant_message", text: "" } },
{ item: { type: "assistant_message", text: ' {"summary":"from timeline"} ' } },
],
})
});
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-2',
agentId: "agent-2",
lastMessage: null,
})
assert.strictEqual(result, '{"summary":"from timeline"}')
console.log('✓ falls back to latest assistant timeline entry')
});
assert.strictEqual(result, '{"summary":"from timeline"}');
console.log("✓ falls back to latest assistant timeline entry");
}
// Test 3: Returns null when timeline has no assistant messages.
{
const client = createClient({
entries: [
{ item: { type: 'user_message', text: 'prompt' } },
{ item: { type: 'reasoning', text: 'thinking' } },
{ item: { type: "user_message", text: "prompt" } },
{ item: { type: "reasoning", text: "thinking" } },
],
})
});
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-3',
agentId: "agent-3",
lastMessage: null,
})
assert.strictEqual(result, null)
console.log('✓ returns null when no assistant messages exist')
});
assert.strictEqual(result, null);
console.log("✓ returns null when no assistant messages exist");
}
// Test 4: Returns null if timeline fetch fails.
{
const client = createClient({ throwOnFetch: true })
const client = createClient({ throwOnFetch: true });
const result = await resolveStructuredResponseMessage({
client,
agentId: 'agent-4',
agentId: "agent-4",
lastMessage: null,
})
assert.strictEqual(result, null)
console.log('✓ returns null when timeline fetch throws')
});
assert.strictEqual(result, null);
console.log("✓ returns null when timeline fetch throws");
}
// Test 5: Provider/model slash syntax resolves both values.
{
const result = resolveProviderAndModel({ provider: 'codex/gpt-5.4' })
const result = resolveProviderAndModel({ provider: "codex/gpt-5.4" });
assert.deepStrictEqual(result, {
provider: 'codex',
model: 'gpt-5.4',
})
console.log('✓ resolves provider/model slash syntax')
provider: "codex",
model: "gpt-5.4",
});
console.log("✓ resolves provider/model slash syntax");
}
// Test 6: Explicit matching --model coexists with slash syntax.
{
const result = resolveProviderAndModel({ provider: 'codex/gpt-5.4', model: 'gpt-5.4' })
const result = resolveProviderAndModel({ provider: "codex/gpt-5.4", model: "gpt-5.4" });
assert.deepStrictEqual(result, {
provider: 'codex',
model: 'gpt-5.4',
})
console.log('✓ accepts matching explicit model with slash syntax')
provider: "codex",
model: "gpt-5.4",
});
console.log("✓ accepts matching explicit model with slash syntax");
}
// Test 7: Conflicting --model is rejected.
{
assert.throws(() => resolveProviderAndModel({ provider: 'codex/gpt-5.4', model: 'gpt-5.5' }), {
message: 'Conflicting model values provided',
})
console.log('✓ rejects conflicting explicit model with slash syntax')
assert.throws(() => resolveProviderAndModel({ provider: "codex/gpt-5.4", model: "gpt-5.5" }), {
message: "Conflicting model values provided",
});
console.log("✓ rejects conflicting explicit model with slash syntax");
}
console.log('\n=== All helper tests passed ===')
console.log("\n=== All helper tests passed ===");
@@ -5,223 +5,235 @@
* without allowing daemon-runner to respawn a new worker process.
*/
import assert from 'node:assert'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
const pollIntervalMs = 100
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch {
return false
return false;
}
}
type PidLockState = {
pid: number | null
}
pid: number | null;
};
async function readPidLockState(paseoHome: string): Promise<PidLockState> {
const pidPath = join(paseoHome, 'paseo.pid')
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, 'utf-8')
const parsed = JSON.parse(content) as { pid?: unknown }
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { pid }
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { pid };
} catch {
return { pid: null }
return { pid: null };
}
}
function readProcessCommand(pid: number): string | null {
const result = spawnSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' })
const result = spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null
return null;
}
const command = result.stdout.trim()
return command.length > 0 ? command : null
const command = result.stdout.trim();
return command.length > 0 ? command : null;
}
type DaemonStatus = {
status: string | null
pid: number | null
}
status: string | null;
pid: number | null;
};
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { status: null, pid: null }
return { status: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
const status = typeof parsed.status === "string" ? parsed.status : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { status, pid };
} catch {
return { status: null, pid: null }
return { status: null, pid: null };
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return
return;
}
await sleep(pollIntervalMs)
await sleep(pollIntervalMs);
}
throw new Error(message)
throw new Error(message);
}
console.log('=== Daemon Stop (supervisor regression) ===\n')
console.log("=== Daemon Stop (supervisor regression) ===\n");
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-supervisor-"));
const cliRoot = join(import.meta.dirname, "..");
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ['ignore', 'pipe', 'pipe'],
})
stdio: ["ignore", "pipe", "pipe"],
});
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
supervisorProcess.stderr?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
const status = await readDaemonStatus(paseoHome);
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
},
120000,
'daemon did not become running in time'
)
"daemon did not become running in time",
);
const statusBeforeStop = await readDaemonStatus(paseoHome)
const daemonPid = statusBeforeStop.pid
assert.strictEqual(statusBeforeStop.status, 'running', 'daemon should be running before stop')
assert(daemonPid !== null, 'daemon pid should exist once daemon starts')
assert(isProcessRunning(daemonPid), 'daemon process should be running')
const pidLockBeforeStop = await readPidLockState(paseoHome)
assert.strictEqual(pidLockBeforeStop.pid, daemonPid, 'pid lock should match status pid')
const command = readProcessCommand(daemonPid)
assert(command !== null, 'pid lock pid should resolve to a running process command')
const statusBeforeStop = await readDaemonStatus(paseoHome);
const daemonPid = statusBeforeStop.pid;
assert.strictEqual(statusBeforeStop.status, "running", "daemon should be running before stop");
assert(daemonPid !== null, "daemon pid should exist once daemon starts");
assert(isProcessRunning(daemonPid), "daemon process should be running");
const pidLockBeforeStop = await readPidLockState(paseoHome);
assert.strictEqual(pidLockBeforeStop.pid, daemonPid, "pid lock should match status pid");
const command = readProcessCommand(daemonPid);
assert(command !== null, "pid lock pid should resolve to a running process command");
assert(
command.includes('daemon-runner.ts') || command.includes('daemon-runner.js'),
`pid lock pid should be daemon-runner process, got: ${command}`
)
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`)
command.includes("daemon-runner.ts") || command.includes("daemon-runner.js"),
`pid lock pid should be daemon-runner process, got: ${command}`,
);
console.log(`✓ dev daemon started with daemon pid ${daemonPid}\n`);
console.log('Test 2: `paseo daemon stop` should stop without respawn')
console.log("Test 2: `paseo daemon stop` should stop without respawn");
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(stopJson.action, 'stopped', 'stop should report stopped action')
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
const stopJson = JSON.parse(stopResult.stdout) as { action?: unknown };
assert.strictEqual(stopJson.action, "stopped", "stop should report stopped action");
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after stop')
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.status === "stopped";
},
15000,
"daemon status did not transition to stopped after stop",
);
if (supervisorProcess.pid) {
await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
15000,
'daemon-runner supervisor remained running after stop'
)
"daemon-runner supervisor remained running after stop",
);
}
await sleep(1000)
await sleep(1000);
const pidAfterStop = await readPidLockState(paseoHome)
const respawned = pidAfterStop.pid !== null && isProcessRunning(pidAfterStop.pid)
const pidAfterStop = await readPidLockState(paseoHome);
const respawned = pidAfterStop.pid !== null && isProcessRunning(pidAfterStop.pid);
assert.strictEqual(
respawned,
false,
`daemon respawned after stop (pid: ${pidAfterStop.pid ?? 'unknown'})`
)
`daemon respawned after stop (pid: ${pidAfterStop.pid ?? "unknown"})`,
);
const statusAfterStop = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterStop.status, 'stopped', 'daemon should remain stopped after stop command')
const statusAfterStop = await readDaemonStatus(paseoHome);
assert.strictEqual(
statusAfterStop.status,
"stopped",
"daemon should remain stopped after stop command",
);
assert(
recentSupervisorLogs.includes('Shutdown requested by worker. Stopping worker...'),
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`
)
recentSupervisorLogs.includes("Shutdown requested by worker. Stopping worker..."),
`stop should request lifecycle shutdown from daemon worker, logs:\n${recentSupervisorLogs}`,
);
assert(
!recentSupervisorLogs.includes('cli_shutdown'),
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`
)
console.log('✓ stop leaves supervised daemon stopped (no respawn)\n')
!recentSupervisorLogs.includes("cli_shutdown"),
`supervisor logs should not route shutdown by reason string:\n${recentSupervisorLogs}`,
);
console.log("✓ stop leaves supervised daemon stopped (no respawn)\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
supervisorProcess?.kill('SIGKILL')
}
)
supervisorProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
5000,
"supervisor cleanup timed out",
).catch(() => {
supervisorProcess?.kill("SIGKILL");
});
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
if (recentSupervisorLogs.trim().length === 0) {
// Keep output stable while still surfacing that logs were captured when needed.
console.log('(no supervisor logs captured)')
console.log("(no supervisor logs captured)");
}
console.log('=== Supervisor stop regression test passed ===')
console.log("=== Supervisor stop regression test passed ===");
@@ -5,213 +5,224 @@
* graceful daemon lifecycle shutdown to complete (no early forced exit path).
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
const pollIntervalMs = 100
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch {
return false
return false;
}
}
function signalProcessGroup(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
if (process.platform === 'win32') {
if (process.platform === "win32") {
try {
process.kill(pid, signal)
return true
process.kill(pid, signal);
return true;
} catch {
return false
return false;
}
}
try {
process.kill(-pid, signal)
return true
process.kill(-pid, signal);
return true;
} catch {
return false
return false;
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
status: string | null;
pid: number | null;
};
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { status: null, pid: null }
return { status: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
const status = typeof parsed.status === "string" ? parsed.status : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { status, pid };
} catch {
return { status: null, pid: null }
return { status: null, pid: null };
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return
return;
}
await sleep(pollIntervalMs)
await sleep(pollIntervalMs);
}
throw new Error(message)
throw new Error(message);
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
code: number | null;
signal: NodeJS.Signals | null;
};
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
console.log('=== Daemon SIGINT (supervisor regression) ===\n')
console.log("=== Daemon SIGINT (supervisor regression) ===\n");
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-sigint-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-sigint-supervisor-"));
const cliRoot = join(import.meta.dirname, "..");
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `127.0.0.1:${port}`,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
supervisorProcess.stderr?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
const status = await readDaemonStatus(paseoHome);
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
},
120000,
'daemon did not become running in time'
)
"daemon did not become running in time",
);
console.log('✓ supervised daemon started\n')
console.log("✓ supervised daemon started\n");
console.log('Test 2: single SIGINT should shutdown gracefully without forced exit')
const exitPromise = waitForProcessExit(supervisorProcess, 30000)
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, 'SIGINT')
console.log("Test 2: single SIGINT should shutdown gracefully without forced exit");
const exitPromise = waitForProcessExit(supervisorProcess, 30000);
const signaledGroup = signalProcessGroup(supervisorProcess.pid ?? -1, "SIGINT");
if (!signaledGroup) {
supervisorProcess.kill('SIGINT')
supervisorProcess.kill("SIGINT");
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `supervisor should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`)
const exit = await exitPromise;
assert.strictEqual(
exit.signal,
null,
`supervisor should exit cleanly, got signal=${exit.signal}`,
);
assert.strictEqual(exit.code, 0, `supervisor should exit with status 0, got code=${exit.code}`);
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after SIGINT')
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.status === "stopped";
},
15000,
"daemon status did not transition to stopped after SIGINT",
);
assert(
!recentSupervisorLogs.includes('Forcing exit...'),
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`
)
!recentSupervisorLogs.includes("Forcing exit..."),
`worker entered forced-exit path during single SIGINT:\n${recentSupervisorLogs}`,
);
assert(
!recentSupervisorLogs.includes("Forcing shutdown - HTTP server didn't close in time"),
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`
)
`worker hit shutdown timeout during single SIGINT:\n${recentSupervisorLogs}`,
);
console.log('✓ single SIGINT completed graceful shutdown without forced exit\n')
console.log("✓ single SIGINT completed graceful shutdown without forced exit\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
const signaledGroup = signalProcessGroup(supervisorProcess.pid, 'SIGTERM')
const signaledGroup = signalProcessGroup(supervisorProcess.pid, "SIGTERM");
if (!signaledGroup) {
supervisorProcess.kill('SIGTERM')
supervisorProcess.kill("SIGTERM");
}
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, 'SIGKILL')
if (!killedGroup) {
supervisorProcess?.kill('SIGKILL')
}
await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
5000,
"supervisor cleanup timed out",
).catch(() => {
const killedGroup = signalProcessGroup(supervisorProcess!.pid ?? -1, "SIGKILL");
if (!killedGroup) {
supervisorProcess?.kill("SIGKILL");
}
)
});
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
console.log("(no supervisor logs captured)");
}
console.log('=== Supervisor SIGINT regression test passed ===')
console.log("=== Supervisor SIGINT regression test passed ===");
@@ -5,111 +5,115 @@
* must not discover/kill processes via home-scoped `ps` command heuristics.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
$.verbose = false
$.verbose = false;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch {
return false
return false;
}
}
async function waitForRunning(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (isProcessRunning(pid)) {
return
return;
}
await sleep(50)
await sleep(50);
}
throw new Error(`Process ${pid} did not become running in time`)
throw new Error(`Process ${pid} did not become running in time`);
}
console.log('=== Daemon Stop Ownership Regression ===\n')
console.log("=== Daemon Stop Ownership Regression ===\n");
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-stop-ownership-'))
let decoyProcess: ChildProcess | null = null
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-stop-ownership-"));
let decoyProcess: ChildProcess | null = null;
try {
console.log('Test 1: start decoy process with daemon-like command markers')
console.log("Test 1: start decoy process with daemon-like command markers");
decoyProcess = spawn(
process.execPath,
[
'-e',
"-e",
// Keep the process alive long enough for stop command assertions.
'setInterval(() => {}, 1000)',
'daemon-runner.ts',
"setInterval(() => {}, 1000)",
"daemon-runner.ts",
],
{
env: {
...process.env,
PASEO_HOME: paseoHome,
},
stdio: 'ignore',
detached: process.platform !== 'win32',
}
)
decoyProcess.unref()
stdio: "ignore",
detached: process.platform !== "win32",
},
);
decoyProcess.unref();
const decoyPid = decoyProcess.pid
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, 'decoy pid should exist')
await waitForRunning(decoyPid!, 5000)
console.log(`✓ decoy process started (${decoyPid})\n`)
const decoyPid = decoyProcess.pid;
assert(Number.isInteger(decoyPid) && (decoyPid ?? 0) > 0, "decoy pid should exist");
await waitForRunning(decoyPid!, 5000);
console.log(`✓ decoy process started (${decoyPid})\n`);
console.log('Test 2: daemon stop should report not_running and leave decoy untouched')
console.log("Test 2: daemon stop should report not_running and leave decoy untouched");
const stopResult =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow()
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`)
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --json`.nothrow();
assert.strictEqual(stopResult.exitCode, 0, `stop should succeed: ${stopResult.stderr}`);
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown }
assert.strictEqual(parsed.action, 'not_running', `stop should not target decoy process: ${stopResult.stdout}`)
assert(isProcessRunning(decoyPid!), 'decoy process must remain alive after stop')
const parsed = JSON.parse(stopResult.stdout) as { action?: unknown };
assert.strictEqual(
parsed.action,
"not_running",
`stop should not target decoy process: ${stopResult.stdout}`,
);
assert(isProcessRunning(decoyPid!), "decoy process must remain alive after stop");
console.log('✓ stop is ownership-driven and does not kill decoy process\n')
console.log("✓ stop is ownership-driven and does not kill decoy process\n");
} finally {
if (decoyProcess?.pid && isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGTERM')
process.kill(decoyProcess.pid, "SIGTERM");
} catch {
// ignore
}
await sleep(100)
await sleep(100);
if (isProcessRunning(decoyProcess.pid)) {
try {
process.kill(decoyProcess.pid, 'SIGKILL')
process.kill(decoyProcess.pid, "SIGKILL");
} catch {
// ignore
}
}
}
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
await $`PASEO_HOME=${paseoHome} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== Daemon stop ownership regression test passed ===')
console.log("=== Daemon stop ownership regression test passed ===");
@@ -5,210 +5,240 @@
* (worker PID changes) while keeping the daemon healthy.
*/
import assert from 'node:assert'
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
const pollIntervalMs = 100
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch {
return false
return false;
}
}
function readWorkerPid(supervisorPid: number): number | null {
if (!Number.isInteger(supervisorPid) || supervisorPid <= 0) {
return null
return null;
}
const result = spawnSync('ps', ['ax', '-o', 'pid=,ppid='], { encoding: 'utf8' })
const result = spawnSync("ps", ["ax", "-o", "pid=,ppid="], { encoding: "utf8" });
if (result.status !== 0 || result.error) {
return null
return null;
}
for (const line of result.stdout.split('\n')) {
const trimmed = line.trim()
for (const line of result.stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) {
continue
continue;
}
const [pidToken, ppidToken] = trimmed.split(/\s+/)
const pid = Number.parseInt(pidToken ?? '', 10)
const ppid = Number.parseInt(ppidToken ?? '', 10)
const [pidToken, ppidToken] = trimmed.split(/\s+/);
const pid = Number.parseInt(pidToken ?? "", 10);
const ppid = Number.parseInt(ppidToken ?? "", 10);
if (!Number.isInteger(pid) || !Number.isInteger(ppid)) {
continue
continue;
}
if (ppid === supervisorPid && pid > 0) {
return pid
return pid;
}
}
return null
return null;
}
type DaemonStatus = {
status: string | null
pid: number | null
}
status: string | null;
pid: number | null;
};
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { status: null, pid: null }
return { status: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
const status = typeof parsed.status === "string" ? parsed.status : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { status, pid };
} catch {
return { status: null, pid: null }
return { status: null, pid: null };
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return
return;
}
await sleep(pollIntervalMs)
await sleep(pollIntervalMs);
}
throw new Error(message)
throw new Error(message);
}
console.log('=== Daemon Restart (supervisor regression) ===\n')
console.log("=== Daemon Restart (supervisor regression) ===\n");
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-supervisor-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-supervisor-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let supervisorProcess: ChildProcess | null = null
let recentSupervisorLogs = ''
let supervisorProcess: ChildProcess | null = null;
let recentSupervisorLogs = "";
try {
console.log('Test 1: start daemon-runner in dev mode with isolated PASEO_HOME')
console.log("Test 1: start daemon-runner in dev mode with isolated PASEO_HOME");
supervisorProcess = spawn('npx', ['tsx', '../server/scripts/daemon-runner.ts', '--dev'], {
supervisorProcess = spawn("npx", ["tsx", "../server/scripts/daemon-runner.ts", "--dev"], {
cwd: cliRoot,
env: {
...process.env,
...testEnv,
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ['ignore', 'pipe', 'pipe'],
})
stdio: ["ignore", "pipe", "pipe"],
});
supervisorProcess.stdout?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stderr?.on('data', (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000)
})
supervisorProcess.stdout?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
supervisorProcess.stderr?.on("data", (chunk) => {
recentSupervisorLogs = (recentSupervisorLogs + chunk.toString()).slice(-8000);
});
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
const status = await readDaemonStatus(paseoHome);
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
},
120000,
'daemon did not become running in time'
)
"daemon did not become running in time",
);
const statusBeforeRestart = await readDaemonStatus(paseoHome)
const supervisorPid = statusBeforeRestart.pid
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(supervisorPid !== null, 'supervisor pid should exist once daemon starts')
assert(isProcessRunning(supervisorPid), 'supervisor process should be running')
const workerPidBeforeRestart = readWorkerPid(supervisorPid)
assert(workerPidBeforeRestart !== null, 'supervisor should have a worker process before restart')
assert(isProcessRunning(workerPidBeforeRestart), 'worker process should be running before restart')
console.log(`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`)
const statusBeforeRestart = await readDaemonStatus(paseoHome);
const supervisorPid = statusBeforeRestart.pid;
assert.strictEqual(
statusBeforeRestart.status,
"running",
"daemon should be running before restart",
);
assert(supervisorPid !== null, "supervisor pid should exist once daemon starts");
assert(isProcessRunning(supervisorPid), "supervisor process should be running");
const workerPidBeforeRestart = readWorkerPid(supervisorPid);
assert(workerPidBeforeRestart !== null, "supervisor should have a worker process before restart");
assert(
isProcessRunning(workerPidBeforeRestart),
"worker process should be running before restart",
);
console.log(
`✓ daemon running with supervisor ${supervisorPid} and worker ${workerPidBeforeRestart}\n`,
);
console.log('Test 2: app-style restart request should restart worker and keep daemon healthy')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
console.log("Test 2: app-style restart request should restart worker and keep daemon healthy");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined)
await client?.close().catch(() => undefined);
}
await waitFor(() => {
const workerPid = readWorkerPid(supervisorPid)
return workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
}, 20000, 'worker pid did not change after restart request')
await waitFor(
() => {
const workerPid = readWorkerPid(supervisorPid);
return (
workerPid !== null && workerPid !== workerPidBeforeRestart && isProcessRunning(workerPid)
);
},
20000,
"worker pid did not change after restart request",
);
const workerPidAfterRestart = readWorkerPid(supervisorPid)
assert(workerPidAfterRestart !== null, 'worker process should exist after restart')
const workerPidAfterRestart = readWorkerPid(supervisorPid);
assert(workerPidAfterRestart !== null, "worker process should exist after restart");
assert.notStrictEqual(
workerPidAfterRestart,
workerPidBeforeRestart,
'worker pid should change after restart'
)
"worker pid should change after restart",
);
const statusAfterRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusAfterRestart.status, 'running', 'daemon should stay running after restart')
assert.strictEqual(statusAfterRestart.pid, supervisorPid, 'supervisor pid should remain stable across restart')
const statusAfterRestart = await readDaemonStatus(paseoHome);
assert.strictEqual(
statusAfterRestart.status,
"running",
"daemon should stay running after restart",
);
assert.strictEqual(
statusAfterRestart.pid,
supervisorPid,
"supervisor pid should remain stable across restart",
);
assert(
recentSupervisorLogs.includes('Restart requested by worker. Stopping worker for restart...'),
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`
)
console.log('✓ app-style restart keeps daemon healthy and restarts worker\n')
recentSupervisorLogs.includes("Restart requested by worker. Stopping worker for restart..."),
`restart should route through supervisor restart intent, logs:\n${recentSupervisorLogs}`,
);
console.log("✓ app-style restart keeps daemon healthy and restarts worker\n");
} finally {
if (supervisorProcess?.pid && isProcessRunning(supervisorProcess.pid)) {
supervisorProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(supervisorProcess!.pid ?? -1), 5000, 'supervisor cleanup timed out').catch(
() => {
supervisorProcess?.kill('SIGKILL')
}
)
supervisorProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(supervisorProcess!.pid ?? -1),
5000,
"supervisor cleanup timed out",
).catch(() => {
supervisorProcess?.kill("SIGKILL");
});
}
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow()
await rm(paseoHome, { recursive: true, force: true })
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon stop --home ${paseoHome} --force`.nothrow();
await rm(paseoHome, { recursive: true, force: true });
}
if (recentSupervisorLogs.trim().length === 0) {
console.log('(no supervisor logs captured)')
console.log("(no supervisor logs captured)");
}
console.log('=== Supervisor restart regression test passed ===')
console.log("=== Supervisor restart regression test passed ===");
@@ -5,198 +5,217 @@
* so an external owner can decide whether to respawn.
*/
import assert from 'node:assert'
import { spawn, type ChildProcess } from 'node:child_process'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { $ } from 'zx'
import { tryConnectToDaemon } from '../src/utils/client.ts'
import { getAvailablePort } from './helpers/network.ts'
import assert from "node:assert";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { $ } from "zx";
import { tryConnectToDaemon } from "../src/utils/client.ts";
import { getAvailablePort } from "./helpers/network.ts";
$.verbose = false
$.verbose = false;
const pollIntervalMs = 100
const pollIntervalMs = 100;
const testEnv = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isProcessRunning(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
try {
process.kill(pid, 0)
return true
process.kill(pid, 0);
return true;
} catch {
return false
return false;
}
}
type DaemonStatus = {
status: string | null
pid: number | null
}
status: string | null;
pid: number | null;
};
async function readDaemonStatus(paseoHome: string): Promise<DaemonStatus> {
const result =
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow()
await $`PASEO_HOME=${paseoHome} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnv.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnv.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnv.PASEO_VOICE_MODE_ENABLED} npx paseo daemon status --home ${paseoHome} --json`.nothrow();
if (result.exitCode !== 0) {
return { status: null, pid: null }
return { status: null, pid: null };
}
try {
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown }
const status = typeof parsed.status === 'string' ? parsed.status : null
const pid = typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null
return { status, pid }
const parsed = JSON.parse(result.stdout) as { status?: unknown; pid?: unknown };
const status = typeof parsed.status === "string" ? parsed.status : null;
const pid =
typeof parsed.pid === "number" && Number.isInteger(parsed.pid) && parsed.pid > 0
? parsed.pid
: null;
return { status, pid };
} catch {
return { status: null, pid: null }
return { status: null, pid: null };
}
}
async function waitFor(
check: () => Promise<boolean> | boolean,
timeoutMs: number,
message: string
message: string,
): Promise<void> {
const deadline = Date.now() + timeoutMs
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await check()) {
return
return;
}
await sleep(pollIntervalMs)
await sleep(pollIntervalMs);
}
throw new Error(message)
throw new Error(message);
}
type ExitResult = {
code: number | null
signal: NodeJS.Signals | null
}
code: number | null;
signal: NodeJS.Signals | null;
};
function waitForProcessExit(processRef: ChildProcess, timeoutMs: number): Promise<ExitResult> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('timed out waiting for process exit'))
}, timeoutMs)
reject(new Error("timed out waiting for process exit"));
}, timeoutMs);
processRef.once('exit', (code, signal) => {
clearTimeout(timeout)
resolve({ code, signal })
})
})
processRef.once("exit", (code, signal) => {
clearTimeout(timeout);
resolve({ code, signal });
});
});
}
async function readPidLockPid(paseoHome: string): Promise<number | null> {
const pidPath = join(paseoHome, 'paseo.pid')
const pidPath = join(paseoHome, "paseo.pid");
try {
const content = await readFile(pidPath, 'utf-8')
const parsed = JSON.parse(content) as { pid?: unknown }
if (typeof parsed.pid !== 'number' || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null
const content = await readFile(pidPath, "utf-8");
const parsed = JSON.parse(content) as { pid?: unknown };
if (typeof parsed.pid !== "number" || !Number.isInteger(parsed.pid) || parsed.pid <= 0) {
return null;
}
return parsed.pid
return parsed.pid;
} catch {
return null
return null;
}
}
console.log('=== Daemon Restart (unsupervised regression) ===\n')
console.log("=== Daemon Restart (unsupervised regression) ===\n");
const port = await getAvailablePort()
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-restart-unsupervised-'))
const cliRoot = join(import.meta.dirname, '..')
const host = `127.0.0.1:${port}`
const port = await getAvailablePort();
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-restart-unsupervised-"));
const cliRoot = join(import.meta.dirname, "..");
const host = `127.0.0.1:${port}`;
let daemonProcess: ChildProcess | null = null
let daemonProcess: ChildProcess | null = null;
try {
console.log('Test 1: start unsupervised daemon worker directly')
console.log("Test 1: start unsupervised daemon worker directly");
daemonProcess = spawn(
process.execPath,
[...process.execArgv, '--import', 'tsx', '../server/src/server/index.ts'],
[...process.execArgv, "--import", "tsx", "../server/src/server/index.ts"],
{
cwd: cliRoot,
env: {
...process.env,
...testEnv,
// This test validates direct unsupervised worker ownership semantics.
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
// which would delegate lock ownership away from this process and make
// daemon status checks fail to observe a running owner PID.
PASEO_PID_LOCK_MODE: 'self',
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: 'false',
CI: 'true',
cwd: cliRoot,
env: {
...process.env,
...testEnv,
// This test validates direct unsupervised worker ownership semantics.
// Agent-orchestrated shells may export PASEO_PID_LOCK_MODE=external,
// which would delegate lock ownership away from this process and make
// daemon status checks fail to observe a running owner PID.
PASEO_PID_LOCK_MODE: "self",
PASEO_HOME: paseoHome,
PASEO_LISTEN: host,
PASEO_RELAY_ENABLED: "false",
CI: "true",
},
stdio: ["ignore", "pipe", "pipe"],
},
stdio: ['ignore', 'pipe', 'pipe'],
}
)
);
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'running' && status.pid !== null && isProcessRunning(status.pid)
const status = await readDaemonStatus(paseoHome);
return status.status === "running" && status.pid !== null && isProcessRunning(status.pid);
},
120000,
'daemon did not become running in time'
)
"daemon did not become running in time",
);
const statusBeforeRestart = await readDaemonStatus(paseoHome)
assert.strictEqual(statusBeforeRestart.status, 'running', 'daemon should be running before restart')
assert(daemonProcess.pid, 'unsupervised daemon process pid should exist')
assert.strictEqual(statusBeforeRestart.pid, daemonProcess.pid, 'status pid should match daemon process pid')
const lockPid = await readPidLockPid(paseoHome)
assert.strictEqual(lockPid, daemonProcess.pid, 'unsupervised worker should own pid lock')
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`)
const statusBeforeRestart = await readDaemonStatus(paseoHome);
assert.strictEqual(
statusBeforeRestart.status,
"running",
"daemon should be running before restart",
);
assert(daemonProcess.pid, "unsupervised daemon process pid should exist");
assert.strictEqual(
statusBeforeRestart.pid,
daemonProcess.pid,
"status pid should match daemon process pid",
);
const lockPid = await readPidLockPid(paseoHome);
assert.strictEqual(lockPid, daemonProcess.pid, "unsupervised worker should own pid lock");
console.log(`✓ unsupervised daemon started with pid ${daemonProcess.pid}\n`);
console.log('Test 2: restart request should gracefully stop and exit code 0')
const client = await tryConnectToDaemon({ host, timeout: 5000 })
assert(client, 'daemon client should connect')
console.log("Test 2: restart request should gracefully stop and exit code 0");
const client = await tryConnectToDaemon({ host, timeout: 5000 });
assert(client, "daemon client should connect");
const exitPromise = waitForProcessExit(daemonProcess, 30000)
const exitPromise = waitForProcessExit(daemonProcess, 30000);
try {
const restartAck = await client.restartServer('settings_update')
assert.strictEqual(restartAck.status, 'restart_requested', 'restart request should be acknowledged')
const restartAck = await client.restartServer("settings_update");
assert.strictEqual(
restartAck.status,
"restart_requested",
"restart request should be acknowledged",
);
} finally {
await client?.close().catch(() => undefined)
await client?.close().catch(() => undefined);
}
const exit = await exitPromise
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`)
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`)
const exit = await exitPromise;
assert.strictEqual(exit.signal, null, `daemon should exit cleanly, got signal=${exit.signal}`);
assert.strictEqual(exit.code, 0, `daemon should exit with status 0, got code=${exit.code}`);
await waitFor(async () => {
const status = await readDaemonStatus(paseoHome)
return status.status === 'stopped'
}, 15000, 'daemon status did not transition to stopped after unsupervised restart request')
await waitFor(
async () => {
const status = await readDaemonStatus(paseoHome);
return status.status === "stopped";
},
15000,
"daemon status did not transition to stopped after unsupervised restart request",
);
console.log('✓ unsupervised restart exited cleanly with code 0\n')
console.log("✓ unsupervised restart exited cleanly with code 0\n");
} finally {
if (daemonProcess?.pid && isProcessRunning(daemonProcess.pid)) {
daemonProcess.kill('SIGTERM')
await waitFor(() => !isProcessRunning(daemonProcess!.pid ?? -1), 5000, 'daemon cleanup timed out').catch(
() => {
daemonProcess?.kill('SIGKILL')
}
)
daemonProcess.kill("SIGTERM");
await waitFor(
() => !isProcessRunning(daemonProcess!.pid ?? -1),
5000,
"daemon cleanup timed out",
).catch(() => {
daemonProcess?.kill("SIGKILL");
});
}
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== Unsupervised restart regression test passed ===')
console.log("=== Unsupervised restart regression test passed ===");
+72 -72
View File
@@ -10,115 +10,115 @@
* - All flags are accepted
*/
import assert from 'node:assert'
import { $ } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
import assert from "node:assert";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
$.verbose = false
$.verbose = false;
console.log('=== Delete Command Tests ===\n')
console.log("=== Delete Command Tests ===\n");
const cliRoot = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(cliRoot, '..', '..', '..')
const port = 10000 + Math.floor(Math.random() * 50000)
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-delete-test-home-'))
const cliRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(cliRoot, "..", "..", "..");
const port = 10000 + Math.floor(Math.random() * 50000);
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-delete-test-home-"));
async function runCli(args: string[]) {
return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow()
return $`npm --prefix ${repoRoot} run cli -- ${args}`.nothrow();
}
async function runDelete(args: string[]) {
return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow()
return $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- delete ${args}`.nothrow();
}
try {
{
console.log('Test 1: delete --help shows options')
const result = await runDelete(['--help'])
assert.strictEqual(result.exitCode, 0, 'delete --help should exit 0')
assert(result.stdout.includes('--all'), 'help should mention --all flag')
assert(result.stdout.includes('--cwd'), 'help should mention --cwd option')
assert(result.stdout.includes('--host'), 'help should mention --host option')
assert(result.stdout.includes('[id]'), 'help should mention optional id argument')
console.log('✓ delete --help shows options\n')
console.log("Test 1: delete --help shows options");
const result = await runDelete(["--help"]);
assert.strictEqual(result.exitCode, 0, "delete --help should exit 0");
assert(result.stdout.includes("--all"), "help should mention --all flag");
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
assert(result.stdout.includes("--host"), "help should mention --host option");
assert(result.stdout.includes("[id]"), "help should mention optional id argument");
console.log("✓ delete --help shows options\n");
}
{
console.log('Test 2: delete requires ID, --all, or --cwd')
const result = await runDelete([])
assert.notStrictEqual(result.exitCode, 0, 'should fail without id, --all, or --cwd')
const output = result.stdout + result.stderr
console.log("Test 2: delete requires ID, --all, or --cwd");
const result = await runDelete([]);
assert.notStrictEqual(result.exitCode, 0, "should fail without id, --all, or --cwd");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('missing') ||
output.toLowerCase().includes('required') ||
output.toLowerCase().includes('argument') ||
output.toLowerCase().includes('id')
assert(hasError, 'error should mention missing argument')
console.log('✓ delete requires ID, --all, or --cwd\n')
output.toLowerCase().includes("missing") ||
output.toLowerCase().includes("required") ||
output.toLowerCase().includes("argument") ||
output.toLowerCase().includes("id");
assert(hasError, "error should mention missing argument");
console.log("✓ delete requires ID, --all, or --cwd\n");
}
{
console.log('Test 3: delete handles daemon not running')
const result = await runDelete(['abc123'])
assert.notStrictEqual(result.exitCode, 0, 'should fail when daemon not running')
const output = result.stdout + result.stderr
console.log("Test 3: delete handles daemon not running");
const result = await runDelete(["abc123"]);
assert.notStrictEqual(result.exitCode, 0, "should fail when daemon not running");
const output = result.stdout + result.stderr;
const hasError =
output.toLowerCase().includes('daemon') ||
output.toLowerCase().includes('connect') ||
output.toLowerCase().includes('cannot')
assert(hasError, 'error message should mention connection issue')
console.log('✓ delete handles daemon not running\n')
output.toLowerCase().includes("daemon") ||
output.toLowerCase().includes("connect") ||
output.toLowerCase().includes("cannot");
assert(hasError, "error message should mention connection issue");
console.log("✓ delete handles daemon not running\n");
}
{
console.log('Test 4: delete --all flag is accepted')
const result = await runDelete(['--all'])
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --all flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ delete --all flag is accepted\n')
console.log("Test 4: delete --all flag is accepted");
const result = await runDelete(["--all"]);
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --all flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ delete --all flag is accepted\n");
}
{
console.log('Test 5: delete --cwd flag is accepted')
const result = await runDelete(['--cwd', '/tmp'])
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --cwd flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ delete --cwd flag is accepted\n')
console.log("Test 5: delete --cwd flag is accepted");
const result = await runDelete(["--cwd", "/tmp"]);
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --cwd flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ delete --cwd flag is accepted\n");
}
{
console.log('Test 6: delete with ID and --host flag is accepted')
const result = await runDelete(['abc123', '--host', `localhost:${port}`])
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept --host flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ delete with ID and --host flag is accepted\n')
console.log("Test 6: delete with ID and --host flag is accepted");
const result = await runDelete(["abc123", "--host", `localhost:${port}`]);
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept --host flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ delete with ID and --host flag is accepted\n");
}
{
console.log('Test 7: paseo --help shows delete command')
const result = await runCli(['--help'])
assert.strictEqual(result.exitCode, 0, 'paseo --help should exit 0')
assert(result.stdout.includes('delete'), 'help should mention delete command')
console.log('✓ paseo --help shows delete command\n')
console.log("Test 7: paseo --help shows delete command");
const result = await runCli(["--help"]);
assert.strictEqual(result.exitCode, 0, "paseo --help should exit 0");
assert(result.stdout.includes("delete"), "help should mention delete command");
console.log("✓ paseo --help shows delete command\n");
}
{
console.log('Test 8: -q (quiet) flag is accepted with delete')
console.log("Test 8: -q (quiet) flag is accepted with delete");
const result =
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow()
const output = result.stdout + result.stderr
assert(!output.includes('unknown option'), 'should accept -q flag')
assert(!output.includes('error: option'), 'should not have option parsing error')
console.log('✓ -q (quiet) flag is accepted with delete\n')
await $`PASEO_HOST=localhost:${port} PASEO_HOME=${paseoHome} npm --prefix ${repoRoot} run cli -- -q delete abc123`.nothrow();
const output = result.stdout + result.stderr;
assert(!output.includes("unknown option"), "should accept -q flag");
assert(!output.includes("error: option"), "should not have option parsing error");
console.log("✓ -q (quiet) flag is accepted with delete\n");
}
} finally {
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
console.log('=== All delete tests passed ===')
console.log("=== All delete tests passed ===");
@@ -1,111 +1,111 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import assert from "node:assert";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import {
getDaemonHost,
normalizeDaemonHost,
resolveDaemonTarget,
resolveDefaultDaemonHosts,
} from '../src/utils/client.js'
} from "../src/utils/client.js";
console.log('=== CLI IPC Target Helpers ===\n')
console.log("=== CLI IPC Target Helpers ===\n");
{
console.log('Test 1: unix hosts resolve to ws+unix URLs')
const target = resolveDaemonTarget('unix:///tmp/paseo.sock')
console.log("Test 1: unix hosts resolve to ws+unix URLs");
const target = resolveDaemonTarget("unix:///tmp/paseo.sock");
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws+unix:///tmp/paseo.sock:/ws',
socketPath: '/tmp/paseo.sock',
})
console.log('✓ unix hosts resolve to ws+unix URLs\n')
type: "ipc",
url: "ws+unix:///tmp/paseo.sock:/ws",
socketPath: "/tmp/paseo.sock",
});
console.log("✓ unix hosts resolve to ws+unix URLs\n");
}
{
console.log('Test 2: pipe hosts preserve the Node socketPath transport form')
const target = resolveDaemonTarget('pipe://\\\\.\\pipe\\paseo-managed-test')
console.log("Test 2: pipe hosts preserve the Node socketPath transport form");
const target = resolveDaemonTarget("pipe://\\\\.\\pipe\\paseo-managed-test");
assert.deepStrictEqual(target, {
type: 'ipc',
url: 'ws://localhost/ws',
socketPath: '\\\\.\\pipe\\paseo-managed-test',
})
console.log('✓ pipe hosts preserve Node socketPath transport form\n')
type: "ipc",
url: "ws://localhost/ws",
socketPath: "\\\\.\\pipe\\paseo-managed-test",
});
console.log("✓ pipe hosts preserve Node socketPath transport form\n");
}
{
console.log('Test 3: local unix socket paths normalize into IPC daemon targets')
assert.strictEqual(normalizeDaemonHost('/tmp/paseo.sock'), 'unix:///tmp/paseo.sock')
console.log('✓ local unix socket paths normalize into IPC daemon targets\n')
console.log("Test 3: local unix socket paths normalize into IPC daemon targets");
assert.strictEqual(normalizeDaemonHost("/tmp/paseo.sock"), "unix:///tmp/paseo.sock");
console.log("✓ local unix socket paths normalize into IPC daemon targets\n");
}
{
console.log('Test 4: default host resolution tries local IPC first, then localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-'))
console.log("Test 4: default host resolution tries local IPC first, then localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-"));
try {
mkdirSync(paseoHome, { recursive: true })
mkdirSync(paseoHome, { recursive: true });
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-from-pid.sock' })
)
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-from-pid.sock" }),
);
assert.deepStrictEqual(resolveDefaultDaemonHosts({ PASEO_HOME: paseoHome }), [
'unix:///tmp/paseo-from-pid.sock',
'localhost:6767',
])
const previousHome = process.env.PASEO_HOME
const previousHost = process.env.PASEO_HOST
process.env.PASEO_HOME = paseoHome
delete process.env.PASEO_HOST
assert.strictEqual(getDaemonHost(), 'unix:///tmp/paseo-from-pid.sock')
if (previousHome === undefined) delete process.env.PASEO_HOME
else process.env.PASEO_HOME = previousHome
if (previousHost === undefined) delete process.env.PASEO_HOST
else process.env.PASEO_HOST = previousHost
"unix:///tmp/paseo-from-pid.sock",
"localhost:6767",
]);
const previousHome = process.env.PASEO_HOME;
const previousHost = process.env.PASEO_HOST;
process.env.PASEO_HOME = paseoHome;
delete process.env.PASEO_HOST;
assert.strictEqual(getDaemonHost(), "unix:///tmp/paseo-from-pid.sock");
if (previousHome === undefined) delete process.env.PASEO_HOME;
else process.env.PASEO_HOME = previousHome;
if (previousHost === undefined) delete process.env.PASEO_HOST;
else process.env.PASEO_HOST = previousHost;
} finally {
rmSync(paseoHome, { recursive: true, force: true })
rmSync(paseoHome, { recursive: true, force: true });
}
console.log('✓ default host resolution tries local IPC first, then localhost fallback\n')
console.log("✓ default host resolution tries local IPC first, then localhost fallback\n");
}
{
console.log('Test 5: configured TCP host is preserved before the localhost fallback')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-tcp-'))
console.log("Test 5: configured TCP host is preserved before the localhost fallback");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-tcp-"));
try {
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
PASEO_LISTEN: "127.0.0.1:7777",
}),
['127.0.0.1:7777', 'localhost:6767']
)
["127.0.0.1:7777", "localhost:6767"],
);
} finally {
rmSync(paseoHome, { recursive: true, force: true })
rmSync(paseoHome, { recursive: true, force: true });
}
console.log('✓ configured TCP host is preserved before the localhost fallback\n')
console.log("✓ configured TCP host is preserved before the localhost fallback\n");
}
{
console.log('Test 6: local IPC still takes priority over configured TCP hosts')
const paseoHome = mkdtempSync(path.join(os.tmpdir(), 'paseo-client-targets-order-'))
console.log("Test 6: local IPC still takes priority over configured TCP hosts");
const paseoHome = mkdtempSync(path.join(os.tmpdir(), "paseo-client-targets-order-"));
try {
mkdirSync(paseoHome, { recursive: true })
mkdirSync(paseoHome, { recursive: true });
writeFileSync(
path.join(paseoHome, 'paseo.pid'),
JSON.stringify({ pid: process.pid, listen: '/tmp/paseo-priority.sock' })
)
path.join(paseoHome, "paseo.pid"),
JSON.stringify({ pid: process.pid, listen: "/tmp/paseo-priority.sock" }),
);
assert.deepStrictEqual(
resolveDefaultDaemonHosts({
PASEO_HOME: paseoHome,
PASEO_LISTEN: '127.0.0.1:7777',
PASEO_LISTEN: "127.0.0.1:7777",
}),
['unix:///tmp/paseo-priority.sock', '127.0.0.1:7777', 'localhost:6767']
)
["unix:///tmp/paseo-priority.sock", "127.0.0.1:7777", "localhost:6767"],
);
} finally {
rmSync(paseoHome, { recursive: true, force: true })
rmSync(paseoHome, { recursive: true, force: true });
}
console.log('✓ local IPC still takes priority over configured TCP hosts\n')
console.log("✓ local IPC still takes priority over configured TCP hosts\n");
}
console.log('=== All CLI IPC target tests passed ===')
console.log("=== All CLI IPC target tests passed ===");
@@ -1,44 +1,44 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { resolveAgentId } from '../src/utils/client.js'
import assert from "node:assert";
import { resolveAgentId } from "../src/utils/client.js";
console.log('=== Agent ID Resolution Tests ===\n')
console.log("=== Agent ID Resolution Tests ===\n");
const agents = [
{ id: 'abc1234-full-agent-id', title: 'Build docs' },
{ id: 'def5678-other-agent-id', title: 'Fix archive flow' },
{ id: '987zyxw-third-agent-id', title: 'Review PR' },
]
{ id: "abc1234-full-agent-id", title: "Build docs" },
{ id: "def5678-other-agent-id", title: "Fix archive flow" },
{ id: "987zyxw-third-agent-id", title: "Review PR" },
];
{
console.log('Test 1: exact ID match resolves')
assert.strictEqual(resolveAgentId('abc1234-full-agent-id', agents), 'abc1234-full-agent-id')
console.log('✓ exact ID match resolves\n')
console.log("Test 1: exact ID match resolves");
assert.strictEqual(resolveAgentId("abc1234-full-agent-id", agents), "abc1234-full-agent-id");
console.log("✓ exact ID match resolves\n");
}
{
console.log('Test 2: ID prefix resolves')
assert.strictEqual(resolveAgentId('def5678', agents), 'def5678-other-agent-id')
console.log('✓ ID prefix resolves\n')
console.log("Test 2: ID prefix resolves");
assert.strictEqual(resolveAgentId("def5678", agents), "def5678-other-agent-id");
console.log("✓ ID prefix resolves\n");
}
{
console.log('Test 3: exact name match resolves case-insensitively')
assert.strictEqual(resolveAgentId('fix archive flow', agents), 'def5678-other-agent-id')
console.log('✓ exact name match resolves case-insensitively\n')
console.log("Test 3: exact name match resolves case-insensitively");
assert.strictEqual(resolveAgentId("fix archive flow", agents), "def5678-other-agent-id");
console.log("✓ exact name match resolves case-insensitively\n");
}
{
console.log('Test 4: partial name match resolves when unique')
assert.strictEqual(resolveAgentId('review', agents), '987zyxw-third-agent-id')
console.log('✓ partial name match resolves when unique\n')
console.log("Test 4: partial name match resolves when unique");
assert.strictEqual(resolveAgentId("review", agents), "987zyxw-third-agent-id");
console.log("✓ partial name match resolves when unique\n");
}
{
console.log('Test 5: missing agent returns null')
assert.strictEqual(resolveAgentId('does not exist', agents), null)
console.log('✓ missing agent returns null\n')
console.log("Test 5: missing agent returns null");
assert.strictEqual(resolveAgentId("does not exist", agents), null);
console.log("✓ missing agent returns null\n");
}
console.log('=== All agent ID resolution tests passed ===')
console.log("=== All agent ID resolution tests passed ===");
+139 -127
View File
@@ -22,238 +22,250 @@
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
let ctx: E2EContext
let ctx: E2EContext;
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
console.log("Setting up E2E test context...");
console.log("Starting test daemon on random port (this may take a few seconds)...");
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
ctx = await createE2ETestContext({ timeout: 45000 });
console.log(`Test daemon started on port ${ctx.port}`);
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
console.log(`Work directory: ${ctx.workDir}`);
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
console.error("Failed to start test daemon:", err);
throw err;
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
console.log("\nCleaning up...");
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
await ctx.stop();
console.log("Test daemon stopped and temp directories removed");
}
}
async function test_agent_ls_empty(): Promise<void> {
console.log('\n--- Test: agent ls with empty list ---')
console.log("\n--- Test: agent ls with empty list ---");
const result = await ctx.paseo(['ls', '--json'])
const result = await ctx.paseo(["ls", "--json"]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
assert.strictEqual(result.exitCode, 0, "agent ls should succeed");
const agents = JSON.parse(result.stdout.trim())
assert(Array.isArray(agents), 'Output should be JSON array')
assert.strictEqual(agents.length, 0, 'Should have no agents initially')
const agents = JSON.parse(result.stdout.trim());
assert(Array.isArray(agents), "Output should be JSON array");
assert.strictEqual(agents.length, 0, "Should have no agents initially");
console.log('PASS: agent ls returns empty list')
console.log("PASS: agent ls returns empty list");
}
async function test_agent_run_detached(): Promise<string> {
console.log('\n--- Test: agent run detached ---')
console.log("\n--- Test: agent run detached ---");
// Use quiet mode to get just the agent ID
// CRITICAL: Use haiku model for fast, cheap tests
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
const result = await ctx.paseo(
[
'-q',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--mode',
'bypassPermissions',
'--name',
'E2E Test Agent',
'Say hello world',
"-q",
"run",
"-d",
"--provider",
"claude",
"--model",
"claude-3-5-haiku-20241022",
"--mode",
"bypassPermissions",
"--name",
"E2E Test Agent",
"Say hello world",
],
{ timeout: 60000 }
)
{ timeout: 60000 },
);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
const agentId = result.stdout.trim();
assert(agentId.length > 0, "Should return agent ID");
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
console.log(`PASS: agent created with ID: ${agentId}`);
return agentId;
}
async function test_agent_ls_shows_agent(agentId: string): Promise<void> {
console.log('\n--- Test: agent ls shows created agent ---')
console.log("\n--- Test: agent ls shows created agent ---");
const result = await ctx.paseo(['ls', '--json'])
const result = await ctx.paseo(["ls", "--json"]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
assert.strictEqual(result.exitCode, 0, 'agent ls should succeed')
assert.strictEqual(result.exitCode, 0, "agent ls should succeed");
const agents = JSON.parse(result.stdout.trim()) as Array<{ id: string; title?: string; status?: string }>
assert(Array.isArray(agents), 'Output should be JSON array')
assert(agents.length >= 1, 'Should have at least one agent')
const agents = JSON.parse(result.stdout.trim()) as Array<{
id: string;
title?: string;
status?: string;
}>;
assert(Array.isArray(agents), "Output should be JSON array");
assert(agents.length >= 1, "Should have at least one agent");
// Find our agent by ID prefix
const ourAgent = agents.find((a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)))
assert(ourAgent, `Our agent ${agentId} should be in the list`)
const ourAgent = agents.find(
(a) => agentId.startsWith(a.id) || a.id.startsWith(agentId.slice(0, 7)),
);
assert(ourAgent, `Our agent ${agentId} should be in the list`);
console.log('Agent found:', ourAgent)
console.log('PASS: agent ls shows created agent')
console.log("Agent found:", ourAgent);
console.log("PASS: agent ls shows created agent");
}
async function test_agent_wait(agentId: string): Promise<void> {
console.log('\n--- Test: agent wait ---')
console.log("\n--- Test: agent wait ---");
// Wait for agent to become idle (with generous timeout for haiku model)
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
assert.strictEqual(result.exitCode, 0, "agent wait should succeed");
console.log('PASS: agent wait completed successfully')
console.log("PASS: agent wait completed successfully");
}
async function test_agent_inspect(agentId: string): Promise<void> {
console.log('\n--- Test: agent inspect ---')
console.log("\n--- Test: agent inspect ---");
const result = await ctx.paseo(['inspect', agentId])
const result = await ctx.paseo(["inspect", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
assert.strictEqual(result.exitCode, 0, 'agent inspect should succeed')
assert.strictEqual(result.exitCode, 0, "agent inspect should succeed");
// Check that output contains expected fields (table format)
const output = result.stdout
assert(output.includes('Id'), 'Output should contain Id field')
assert(output.includes('Provider'), 'Output should contain Provider field')
assert(output.includes('Status'), 'Output should contain Status field')
assert(output.includes('claude'), 'Output should mention claude provider')
const output = result.stdout;
assert(output.includes("Id"), "Output should contain Id field");
assert(output.includes("Provider"), "Output should contain Provider field");
assert(output.includes("Status"), "Output should contain Status field");
assert(output.includes("claude"), "Output should mention claude provider");
console.log('PASS: agent inspect shows expected fields')
console.log("PASS: agent inspect shows expected fields");
}
async function test_agent_logs(agentId: string): Promise<void> {
console.log('\n--- Test: agent logs ---')
console.log("\n--- Test: agent logs ---");
const result = await ctx.paseo(['logs', '--tail', '20', agentId])
const result = await ctx.paseo(["logs", "--tail", "20", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout length:', result.stdout.length)
console.log("Exit code:", result.exitCode);
console.log("Stdout length:", result.stdout.length);
// Don't print full logs as they can be verbose
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
assert.strictEqual(result.exitCode, 0, "agent logs should succeed");
// Logs should have some content (agent was asked to say hello)
assert(result.stdout.length > 0, 'Logs should have some content')
assert(result.stdout.length > 0, "Logs should have some content");
console.log('PASS: agent logs returns content')
console.log("PASS: agent logs returns content");
}
async function test_agent_stop(agentId: string): Promise<void> {
console.log('\n--- Test: agent stop ---')
console.log("\n--- Test: agent stop ---");
const result = await ctx.paseo(['stop', agentId])
const result = await ctx.paseo(["stop", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent stop should succeed')
assert.strictEqual(result.exitCode, 0, "agent stop should succeed");
const inspectResult = await ctx.paseo(['inspect', agentId])
assert.strictEqual(inspectResult.exitCode, 0, 'agent should remain inspectable after stop')
assert(!inspectResult.stdout.toLowerCase().includes('running'), 'Agent should not be running after stop')
const inspectResult = await ctx.paseo(["inspect", agentId]);
assert.strictEqual(inspectResult.exitCode, 0, "agent should remain inspectable after stop");
assert(
!inspectResult.stdout.toLowerCase().includes("running"),
"Agent should not be running after stop",
);
console.log('PASS: agent stop interrupted without deleting the agent')
console.log("PASS: agent stop interrupted without deleting the agent");
}
async function test_agent_delete(agentId: string): Promise<void> {
console.log('\n--- Test: agent delete ---')
console.log("\n--- Test: agent delete ---");
const result = await ctx.paseo(['delete', agentId])
const result = await ctx.paseo(["delete", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
const inspectResult = await ctx.paseo(['inspect', agentId])
assert.notStrictEqual(inspectResult.exitCode, 0, 'deleted agent should not be inspectable')
const inspectResult = await ctx.paseo(["inspect", agentId]);
assert.notStrictEqual(inspectResult.exitCode, 0, "deleted agent should not be inspectable");
console.log('PASS: agent delete removed the agent')
console.log("PASS: agent delete removed the agent");
}
async function main(): Promise<void> {
console.log('=== E2E Test: Agent Lifecycle ===\n')
console.log('This test creates REAL agents with REAL daemons.')
console.log('It may take some time as it waits for agent responses.\n')
console.log("=== E2E Test: Agent Lifecycle ===\n");
console.log("This test creates REAL agents with REAL daemons.");
console.log("It may take some time as it waits for agent responses.\n");
try {
await setup()
await setup();
// Run tests in sequence
await test_agent_ls_empty()
await test_agent_ls_empty();
const agentId = await test_agent_run_detached()
const agentId = await test_agent_run_detached();
await test_agent_ls_shows_agent(agentId)
await test_agent_ls_shows_agent(agentId);
await test_agent_wait(agentId)
await test_agent_wait(agentId);
await test_agent_inspect(agentId)
await test_agent_inspect(agentId);
await test_agent_logs(agentId)
await test_agent_logs(agentId);
await test_agent_stop(agentId)
await test_agent_delete(agentId)
await test_agent_stop(agentId);
await test_agent_delete(agentId);
console.log('\n=== All E2E tests passed! ===')
console.log("\n=== All E2E tests passed! ===");
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
console.error("\n=== E2E test FAILED ===");
console.error(err);
process.exitCode = 1;
} finally {
await cleanup()
await cleanup();
}
}
main()
main();
+99 -95
View File
@@ -21,185 +21,189 @@
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
let ctx: E2EContext
let ctx: E2EContext;
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
console.log("Setting up E2E test context...");
console.log("Starting test daemon on random port (this may take a few seconds)...");
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
ctx = await createE2ETestContext({ timeout: 45000 });
console.log(`Test daemon started on port ${ctx.port}`);
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
console.log(`Work directory: ${ctx.workDir}`);
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
console.error("Failed to start test daemon:", err);
throw err;
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
console.log("\nCleaning up...");
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
await ctx.stop();
console.log("Test daemon stopped and temp directories removed");
}
}
async function test_create_agent(): Promise<string> {
console.log('\n--- Test: Create agent in detached mode ---')
console.log("\n--- Test: Create agent in detached mode ---");
// CRITICAL: Use haiku model for fast, cheap tests
// CRITICAL: Use bypassPermissions mode so agent doesn't wait for permission approvals
const result = await ctx.paseo(
[
'-q',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--mode',
'bypassPermissions',
'--name',
'Send Test Agent',
"-q",
"run",
"-d",
"--provider",
"claude",
"--model",
"claude-3-5-haiku-20241022",
"--mode",
"bypassPermissions",
"--name",
"Send Test Agent",
'Say "initial task complete"',
],
{ timeout: 60000 }
)
{ timeout: 60000 },
);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
const agentId = result.stdout.trim();
assert(agentId.length > 0, "Should return agent ID");
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
console.log(`PASS: agent created with ID: ${agentId}`);
return agentId;
}
async function test_wait_for_initial_task(agentId: string): Promise<void> {
console.log('\n--- Test: Wait for initial task to complete ---')
console.log("\n--- Test: Wait for initial task to complete ---");
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed')
assert.strictEqual(result.exitCode, 0, "agent wait should succeed");
console.log('PASS: Initial task completed')
console.log("PASS: Initial task completed");
}
async function test_agent_send(agentId: string): Promise<void> {
console.log('\n--- Test: Send follow-up message ---')
console.log("\n--- Test: Send follow-up message ---");
// Send a follow-up message to the agent
const result = await ctx.paseo(
['send', agentId, 'Now say "follow-up task complete"'],
{ timeout: 180000 }
)
const result = await ctx.paseo(["send", agentId, 'Now say "follow-up task complete"'], {
timeout: 180000,
});
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent send should succeed')
assert.strictEqual(result.exitCode, 0, "agent send should succeed");
// Verify output contains expected status
assert(
result.stdout.includes('completed') || result.stdout.includes('sent'),
'Should indicate message was sent or completed'
)
result.stdout.includes("completed") || result.stdout.includes("sent"),
"Should indicate message was sent or completed",
);
console.log('PASS: Follow-up message sent successfully')
console.log("PASS: Follow-up message sent successfully");
}
async function test_verify_logs(agentId: string): Promise<void> {
console.log('\n--- Test: Verify agent processed both messages ---')
console.log("\n--- Test: Verify agent processed both messages ---");
const result = await ctx.paseo(['logs', '--tail', '50', agentId])
const result = await ctx.paseo(["logs", "--tail", "50", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout length:', result.stdout.length)
console.log("Exit code:", result.exitCode);
console.log("Stdout length:", result.stdout.length);
assert.strictEqual(result.exitCode, 0, 'agent logs should succeed')
assert.strictEqual(result.exitCode, 0, "agent logs should succeed");
// Logs should have content from both tasks
assert(result.stdout.length > 0, 'Logs should have content')
assert(result.stdout.length > 0, "Logs should have content");
// The agent was asked to say specific things, check if either appears in logs
// This is a loose check since exact log format may vary
const logsLower = result.stdout.toLowerCase()
const hasInitialTask = logsLower.includes('initial') || logsLower.includes('hello') || logsLower.includes('task')
const hasFollowUp = logsLower.includes('follow') || logsLower.includes('complete') || logsLower.includes('task')
const logsLower = result.stdout.toLowerCase();
const hasInitialTask =
logsLower.includes("initial") || logsLower.includes("hello") || logsLower.includes("task");
const hasFollowUp =
logsLower.includes("follow") || logsLower.includes("complete") || logsLower.includes("task");
// At minimum, there should be log entries (we can't guarantee exact content)
assert(result.stdout.split('\n').length > 3, 'Should have multiple log entries from both tasks')
assert(result.stdout.split("\n").length > 3, "Should have multiple log entries from both tasks");
console.log('PASS: Agent logs show activity from both tasks')
console.log("PASS: Agent logs show activity from both tasks");
}
async function test_agent_delete(agentId: string): Promise<void> {
console.log('\n--- Test: Delete agent ---')
console.log("\n--- Test: Delete agent ---");
const result = await ctx.paseo(['delete', agentId])
const result = await ctx.paseo(["delete", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
console.log('PASS: Agent deleted successfully')
console.log("PASS: Agent deleted successfully");
}
async function main(): Promise<void> {
console.log('=== E2E Test: Agent Send Command ===\n')
console.log('This test verifies the `send` command with REAL agents.')
console.log('It may take some time as it waits for agent responses.\n')
console.log("=== E2E Test: Agent Send Command ===\n");
console.log("This test verifies the `send` command with REAL agents.");
console.log("It may take some time as it waits for agent responses.\n");
try {
await setup()
await setup();
// Create agent and wait for initial task
const agentId = await test_create_agent()
await test_wait_for_initial_task(agentId)
const agentId = await test_create_agent();
await test_wait_for_initial_task(agentId);
// Send follow-up message (this waits for completion by default)
await test_agent_send(agentId)
await test_agent_send(agentId);
// Verify both messages were processed
await test_verify_logs(agentId)
await test_verify_logs(agentId);
// Cleanup agent
await test_agent_delete(agentId)
await test_agent_delete(agentId);
console.log('\n=== All agent-send E2E tests passed! ===')
console.log("\n=== All agent-send E2E tests passed! ===");
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
console.error("\n=== E2E test FAILED ===");
console.error(err);
process.exitCode = 1;
} finally {
await cleanup()
await cleanup();
}
}
main()
main();
+139 -126
View File
@@ -21,106 +21,111 @@
* - Clean up resources after test completes
*/
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
let ctx: E2EContext
let ctx: E2EContext;
async function setup(): Promise<void> {
console.log('Setting up E2E test context...')
console.log('Starting test daemon on random port (this may take a few seconds)...')
console.log("Setting up E2E test context...");
console.log("Starting test daemon on random port (this may take a few seconds)...");
try {
ctx = await createE2ETestContext({ timeout: 45000 })
console.log(`Test daemon started on port ${ctx.port}`)
console.log(`PASEO_HOME: ${ctx.paseoHome}`)
console.log(`Work directory: ${ctx.workDir}`)
ctx = await createE2ETestContext({ timeout: 45000 });
console.log(`Test daemon started on port ${ctx.port}`);
console.log(`PASEO_HOME: ${ctx.paseoHome}`);
console.log(`Work directory: ${ctx.workDir}`);
} catch (err) {
console.error('Failed to start test daemon:', err)
throw err
console.error("Failed to start test daemon:", err);
throw err;
}
}
async function cleanup(): Promise<void> {
console.log('\nCleaning up...')
console.log("\nCleaning up...");
if (ctx) {
await ctx.stop()
console.log('Test daemon stopped and temp directories removed')
await ctx.stop();
console.log("Test daemon stopped and temp directories removed");
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function test_create_agent_with_permissions(): Promise<string> {
console.log('\n--- Test: Create agent in default mode (will request permissions) ---')
console.log("\n--- Test: Create agent in default mode (will request permissions) ---");
// Create agent WITHOUT bypassPermissions - it will need to request permissions
// Use a task that is very likely to trigger a tool use (and thus permission request)
const result = await ctx.paseo(
[
'-q',
'agent',
'run',
'-d',
'--provider',
'claude',
'--model',
'claude-3-5-haiku-20241022',
'--name',
'Permission Test Agent',
"-q",
"agent",
"run",
"-d",
"--provider",
"claude",
"--model",
"claude-3-5-haiku-20241022",
"--name",
"Permission Test Agent",
// This prompt should trigger file read or bash command, requiring permission
'List the files in the current directory using ls',
"List the files in the current directory using ls",
],
{ timeout: 60000 }
)
{ timeout: 60000 },
);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent run should succeed')
assert.strictEqual(result.exitCode, 0, "agent run should succeed");
const agentId = result.stdout.trim()
assert(agentId.length > 0, 'Should return agent ID')
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`)
const agentId = result.stdout.trim();
assert(agentId.length > 0, "Should return agent ID");
assert(agentId.match(/^[a-z0-9-]+$/), `Agent ID should be alphanumeric: ${agentId}`);
console.log(`PASS: agent created with ID: ${agentId}`)
return agentId
console.log(`PASS: agent created with ID: ${agentId}`);
return agentId;
}
async function test_wait_for_permission_request(agentId: string): Promise<void> {
console.log('\n--- Test: Wait for agent to request permission ---')
console.log("\n--- Test: Wait for agent to request permission ---");
// Poll for permission requests with timeout
const maxWait = 60000 // 60 seconds max
const pollInterval = 1000 // 1 second
const startTime = Date.now()
const maxWait = 60000; // 60 seconds max
const pollInterval = 1000; // 1 second
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
const result = await ctx.paseo(['permit', 'ls', '--json'])
const result = await ctx.paseo(["permit", "ls", "--json"]);
if (result.exitCode === 0) {
try {
const permissions = JSON.parse(result.stdout.trim())
const permissions = JSON.parse(result.stdout.trim());
if (Array.isArray(permissions) && permissions.length > 0) {
// Check if any permission is for our agent
const ourPermission = permissions.find(
(p: { agentId?: string }) => p.agentId?.startsWith(agentId.slice(0, 7)) || agentId.startsWith(p.agentId?.slice(0, 7) || '')
)
(p: { agentId?: string }) =>
p.agentId?.startsWith(agentId.slice(0, 7)) ||
agentId.startsWith(p.agentId?.slice(0, 7) || ""),
);
if (ourPermission) {
console.log('Permission request detected:', ourPermission)
console.log('PASS: Agent requested permission')
return
console.log("Permission request detected:", ourPermission);
console.log("PASS: Agent requested permission");
return;
}
}
} catch {
@@ -128,158 +133,166 @@ async function test_wait_for_permission_request(agentId: string): Promise<void>
}
}
await sleep(pollInterval)
await sleep(pollInterval);
}
// If we get here, check agent status - it might have already completed
const statusResult = await ctx.paseo(['inspect', agentId])
console.log('Agent status:', statusResult.stdout)
const statusResult = await ctx.paseo(["inspect", agentId]);
console.log("Agent status:", statusResult.stdout);
// It's possible the agent completed without needing permissions (e.g., haiku might not use tools)
// In that case, we should skip the permission tests
if (statusResult.stdout.includes('idle') || statusResult.stdout.includes('completed')) {
console.log('SKIP: Agent completed without requiring permissions (model chose not to use tools)')
throw new Error('SKIP_PERMISSION_TEST')
if (statusResult.stdout.includes("idle") || statusResult.stdout.includes("completed")) {
console.log(
"SKIP: Agent completed without requiring permissions (model chose not to use tools)",
);
throw new Error("SKIP_PERMISSION_TEST");
}
throw new Error('Timeout waiting for agent to request permission')
throw new Error("Timeout waiting for agent to request permission");
}
async function test_permit_ls(): Promise<{ agentShortId: string; requestId: string }> {
console.log('\n--- Test: List pending permissions with permit ls ---')
console.log("\n--- Test: List pending permissions with permit ls ---");
const result = await ctx.paseo(['permit', 'ls', '--json'])
const result = await ctx.paseo(["permit", "ls", "--json"]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'permit ls should succeed')
assert.strictEqual(result.exitCode, 0, "permit ls should succeed");
const permissions = JSON.parse(result.stdout.trim())
assert(Array.isArray(permissions), 'Output should be JSON array')
assert(permissions.length > 0, 'Should have at least one pending permission')
const permissions = JSON.parse(result.stdout.trim());
assert(Array.isArray(permissions), "Output should be JSON array");
assert(permissions.length > 0, "Should have at least one pending permission");
const permission = permissions[0]
assert(permission.id, 'Permission should have id')
assert(permission.agentShortId, 'Permission should have agentShortId')
assert(permission.name, 'Permission should have tool name')
const permission = permissions[0];
assert(permission.id, "Permission should have id");
assert(permission.agentShortId, "Permission should have agentShortId");
assert(permission.name, "Permission should have tool name");
console.log(`PASS: Found ${permissions.length} pending permission(s)`)
console.log(` Request ID: ${permission.id}`)
console.log(` Agent: ${permission.agentShortId}`)
console.log(` Tool: ${permission.name}`)
console.log(`PASS: Found ${permissions.length} pending permission(s)`);
console.log(` Request ID: ${permission.id}`);
console.log(` Agent: ${permission.agentShortId}`);
console.log(` Tool: ${permission.name}`);
return {
agentShortId: permission.agentShortId,
requestId: permission.id,
}
};
}
async function test_permit_allow(agentShortId: string, requestId: string): Promise<void> {
console.log('\n--- Test: Allow permission with permit allow ---')
console.log("\n--- Test: Allow permission with permit allow ---");
const result = await ctx.paseo(['permit', 'allow', agentShortId, requestId])
const result = await ctx.paseo(["permit", "allow", agentShortId, requestId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'permit allow should succeed')
assert.strictEqual(result.exitCode, 0, "permit allow should succeed");
// Check that output shows the permission was allowed
const output = result.stdout.toLowerCase()
assert(output.includes('allowed') || output.includes(requestId.slice(0, 8).toLowerCase()), 'Should confirm permission was allowed')
const output = result.stdout.toLowerCase();
assert(
output.includes("allowed") || output.includes(requestId.slice(0, 8).toLowerCase()),
"Should confirm permission was allowed",
);
console.log('PASS: Permission allowed successfully')
console.log("PASS: Permission allowed successfully");
}
async function test_agent_continues(agentId: string): Promise<void> {
console.log('\n--- Test: Verify agent continues after permission granted ---')
console.log("\n--- Test: Verify agent continues after permission granted ---");
// Wait for agent to become idle (should continue after permission)
const result = await ctx.paseo(['wait', '--timeout', '120s', agentId], { timeout: 130000 })
const result = await ctx.paseo(["wait", "--timeout", "120s", agentId], { timeout: 130000 });
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent wait should succeed after permission granted')
assert.strictEqual(result.exitCode, 0, "agent wait should succeed after permission granted");
// Verify agent status
const inspectResult = await ctx.paseo(['inspect', agentId])
console.log('Final agent status:', inspectResult.stdout)
const inspectResult = await ctx.paseo(["inspect", agentId]);
console.log("Final agent status:", inspectResult.stdout);
assert(inspectResult.stdout.includes('idle') || inspectResult.stdout.includes('completed'), 'Agent should be idle after completing')
assert(
inspectResult.stdout.includes("idle") || inspectResult.stdout.includes("completed"),
"Agent should be idle after completing",
);
console.log('PASS: Agent completed after permission was granted')
console.log("PASS: Agent completed after permission was granted");
}
async function test_agent_delete(agentId: string): Promise<void> {
console.log('\n--- Test: Delete agent ---')
console.log("\n--- Test: Delete agent ---");
const result = await ctx.paseo(['delete', agentId])
const result = await ctx.paseo(["delete", agentId]);
console.log('Exit code:', result.exitCode)
console.log('Stdout:', result.stdout)
if (result.stderr) console.log('Stderr:', result.stderr)
console.log("Exit code:", result.exitCode);
console.log("Stdout:", result.stdout);
if (result.stderr) console.log("Stderr:", result.stderr);
assert.strictEqual(result.exitCode, 0, 'agent delete should succeed')
assert.strictEqual(result.exitCode, 0, "agent delete should succeed");
console.log('PASS: Agent deleted successfully')
console.log("PASS: Agent deleted successfully");
}
async function main(): Promise<void> {
console.log('=== E2E Test: Permissions Workflow ===\n')
console.log('This test verifies the permission request/allow workflow.')
console.log('It creates an agent that will request tool permissions.\n')
console.log("=== E2E Test: Permissions Workflow ===\n");
console.log("This test verifies the permission request/allow workflow.");
console.log("It creates an agent that will request tool permissions.\n");
let agentId: string | undefined
let agentId: string | undefined;
try {
await setup()
await setup();
// Create agent that will need permissions
agentId = await test_create_agent_with_permissions()
agentId = await test_create_agent_with_permissions();
try {
// Wait for permission request
await test_wait_for_permission_request(agentId)
await test_wait_for_permission_request(agentId);
// List and verify permissions
const { agentShortId, requestId } = await test_permit_ls()
const { agentShortId, requestId } = await test_permit_ls();
// Allow the permission
await test_permit_allow(agentShortId, requestId)
await test_permit_allow(agentShortId, requestId);
// Verify agent continues and completes
await test_agent_continues(agentId)
await test_agent_continues(agentId);
console.log('\n=== All permissions E2E tests passed! ===')
console.log("\n=== All permissions E2E tests passed! ===");
} catch (err) {
if (err instanceof Error && err.message === 'SKIP_PERMISSION_TEST') {
console.log('\n=== Permissions test skipped (agent completed without tool use) ===')
console.log('This can happen when the model chooses not to use tools.')
if (err instanceof Error && err.message === "SKIP_PERMISSION_TEST") {
console.log("\n=== Permissions test skipped (agent completed without tool use) ===");
console.log("This can happen when the model chooses not to use tools.");
// Still considered a pass - the CLI works, just the model behavior varies
} else {
throw err
throw err;
}
}
} catch (err) {
console.error('\n=== E2E test FAILED ===')
console.error(err)
process.exitCode = 1
console.error("\n=== E2E test FAILED ===");
console.error(err);
process.exitCode = 1;
} finally {
// Always try to delete the agent if it was created
if (agentId) {
try {
await test_agent_delete(agentId)
await test_agent_delete(agentId);
} catch {
// Ignore errors during cleanup
}
}
await cleanup()
await cleanup();
}
}
main()
main();
@@ -1,141 +1,144 @@
#!/usr/bin/env npx tsx
import assert from 'node:assert'
import { createE2ETestContext, type TestDaemonContext } from '../helpers/test-daemon.ts'
import assert from "node:assert";
import { createE2ETestContext, type TestDaemonContext } from "../helpers/test-daemon.ts";
interface E2EContext extends TestDaemonContext {
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
const schema = JSON.stringify({
type: 'object',
type: "object",
properties: {
summary: { type: 'string' },
summary: { type: "string" },
},
required: ['summary'],
required: ["summary"],
additionalProperties: false,
})
});
const impossibleSchema = JSON.stringify({
type: 'object',
type: "object",
properties: {
summary: { type: 'string' },
summary: { type: "string" },
},
required: ['summary'],
required: ["summary"],
allOf: [
{ properties: { summary: { type: 'string' } } },
{ properties: { summary: { type: 'number' } } },
{ properties: { summary: { type: "string" } } },
{ properties: { summary: { type: "number" } } },
],
})
});
const OPEN_CODE_STRUCTURED_MODEL = 'opencode/gpt-5-nano'
const OPEN_CODE_STRUCTURED_MODEL = "opencode/gpt-5-nano";
let ctx: E2EContext
let ctx: E2EContext;
async function setup(): Promise<void> {
ctx = await createE2ETestContext({ timeout: 180000 })
ctx = await createE2ETestContext({ timeout: 180000 });
}
async function cleanup(): Promise<void> {
if (ctx) {
await ctx.stop()
await ctx.stop();
}
}
async function runProviderCase(input: {
provider: 'claude' | 'codex' | 'opencode'
mode: string
model: string
provider: "claude" | "codex" | "opencode";
mode: string;
model: string;
}): Promise<void> {
const result = await ctx.paseo(
[
'run',
'--provider',
"run",
"--provider",
input.provider,
'--mode',
"--mode",
input.mode,
'--model',
"--model",
input.model,
'--output-schema',
"--output-schema",
schema,
`Return valid JSON with a short summary for provider ${input.provider}.`,
],
{ timeout: 180000 }
)
{ timeout: 180000 },
);
assert.strictEqual(
result.exitCode,
0,
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
`expected ${input.provider} run to succeed, got ${result.exitCode}\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown }
const parsed = JSON.parse(result.stdout.trim()) as { summary?: unknown };
assert.strictEqual(
typeof parsed.summary,
'string',
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`
)
assert(parsed.summary && parsed.summary.length > 0, 'summary must not be empty')
"string",
`expected ${input.provider} output to contain string summary, got: ${result.stdout}`,
);
assert(parsed.summary && parsed.summary.length > 0, "summary must not be empty");
}
async function test_all_providers_return_structured_output(): Promise<void> {
await runProviderCase({
provider: 'claude',
mode: 'bypassPermissions',
model: 'haiku',
})
provider: "claude",
mode: "bypassPermissions",
model: "haiku",
});
await runProviderCase({
provider: 'codex',
mode: 'full-access',
model: 'gpt-5.3-codex',
})
provider: "codex",
mode: "full-access",
model: "gpt-5.3-codex",
});
await runProviderCase({
provider: 'opencode',
mode: 'default',
provider: "opencode",
mode: "default",
model: OPEN_CODE_STRUCTURED_MODEL,
})
});
}
async function test_schema_validation_is_enforced(): Promise<void> {
const result = await ctx.paseo(
[
'run',
'--provider',
'claude',
'--mode',
'bypassPermissions',
'--model',
'haiku',
'--output-schema',
"run",
"--provider",
"claude",
"--mode",
"bypassPermissions",
"--model",
"haiku",
"--output-schema",
impossibleSchema,
'Return exactly {"summary":"ok"} and nothing else.',
],
{ timeout: 180000 }
)
{ timeout: 180000 },
);
assert.notStrictEqual(result.exitCode, 0, 'expected impossible schema to fail')
const output = `${result.stdout}\n${result.stderr}`
assert.notStrictEqual(result.exitCode, 0, "expected impossible schema to fail");
const output = `${result.stdout}\n${result.stderr}`;
assert(
output.includes('OUTPUT_SCHEMA_FAILED'),
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
)
output.includes("OUTPUT_SCHEMA_FAILED"),
`expected OUTPUT_SCHEMA_FAILED for impossible schema\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`,
);
}
async function main(): Promise<void> {
try {
await setup()
await test_all_providers_return_structured_output()
await test_schema_validation_is_enforced()
await setup();
await test_all_providers_return_structured_output();
await test_schema_validation_is_enforced();
} catch (error) {
console.error(error)
process.exitCode = 1
console.error(error);
process.exitCode = 1;
} finally {
await cleanup()
await cleanup();
}
}
main()
main();
+5 -5
View File
@@ -1,9 +1,9 @@
import { $, ProcessPromise } from 'zx'
import { join } from 'node:path'
import { $, ProcessPromise } from "zx";
import { join } from "node:path";
const CLI_ENTRY = join(import.meta.dirname, '..', '..', 'dist', 'index.js')
const CLI_ENTRY = join(import.meta.dirname, "..", "..", "dist", "index.js");
export function runLocalPaseo(args: string[], env: NodeJS.ProcessEnv = {}): ProcessPromise {
$.verbose = false
return $({ env: { ...process.env, ...env } })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow()
$.verbose = false;
return $({ env: { ...process.env, ...env } })`${process.execPath} ${CLI_ENTRY} ${args}`.nothrow();
}
+17 -17
View File
@@ -1,4 +1,4 @@
import { createServer } from 'node:net'
import { createServer } from "node:net";
/**
* Reserve and release an ephemeral TCP port, returning the port number.
@@ -6,27 +6,27 @@ import { createServer } from 'node:net'
*/
export async function getAvailablePort(): Promise<number> {
return new Promise<number>((resolve, reject) => {
const server = createServer()
server.unref()
const server = createServer();
server.unref();
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
if (!address || typeof address === 'string') {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => {
reject(new Error('Failed to resolve an available TCP port'))
})
return
reject(new Error("Failed to resolve an available TCP port"));
});
return;
}
const { port } = address
const { port } = address;
server.close((error) => {
if (error) {
reject(error)
return
reject(error);
return;
}
resolve(port)
})
})
})
resolve(port);
});
});
});
}
+174 -175
View File
@@ -12,139 +12,139 @@
* 5. Cleanup: Kill daemon and remove temp dirs after each test
*/
import { mkdtemp, rm, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { ChildProcess, spawn } from 'child_process'
import { getAvailablePort } from './network.ts'
import { mkdtemp, rm, mkdir } from "fs/promises";
import { existsSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { ChildProcess, spawn } from "child_process";
import { getAvailablePort } from "./network.ts";
export interface TestDaemonContext {
/** Available local port for test daemon (never 6767) */
port: number
port: number;
/** WebSocket URL for connecting to daemon */
wsUrl: string
wsUrl: string;
/** Temp directory for PASEO_HOME */
paseoHome: string
paseoHome: string;
/** Temp directory for agent working directory */
workDir: string
workDir: string;
/** Running daemon process */
process: ChildProcess | null
process: ChildProcess | null;
/** Whether the daemon is ready to accept connections */
isReady: boolean
isReady: boolean;
/** Stop the daemon and clean up resources */
stop: () => Promise<void>
stop: () => Promise<void>;
}
const TEST_DAEMON_ENV_DEFAULTS: Record<string, string> = {
PASEO_RELAY_ENABLED: 'false',
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
const TEST_DAEMON_HOST = '127.0.0.1'
PASEO_RELAY_ENABLED: "false",
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
const TEST_DAEMON_HOST = "127.0.0.1";
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024
const DEFAULT_OUTPUT_CAPTURE_LIMIT = 256 * 1024;
const TEST_OUTPUT_CAPTURE_LIMIT = Number.parseInt(
process.env.PASEO_TEST_OUTPUT_CAPTURE_BYTES ?? `${DEFAULT_OUTPUT_CAPTURE_LIMIT}`,
10
)
10,
);
type OutputCapture = {
value: string
truncated: boolean
}
value: string;
truncated: boolean;
};
function createOutputCapture(): OutputCapture {
return { value: '', truncated: false }
return { value: "", truncated: false };
}
function appendOutputCapture(target: OutputCapture, chunk: Buffer): void {
const next = target.value + chunk.toString()
const next = target.value + chunk.toString();
if (next.length <= TEST_OUTPUT_CAPTURE_LIMIT) {
target.value = next
return
target.value = next;
return;
}
target.truncated = true
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT)
target.truncated = true;
target.value = next.slice(next.length - TEST_OUTPUT_CAPTURE_LIMIT);
}
function formatOutputCapture(target: OutputCapture): string {
if (!target.truncated) {
return target.value
return target.value;
}
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`
return `[truncated; showing last ${TEST_OUTPUT_CAPTURE_LIMIT} chars]\n${target.value}`;
}
function readNodeErrnoCode(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || !('code' in error)) {
return undefined
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
return typeof (error as { code?: unknown }).code === 'string'
? ((error as { code: string }).code)
: undefined
return typeof (error as { code?: unknown }).code === "string"
? (error as { code: string }).code
: undefined;
}
function signalProcessTree(pid: number, signal: NodeJS.Signals): boolean {
if (!Number.isInteger(pid) || pid <= 0) {
return false
return false;
}
if (process.platform !== 'win32') {
if (process.platform !== "win32") {
try {
process.kill(-pid, signal)
return true
process.kill(-pid, signal);
return true;
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
const code = readNodeErrnoCode(error);
if (code === "ESRCH") {
return false;
}
}
}
try {
process.kill(pid, signal)
return true
process.kill(pid, signal);
return true;
} catch (error) {
const code = readNodeErrnoCode(error)
if (code === 'ESRCH') {
return false
const code = readNodeErrnoCode(error);
if (code === "ESRCH") {
return false;
}
throw error
throw error;
}
}
async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number): Promise<void> {
const pid = processRef.pid
const pid = processRef.pid;
if (!Number.isInteger(pid) || pid <= 0) {
return
return;
}
if (processRef.exitCode !== null || processRef.signalCode !== null) {
return
return;
}
signalProcessTree(pid, 'SIGTERM')
signalProcessTree(pid, "SIGTERM");
await new Promise<void>((resolve) => {
let settled = false
let settled = false;
const finish = () => {
if (settled) {
return
return;
}
settled = true
clearTimeout(timeoutId)
resolve()
}
settled = true;
clearTimeout(timeoutId);
resolve();
};
const timeoutId = setTimeout(() => {
signalProcessTree(pid, 'SIGKILL')
finish()
}, timeoutMs)
signalProcessTree(pid, "SIGKILL");
finish();
}, timeoutMs);
processRef.once('exit', () => {
finish()
})
})
processRef.once("exit", () => {
finish();
});
});
}
/**
@@ -153,32 +153,29 @@ async function terminateProcessTree(processRef: ChildProcess, timeoutMs: number)
* NEVER uses 6767 (user's running daemon)
*/
export function getRandomPort(): number {
return 20000 + Math.floor(Math.random() * 10000)
return 20000 + Math.floor(Math.random() * 10000);
}
/**
* Create isolated temp directories for testing
*/
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-e2e-home-'))
const workDir = await mkdtemp(join(tmpdir(), 'paseo-e2e-work-'))
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-e2e-home-"));
const workDir = await mkdtemp(join(tmpdir(), "paseo-e2e-work-"));
// Create the agents directory that the daemon expects
const agentsDir = join(paseoHome, 'agents')
await mkdir(agentsDir, { recursive: true })
const agentsDir = join(paseoHome, "agents");
await mkdir(agentsDir, { recursive: true });
return { paseoHome, workDir }
return { paseoHome, workDir };
}
/**
* Wait for daemon to be ready by running `paseo agent ls`
* This connects via WebSocket and ensures the daemon is responsive
*/
async function waitForDaemonReady(
port: number,
timeout = 30000
): Promise<void> {
const start = Date.now()
async function waitForDaemonReady(port: number, timeout = 30000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
@@ -186,28 +183,28 @@ async function waitForDaemonReady(
{
port,
wsUrl: `ws://${TEST_DAEMON_HOST}:${port}`,
paseoHome: '',
workDir: '',
paseoHome: "",
workDir: "",
process: null,
isReady: false,
stop: async () => {}
stop: async () => {},
},
['agent', 'ls']
)
["agent", "ls"],
);
if (exitCode === 0) {
return // Daemon is ready
return; // Daemon is ready
}
} catch {
// Connection failed, keep trying
}
await sleep(100)
await sleep(100);
}
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`)
throw new Error(`Daemon failed to become ready on port ${port} within ${timeout}ms`);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
@@ -217,57 +214,58 @@ function sleep(ms: number): Promise<void> {
* with isolated PASEO_HOME and PASEO_LISTEN environment variables.
*/
export async function startTestDaemon(options?: {
port?: number
paseoHome?: string
workDir?: string
timeout?: number
port?: number;
paseoHome?: string;
workDir?: string;
timeout?: number;
}): Promise<TestDaemonContext> {
const port = options?.port ?? await getAvailablePort()
const { paseoHome, workDir } = options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs()
const timeout = options?.timeout ?? 30000
const port = options?.port ?? (await getAvailablePort());
const { paseoHome, workDir } =
options?.paseoHome && options?.workDir
? { paseoHome: options.paseoHome, workDir: options.workDir }
: await createTempDirs();
const timeout = options?.timeout ?? 30000;
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`
const wsUrl = `ws://${TEST_DAEMON_HOST}:${port}`;
// Find the CLI entry point - use the source file directly with tsx
const cliDir = join(import.meta.dirname, '..', '..')
const cliSrcPath = join(cliDir, 'src', 'index.ts')
const cliDir = join(import.meta.dirname, "..", "..");
const cliSrcPath = join(cliDir, "src", "index.ts");
// Start daemon process using tsx to run TypeScript directly
const daemonProcess = spawn('npx', ['tsx', cliSrcPath, 'daemon', 'start', '--foreground'], {
const daemonProcess = spawn("npx", ["tsx", cliSrcPath, "daemon", "start", "--foreground"], {
env: {
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
PASEO_HOME: paseoHome,
PASEO_LISTEN: `${TEST_DAEMON_HOST}:${port}`,
// Force no TTY to prevent QR code output
CI: 'true',
CI: "true",
},
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
const stdout = createOutputCapture()
const stderr = createOutputCapture()
const stdout = createOutputCapture();
const stderr = createOutputCapture();
daemonProcess.stdout?.on('data', (data) => {
appendOutputCapture(stdout, data)
})
daemonProcess.stdout?.on("data", (data) => {
appendOutputCapture(stdout, data);
});
daemonProcess.stderr?.on('data', (data) => {
appendOutputCapture(stderr, data)
})
daemonProcess.stderr?.on("data", (data) => {
appendOutputCapture(stderr, data);
});
const cleanup = async () => {
if (daemonProcess) {
await terminateProcessTree(daemonProcess, 5000)
await terminateProcessTree(daemonProcess, 5000);
}
// Clean up temp directories
try {
if (existsSync(paseoHome)) {
await rm(paseoHome, { recursive: true, force: true })
await rm(paseoHome, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
@@ -275,27 +273,27 @@ export async function startTestDaemon(options?: {
try {
if (existsSync(workDir)) {
await rm(workDir, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true });
}
} catch {
// Ignore cleanup errors
}
}
};
// Handle process errors
daemonProcess.on('error', (err) => {
console.error('Daemon process error:', err)
})
daemonProcess.on("error", (err) => {
console.error("Daemon process error:", err);
});
daemonProcess.on('exit', (code) => {
daemonProcess.on("exit", (code) => {
if (code !== 0 && code !== null) {
console.error(`Daemon process exited with code ${code}`)
const stderrText = formatOutputCapture(stderr)
console.error(`Daemon process exited with code ${code}`);
const stderrText = formatOutputCapture(stderr);
if (stderrText) {
console.error('Daemon stderr:', stderrText)
console.error("Daemon stderr:", stderrText);
}
}
})
});
const ctx: TestDaemonContext = {
port,
@@ -305,22 +303,22 @@ export async function startTestDaemon(options?: {
process: daemonProcess,
isReady: false,
stop: cleanup,
}
};
// Wait for daemon to be ready
try {
await waitForDaemonReady(port, timeout)
ctx.isReady = true
await waitForDaemonReady(port, timeout);
ctx.isReady = true;
} catch (err) {
// Daemon failed to start - clean up and rethrow
await cleanup()
const message = err instanceof Error ? err.message : String(err)
await cleanup();
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`
)
`Failed to start test daemon: ${message}\nStdout: ${formatOutputCapture(stdout)}\nStderr: ${formatOutputCapture(stderr)}`,
);
}
return ctx
return ctx;
}
/**
@@ -333,18 +331,18 @@ export async function runPaseoCli(
ctx: TestDaemonContext,
args: string[],
options?: {
timeout?: number
cwd?: string
}
timeout?: number;
cwd?: string;
},
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
const timeout = options?.timeout ?? 60000
const cwd = options?.cwd ?? ctx.workDir
const timeout = options?.timeout ?? 60000;
const cwd = options?.cwd ?? ctx.workDir;
const cliDir = join(import.meta.dirname, '..', '..')
const cliSrcPath = join(cliDir, 'src', 'index.ts')
const cliDir = join(import.meta.dirname, "..", "..");
const cliSrcPath = join(cliDir, "src", "index.ts");
return new Promise((resolve, reject) => {
const proc = spawn('npx', ['tsx', cliSrcPath, ...args], {
const proc = spawn("npx", ["tsx", cliSrcPath, ...args], {
env: {
...process.env,
...TEST_DAEMON_ENV_DEFAULTS,
@@ -352,42 +350,42 @@ export async function runPaseoCli(
PASEO_HOME: ctx.paseoHome,
},
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
detached: process.platform !== 'win32',
})
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
});
const stdout = createOutputCapture()
const stderr = createOutputCapture()
const stdout = createOutputCapture();
const stderr = createOutputCapture();
proc.stdout?.on('data', (data) => {
appendOutputCapture(stdout, data)
})
proc.stdout?.on("data", (data) => {
appendOutputCapture(stdout, data);
});
proc.stderr?.on('data', (data) => {
appendOutputCapture(stderr, data)
})
proc.stderr?.on("data", (data) => {
appendOutputCapture(stderr, data);
});
const timeoutId = setTimeout(() => {
if (proc.pid) {
signalProcessTree(proc.pid, 'SIGKILL')
signalProcessTree(proc.pid, "SIGKILL");
}
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(' ')}`))
}, timeout)
reject(new Error(`CLI command timed out after ${timeout}ms: paseo ${args.join(" ")}`));
}, timeout);
proc.on('exit', (code) => {
clearTimeout(timeoutId)
proc.on("exit", (code) => {
clearTimeout(timeoutId);
resolve({
exitCode: code ?? 1,
stdout: formatOutputCapture(stdout),
stderr: formatOutputCapture(stderr),
})
})
});
});
proc.on('error', (err) => {
clearTimeout(timeoutId)
reject(err)
})
})
proc.on("error", (err) => {
clearTimeout(timeoutId);
reject(err);
});
});
}
/**
@@ -396,25 +394,26 @@ export async function runPaseoCli(
*
* This is the main entry point for E2E tests.
*/
export async function createE2ETestContext(options?: {
timeout?: number
}): Promise<
export async function createE2ETestContext(options?: { timeout?: number }): Promise<
TestDaemonContext & {
/** Run a paseo CLI command against this daemon */
paseo: (args: string[], opts?: { timeout?: number; cwd?: string }) => Promise<{
exitCode: number
stdout: string
stderr: string
}>
paseo: (
args: string[],
opts?: { timeout?: number; cwd?: string },
) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
}>;
}
> {
const ctx = await startTestDaemon({ timeout: options?.timeout })
const ctx = await startTestDaemon({ timeout: options?.timeout });
const paseo = (args: string[], opts?: { timeout?: number; cwd?: string }) =>
runPaseoCli(ctx, args, opts)
runPaseoCli(ctx, args, opts);
return {
...ctx,
paseo,
}
};
}
+86 -87
View File
@@ -7,48 +7,48 @@
* Each test is a separate .ts file that can also be run independently.
*/
import { $ } from 'zx'
import { readdir, writeFile } from 'fs/promises'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import { $ } from "zx";
import { readdir, writeFile } from "fs/promises";
import { join, dirname } from "path";
import { fileURLToPath } from "url";
const __dirname = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const __dirname = dirname(fileURLToPath(import.meta.url));
const args = process.argv.slice(2);
const testEnvDefaults = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
let jsonOutputPath: string | null = null
let jsonOutputPath: string | null = null;
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (arg === '--json-output') {
const value = args[i + 1]
const arg = args[i];
if (arg === "--json-output") {
const value = args[i + 1];
if (!value) {
throw new Error('--json-output requires a file path')
throw new Error("--json-output requires a file path");
}
jsonOutputPath = value
i++
continue
jsonOutputPath = value;
i++;
continue;
}
}
$.verbose = false
$.verbose = false;
type Failure = { test: string; error: string }
type Failure = { test: string; error: string };
async function runCommand(label: string, command: string): Promise<void> {
console.log(`\n${'─'.repeat(50)}`)
console.log(`🔧 ${label}...`)
console.log('─'.repeat(50))
console.log(`\n${"─".repeat(50)}`);
console.log(`🔧 ${label}...`);
console.log("─".repeat(50));
const result = await $`bash -lc ${command}`.nothrow()
const result = await $`bash -lc ${command}`.nothrow();
if (result.exitCode !== 0) {
const error = result.stderr || result.stdout || `Exit code: ${result.exitCode}`
console.error(`\n❌ ${label} failed`)
console.error(error)
throw new Error(error)
const error = result.stderr || result.stdout || `Exit code: ${result.exitCode}`;
console.error(`\n❌ ${label} failed`);
console.error(error);
throw new Error(error);
}
}
@@ -57,20 +57,20 @@ async function writeJsonSummary({
failed,
failures,
}: {
passed: number
failed: number
failures: Failure[]
passed: number;
failed: number;
failures: Failure[];
}) {
if (!jsonOutputPath) {
return
return;
}
await writeFile(
jsonOutputPath,
JSON.stringify(
{
suite: 'cli-local',
command: 'npm run test:local --workspace=@getpaseo/cli',
suite: "cli-local",
command: "npm run test:local --workspace=@getpaseo/cli",
counts: {
passed,
failed,
@@ -79,96 +79,95 @@ async function writeJsonSummary({
skippedTests: [],
failures: failures.map(({ test, error }) => ({
test,
error: error.split('\n')[0] ?? '',
error: error.split("\n")[0] ?? "",
})),
},
null,
2
) + '\n'
)
2,
) + "\n",
);
}
console.log('🧪 Paseo CLI E2E Test Runner\n')
console.log('='.repeat(50))
console.log("🧪 Paseo CLI E2E Test Runner\n");
console.log("=".repeat(50));
// Discover all test files
const files = await readdir(__dirname)
const testFiles = files
.filter(f => f.match(/^\d{2}-.*\.test\.ts$/))
.sort()
const files = await readdir(__dirname);
const testFiles = files.filter((f) => f.match(/^\d{2}-.*\.test\.ts$/)).sort();
if (testFiles.length === 0) {
console.log('❌ No test files found')
await writeJsonSummary({ passed: 0, failed: 0, failures: [] })
process.exit(1)
console.log("❌ No test files found");
await writeJsonSummary({ passed: 0, failed: 0, failures: [] });
process.exit(1);
}
console.log(`Found ${testFiles.length} test file(s):\n`)
console.log(`Found ${testFiles.length} test file(s):\n`);
for (const file of testFiles) {
console.log(` - ${file}`)
console.log(` - ${file}`);
}
console.log()
console.log();
let passed = 0
let failed = 0
const failures: Failure[] = []
let passed = 0;
let failed = 0;
const failures: Failure[] = [];
await runCommand('Building relay', 'npm run build --workspace=@getpaseo/relay')
await runCommand('Building server', 'npm run build --workspace=@getpaseo/server')
await runCommand('Building CLI', 'npm run build --workspace=@getpaseo/cli')
await runCommand("Building relay", "npm run build --workspace=@getpaseo/relay");
await runCommand("Building server", "npm run build --workspace=@getpaseo/server");
await runCommand("Building CLI", "npm run build --workspace=@getpaseo/cli");
for (const testFile of testFiles) {
const testPath = join(__dirname, testFile)
const testName = testFile.replace(/\.test\.ts$/, '')
const testPath = join(__dirname, testFile);
const testName = testFile.replace(/\.test\.ts$/, "");
console.log(`\n${'─'.repeat(50)}`)
console.log(`📋 Running ${testName}...`)
console.log('─'.repeat(50))
console.log(`\n${"─".repeat(50)}`);
console.log(`📋 Running ${testName}...`);
console.log("─".repeat(50));
try {
const result = await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow()
const result =
await $`PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${testEnvDefaults.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${testEnvDefaults.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${testEnvDefaults.PASEO_VOICE_MODE_ENABLED} npx tsx ${testPath}`.nothrow();
if (result.exitCode === 0) {
console.log(`\n✅ ${testName} PASSED`)
passed++
console.log(`\n✅ ${testName} PASSED`);
passed++;
} else {
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`)
console.log(`\n❌ ${testName} FAILED (exit code: ${result.exitCode})`);
if (result.stderr) {
console.log('stderr:', result.stderr)
console.log("stderr:", result.stderr);
}
failed++
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` })
break
failed++;
failures.push({ test: testName, error: result.stderr || `Exit code: ${result.exitCode}` });
break;
}
} catch (e) {
const error = e instanceof Error ? e.message : String(e)
console.log(`\n❌ ${testName} FAILED`)
console.log('Error:', error)
failed++
failures.push({ test: testName, error })
break
const error = e instanceof Error ? e.message : String(e);
console.log(`\n❌ ${testName} FAILED`);
console.log("Error:", error);
failed++;
failures.push({ test: testName, error });
break;
}
}
// Summary
console.log('\n' + '='.repeat(50))
console.log('📊 Test Results')
console.log('='.repeat(50))
console.log(` ✅ Passed: ${passed}`)
console.log(` ❌ Failed: ${failed}`)
console.log(` 📝 Total: ${passed + failed}`)
console.log("\n" + "=".repeat(50));
console.log("📊 Test Results");
console.log("=".repeat(50));
console.log(` ✅ Passed: ${passed}`);
console.log(` ❌ Failed: ${failed}`);
console.log(` 📝 Total: ${passed + failed}`);
if (failures.length > 0) {
console.log('\n❌ Failed tests:')
console.log("\n❌ Failed tests:");
for (const { test, error } of failures) {
console.log(` - ${test}`)
console.log(` - ${test}`);
if (error) {
console.log(` ${error.split('\n')[0]}`)
console.log(` ${error.split("\n")[0]}`);
}
}
}
console.log()
console.log();
await writeJsonSummary({ passed, failed, failures })
await writeJsonSummary({ passed, failed, failures });
process.exit(failed > 0 ? 1 : 0)
process.exit(failed > 0 ? 1 : 0);
+65 -67
View File
@@ -9,57 +9,57 @@
* 5. Cleanup: Kill daemon and remove temp dirs after each test
*/
import { $, ProcessPromise, sleep } from 'zx'
import { mkdtemp, rm } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { $, ProcessPromise, sleep } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
const TEST_ENV_DEFAULTS = {
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? '0',
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? '0',
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? '0',
}
PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD: process.env.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD ?? "0",
PASEO_DICTATION_ENABLED: process.env.PASEO_DICTATION_ENABLED ?? "0",
PASEO_VOICE_MODE_ENABLED: process.env.PASEO_VOICE_MODE_ENABLED ?? "0",
};
function killPidTree(pid: number, signal: NodeJS.Signals): void {
if (!Number.isInteger(pid) || pid <= 0) {
return
return;
}
if (process.platform !== 'win32') {
if (process.platform !== "win32") {
try {
process.kill(-pid, signal)
return
process.kill(-pid, signal);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ESRCH') {
return
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") {
return;
}
}
}
try {
process.kill(pid, signal)
process.kill(pid, signal);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code !== 'ESRCH') {
throw error
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ESRCH") {
throw error;
}
}
}
export interface TestContext {
/** Random port for test daemon (never 6767) */
port: number
port: number;
/** Temp directory for PASEO_HOME */
paseoHome: string
paseoHome: string;
/** Temp directory for agent working directory */
workDir: string
workDir: string;
/** Running daemon process */
daemon: ProcessPromise | null
daemon: ProcessPromise | null;
/** Run a paseo CLI command against the test daemon */
paseo: (args: string[]) => ProcessPromise
paseo: (args: string[]) => ProcessPromise;
/** Clean up all resources */
cleanup: () => Promise<void>
cleanup: () => Promise<void>;
}
/**
@@ -67,16 +67,16 @@ export interface TestContext {
* NEVER uses 6767 (user's running daemon)
*/
export function getRandomPort(): number {
return 10000 + Math.floor(Math.random() * 50000)
return 10000 + Math.floor(Math.random() * 50000);
}
/**
* Create isolated temp directories for testing
*/
export async function createTempDirs(): Promise<{ paseoHome: string; workDir: string }> {
const paseoHome = await mkdtemp(join(tmpdir(), 'paseo-test-home-'))
const workDir = await mkdtemp(join(tmpdir(), 'paseo-test-work-'))
return { paseoHome, workDir }
const paseoHome = await mkdtemp(join(tmpdir(), "paseo-test-home-"));
const workDir = await mkdtemp(join(tmpdir(), "paseo-test-work-"));
return { paseoHome, workDir };
}
/**
@@ -84,58 +84,56 @@ export async function createTempDirs(): Promise<{ paseoHome: string; workDir: st
* Uses `paseo agent ls` which connects via WebSocket
*/
export async function waitForDaemon(port: number, timeout = 30000): Promise<void> {
const start = Date.now()
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow()
if (result.exitCode === 0) return
const result = await $`PASEO_HOST=localhost:${port} paseo agent ls`.nothrow();
if (result.exitCode === 0) return;
} catch {
// Connection failed, keep trying
}
await sleep(100)
await sleep(100);
}
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`)
throw new Error(`Daemon failed to start on port ${port} within ${timeout}ms`);
}
/**
* Start an isolated test daemon
*/
export async function startDaemon(
port: number,
paseoHome: string
): Promise<ProcessPromise> {
$.verbose = false
const daemon = $`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow()
return daemon
export async function startDaemon(port: number, paseoHome: string): Promise<ProcessPromise> {
$.verbose = false;
const daemon =
$`PASEO_HOME=${paseoHome} PASEO_LISTEN=127.0.0.1:${port} PASEO_RELAY_ENABLED=false PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} CI=true paseo daemon start --foreground`.nothrow();
return daemon;
}
/**
* Create a full test context with daemon, temp dirs, and helpers
*/
export async function createTestContext(): Promise<TestContext> {
const port = getRandomPort()
const { paseoHome, workDir } = await createTempDirs()
const port = getRandomPort();
const { paseoHome, workDir } = await createTempDirs();
// Helper to run CLI commands against test daemon
const paseo = (args: string[]): ProcessPromise => {
$.verbose = false
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow()
}
$.verbose = false;
return $`PASEO_HOST=localhost:${port} PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD=${TEST_ENV_DEFAULTS.PASEO_LOCAL_SPEECH_AUTO_DOWNLOAD} PASEO_DICTATION_ENABLED=${TEST_ENV_DEFAULTS.PASEO_DICTATION_ENABLED} PASEO_VOICE_MODE_ENABLED=${TEST_ENV_DEFAULTS.PASEO_VOICE_MODE_ENABLED} paseo ${args}`.nothrow();
};
// Cleanup function
const cleanup = async (): Promise<void> => {
if (ctx.daemon) {
if (typeof ctx.daemon.pid === 'number') {
killPidTree(ctx.daemon.pid, 'SIGTERM')
await sleep(250)
killPidTree(ctx.daemon.pid, 'SIGKILL')
if (typeof ctx.daemon.pid === "number") {
killPidTree(ctx.daemon.pid, "SIGTERM");
await sleep(250);
killPidTree(ctx.daemon.pid, "SIGKILL");
} else {
ctx.daemon.kill()
ctx.daemon.kill();
}
}
await rm(paseoHome, { recursive: true, force: true })
await rm(workDir, { recursive: true, force: true })
}
await rm(paseoHome, { recursive: true, force: true });
await rm(workDir, { recursive: true, force: true });
};
const ctx: TestContext = {
port,
@@ -144,9 +142,9 @@ export async function createTestContext(): Promise<TestContext> {
daemon: null,
paseo,
cleanup,
}
};
return ctx
return ctx;
}
/**
@@ -154,10 +152,10 @@ export async function createTestContext(): Promise<TestContext> {
* Use this for tests that need a running daemon
*/
export async function createTestContextWithDaemon(): Promise<TestContext> {
const ctx = await createTestContext()
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome)
await waitForDaemon(ctx.port)
return ctx
const ctx = await createTestContext();
ctx.daemon = await startDaemon(ctx.port, ctx.paseoHome);
await waitForDaemon(ctx.port);
return ctx;
}
/**
@@ -165,13 +163,13 @@ export async function createTestContextWithDaemon(): Promise<TestContext> {
*/
export function registerCleanupHandlers(cleanup: () => Promise<void>): void {
const handler = async () => {
await cleanup()
process.exit(0)
}
await cleanup();
process.exit(0);
};
process.on('exit', () => {
process.on("exit", () => {
// Can't await in exit handler, but at least try to kill daemon
})
process.on('SIGINT', handler)
process.on('SIGTERM', handler)
});
process.on("SIGINT", handler);
process.on("SIGTERM", handler);
}
+23 -14
View File
@@ -1,20 +1,29 @@
import { createE2ETestContext } from '/Users/moboudra/.paseo/worktrees/1luy0po7/beefy-parrot/packages/cli/tests/helpers/test-daemon.ts'
import { createE2ETestContext } from "/Users/moboudra/.paseo/worktrees/1luy0po7/beefy-parrot/packages/cli/tests/helpers/test-daemon.ts";
async function main() {
const ctx = await createE2ETestContext({ timeout: 180000 })
const ctx = await createE2ETestContext({ timeout: 180000 });
try {
const run = await ctx.paseo([
'run',
'--provider','opencode',
'--mode','default',
'--model','opencode/kimi-k2.5-free',
'Say hello in one short sentence.'
], { timeout: 180000 })
console.log('EXIT', run.exitCode)
console.log('STDOUT\n' + run.stdout)
console.log('STDERR\n' + run.stderr)
const run = await ctx.paseo(
[
"run",
"--provider",
"opencode",
"--mode",
"default",
"--model",
"opencode/kimi-k2.5-free",
"Say hello in one short sentence.",
],
{ timeout: 180000 },
);
console.log("EXIT", run.exitCode);
console.log("STDOUT\n" + run.stdout);
console.log("STDERR\n" + run.stderr);
} finally {
await ctx.stop()
await ctx.stop();
}
}
main().catch((error) => { console.error(error); process.exitCode = 1 })
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -1,43 +1,50 @@
import { createE2ETestContext } from '../helpers/test-daemon.ts'
import { createE2ETestContext } from "../helpers/test-daemon.ts";
const schema = JSON.stringify({
type: 'object',
properties: { summary: { type: 'string' } },
required: ['summary'],
type: "object",
properties: { summary: { type: "string" } },
required: ["summary"],
additionalProperties: false,
})
});
async function main() {
const ctx = await createE2ETestContext({ timeout: 180000 })
const ctx = await createE2ETestContext({ timeout: 180000 });
try {
const run = await ctx.paseo([
'run',
'--provider','opencode',
'--mode','default',
'--model','opencode/kimi-k2.5-free',
'--output-schema', schema,
'Return valid JSON with a short summary for provider opencode.'
], { timeout: 180000 })
console.log('RUN EXIT', run.exitCode)
console.log('RUN STDOUT\n' + run.stdout)
console.log('RUN STDERR\n' + run.stderr)
const run = await ctx.paseo(
[
"run",
"--provider",
"opencode",
"--mode",
"default",
"--model",
"opencode/kimi-k2.5-free",
"--output-schema",
schema,
"Return valid JSON with a short summary for provider opencode.",
],
{ timeout: 180000 },
);
console.log("RUN EXIT", run.exitCode);
console.log("RUN STDOUT\n" + run.stdout);
console.log("RUN STDERR\n" + run.stderr);
const ls = await ctx.paseo(['ls','--json'])
console.log('LS\n' + ls.stdout)
const agents = JSON.parse(ls.stdout)
const agentId = agents[0]?.id
const ls = await ctx.paseo(["ls", "--json"]);
console.log("LS\n" + ls.stdout);
const agents = JSON.parse(ls.stdout);
const agentId = agents[0]?.id;
if (agentId) {
const inspect = await ctx.paseo(['inspect', agentId])
console.log('INSPECT\n' + inspect.stdout)
const logs = await ctx.paseo(['logs','--tail','100', agentId], { timeout: 30000 })
console.log('LOGS\n' + logs.stdout)
const inspect = await ctx.paseo(["inspect", agentId]);
console.log("INSPECT\n" + inspect.stdout);
const logs = await ctx.paseo(["logs", "--tail", "100", agentId], { timeout: 30000 });
console.log("LOGS\n" + logs.stdout);
}
} finally {
await ctx.stop()
await ctx.stop();
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
console.error(error);
process.exitCode = 1;
});