feat(cli): add paseo import --provider <name> <id> to resume existing sessions (#632)
Surfaces the daemon's existing resume-from-persistence capability through a new top-level CLI command so users can pull existing Claude Code, Codex, or OpenCode sessions into Paseo without losing history. Adds a new additive `import_agent_request` WebSocket message; no existing schemas change. Refs #611, #237, #492; partially addresses #268.
This commit is contained in:
@@ -22,6 +22,7 @@ import { addInspectOptions, runInspectCommand } from "./commands/agent/inspect.j
|
||||
import { addWaitOptions, runWaitCommand } from "./commands/agent/wait.js";
|
||||
import { addArchiveOptions, runArchiveCommand } from "./commands/agent/archive.js";
|
||||
import { addAttachOptions, runAttachCommand } from "./commands/agent/attach.js";
|
||||
import { addImportOptions, runImportCommand } from "./commands/agent/import.js";
|
||||
import { withOutput } from "./output/index.js";
|
||||
import { onboardCommand } from "./commands/onboard.js";
|
||||
import {
|
||||
@@ -60,6 +61,10 @@ export function createCli(): Command {
|
||||
withOutput(runRunCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(addImportOptions(program.command("import"))).action(
|
||||
withOutput(runImportCommand),
|
||||
);
|
||||
|
||||
addDaemonHostOption(addAttachOptions(program.command("attach"))).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(addLogsOptions(program.command("logs"))).action(runLogsCommand);
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import type { Command } from "commander";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import type { CommandError, CommandOptions, SingleResult } from "../../output/index.js";
|
||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
|
||||
const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]);
|
||||
|
||||
export function addImportOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Import an existing provider session as a Paseo agent")
|
||||
.argument("<id>", "Provider session/thread ID to import")
|
||||
.requiredOption("--provider <provider>", "Agent provider: claude, codex, opencode, or acp")
|
||||
.option("--cwd <path>", "Working directory for providers that require it")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Add label(s) to the agent (can be used multiple times)",
|
||||
collectMultiple,
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
export interface AgentImportOptions extends CommandOptions {
|
||||
provider?: string;
|
||||
cwd?: string;
|
||||
label?: string[];
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export type AgentImportCommandResult = SingleResult<AgentRunResult>;
|
||||
|
||||
function toImportResult(agent: AgentSnapshotPayload): AgentRunResult {
|
||||
return {
|
||||
agentId: agent.id,
|
||||
status: agent.status === "running" ? "running" : "created",
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
title: agent.title,
|
||||
};
|
||||
}
|
||||
|
||||
function parseImportProvider(provider: string | undefined): string {
|
||||
const normalizedProvider = provider?.trim();
|
||||
if (!normalizedProvider) {
|
||||
throw {
|
||||
code: "MISSING_PROVIDER",
|
||||
message: "Provider is required",
|
||||
details: "Usage: paseo import --provider <provider> <id>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
if (!IMPORT_PROVIDERS.has(normalizedProvider)) {
|
||||
throw {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: `Unsupported provider: ${normalizedProvider}`,
|
||||
details: "Supported providers: claude, codex, opencode, acp",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
return normalizedProvider;
|
||||
}
|
||||
|
||||
function parseImportLabels(labelFlags: string[] | undefined): Record<string, string> {
|
||||
const labels: Record<string, string> = {};
|
||||
if (!labelFlags) {
|
||||
return labels;
|
||||
}
|
||||
|
||||
for (const labelFlag of labelFlags) {
|
||||
const eqIndex = labelFlag.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
throw {
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelFlag}`,
|
||||
details: "Labels must be in key=value format",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const key = labelFlag.slice(0, eqIndex).trim();
|
||||
if (!key) {
|
||||
throw {
|
||||
code: "INVALID_LABEL",
|
||||
message: `Invalid label format: ${labelFlag}`,
|
||||
details: "Labels must include a non-empty key in key=value format",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
labels[key] = labelFlag.slice(eqIndex + 1);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
|
||||
async function connectToDaemonOrThrow(
|
||||
hostOption: string | undefined,
|
||||
host: string,
|
||||
): Promise<Awaited<ReturnType<typeof connectToDaemon>>> {
|
||||
try {
|
||||
return await connectToDaemon({ host: hostOption });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runImportCommand(
|
||||
sessionIdArg: string,
|
||||
options: AgentImportOptions,
|
||||
_command: Command,
|
||||
): Promise<AgentImportCommandResult> {
|
||||
const host = getDaemonHost({ host: options.host as string | undefined });
|
||||
const sessionId = sessionIdArg.trim();
|
||||
if (!sessionId) {
|
||||
throw {
|
||||
code: "MISSING_SESSION_ID",
|
||||
message: "Session ID is required",
|
||||
details: "Usage: paseo import --provider <provider> <id>",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const provider = parseImportProvider(options.provider);
|
||||
const cwd = options.cwd?.trim();
|
||||
if (options.cwd !== undefined && !cwd) {
|
||||
throw {
|
||||
code: "INVALID_CWD",
|
||||
message: "--cwd cannot be empty",
|
||||
details: "Provide a working directory path or omit --cwd",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
const labels = parseImportLabels(options.label);
|
||||
const client = await connectToDaemonOrThrow(options.host as string | undefined, host);
|
||||
|
||||
try {
|
||||
const agent = await client.importAgent({
|
||||
provider,
|
||||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(Object.keys(labels).length > 0 ? { labels } : {}),
|
||||
});
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: toImportResult(agent),
|
||||
schema: agentRunSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
throw {
|
||||
code: "AGENT_IMPORT_FAILED",
|
||||
message: `Failed to import agent: ${message}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { addInspectOptions, runInspectCommand } from "./inspect.js";
|
||||
import { addWaitOptions, runWaitCommand } from "./wait.js";
|
||||
import { addAttachOptions, runAttachCommand } from "./attach.js";
|
||||
import { addReloadOptions, runReloadCommand } from "./reload.js";
|
||||
import { addImportOptions, runImportCommand } from "./import.js";
|
||||
import { runUpdateCommand } from "./update.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import {
|
||||
@@ -29,6 +30,10 @@ export function createAgentCommand(): Command {
|
||||
withOutput(runRunCommand),
|
||||
);
|
||||
|
||||
addJsonAndDaemonHostOptions(addImportOptions(agent.command("import"))).action(
|
||||
withOutput(runImportCommand),
|
||||
);
|
||||
|
||||
addDaemonHostOption(addAttachOptions(agent.command("attach"))).action(runAttachCommand);
|
||||
|
||||
addDaemonHostOption(addLogsOptions(agent.command("logs"))).action(runLogsCommand);
|
||||
|
||||
Reference in New Issue
Block a user