feat(migrate): add Claude importer

Add a bundled Claude migration provider for Claude Code and Claude Desktop imports.\n\nIncludes source discovery, preview/apply behavior for instructions, MCP servers, skills and command prompts, archive/manual handling for unsafe Claude state, docs, labeler, and tests.
This commit is contained in:
Vincent Koc
2026-04-27 02:35:44 -07:00
committed by GitHub
parent cf499101a2
commit 600df95c8c
21 changed files with 1561 additions and 0 deletions

6
.github/labeler.yml vendored
View File

@@ -40,6 +40,12 @@
- any-glob-to-any-file:
- "extensions/migrate-hermes/**"
- "docs/cli/migrate.md"
"plugin: migrate-claude":
- changed-files:
- any-glob-to-any-file:
- "extensions/migrate-claude/**"
- "docs/cli/migrate.md"
- "docs/install/migrating-claude.md"
"plugin: bonjour":
- changed-files:
- any-glob-to-any-file:

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.
- CLI/migration: add a bundled Claude importer that previews and applies Claude Code and Claude Desktop instructions, MCP servers, skills, command prompts, and safe archive/manual-review state. Thanks @vincentkoc.
- CLI/migration: add `openclaw migrate` with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only report copies, and a bundled Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, and supported credentials. Thanks @NousResearch.
### Fixes

View File

@@ -451,6 +451,14 @@
"source": "Migrating from Hermes",
"target": "从 Hermes 迁移"
},
{
"source": "Migrating from Claude",
"target": "从 Claude 迁移"
},
{
"source": "Agent workspace",
"target": "Agent 工作区"
},
{
"source": "Migration",
"target": "迁移"

View File

@@ -18,11 +18,14 @@ For a user-facing walkthrough of moving from Hermes, see [Migrating from Hermes]
```bash
openclaw migrate list
openclaw migrate claude --dry-run
openclaw migrate hermes --dry-run
openclaw migrate hermes
openclaw migrate apply claude --yes
openclaw migrate apply hermes --yes
openclaw migrate apply hermes --include-secrets --yes
openclaw onboard --flow import
openclaw onboard --import-from claude --import-source ~/.claude
openclaw onboard --import-from hermes --import-source ~/.hermes
```
@@ -76,6 +79,26 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
</Accordion>
</AccordionGroup>
## Claude provider
The bundled Claude provider detects Claude Code state at `~/.claude` by default. Use `--from <path>` to import a specific Claude Code home or project root.
<Tip>
For a user-facing walkthrough, see [Migrating from Claude](/install/migrating-claude).
</Tip>
### What gets imported
- Project `CLAUDE.md` and `.claude/CLAUDE.md` into the OpenClaw agent workspace.
- User `~/.claude/CLAUDE.md` appended to workspace `USER.md`.
- MCP server definitions from project `.mcp.json`, Claude Code `~/.claude.json`, and Claude Desktop `claude_desktop_config.json`.
- Claude skill directories that include `SKILL.md`.
- Claude command Markdown files converted into OpenClaw skills with manual invocation only.
### Archive and manual-review state
Claude hooks, permissions, environment defaults, local memory, path-scoped rules, subagents, caches, plans, and project history are preserved in the migration report or reported as manual-review items. OpenClaw does not execute hooks, copy broad allowlists, or import OAuth/Desktop credential state automatically.
## Hermes provider
The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from <path>` when Hermes lives elsewhere.
@@ -141,6 +164,7 @@ Onboarding imports require a fresh OpenClaw setup. Reset config, credentials, se
## Related
- [Migrating from Hermes](/install/migrating-hermes): user-facing walkthrough.
- [Migrating from Claude](/install/migrating-claude): user-facing walkthrough.
- [Migrating](/install/migrating): move OpenClaw to a new machine.
- [Doctor](/gateway/doctor): health check after applying a migration.
- [Plugins](/tools/plugin): plugin install and registration.

View File

@@ -1012,6 +1012,7 @@
"pages": [
"install/updating",
"install/migrating",
"install/migrating-claude",
"install/migrating-hermes",
"install/migrating-matrix",
"install/uninstall",

View File

@@ -0,0 +1,124 @@
---
summary: "Move Claude Code and Claude Desktop local state into OpenClaw with a previewed import"
read_when:
- You are coming from Claude Code or Claude Desktop and want to keep instructions, MCP servers, and skills
- You need to understand what OpenClaw imports automatically and what stays archive-only
title: "Migrating from Claude"
---
OpenClaw imports local Claude state through the bundled Claude migration provider. The provider previews every item before changing state, redacts secrets in plans and reports, and creates a verified backup before apply.
<Note>
Onboarding imports require a fresh OpenClaw setup. If you already have local OpenClaw state, reset config, credentials, sessions, and the workspace first, or use `openclaw migrate` directly with `--overwrite` after reviewing the plan.
</Note>
## Two ways to import
<Tabs>
<Tab title="Onboarding wizard">
The wizard can offer Claude when it detects local Claude state.
```bash
openclaw onboard --flow import
```
Or point at a specific source:
```bash
openclaw onboard --import-from claude --import-source ~/.claude
```
</Tab>
<Tab title="CLI">
Use `openclaw migrate` for scripted or repeatable runs. See [`openclaw migrate`](/cli/migrate) for the full reference.
```bash
openclaw migrate claude --dry-run
openclaw migrate apply claude --yes
```
Add `--from <path>` to import a specific Claude Code home or project root.
</Tab>
</Tabs>
## What gets imported
<AccordionGroup>
<Accordion title="Instructions and memory">
- Project `CLAUDE.md` and `.claude/CLAUDE.md` content is copied or appended into the OpenClaw agent workspace `AGENTS.md`.
- User `~/.claude/CLAUDE.md` content is appended into workspace `USER.md`.
</Accordion>
<Accordion title="MCP servers">
MCP server definitions are imported from project `.mcp.json`, Claude Code `~/.claude.json`, and Claude Desktop `claude_desktop_config.json` when present.
</Accordion>
<Accordion title="Skills and commands">
- Claude skills with a `SKILL.md` file are copied into the OpenClaw workspace skills directory.
- Claude command Markdown files under `.claude/commands/` or `~/.claude/commands/` are converted into OpenClaw skills with `disable-model-invocation: true`.
</Accordion>
</AccordionGroup>
## What stays archive-only
The provider copies these into the migration report for manual review, but does **not** load them into live OpenClaw config:
- Claude hooks
- Claude permissions and broad tool allowlists
- Claude environment defaults
- `CLAUDE.local.md`
- `.claude/rules/`
- Claude subagents under `.claude/agents/` or `~/.claude/agents/`
- Claude Code caches, plans, and project history directories
- Claude Desktop extensions and OS-stored credentials
OpenClaw refuses to execute hooks, trust permission allowlists, or decode opaque OAuth and Desktop credential state automatically.
## Recommended flow
<Steps>
<Step title="Preview the plan">
```bash
openclaw migrate claude --dry-run
```
The plan lists everything that will change, including conflicts, skipped items, and sensitive values redacted from nested MCP `env` or `headers` fields.
</Step>
<Step title="Apply with backup">
```bash
openclaw migrate apply claude --yes
```
OpenClaw creates and verifies a backup before applying.
</Step>
<Step title="Run doctor">
```bash
openclaw doctor
```
[Doctor](/gateway/doctor) checks for config or state issues after the import.
</Step>
</Steps>
## Source selection
Without `--from`, OpenClaw inspects the default Claude Code home at `~/.claude`, the sampled Claude Code `~/.claude.json` state file, and the Claude Desktop MCP config on macOS.
When `--from` points at a project root, OpenClaw imports only that project's Claude files such as `CLAUDE.md`, `.claude/settings.json`, `.claude/commands/`, `.claude/skills/`, and `.mcp.json`. It does not read your global Claude home during a project-root import.
## Conflict handling
Apply refuses to continue when the plan reports conflicts.
<Warning>
Rerun with `--overwrite` only when replacing the existing target is intentional. Providers may still write item-level backups for overwritten files in the migration report directory.
</Warning>
## Related
- [`openclaw migrate`](/cli/migrate): full CLI reference, plugin contract, and JSON shapes.
- [Onboarding](/cli/onboard): wizard flow and non-interactive flags.
- [Doctor](/gateway/doctor): post-migration health check.
- [Agent workspace](/concepts/agent-workspace): where `AGENTS.md`, `USER.md`, and skills live.

View File

@@ -0,0 +1,60 @@
import path from "node:path";
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
writeMigrationReport,
} from "openclaw/plugin-sdk/migration-runtime";
import type {
MigrationApplyResult,
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { applyConfigItem, applyManualItem } from "./config.js";
import { appendItem } from "./helpers.js";
import { buildClaudePlan } from "./plan.js";
import { applyGeneratedSkillItem } from "./skills.js";
export async function applyClaudePlan(params: {
ctx: MigrationProviderContext;
plan?: MigrationPlan;
runtime?: MigrationProviderContext["runtime"];
}): Promise<MigrationApplyResult> {
const plan = params.plan ?? (await buildClaudePlan(params.ctx));
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "claude");
const items: MigrationItem[] = [];
for (const item of plan.items) {
if (item.status !== "planned") {
items.push(item);
continue;
}
if (item.kind === "config") {
items.push(
await applyConfigItem(
{ ...params.ctx, runtime: params.ctx.runtime ?? params.runtime },
item,
),
);
} else if (item.kind === "manual") {
items.push(applyManualItem(item));
} else if (item.action === "archive") {
items.push(await archiveMigrationItem(item, reportDir));
} else if (item.action === "append") {
items.push(await appendItem(item));
} else if (item.action === "create" && item.kind === "skill") {
items.push(await applyGeneratedSkillItem(item, { overwrite: params.ctx.overwrite }));
} else {
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
}
}
const result: MigrationApplyResult = {
...plan,
items,
summary: summarizeMigrationItems(items),
backupPath: params.ctx.backupPath,
reportDir,
};
await writeMigrationReport(result, { title: "Claude Migration Report" });
return result;
}

View File

@@ -0,0 +1,325 @@
import {
createMigrationItem,
markMigrationItemConflict,
markMigrationItemError,
markMigrationItemSkipped,
MIGRATION_REASON_TARGET_EXISTS,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { childRecord, isRecord, readJsonObject, sanitizeName } from "./helpers.js";
import type { ClaudeSource } from "./source.js";
type ConfigPatchDetails = {
path: string[];
value: unknown;
};
type MappedMcpSource = {
sourceId: string;
sourceLabel: string;
sourcePath: string;
servers: Record<string, unknown>;
};
const CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable";
const MISSING_CONFIG_PATCH = "missing config patch";
function readPath(root: Record<string, unknown>, path: readonly string[]): unknown {
let current: unknown = root;
for (const segment of path) {
if (!isRecord(current)) {
return undefined;
}
current = current[segment];
}
return current;
}
function mergeValue(left: unknown, right: unknown): unknown {
if (!isRecord(left) || !isRecord(right)) {
return structuredClone(right);
}
const next: Record<string, unknown> = { ...left };
for (const [key, value] of Object.entries(right)) {
next[key] = mergeValue(next[key], value);
}
return next;
}
function writePath(root: Record<string, unknown>, path: readonly string[], value: unknown): void {
let current = root;
for (const segment of path.slice(0, -1)) {
const existing = current[segment];
if (!isRecord(existing)) {
current[segment] = {};
}
current = current[segment] as Record<string, unknown>;
}
const leaf = path.at(-1);
if (!leaf) {
return;
}
current[leaf] = mergeValue(current[leaf], value);
}
function hasPatchConflict(
config: MigrationProviderContext["config"],
path: readonly string[],
value: unknown,
): boolean {
if (!isRecord(value)) {
return readPath(config as Record<string, unknown>, path) !== undefined;
}
const existing = readPath(config as Record<string, unknown>, path);
if (!isRecord(existing)) {
return false;
}
return Object.keys(value).some((key) => existing[key] !== undefined);
}
function createConfigPatchItem(params: {
id: string;
target: string;
path: string[];
value: unknown;
message: string;
conflict?: boolean;
reason?: string;
source?: string;
details?: Record<string, unknown>;
}): MigrationItem {
return createMigrationItem({
id: params.id,
kind: "config",
action: "merge",
source: params.source,
target: params.target,
status: params.conflict ? "conflict" : "planned",
reason: params.conflict ? (params.reason ?? MIGRATION_REASON_TARGET_EXISTS) : undefined,
message: params.message,
details: { ...params.details, path: params.path, value: params.value },
});
}
function createManualItem(params: {
id: string;
source: string;
message: string;
recommendation: string;
}): MigrationItem {
return createMigrationItem({
id: params.id,
kind: "manual",
action: "manual",
source: params.source,
status: "skipped",
message: params.message,
reason: params.recommendation,
});
}
function mapMcpServers(raw: unknown): Record<string, unknown> | undefined {
if (!isRecord(raw)) {
return undefined;
}
const mapped: Record<string, unknown> = {};
for (const [name, value] of Object.entries(raw)) {
if (!name.trim() || !isRecord(value)) {
continue;
}
const next: Record<string, unknown> = {};
for (const key of [
"command",
"args",
"env",
"cwd",
"workingDirectory",
"url",
"type",
"transport",
"headers",
"connectionTimeoutMs",
]) {
if (value[key] !== undefined) {
next[key] = value[key];
}
}
if (Object.keys(next).length > 0) {
mapped[name] = next;
}
}
return Object.keys(mapped).length > 0 ? mapped : undefined;
}
async function collectMcpSources(source: ClaudeSource): Promise<MappedMcpSource[]> {
const sources: MappedMcpSource[] = [];
const projectMcp = await readJsonObject(source.projectMcpPath);
const projectServers = mapMcpServers(projectMcp.mcpServers ?? projectMcp);
if (projectServers && source.projectMcpPath) {
sources.push({
sourceId: "project-mcp",
sourceLabel: "project .mcp.json",
sourcePath: source.projectMcpPath,
servers: projectServers,
});
}
const claudeJson = await readJsonObject(source.userClaudeJsonPath);
const userServers = mapMcpServers(claudeJson.mcpServers);
if (userServers && source.userClaudeJsonPath) {
sources.push({
sourceId: "user-claude-json",
sourceLabel: "user ~/.claude.json",
sourcePath: source.userClaudeJsonPath,
servers: userServers,
});
}
if (source.projectDir) {
const projectRecord = childRecord(childRecord(claudeJson, "projects"), source.projectDir);
const projectScopedServers = mapMcpServers(projectRecord.mcpServers);
if (projectScopedServers && source.userClaudeJsonPath) {
sources.push({
sourceId: "user-claude-json-project",
sourceLabel: "project entry in ~/.claude.json",
sourcePath: source.userClaudeJsonPath,
servers: projectScopedServers,
});
}
}
const desktopConfig = await readJsonObject(source.desktopConfigPath);
const desktopServers = mapMcpServers(desktopConfig.mcpServers);
if (desktopServers && source.desktopConfigPath) {
sources.push({
sourceId: "desktop",
sourceLabel: "Claude Desktop config",
sourcePath: source.desktopConfigPath,
servers: desktopServers,
});
}
return sources;
}
export async function buildConfigItems(params: {
ctx: MigrationProviderContext;
source: ClaudeSource;
}): Promise<MigrationItem[]> {
const items: MigrationItem[] = [];
const mcpSources = await collectMcpSources(params.source);
const counts = new Map<string, number>();
for (const mcpSource of mcpSources) {
for (const name of Object.keys(mcpSource.servers)) {
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
for (const mcpSource of mcpSources) {
for (const [name, value] of Object.entries(mcpSource.servers)) {
const patch = { [name]: value };
const duplicate = (counts.get(name) ?? 0) > 1;
const conflict =
duplicate ||
(!params.ctx.overwrite && hasPatchConflict(params.ctx.config, ["mcp", "servers"], patch));
items.push(
createConfigPatchItem({
id: `config:mcp-server:${sanitizeName(mcpSource.sourceId)}:${sanitizeName(name)}`,
source: mcpSource.sourcePath,
target: `mcp.servers.${name}`,
path: ["mcp", "servers"],
value: patch,
message: `Import Claude MCP server "${name}" from ${mcpSource.sourceLabel}.`,
conflict,
reason: duplicate
? `multiple Claude MCP sources define "${name}"`
: MIGRATION_REASON_TARGET_EXISTS,
details: { sourceLabel: mcpSource.sourceLabel },
}),
);
}
}
for (const settingsPath of [
params.source.userSettingsPath,
params.source.userLocalSettingsPath,
params.source.projectSettingsPath,
params.source.projectLocalSettingsPath,
]) {
const settings = await readJsonObject(settingsPath);
if (settingsPath && settings.hooks !== undefined) {
items.push(
createManualItem({
id: `manual:hooks:${sanitizeName(settingsPath)}`,
source: settingsPath,
message: "Claude hooks were found but are not enabled automatically.",
recommendation: "Review hook commands before recreating equivalent OpenClaw automation.",
}),
);
}
if (settingsPath && settings.permissions !== undefined) {
items.push(
createManualItem({
id: `manual:permissions:${sanitizeName(settingsPath)}`,
source: settingsPath,
message: "Claude permission settings were found but are not translated automatically.",
recommendation:
"Review deny and allow rules manually. Do not import broad allow rules without a policy review.",
}),
);
}
if (settingsPath && settings.env !== undefined) {
items.push(
createManualItem({
id: `manual:env:${sanitizeName(settingsPath)}`,
source: settingsPath,
message: "Claude environment defaults were found but are not copied automatically.",
recommendation:
"Move non-secret values manually and store credentials through OpenClaw credential flows.",
}),
);
}
}
return items;
}
function readConfigPatchDetails(item: MigrationItem): ConfigPatchDetails | undefined {
const path = item.details?.path;
if (
!Array.isArray(path) ||
!path.every((segment): segment is string => typeof segment === "string")
) {
return undefined;
}
return { path, value: item.details?.value };
}
export async function applyConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
if (item.status !== "planned") {
return item;
}
const details = readConfigPatchDetails(item);
if (!details) {
return markMigrationItemError(item, MISSING_CONFIG_PATCH);
}
if (!ctx.runtime?.config.writeConfigFile) {
return markMigrationItemError(item, CONFIG_RUNTIME_UNAVAILABLE);
}
try {
const nextConfig = structuredClone(ctx.runtime.config.loadConfig?.() ?? ctx.config);
if (!ctx.overwrite && hasPatchConflict(nextConfig, details.path, details.value)) {
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
}
writePath(nextConfig as Record<string, unknown>, details.path, details.value);
await ctx.runtime.config.writeConfigFile(nextConfig);
return { ...item, status: "migrated" };
} catch (err) {
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}
export function applyManualItem(item: MigrationItem): MigrationItem {
return markMigrationItemSkipped(item, item.reason ?? "manual follow-up required");
}

View File

@@ -0,0 +1,111 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
markMigrationItemError,
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
export function resolveHomePath(input: string): string {
if (input === "~") {
return os.homedir();
}
if (input.startsWith("~/")) {
return path.join(os.homedir(), input.slice(2));
}
return path.resolve(input);
}
export async function exists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function isDirectory(dirPath: string): Promise<boolean> {
try {
return (await fs.stat(dirPath)).isDirectory();
} catch {
return false;
}
}
export function sanitizeName(name: string): string {
return name
.trim()
.toLowerCase()
.replaceAll(/[^a-z0-9._-]+/g, "-")
.replaceAll(/^-+|-+$/g, "");
}
export async function readText(filePath: string | undefined): Promise<string | undefined> {
if (!filePath) {
return undefined;
}
try {
return await fs.readFile(filePath, "utf8");
} catch {
return undefined;
}
}
export async function readJsonObject(
filePath: string | undefined,
): Promise<Record<string, unknown>> {
const content = await readText(filePath);
if (!content) {
return {};
}
try {
const parsed = JSON.parse(content) as unknown;
return isRecord(parsed) ? parsed : {};
} catch {
return {};
}
}
export function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function childRecord(
root: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> {
const value = root?.[key];
return isRecord(value) ? value : {};
}
export function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
export function readStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
}
export async function appendItem(item: MigrationItem): Promise<MigrationItem> {
if (!item.source || !item.target) {
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);
}
try {
const content = await fs.readFile(item.source, "utf8");
const label =
typeof item.details?.sourceLabel === "string"
? item.details.sourceLabel
: path.basename(item.source);
const header = `\n\n<!-- Imported from Claude: ${label} -->\n\n`;
await fs.mkdir(path.dirname(item.target), { recursive: true });
await fs.appendFile(item.target, `${header}${content.trimEnd()}\n`, "utf8");
return { ...item, status: "migrated" };
} catch (err) {
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}

View File

@@ -0,0 +1,11 @@
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
import { buildClaudeMigrationProvider } from "./provider.js";
export default definePluginEntry({
id: "migrate-claude",
name: "Claude Migration",
description: "Imports Claude state into OpenClaw.",
register(api) {
api.registerMigrationProvider(buildClaudeMigrationProvider({ runtime: api.runtime }));
},
});

View File

@@ -0,0 +1,71 @@
import path from "node:path";
import { createMigrationItem, MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { exists } from "./helpers.js";
import type { ClaudeSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
async function addMemoryItem(params: {
items: MigrationItem[];
id: string;
source?: string;
target: string;
sourceLabel: string;
copyWhenMissing?: boolean;
overwrite?: boolean;
}): Promise<void> {
if (!params.source) {
return;
}
const targetExists = await exists(params.target);
const action = params.copyWhenMissing && !targetExists ? "copy" : "append";
params.items.push(
createMigrationItem({
id: params.id,
kind: params.target.endsWith("AGENTS.md") ? "workspace" : "memory",
action,
source: params.source,
target: params.target,
status: action === "copy" && targetExists && !params.overwrite ? "conflict" : "planned",
reason:
action === "copy" && targetExists && !params.overwrite
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
details: { sourceLabel: params.sourceLabel },
}),
);
}
export async function buildMemoryItems(params: {
source: ClaudeSource;
targets: PlannedTargets;
overwrite?: boolean;
}): Promise<MigrationItem[]> {
const items: MigrationItem[] = [];
await addMemoryItem({
items,
id: "workspace:CLAUDE.md",
source: params.source.projectMemoryPath,
target: path.join(params.targets.workspaceDir, "AGENTS.md"),
sourceLabel: "project CLAUDE.md",
copyWhenMissing: true,
overwrite: params.overwrite,
});
await addMemoryItem({
items,
id: "workspace:.claude/CLAUDE.md",
source: params.source.projectDotClaudeMemoryPath,
target: path.join(params.targets.workspaceDir, "AGENTS.md"),
sourceLabel: "project .claude/CLAUDE.md",
overwrite: params.overwrite,
});
await addMemoryItem({
items,
id: "memory:user-CLAUDE.md",
source: params.source.userMemoryPath,
target: path.join(params.targets.workspaceDir, "USER.md"),
sourceLabel: "user ~/.claude/CLAUDE.md",
overwrite: params.overwrite,
});
return items;
}

View File

@@ -0,0 +1,13 @@
{
"id": "migrate-claude",
"name": "Claude Migration",
"description": "Imports Claude Code and Claude Desktop instructions, MCP servers, skills, and safe configuration into OpenClaw.",
"contracts": {
"migrationProviders": ["claude"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/migrate-claude",
"version": "2026.4.26",
"private": true,
"description": "Claude to OpenClaw migration provider",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.26"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,101 @@
import { createMigrationItem, summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import type {
MigrationItem,
MigrationPlan,
MigrationProviderContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { buildConfigItems } from "./config.js";
import { buildMemoryItems } from "./memory.js";
import { buildSkillItems } from "./skills.js";
import { discoverClaudeSource, hasClaudeSource } from "./source.js";
import { resolveTargets } from "./targets.js";
function addArchiveItem(
items: MigrationItem[],
params: { id: string; source?: string; relativePath: string; message?: string },
): void {
if (!params.source) {
return;
}
items.push(
createMigrationItem({
id: params.id,
kind: "archive",
action: "archive",
source: params.source,
message:
params.message ??
"Archived in the migration report for manual review; not imported into live config.",
details: { archiveRelativePath: params.relativePath },
}),
);
}
export async function buildClaudePlan(ctx: MigrationProviderContext): Promise<MigrationPlan> {
const source = await discoverClaudeSource(ctx.source);
if (!hasClaudeSource(source)) {
throw new Error(
`Claude state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
);
}
const targets = resolveTargets(ctx);
const items: MigrationItem[] = [];
items.push(...(await buildMemoryItems({ source, targets, overwrite: ctx.overwrite })));
items.push(...(await buildConfigItems({ ctx, source })));
items.push(...(await buildSkillItems({ source, targets, overwrite: ctx.overwrite })));
for (const archivePath of source.archivePaths) {
addArchiveItem(items, {
id: archivePath.id,
source: archivePath.path,
relativePath: archivePath.relativePath,
});
}
addArchiveItem(items, {
id: "archive:CLAUDE.local.md",
source: source.projectLocalMemoryPath,
relativePath: "CLAUDE.local.md",
message:
"Claude local project memory is personal machine-local state. It is archived for manual review.",
});
addArchiveItem(items, {
id: "archive:.claude/rules",
source: source.projectRulesDir,
relativePath: ".claude/rules",
});
addArchiveItem(items, {
id: "archive:user-agents",
source: source.userAgentsDir,
relativePath: "agents/user",
});
addArchiveItem(items, {
id: "archive:project-agents",
source: source.projectAgentsDir,
relativePath: "agents/project",
});
const warnings = [
...(items.some((item) => item.status === "conflict")
? [
"Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.",
]
: []),
...(items.some((item) => item.kind === "archive")
? [
"Some Claude files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.",
]
: []),
...(items.some((item) => item.kind === "manual")
? ["Some Claude settings require manual review before they can be activated safely."]
: []),
];
return {
providerId: "claude",
source: source.root,
target: targets.workspaceDir,
summary: summarizeMigrationItems(items),
items,
warnings,
nextSteps: ["Run openclaw doctor after applying the migration."],
metadata: { agentDir: targets.agentDir },
};
}

View File

@@ -0,0 +1,156 @@
import fs from "node:fs/promises";
import path from "node:path";
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
import { buildClaudeMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
makeConfigRuntime,
makeContext,
makeTempRoot,
writeFile,
} from "./test/provider-helpers.js";
describe("Claude migration provider", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("registers a Claude migration provider", async () => {
const provider = buildClaudeMigrationProvider();
expect(provider.id).toBe("claude");
expect(provider.label).toBe("Claude");
});
it("rejects missing Claude sources before planning", async () => {
const root = await makeTempRoot();
const source = path.join(root, "missing");
const provider = buildClaudeMigrationProvider();
await expect(
provider.plan(
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir: root }),
),
).rejects.toThrow("Claude state was not found");
});
it("plans project memory, MCP servers, commands, skills, and manual review items", async () => {
const root = await makeTempRoot();
const source = path.join(root, "project");
const workspaceDir = path.join(root, "workspace");
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
await writeFile(path.join(source, "CLAUDE.local.md"), "local-only\n");
await writeFile(
path.join(source, ".mcp.json"),
JSON.stringify({
mcpServers: {
filesystem: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
env: { ANTHROPIC_API_KEY: "short-dev-key" },
},
},
}),
);
await writeFile(
path.join(source, ".claude", "settings.json"),
JSON.stringify({
hooks: { PreToolUse: [] },
permissions: { allow: ["Bash(*)"] },
env: { FOO: "bar" },
}),
);
await writeFile(path.join(source, ".claude", "commands", "commit.md"), "Commit $ARGUMENTS\n");
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
await writeFile(path.join(source, ".claude", "agents", "reviewer.md"), "# Reviewer\n");
const provider = buildClaudeMigrationProvider();
const plan = await provider.plan(
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir }),
);
expect(plan.summary.total).toBeGreaterThan(0);
expect(plan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "workspace:CLAUDE.md", kind: "workspace" }),
expect.objectContaining({
id: "config:mcp-server:project-mcp:filesystem",
kind: "config",
}),
expect.objectContaining({ id: "skill:claude-command-commit", action: "create" }),
expect.objectContaining({ id: "skill:review", action: "copy" }),
expect.objectContaining({ id: "archive:CLAUDE.local.md", action: "archive" }),
expect.objectContaining({ id: "archive:project-agents", action: "archive" }),
expect.objectContaining({ id: expect.stringMatching(/^manual:hooks:/u), kind: "manual" }),
]),
);
const redacted = JSON.stringify(redactMigrationPlan(plan));
expect(redacted).not.toContain("short-dev-key");
expect(redacted).toContain("[redacted]");
});
it("applies project imports without reading global Claude state", async () => {
const root = await makeTempRoot();
const source = path.join(root, "project");
const workspaceDir = path.join(root, "workspace");
const stateDir = path.join(root, "state");
const reportDir = path.join(root, "report");
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Existing agents\n");
await writeFile(
path.join(source, ".mcp.json"),
JSON.stringify({
mcpServers: {
filesystem: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
},
}),
);
await writeFile(path.join(source, ".claude", "commands", "ship.md"), "Ship $ARGUMENTS\n");
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
const config = {
agents: {
defaults: {
workspace: workspaceDir,
},
},
} as never;
const provider = buildClaudeMigrationProvider();
const result = await provider.apply(
makeContext({
source,
stateDir,
workspaceDir,
reportDir,
runtime: makeConfigRuntime(config),
config,
}),
);
expect(result.summary.errors).toBe(0);
const mcpItem = result.items.find(
(item) => item.id === "config:mcp-server:project-mcp:filesystem",
);
expect(mcpItem?.status).toBe("migrated");
expect((config as { mcp?: { servers?: Record<string, unknown> } }).mcp?.servers).toEqual({
filesystem: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
},
});
expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toContain(
"Imported from Claude: project CLAUDE.md",
);
await expect(
fs.access(path.join(workspaceDir, "skills", "claude-command-ship", "SKILL.md")),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(workspaceDir, "skills", "review", "SKILL.md")),
).resolves.toBeUndefined();
await expect(fs.access(path.join(reportDir, "summary.md"))).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,35 @@
import type {
MigrationPlan,
MigrationProviderContext,
MigrationProviderPlugin,
} from "openclaw/plugin-sdk/plugin-entry";
import { applyClaudePlan } from "./apply.js";
import { buildClaudePlan } from "./plan.js";
import { discoverClaudeSource, hasClaudeSource } from "./source.js";
export function buildClaudeMigrationProvider(
params: {
runtime?: MigrationProviderContext["runtime"];
} = {},
): MigrationProviderPlugin {
return {
id: "claude",
label: "Claude",
description: "Import Claude Code and Claude Desktop instructions, MCP servers, and skills.",
async detect(ctx) {
const source = await discoverClaudeSource(ctx.source);
const found = hasClaudeSource(source);
return {
found,
source: source.root,
label: "Claude",
confidence: found ? source.confidence : "low",
message: found ? "Claude state found." : "Claude state not found.",
};
},
plan: buildClaudePlan,
async apply(ctx, plan?: MigrationPlan) {
return await applyClaudePlan({ ctx, plan, runtime: params.runtime });
},
};
}

View File

@@ -0,0 +1,194 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
createMigrationItem,
markMigrationItemConflict,
markMigrationItemError,
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
MIGRATION_REASON_TARGET_EXISTS,
} from "openclaw/plugin-sdk/migration";
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
import { exists, readText, sanitizeName } from "./helpers.js";
import type { ClaudeSource } from "./source.js";
import type { PlannedTargets } from "./targets.js";
type PlannedSkill = {
name: string;
source: string;
target: string;
action: "copy" | "create";
sourceLabel: string;
};
async function listMarkdownFiles(root: string): Promise<string[]> {
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const fullPath = path.join(root, entry.name);
if (entry.isDirectory()) {
files.push(...(await listMarkdownFiles(fullPath)));
} else if (entry.isFile() && entry.name.endsWith(".md")) {
files.push(fullPath);
}
}
return files;
}
async function collectSkillDirs(
planned: PlannedSkill[],
dir: string | undefined,
targets: PlannedTargets,
scope: string,
): Promise<void> {
if (!dir) {
return;
}
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const source = path.join(dir, entry.name);
if (!(await exists(path.join(source, "SKILL.md")))) {
continue;
}
const name = sanitizeName(entry.name);
if (!name) {
continue;
}
planned.push({
name,
source,
target: path.join(targets.workspaceDir, "skills", name),
action: "copy",
sourceLabel: `${scope} Claude skill`,
});
}
}
async function collectCommandFiles(
planned: PlannedSkill[],
dir: string | undefined,
targets: PlannedTargets,
scope: string,
): Promise<void> {
if (!dir) {
return;
}
for (const file of await listMarkdownFiles(dir)) {
const relative = path.relative(dir, file);
const parsed = path.parse(relative);
const namespace = sanitizeName(parsed.dir.replaceAll(path.sep, "-"));
const commandName = sanitizeName(parsed.name);
const name = sanitizeName(["claude-command", namespace, commandName].filter(Boolean).join("-"));
if (!name) {
continue;
}
planned.push({
name,
source: file,
target: path.join(targets.workspaceDir, "skills", name),
action: "create",
sourceLabel: `${scope} Claude command ${relative}`,
});
}
}
export async function buildSkillItems(params: {
source: ClaudeSource;
targets: PlannedTargets;
overwrite?: boolean;
}): Promise<MigrationItem[]> {
const planned: PlannedSkill[] = [];
await collectSkillDirs(planned, params.source.userSkillsDir, params.targets, "user");
await collectSkillDirs(planned, params.source.projectSkillsDir, params.targets, "project");
await collectCommandFiles(planned, params.source.userCommandsDir, params.targets, "user");
await collectCommandFiles(planned, params.source.projectCommandsDir, params.targets, "project");
const counts = new Map<string, number>();
for (const skill of planned) {
counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1);
}
const items: MigrationItem[] = [];
for (const skill of planned) {
const collides = (counts.get(skill.name) ?? 0) > 1;
const targetExists = await exists(skill.target);
items.push(
createMigrationItem({
id: `skill:${skill.name}`,
kind: "skill",
action: skill.action,
source: skill.source,
target: skill.target,
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
reason: collides
? `multiple Claude skills or commands normalize to "${skill.name}"`
: targetExists && !params.overwrite
? MIGRATION_REASON_TARGET_EXISTS
: undefined,
details: { sourceLabel: skill.sourceLabel, skillName: skill.name },
}),
);
}
return items;
}
function firstParagraph(content: string): string | undefined {
return content
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "")
.split(/\r?\n\r?\n/u)
.map((part) => part.replaceAll(/\s+/g, " ").trim())
.find(Boolean);
}
function generatedCommandSkillContent(params: {
skillName: string;
sourceLabel: string;
commandContent: string;
}): string {
const description =
firstParagraph(params.commandContent) ?? `Imported Claude command ${params.skillName}`;
return [
"---",
`name: ${params.skillName}`,
`description: ${JSON.stringify(description.slice(0, 180))}`,
"disable-model-invocation: true",
"---",
"",
`<!-- Imported from Claude: ${params.sourceLabel} -->`,
"",
params.commandContent.trimEnd(),
"",
].join("\n");
}
export async function applyGeneratedSkillItem(
item: MigrationItem,
opts: { overwrite?: boolean } = {},
): Promise<MigrationItem> {
if (!item.source || !item.target) {
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);
}
try {
if ((await exists(item.target)) && !opts.overwrite) {
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
}
const sourceLabel =
typeof item.details?.sourceLabel === "string"
? item.details.sourceLabel
: path.basename(item.source);
const skillName =
typeof item.details?.skillName === "string" ? item.details.skillName : sanitizeName(item.id);
const content = generatedCommandSkillContent({
skillName,
sourceLabel,
commandContent: (await readText(item.source)) ?? "",
});
await fs.mkdir(item.target, { recursive: true });
await fs.writeFile(path.join(item.target, "SKILL.md"), content, "utf8");
return { ...item, status: "migrated" };
} catch (err) {
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}

View File

@@ -0,0 +1,174 @@
import os from "node:os";
import path from "node:path";
import { exists, isDirectory, readJsonObject, resolveHomePath } from "./helpers.js";
export type ClaudeArchivePath = {
id: string;
path: string;
relativePath: string;
};
export type ClaudeSource = {
root: string;
confidence: "low" | "medium" | "high";
homeDir?: string;
projectDir?: string;
homeProjectsDir?: string;
userSettingsPath?: string;
userLocalSettingsPath?: string;
userClaudeJsonPath?: string;
userMemoryPath?: string;
projectSettingsPath?: string;
projectLocalSettingsPath?: string;
projectMcpPath?: string;
projectMemoryPath?: string;
projectDotClaudeMemoryPath?: string;
projectLocalMemoryPath?: string;
projectRulesDir?: string;
userSkillsDir?: string;
projectSkillsDir?: string;
userCommandsDir?: string;
projectCommandsDir?: string;
userAgentsDir?: string;
projectAgentsDir?: string;
desktopConfigPath?: string;
archivePaths: ClaudeArchivePath[];
};
const HOME_ARCHIVE_DIRS = ["projects", "cache", "plans"] as const;
const PROJECT_ARCHIVE_FILES = [".claude/scheduled_tasks.json"] as const;
function defaultClaudeHome(): string {
return path.join(os.homedir(), ".claude");
}
function defaultDesktopConfig(): string {
return path.join(
os.homedir(),
"Library",
"Application Support",
"Claude",
"claude_desktop_config.json",
);
}
async function addArchivePath(
archivePaths: ClaudeArchivePath[],
id: string,
candidate: string,
relativePath: string,
): Promise<void> {
if ((await exists(candidate)) || (await isDirectory(candidate))) {
archivePaths.push({ id, path: candidate, relativePath });
}
}
export async function discoverClaudeSource(input?: string): Promise<ClaudeSource> {
const explicitInput = Boolean(input?.trim());
const root = resolveHomePath(input?.trim() || defaultClaudeHome());
const rootIsHome = path.basename(root) === ".claude";
const inspectGlobal = !explicitInput || rootIsHome;
const homeDir = inspectGlobal ? (rootIsHome ? root : defaultClaudeHome()) : undefined;
const projectDir = rootIsHome ? undefined : root;
const archivePaths: ClaudeArchivePath[] = [];
const userSettingsPath = homeDir ? path.join(homeDir, "settings.json") : undefined;
const userLocalSettingsPath = homeDir ? path.join(homeDir, "settings.local.json") : undefined;
const userClaudeJsonPath = inspectGlobal ? path.join(os.homedir(), ".claude.json") : undefined;
const userMemoryPath = homeDir ? path.join(homeDir, "CLAUDE.md") : undefined;
const desktopConfigPath = inspectGlobal ? defaultDesktopConfig() : undefined;
const homeProjectsDir = homeDir ? path.join(homeDir, "projects") : undefined;
const userSkillsDir = homeDir ? path.join(homeDir, "skills") : undefined;
const userCommandsDir = homeDir ? path.join(homeDir, "commands") : undefined;
const userAgentsDir = homeDir ? path.join(homeDir, "agents") : undefined;
if (homeDir) {
for (const dir of HOME_ARCHIVE_DIRS) {
await addArchivePath(archivePaths, `archive:home:${dir}`, path.join(homeDir, dir), dir);
}
}
const source: ClaudeSource = {
root,
confidence: "low",
archivePaths,
...(homeDir && (await isDirectory(homeDir)) ? { homeDir } : {}),
...(homeProjectsDir && (await isDirectory(homeProjectsDir)) ? { homeProjectsDir } : {}),
...(projectDir ? { projectDir } : {}),
...(userSettingsPath && (await exists(userSettingsPath)) ? { userSettingsPath } : {}),
...(userLocalSettingsPath && (await exists(userLocalSettingsPath))
? { userLocalSettingsPath }
: {}),
...(userClaudeJsonPath && (await exists(userClaudeJsonPath)) ? { userClaudeJsonPath } : {}),
...(userMemoryPath && (await exists(userMemoryPath)) ? { userMemoryPath } : {}),
...(userSkillsDir && (await isDirectory(userSkillsDir)) ? { userSkillsDir } : {}),
...(userCommandsDir && (await isDirectory(userCommandsDir)) ? { userCommandsDir } : {}),
...(userAgentsDir && (await isDirectory(userAgentsDir)) ? { userAgentsDir } : {}),
...(desktopConfigPath && (await exists(desktopConfigPath)) ? { desktopConfigPath } : {}),
};
if (projectDir) {
const projectSettingsPath = path.join(projectDir, ".claude", "settings.json");
const projectLocalSettingsPath = path.join(projectDir, ".claude", "settings.local.json");
const projectMcpPath = path.join(projectDir, ".mcp.json");
const projectMemoryPath = path.join(projectDir, "CLAUDE.md");
const projectDotClaudeMemoryPath = path.join(projectDir, ".claude", "CLAUDE.md");
const projectLocalMemoryPath = path.join(projectDir, "CLAUDE.local.md");
const projectRulesDir = path.join(projectDir, ".claude", "rules");
const projectSkillsDir = path.join(projectDir, ".claude", "skills");
const projectCommandsDir = path.join(projectDir, ".claude", "commands");
const projectAgentsDir = path.join(projectDir, ".claude", "agents");
Object.assign(source, {
...((await exists(projectSettingsPath)) ? { projectSettingsPath } : {}),
...((await exists(projectLocalSettingsPath)) ? { projectLocalSettingsPath } : {}),
...((await exists(projectMcpPath)) ? { projectMcpPath } : {}),
...((await exists(projectMemoryPath)) ? { projectMemoryPath } : {}),
...((await exists(projectDotClaudeMemoryPath)) ? { projectDotClaudeMemoryPath } : {}),
...((await exists(projectLocalMemoryPath)) ? { projectLocalMemoryPath } : {}),
...((await isDirectory(projectRulesDir)) ? { projectRulesDir } : {}),
...((await isDirectory(projectSkillsDir)) ? { projectSkillsDir } : {}),
...((await isDirectory(projectCommandsDir)) ? { projectCommandsDir } : {}),
...((await isDirectory(projectAgentsDir)) ? { projectAgentsDir } : {}),
});
for (const file of PROJECT_ARCHIVE_FILES) {
await addArchivePath(
archivePaths,
`archive:project:${file}`,
path.join(projectDir, file),
file,
);
}
}
const claudeJson = await readJsonObject(source.userClaudeJsonPath);
const hasClaudeJsonState = Boolean(claudeJson.mcpServers || claudeJson.projects);
const desktopConfig = await readJsonObject(source.desktopConfigPath);
const hasDesktopMcp = Boolean(desktopConfig.mcpServers);
const high = Boolean(
source.userSettingsPath ||
source.userMemoryPath ||
source.projectSettingsPath ||
source.projectMcpPath ||
source.projectMemoryPath ||
source.projectDotClaudeMemoryPath ||
hasClaudeJsonState ||
hasDesktopMcp,
);
const medium = Boolean(
source.userSkillsDir ||
source.projectSkillsDir ||
source.userCommandsDir ||
source.projectCommandsDir ||
source.userAgentsDir ||
source.projectAgentsDir ||
source.projectRulesDir ||
source.projectLocalMemoryPath ||
source.homeProjectsDir,
);
source.confidence = high ? "high" : medium ? "medium" : "low";
return source;
}
export function hasClaudeSource(source: ClaudeSource): boolean {
return source.confidence !== "low";
}

View File

@@ -0,0 +1,30 @@
import path from "node:path";
import {
resolveAgentConfig,
resolveAgentWorkspaceDir,
resolveDefaultAgentId,
} from "openclaw/plugin-sdk/agent-runtime";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import { resolveHomePath } from "./helpers.js";
export type PlannedTargets = {
workspaceDir: string;
stateDir: string;
agentDir: string;
};
export function resolveTargets(ctx: MigrationProviderContext): PlannedTargets {
const cfg = ctx.config;
const agentId = resolveDefaultAgentId(cfg);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
const agentDir =
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
path.join(ctx.stateDir, "agents", agentId, "agent");
return {
workspaceDir,
stateDir: ctx.stateDir,
agentDir,
};
}

View File

@@ -0,0 +1,83 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
const tempRoots = new Set<string>();
export const logger = {
info() {},
warn() {},
error() {},
debug() {},
};
export async function makeTempRoot() {
const root = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-migrate-claude-"),
);
tempRoots.add(root);
return root;
}
export async function cleanupTempRoots() {
for (const root of tempRoots) {
await fs.rm(root, { force: true, recursive: true });
}
tempRoots.clear();
}
export async function writeFile(filePath: string, content: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, "utf8");
}
export function makeConfigRuntime(
config: OpenClawConfig,
onWrite?: (next: OpenClawConfig) => void,
): NonNullable<MigrationProviderContext["runtime"]> {
return {
config: {
loadConfig: () => config,
writeConfigFile: async (next: OpenClawConfig) => {
for (const key of Object.keys(config) as Array<keyof OpenClawConfig>) {
delete config[key];
}
Object.assign(config, next);
onWrite?.(next);
},
},
} as NonNullable<MigrationProviderContext["runtime"]>;
}
export function makeContext(params: {
source: string;
stateDir: string;
workspaceDir: string;
config?: OpenClawConfig;
includeSecrets?: boolean;
overwrite?: boolean;
reportDir?: string;
runtime?: MigrationProviderContext["runtime"];
}): MigrationProviderContext {
const config =
params.config ??
({
agents: {
defaults: {
workspace: params.workspaceDir,
},
},
} as OpenClawConfig);
return {
config,
stateDir: params.stateDir,
source: params.source,
includeSecrets: params.includeSecrets,
overwrite: params.overwrite,
reportDir: params.reportDir,
runtime: params.runtime,
logger,
};
}

9
pnpm-lock.yaml generated
View File

@@ -899,6 +899,15 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/migrate-claude:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
openclaw:
specifier: workspace:*
version: link:../..
extensions/migrate-hermes:
dependencies:
yaml: