Export installed agents as grouped Claw packages (#102306)

* Export installed agents as grouped Claw packages

* test(claws): cover exact agent export

* docs(claws): document agent export

* fix(claws): use current bounded file reader

---------

Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
Gio Della-Libera
2026-07-22 06:40:29 -07:00
committed by GitHub
parent 80aaaeea3e
commit 6134fdfdcd
8 changed files with 842 additions and 6 deletions
+20 -6
View File
@@ -185,14 +185,28 @@ Use `--force-referenced` only after reviewing the displayed dependents,
independent owners, and pre-existing origin. It allows selected cleanup despite
those conflicts; it does not skip plan-integrity consent.
## Export an installed agent
Export creates a new package directory and fails if the destination exists or
managed state has drifted:
```bash
openclaw claws export incident-triage --out ./incident-triage-export --json
```
The result contains `package.json`, `openclaw.claw.json`, and managed workspace
sidecars. It is a portable Claw package, not a whole-instance backup: unrelated
agents, credentials, sessions, and unowned local state are excluded.
## Command reference
| Command | Purpose |
| ------------------------------ | --------------------------------------------------- |
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
| `claws add <source>` | Preview or create one new agent and workspace. |
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
| Command | Purpose |
| ----------------------------------- | --------------------------------------------------- |
| `claws inspect <source>` | Validate a package directory or JSON manifest. |
| `claws add <source>` | Preview or create one new agent and workspace. |
| `claws status [claw-or-agent]` | Report installed state, ownership, and drift. |
| `claws remove <claw-or-agent>` | Preview or remove the agent and eligible resources. |
| `claws export <agent> --out <path>` | Create a portable package from an installed agent. |
Use `--json` for experimental machine-readable output.
+1
View File
@@ -1376,6 +1376,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Inspect and preview
- H2: Inspect installed state
- H2: Remove an installed Claw
- H2: Export an installed agent
- H2: Command reference
- H2: See also
+292
View File
@@ -0,0 +1,292 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { applyClawAddPlan } from "./add.js";
import { exportClawAgent } from "./export.js";
import { buildClawAddPlan } from "./lifecycle.js";
import { persistClawPackageRef, updateClawInstallRecordStatus } from "./provenance.js";
import { parseClawManifest } from "./schema.js";
import type { ClawSourceIdentity } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
afterEach(() => {
closeOpenClawStateDatabaseForTest();
vi.unstubAllEnvs();
});
async function installedFixture(
options: {
avatar?: string;
extraWorkspaceFiles?: string[];
withPackage?: boolean;
} = {},
) {
const root = tempDirs.make("openclaw-claw-export-");
await mkdir(join(root, "source", "reference"), { recursive: true });
const content = (label: string) => `managed ${label}\n`;
await writeFile(join(root, "source", "SOUL.md"), content("soul"));
await writeFile(join(root, "source", "reference", "policy.md"), content("policy"));
for (const path of options.extraWorkspaceFiles ?? []) {
await mkdir(join(root, "source", dirname(path)), { recursive: true });
await writeFile(join(root, "source", path), content(path));
}
const parsed = parseClawManifest({
schemaVersion: 1,
agent: {
id: "worker",
name: "Worker",
...(options.avatar ? { identity: { avatar: options.avatar } } : {}),
tools: { deny: ["exec"] },
},
workspace: {
bootstrapFiles: { "SOUL.md": { source: "source/SOUL.md" } },
files: [
{ source: "source/reference/policy.md", path: "reference/policy.md" },
...(options.extraWorkspaceFiles ?? []).map((path) => ({ source: `source/${path}`, path })),
],
},
});
if (!parsed.ok) {
throw new Error(JSON.stringify(parsed.diagnostics));
}
const source: ClawSourceIdentity = {
kind: "package",
name: "@acme/worker",
version: "1.2.3",
packageRoot: root,
manifestPath: join(root, "openclaw.claw.json"),
integrityKind: "artifact",
integrity: "sha256:manifest",
byteLength: 100,
};
const plan = await buildClawAddPlan({
manifest: parsed.manifest,
source,
context: { workspace: join(root, "workspace-worker") },
});
let config: OpenClawConfig = {};
await applyClawAddPlan(plan, {
consentPlanIntegrity: plan.planIntegrity,
env: { OPENCLAW_STATE_DIR: join(root, "state") },
commitConfig: async (transform) => {
config = transform(config);
},
});
if (options.withPackage) {
persistClawPackageRef(
plan,
{
kind: "skill",
source: "clawhub",
ref: "@acme/triage",
version: "2.0.0",
integrity: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
{ env: { OPENCLAW_STATE_DIR: join(root, "state") } },
);
}
return {
root,
plan,
config,
env: { OPENCLAW_STATE_DIR: join(root, "state") },
packageDeps: {
planSkill: async () => ({
ok: true as const,
plan: {
workspaceDir: plan.agent.workspace,
slug: "@acme/triage",
version: "2.0.0",
installedAt: 0,
targetDir: join(plan.agent.workspace, "skills", "@acme", "triage"),
skillFilePath: join(plan.agent.workspace, "skills", "@acme", "triage", "SKILL.md"),
skillFileSha256: "a".repeat(64),
fileTreeSha256: `sha256:${"a".repeat(64)}`,
},
}),
},
};
}
describe("exportClawAgent", () => {
it("writes a grouped package from one installed agent", async () => {
const fixture = await installedFixture({ withPackage: true });
const out = join(fixture.root, "exported");
const result = await exportClawAgent("worker", out, {
env: fixture.env,
config: fixture.config,
packageDeps: fixture.packageDeps,
});
expect(result).toMatchObject({
schemaVersion: "openclaw.clawExportResult.v1",
stability: "experimental",
agentId: "worker",
manifest: {
schemaVersion: 1,
agent: { id: "worker", name: "Worker", tools: { deny: ["exec"] } },
workspace: {
bootstrapFiles: { "SOUL.md": { source: "workspace/SOUL.md" } },
files: [{ source: "workspace/reference/policy.md", path: "reference/policy.md" }],
},
packages: [
{
kind: "skill",
source: "clawhub",
ref: "@acme/triage",
version: "2.0.0",
},
],
mcpServers: {},
cronJobs: [],
},
});
const packageJson = JSON.parse(await readFile(join(out, "package.json"), "utf8"));
expect(packageJson).toMatchObject({
name: "openclaw-claw-worker",
openclaw: { claw: "openclaw.claw.json" },
});
expect(packageJson.version).toMatch(/^0\.0\.0-export\.[0-9a-f]{64}$/);
await expect(readFile(join(out, "workspace", "SOUL.md"), "utf8")).resolves.toBe(
"managed soul\n",
);
});
it("rejects modified managed content instead of silently creating a snapshot", async () => {
const fixture = await installedFixture();
await writeFile(join(fixture.plan.agent.workspace, "SOUL.md"), "operator revision\n", "utf8");
const out = join(fixture.root, "exported-edited");
await expect(
exportClawAgent("worker", out, {
env: fixture.env,
config: fixture.config,
packageDeps: fixture.packageDeps,
}),
).rejects.toMatchObject({ code: "workspace_files_drifted" });
});
it("rejects a partial install rather than exporting an incomplete snapshot", async () => {
const fixture = await installedFixture();
updateClawInstallRecordStatus("worker", "partial", { env: fixture.env });
await expect(
exportClawAgent("worker", join(fixture.root, "exported-partial"), {
env: fixture.env,
config: fixture.config,
packageDeps: fixture.packageDeps,
}),
).rejects.toMatchObject({ code: "install_incomplete" });
});
it("rejects agent configuration drift", async () => {
const fixture = await installedFixture();
const agent = fixture.config.agents!.entries!.worker!;
agent.name = "Locally changed worker";
await expect(
exportClawAgent("worker", join(fixture.root, "exported-agent-drift"), {
env: fixture.env,
config: fixture.config,
}),
).rejects.toMatchObject({ code: "agent_drifted" });
});
it("rejects missing or drifted package dependencies", async () => {
const fixture = await installedFixture({ withPackage: true });
await expect(
exportClawAgent("worker", join(fixture.root, "exported-package-drift"), {
env: fixture.env,
config: fixture.config,
packageDeps: {
planSkill: async () => ({ ok: false as const, code: "missing", error: "missing" }),
},
}),
).rejects.toMatchObject({ code: "packages_drifted" });
});
it("packages a safe workspace-relative avatar as a sidecar", async () => {
const fixture = await installedFixture({
avatar: "avatars/worker.png",
extraWorkspaceFiles: ["avatars/worker.png"],
});
const avatarPath = join(fixture.plan.agent.workspace, "avatars", "worker.png");
const out = join(fixture.root, "exported-avatar");
const result = await exportClawAgent("worker", out, {
env: fixture.env,
config: fixture.config,
});
expect(result.manifest.agent.identity?.avatar).toBe("avatars/worker.png");
expect(result.manifest.workspace.files).toContainEqual({
source: "workspace/avatars/worker.png",
path: "avatars/worker.png",
});
await expect(readFile(join(out, "workspace", "avatars", "worker.png"), "utf8")).resolves.toBe(
"managed avatars/worker.png\n",
);
await expect(readFile(avatarPath, "utf8")).resolves.toBe("managed avatars/worker.png\n");
});
it("rejects an agent whose effective workspace changed after installation", async () => {
const fixture = await installedFixture();
const movedWorkspace = join(fixture.root, "moved-workspace");
await mkdir(movedWorkspace);
const agent = fixture.config.agents!.entries!.worker!;
agent.workspace = movedWorkspace;
await expect(
exportClawAgent("worker", join(fixture.root, "exported-moved-workspace"), {
env: fixture.env,
config: fixture.config,
}),
).rejects.toMatchObject({ code: "workspace_changed" });
});
it("expands a home-relative output directory", async () => {
const fixture = await installedFixture();
vi.stubEnv("HOME", fixture.root);
const result = await exportClawAgent("worker", "~/exported-home", {
env: fixture.env,
config: fixture.config,
});
expect(result.outputDirectory).toBe(join(fixture.root, "exported-home"));
await expect(
readFile(join(result.outputDirectory, "openclaw.claw.json"), "utf8"),
).resolves.toContain('"schemaVersion": 1');
});
it("fails closed when a managed file is unavailable", async () => {
const fixture = await installedFixture();
await writeFile(join(fixture.plan.agent.workspace, "SOUL.md"), "still available\n", "utf8");
await rm(join(fixture.plan.agent.workspace, "reference", "policy.md"));
await expect(
exportClawAgent("worker", join(fixture.root, "exported-missing"), {
env: fixture.env,
config: fixture.config,
}),
).rejects.toMatchObject({ code: "workspace_files_drifted" });
});
it("never writes into an existing output directory", async () => {
const fixture = await installedFixture();
const out = join(fixture.root, "existing");
await mkdir(out);
await writeFile(join(out, "operator.txt"), "keep\n", "utf8");
await expect(
exportClawAgent("worker", out, { env: fixture.env, config: fixture.config }),
).rejects.toMatchObject({ code: "output_collision" });
await expect(readFile(join(out, "operator.txt"), "utf8")).resolves.toBe("keep\n");
});
});
+359
View File
@@ -0,0 +1,359 @@
import { createHash } from "node:crypto";
import { closeSync } from "node:fs";
import { mkdir, realpath, rm } from "node:fs/promises";
import { dirname, relative, resolve, sep } from "node:path";
import { listAgentEntries, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { openLocalAgentAvatarFile } from "../agents/identity-avatar-file.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readFileDescriptorBoundedSync } from "../infra/boundary-file-read.js";
import { root as fsSafeRoot } from "../infra/fs-safe.js";
import { AVATAR_MAX_BYTES, isAvatarDataUrl, isAvatarHttpUrl } from "../shared/avatar-policy.js";
import type { OpenClawStateDatabaseOptions } from "../state/openclaw-state-db.js";
import { resolveUserPath } from "../utils.js";
import { readClawStatus } from "./lifecycle-state.js";
import type { PackageRemovalDeps } from "./package-remove.js";
import { isPortableClawAvatar } from "./schema-portability.js";
import { parseClawManifest } from "./schema.js";
import { MAX_MANAGED_WORKSPACE_BYTES } from "./source-limits.js";
import {
CLAW_BOOTSTRAP_FILE_NAMES,
CLAW_OUTPUT_STABILITY,
CLAW_SCHEMA_VERSION,
type ClawManifest,
} from "./types.js";
export const CLAW_EXPORT_RESULT_SCHEMA_VERSION = "openclaw.clawExportResult.v1" as const;
const MAX_EXPORT_FILE_BYTES = 1024 * 1024;
type AgentConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
type ClawAgent = ClawManifest["agent"];
type ClawBootstrapFileName = keyof ClawManifest["workspace"]["bootstrapFiles"];
type ClawExportResult = {
schemaVersion: typeof CLAW_EXPORT_RESULT_SCHEMA_VERSION;
stability: typeof CLAW_OUTPUT_STABILITY;
agentId: string;
outputDirectory: string;
manifest: ClawManifest;
filesWritten: string[];
};
export class ClawExportError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
this.name = "ClawExportError";
}
}
function portableAgent(agent: AgentConfig, avatar: string | undefined): ClawAgent {
const identity = {
...(agent.identity?.name ? { name: agent.identity.name } : {}),
...(agent.identity?.theme ? { theme: agent.identity.theme } : {}),
...(agent.identity?.emoji ? { emoji: agent.identity.emoji } : {}),
...(avatar ? { avatar } : {}),
};
const tools = {
...(agent.tools?.allow?.length ? { allow: agent.tools.allow } : {}),
...(agent.tools?.deny?.length ? { deny: agent.tools.deny } : {}),
};
return {
id: agent.id,
...(agent.name ? { name: agent.name } : {}),
...(agent.description ? { description: agent.description } : {}),
...(Object.keys(identity).length > 0 ? { identity } : {}),
...(agent.groupChat?.mentionPatterns?.length
? { groupChat: { mentionPatterns: agent.groupChat.mentionPatterns } }
: {}),
...(agent.sandbox
? {
sandbox: {
...(agent.sandbox.mode ? { mode: agent.sandbox.mode } : {}),
...(agent.sandbox.scope ? { scope: agent.sandbox.scope } : {}),
...(agent.sandbox.workspaceAccess
? { workspaceAccess: agent.sandbox.workspaceAccess }
: {}),
},
}
: {}),
...(Object.keys(tools).length > 0 ? { tools } : {}),
...(agent.heartbeat
? {
heartbeat: {
...(agent.heartbeat.every ? { every: agent.heartbeat.every } : {}),
...(agent.heartbeat.activeHours
? {
activeHours: {
...(agent.heartbeat.activeHours.start
? { start: agent.heartbeat.activeHours.start }
: {}),
...(agent.heartbeat.activeHours.end
? { end: agent.heartbeat.activeHours.end }
: {}),
...(agent.heartbeat.activeHours.timezone
? { timezone: agent.heartbeat.activeHours.timezone }
: {}),
},
}
: {}),
...(agent.heartbeat.lightContext !== undefined
? { lightContext: agent.heartbeat.lightContext }
: {}),
...(agent.heartbeat.isolatedSession !== undefined
? { isolatedSession: agent.heartbeat.isolatedSession }
: {}),
...(agent.heartbeat.timeoutSeconds !== undefined
? { timeoutSeconds: agent.heartbeat.timeoutSeconds }
: {}),
},
}
: {}),
...(agent.humanDelay
? {
humanDelay: {
...(agent.humanDelay.mode ? { mode: agent.humanDelay.mode } : {}),
...(agent.humanDelay.minMs !== undefined ? { minMs: agent.humanDelay.minMs } : {}),
...(agent.humanDelay.maxMs !== undefined ? { maxMs: agent.humanDelay.maxMs } : {}),
},
}
: {}),
};
}
function normalizedRelativePath(value: string): string {
return value.split(sep).join("/");
}
function comparePortableText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}
function isClawBootstrapFileName(value: string): value is ClawBootstrapFileName {
return (CLAW_BOOTSTRAP_FILE_NAMES as readonly string[]).includes(value);
}
function readPortableAvatar(params: {
config: OpenClawConfig;
agent: AgentConfig;
workspace: string;
}): { source?: string; sidecar?: { path: string; content: Buffer } } {
const source = params.agent.identity?.avatar?.trim();
if (!source) {
return {};
}
if (isAvatarHttpUrl(source)) {
return {};
}
if (isAvatarDataUrl(source)) {
return isPortableClawAvatar(source) ? { source } : {};
}
const opened = openLocalAgentAvatarFile({
cfg: params.config,
agentId: params.agent.id,
source,
});
if (!opened.ok) {
return {};
}
try {
const content = readFileDescriptorBoundedSync(opened.file.fd, AVATAR_MAX_BYTES);
const path = normalizedRelativePath(relative(params.workspace, opened.file.path));
return { source: path, sidecar: { path, content } };
} catch {
return {};
} finally {
closeSync(opened.file.fd);
}
}
function derivativePackageVersion(manifest: ClawManifest, contents: ExportContent[]): string {
const hash = createHash("sha256").update(JSON.stringify(manifest));
for (const file of contents.toSorted((left, right) =>
comparePortableText(left.path, right.path),
)) {
hash.update(file.path).update("\0").update(file.content).update("\0");
}
return `0.0.0-export.${hash.digest("hex")}`;
}
type ExportContent = { path: string; content: Buffer };
export async function exportClawAgent(
agentId: string,
outputDirectory: string,
options: OpenClawStateDatabaseOptions & {
config: OpenClawConfig;
packageDeps?: PackageRemovalDeps;
},
): Promise<ClawExportResult> {
const status = await readClawStatus(agentId, options);
const record = status.records.find((candidate) => candidate.install.agentId === agentId);
if (!record) {
throw new ClawExportError(
"claw_not_found",
`No installed Claw agent matches ${JSON.stringify(agentId)}.`,
);
}
if (record.install.status !== "complete") {
throw new ClawExportError(
"install_incomplete",
`Installed Claw agent ${JSON.stringify(agentId)} is in ${JSON.stringify(record.install.status)} state; finish or repair it before export.`,
);
}
const agent = listAgentEntries(options.config).find((candidate) => candidate.id === agentId);
if (!agent) {
throw new ClawExportError(
"agent_missing",
`Installed Claw agent ${JSON.stringify(agentId)} is missing from config.`,
);
}
const currentWorkspace = await realpath(
resolve(resolveAgentWorkspaceDir(options.config, agentId)),
).catch(() => resolve(resolveAgentWorkspaceDir(options.config, agentId)));
if (currentWorkspace !== record.install.workspace) {
throw new ClawExportError(
"workspace_changed",
`Agent ${JSON.stringify(agentId)} now resolves to workspace ${JSON.stringify(currentWorkspace)} instead of its recorded Claw workspace ${JSON.stringify(record.install.workspace)}.`,
);
}
if (record.agentState !== "present") {
throw new ClawExportError(
"agent_drifted",
`Agent ${JSON.stringify(agentId)} no longer matches its recorded Claw configuration.`,
);
}
const driftedFiles = record.workspaceFiles.filter((file) => file.state !== "unchanged");
if (driftedFiles.length > 0) {
throw new ClawExportError(
"workspace_files_drifted",
`Cannot export drifted managed files: ${driftedFiles.map((file) => `${file.path} (${file.state})`).join(", ")}.`,
);
}
const driftedPackages = record.packages.filter((pkg) => pkg.state !== "present");
if (driftedPackages.length > 0) {
throw new ClawExportError(
"packages_drifted",
`Cannot export drifted packages: ${driftedPackages.map((pkg) => `${pkg.kind}:${pkg.ref}@${pkg.version} (${pkg.state})`).join(", ")}.`,
);
}
const workspace = await fsSafeRoot(record.install.workspace, {
hardlinks: "reject",
maxBytes: MAX_EXPORT_FILE_BYTES,
symlinks: "reject",
});
const contents: ExportContent[] = await Promise.all(
record.workspaceFiles.map(async (file) => ({
path: normalizedRelativePath(file.path),
content: await workspace.readBytes(file.path, { maxBytes: MAX_EXPORT_FILE_BYTES }),
})),
);
const avatar = readPortableAvatar({
config: options.config,
agent,
workspace: record.install.workspace,
});
const managedPaths = new Set(contents.map((file) => file.path));
if (avatar.sidecar && !managedPaths.has(avatar.sidecar.path)) {
contents.push(avatar.sidecar);
}
const aggregateBytes = contents.reduce((total, file) => total + file.content.byteLength, 0);
if (aggregateBytes > MAX_MANAGED_WORKSPACE_BYTES) {
throw new ClawExportError(
"workspace_files_oversized",
`Exported workspace content exceeds ${MAX_MANAGED_WORKSPACE_BYTES} aggregate bytes.`,
);
}
const bootstrapFiles: ClawManifest["workspace"]["bootstrapFiles"] = {};
const files: ClawManifest["workspace"]["files"] = [];
for (const file of contents) {
const source = `workspace/${file.path}`;
if (isClawBootstrapFileName(file.path)) {
bootstrapFiles[file.path] = { source };
} else {
files.push({ source, path: file.path });
}
}
const manifest: ClawManifest = {
schemaVersion: CLAW_SCHEMA_VERSION,
agent: portableAgent(agent, avatar.source),
workspace: { bootstrapFiles, files },
packages: record.packages
.map((pkg) => ({
kind: pkg.kind,
source: pkg.source,
ref: pkg.ref,
version: pkg.version,
}))
.toSorted((left, right) => {
const leftIdentity = `${left.kind}:${left.ref}:${left.version}`;
const rightIdentity = `${right.kind}:${right.ref}:${right.version}`;
return comparePortableText(leftIdentity, rightIdentity);
}),
mcpServers: {},
cronJobs: [],
};
const parsed = parseClawManifest(manifest);
if (!parsed.ok) {
throw new ClawExportError(
"export_manifest_invalid",
parsed.diagnostics.map((diagnostic) => diagnostic.message).join("; "),
);
}
const target = resolve(resolveUserPath(outputDirectory));
await mkdir(dirname(target), { recursive: true });
try {
await mkdir(target);
} catch (error) {
throw new ClawExportError(
"output_collision",
`Export directory ${JSON.stringify(target)} must not already exist: ${(error as Error).message}`,
);
}
const filesWritten: string[] = [];
try {
const output = await fsSafeRoot(target, {
hardlinks: "reject",
maxBytes: MAX_EXPORT_FILE_BYTES,
symlinks: "reject",
});
for (const file of contents) {
const path = `workspace/${file.path}`;
await output.write(path, file.content, { mkdir: true, overwrite: false });
filesWritten.push(path);
}
const packageJson = {
name: `openclaw-claw-${record.install.agentId}`,
version: derivativePackageVersion(manifest, contents),
type: "module",
openclaw: { claw: "openclaw.claw.json" },
};
await output.write("package.json", Buffer.from(`${JSON.stringify(packageJson, null, 2)}\n`), {
overwrite: false,
});
filesWritten.push("package.json");
await output.write(
"openclaw.claw.json",
Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`),
{ overwrite: false },
);
filesWritten.push("openclaw.claw.json");
} catch (error) {
await rm(target, { recursive: true, force: true }).catch(() => undefined);
throw new ClawExportError(
"export_write_failed",
error instanceof Error ? error.message : String(error),
);
}
return {
schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
agentId,
outputDirectory: target,
manifest,
filesWritten,
};
}
+80
View File
@@ -269,6 +269,86 @@ describe("claws lifecycle cli e2e", () => {
expect(config.agents).toEqual({ entries: { main: { default: true } } });
});
it("exports an installed agent as a self-contained grouped package", async () => {
const source = "src/claws/fixtures/workspace-agent.claw.json";
const addPreview = await runOpenClaw(["claws", "add", source, "--dry-run", "--json"]);
const addPlan = parseJson(addPreview.stdout) as { planIntegrity: string };
const added = await runOpenClaw(
["claws", "add", source, "--yes", "--plan-integrity", addPlan.planIntegrity, "--json"],
{ stateDir: addPreview.stateDir },
);
const outputDirectory = join(added.stateDir, "exported-claw");
const exported = await runOpenClaw(
["claws", "export", "workspace-agent", "--out", outputDirectory, "--json"],
{ stateDir: added.stateDir },
);
expect(parseJson(exported.stdout)).toMatchObject({
schemaVersion: "openclaw.clawExportResult.v1",
stability: "experimental",
agentId: "workspace-agent",
outputDirectory,
manifest: {
schemaVersion: 1,
agent: { id: "workspace-agent" },
workspace: {
bootstrapFiles: {
"SOUL.md": { source: "workspace/SOUL.md" },
"HEARTBEAT.md": { source: "workspace/HEARTBEAT.md" },
},
files: [
{
source: "workspace/reference/policy.md",
path: "reference/policy.md",
},
],
},
},
});
expect(JSON.parse(await readFile(join(outputDirectory, "package.json"), "utf8"))).toMatchObject(
{
name: "openclaw-claw-workspace-agent",
version: expect.stringMatching(/^0\.0\.0-export\.[0-9a-f]{64}$/),
type: "module",
},
);
const inspected = await runOpenClaw(["claws", "inspect", outputDirectory, "--json"]);
expect(parseJson(inspected.stdout)).toMatchObject({
valid: true,
source: { kind: "package" },
manifest: { agent: { id: "workspace-agent" } },
});
const roundTripPreview = await runOpenClaw([
"claws",
"add",
outputDirectory,
"--dry-run",
"--json",
]);
const roundTripPlan = parseJson(roundTripPreview.stdout) as { planIntegrity: string };
const roundTrip = await runOpenClaw(
[
"claws",
"add",
outputDirectory,
"--yes",
"--plan-integrity",
roundTripPlan.planIntegrity,
"--json",
],
{ stateDir: roundTripPreview.stateDir },
);
expect(parseJson(roundTrip.stdout)).toMatchObject({
status: "complete",
claw: { kind: "package" },
agent: { finalId: "workspace-agent" },
workspaceFiles: [
expect.objectContaining({ path: "SOUL.md" }),
expect.objectContaining({ path: "HEARTBEAT.md" }),
expect.objectContaining({ path: "reference/policy.md" }),
],
});
});
it("blocks mutation when declared components need later lifecycle slices", async () => {
const root = tempDirs.make("openclaw-claws-deferred-components-");
const deferredManifestPath = join(root, "deferred.claw.json");
+42
View File
@@ -10,6 +10,11 @@ import {
ClawAddMutationError,
} from "../claws/add.js";
import { assertExperimentalClawsEnabled } from "../claws/experimental.js";
import {
CLAW_EXPORT_RESULT_SCHEMA_VERSION,
ClawExportError,
exportClawAgent,
} from "../claws/export.js";
import {
applyClawRemovePlan,
buildClawRemovePlan,
@@ -38,6 +43,7 @@ import { redactSensitiveText } from "../logging/redact.js";
import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js";
import type {
ClawsAddOptions,
ClawsExportOptions,
ClawsInspectOptions,
ClawsRemoveOptions,
ClawsStatusOptions,
@@ -454,3 +460,39 @@ export async function runClawsRemoveCommand(
runtime.exit(1);
}
}
export async function runClawsExportCommand(
agentId: string,
opts: ClawsExportOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
assertExperimentalClawsEnabled();
try {
const result = await exportClawAgent(agentId, opts.out, { config: getRuntimeConfig() });
if (opts.json) {
writeRuntimeJson(runtime, result);
return;
}
logExperimentalWarning(runtime);
runtime.log(`Exported agent: ${result.agentId}`);
runtime.log(`Package directory: ${result.outputDirectory}`);
runtime.log(
`Workspace files: ${result.manifest.workspace.files.length + Object.keys(result.manifest.workspace.bootstrapFiles).length}`,
);
runtime.log(`Packages: ${result.manifest.packages.length}`);
} catch (error) {
const code = error instanceof ClawExportError ? error.code : "export_failed";
const message = error instanceof Error ? error.message : String(error);
if (opts.json) {
writeRuntimeJson(runtime, {
schemaVersion: CLAW_EXPORT_RESULT_SCHEMA_VERSION,
stability: CLAW_OUTPUT_STABILITY,
status: "failed",
error: { code, message },
});
} else {
runtime.error(message);
}
runtime.exit(1);
}
}
+36
View File
@@ -30,6 +30,7 @@ const mocks = vi.hoisted(() => {
readClawStatus: vi.fn(),
buildClawRemovePlan: vi.fn(),
applyClawRemovePlan: vi.fn(),
exportClawAgent: vi.fn(),
};
});
@@ -60,6 +61,11 @@ vi.mock("../claws/lifecycle-state.js", async () => ({
applyClawRemovePlan: mocks.applyClawRemovePlan,
}));
vi.mock("../claws/export.js", async () => ({
...(await vi.importActual<typeof import("../claws/export.js")>("../claws/export.js")),
exportClawAgent: mocks.exportClawAgent,
}));
const { registerClawsCli } = await import("./claws-cli.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -185,6 +191,22 @@ describe("claws cli", () => {
packages: [],
packageRefsReleased: 1,
});
mocks.exportClawAgent.mockReset();
mocks.exportClawAgent.mockResolvedValue({
schemaVersion: "openclaw.clawExportResult.v1",
stability: "experimental",
agentId: "demo-agent",
outputDirectory: "/tmp/exported",
manifest: {
schemaVersion: 1,
agent: { id: "demo-agent" },
workspace: { bootstrapFiles: {}, files: [] },
packages: [],
mcpServers: {},
cronJobs: [],
},
filesWritten: ["package.json", "openclaw.claw.json"],
});
});
afterEach(() => {
@@ -210,6 +232,7 @@ describe("claws cli", () => {
"add",
"status",
"remove",
"export",
]);
});
@@ -658,4 +681,17 @@ describe("claws cli", () => {
error: { code: "consent_required" },
});
});
it("exports one installed agent to a new package directory", async () => {
await runCli(["claws", "export", "demo-agent", "--out", "/tmp/exported", "--json"]);
expect(mocks.exportClawAgent).toHaveBeenCalledWith("demo-agent", "/tmp/exported", {
config: {},
});
expect(JSON.parse(mocks.logs[0] ?? "{}")).toMatchObject({
schemaVersion: "openclaw.clawExportResult.v1",
stability: "experimental",
agentId: "demo-agent",
});
});
});
+12
View File
@@ -26,6 +26,7 @@ export type ClawsRemoveOptions = {
forceReferenced?: boolean;
json?: boolean;
};
export type ClawsExportOptions = { out: string; json?: boolean };
function collectOption(value: string, previous: string[]): string[] {
return [...previous, value];
@@ -101,5 +102,16 @@ export function registerClawsCli(program: Command) {
await runClawsRemoveCommand(target, opts);
});
claws
.command("export")
.description("Export portable state for one installed Claw agent")
.argument("<agent>", "Final id of the installed Claw agent")
.requiredOption("--out <path>", "New package directory to create")
.option("--json", "Print JSON", false)
.action(async (agent: string, opts: ClawsExportOptions) => {
const { runClawsExportCommand } = await import("./claws-cli.runtime.js");
await runClawsExportCommand(agent, opts);
});
applyParentDefaultHelpAction(claws);
}