From 600df95c8ca8745aa73c8bc406723a0869b61f87 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 02:35:44 -0700 Subject: [PATCH] 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. --- .github/labeler.yml | 6 + CHANGELOG.md | 1 + docs/.i18n/glossary.zh-CN.json | 8 + docs/cli/migrate.md | 24 ++ docs/docs.json | 1 + docs/install/migrating-claude.md | 124 +++++++ extensions/migrate-claude/apply.ts | 60 ++++ extensions/migrate-claude/config.ts | 325 ++++++++++++++++++ extensions/migrate-claude/helpers.ts | 111 ++++++ extensions/migrate-claude/index.ts | 11 + extensions/migrate-claude/memory.ts | 71 ++++ .../migrate-claude/openclaw.plugin.json | 13 + extensions/migrate-claude/package.json | 24 ++ extensions/migrate-claude/plan.ts | 101 ++++++ extensions/migrate-claude/provider.test.ts | 156 +++++++++ extensions/migrate-claude/provider.ts | 35 ++ extensions/migrate-claude/skills.ts | 194 +++++++++++ extensions/migrate-claude/source.ts | 174 ++++++++++ extensions/migrate-claude/targets.ts | 30 ++ .../migrate-claude/test/provider-helpers.ts | 83 +++++ pnpm-lock.yaml | 9 + 21 files changed, 1561 insertions(+) create mode 100644 docs/install/migrating-claude.md create mode 100644 extensions/migrate-claude/apply.ts create mode 100644 extensions/migrate-claude/config.ts create mode 100644 extensions/migrate-claude/helpers.ts create mode 100644 extensions/migrate-claude/index.ts create mode 100644 extensions/migrate-claude/memory.ts create mode 100644 extensions/migrate-claude/openclaw.plugin.json create mode 100644 extensions/migrate-claude/package.json create mode 100644 extensions/migrate-claude/plan.ts create mode 100644 extensions/migrate-claude/provider.test.ts create mode 100644 extensions/migrate-claude/provider.ts create mode 100644 extensions/migrate-claude/skills.ts create mode 100644 extensions/migrate-claude/source.ts create mode 100644 extensions/migrate-claude/targets.ts create mode 100644 extensions/migrate-claude/test/provider-helpers.ts diff --git a/.github/labeler.yml b/.github/labeler.yml index 3d1ab872cf0..76f4e45d0c8 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7091ad200ae..aa68ff74183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index a62ac11e1da..c8dc7525796 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -451,6 +451,14 @@ "source": "Migrating from Hermes", "target": "从 Hermes 迁移" }, + { + "source": "Migrating from Claude", + "target": "从 Claude 迁移" + }, + { + "source": "Agent workspace", + "target": "Agent 工作区" + }, { "source": "Migration", "target": "迁移" diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md index 6fcca6d8c32..92a0f3d3df6 100644 --- a/docs/cli/migrate.md +++ b/docs/cli/migrate.md @@ -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 +## Claude provider + +The bundled Claude provider detects Claude Code state at `~/.claude` by default. Use `--from ` to import a specific Claude Code home or project root. + + +For a user-facing walkthrough, see [Migrating from Claude](/install/migrating-claude). + + +### 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 ` 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. diff --git a/docs/docs.json b/docs/docs.json index 406e2f96044..c479f5edde1 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1012,6 +1012,7 @@ "pages": [ "install/updating", "install/migrating", + "install/migrating-claude", "install/migrating-hermes", "install/migrating-matrix", "install/uninstall", diff --git a/docs/install/migrating-claude.md b/docs/install/migrating-claude.md new file mode 100644 index 00000000000..dd96848a2a6 --- /dev/null +++ b/docs/install/migrating-claude.md @@ -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. + + +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. + + +## Two ways to import + + + + 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 + ``` + + + + 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 ` to import a specific Claude Code home or project root. + + + + +## What gets imported + + + + - 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`. + + + MCP server definitions are imported from project `.mcp.json`, Claude Code `~/.claude.json`, and Claude Desktop `claude_desktop_config.json` when present. + + + - 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`. + + + +## 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 + + + + ```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. + + + + ```bash + openclaw migrate apply claude --yes + ``` + + OpenClaw creates and verifies a backup before applying. + + + + ```bash + openclaw doctor + ``` + + [Doctor](/gateway/doctor) checks for config or state issues after the import. + + + + +## 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. + + +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. + + +## 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. diff --git a/extensions/migrate-claude/apply.ts b/extensions/migrate-claude/apply.ts new file mode 100644 index 00000000000..07f1b86a97e --- /dev/null +++ b/extensions/migrate-claude/apply.ts @@ -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 { + 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; +} diff --git a/extensions/migrate-claude/config.ts b/extensions/migrate-claude/config.ts new file mode 100644 index 00000000000..c97f3e85a9b --- /dev/null +++ b/extensions/migrate-claude/config.ts @@ -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; +}; + +const CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable"; +const MISSING_CONFIG_PATCH = "missing config patch"; + +function readPath(root: Record, 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 = { ...left }; + for (const [key, value] of Object.entries(right)) { + next[key] = mergeValue(next[key], value); + } + return next; +} + +function writePath(root: Record, 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; + } + 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, path) !== undefined; + } + const existing = readPath(config as Record, 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; +}): 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 | undefined { + if (!isRecord(raw)) { + return undefined; + } + const mapped: Record = {}; + for (const [name, value] of Object.entries(raw)) { + if (!name.trim() || !isRecord(value)) { + continue; + } + const next: Record = {}; + 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 { + 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 { + const items: MigrationItem[] = []; + const mcpSources = await collectMcpSources(params.source); + const counts = new Map(); + 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 { + 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, 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"); +} diff --git a/extensions/migrate-claude/helpers.ts b/extensions/migrate-claude/helpers.ts new file mode 100644 index 00000000000..c47cdd44c7b --- /dev/null +++ b/extensions/migrate-claude/helpers.ts @@ -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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function isDirectory(dirPath: string): Promise { + 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 { + if (!filePath) { + return undefined; + } + try { + return await fs.readFile(filePath, "utf8"); + } catch { + return undefined; + } +} + +export async function readJsonObject( + filePath: string | undefined, +): Promise> { + 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 { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +export function childRecord( + root: Record | undefined, + key: string, +): Record { + 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 { + 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\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)); + } +} diff --git a/extensions/migrate-claude/index.ts b/extensions/migrate-claude/index.ts new file mode 100644 index 00000000000..ac6d6331f0e --- /dev/null +++ b/extensions/migrate-claude/index.ts @@ -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 })); + }, +}); diff --git a/extensions/migrate-claude/memory.ts b/extensions/migrate-claude/memory.ts new file mode 100644 index 00000000000..2f0d8cc7c7a --- /dev/null +++ b/extensions/migrate-claude/memory.ts @@ -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 { + 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 { + 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; +} diff --git a/extensions/migrate-claude/openclaw.plugin.json b/extensions/migrate-claude/openclaw.plugin.json new file mode 100644 index 00000000000..39b28a8766a --- /dev/null +++ b/extensions/migrate-claude/openclaw.plugin.json @@ -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": {} + } +} diff --git a/extensions/migrate-claude/package.json b/extensions/migrate-claude/package.json new file mode 100644 index 00000000000..473469b83a7 --- /dev/null +++ b/extensions/migrate-claude/package.json @@ -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" + ] + } +} diff --git a/extensions/migrate-claude/plan.ts b/extensions/migrate-claude/plan.ts new file mode 100644 index 00000000000..f614ab13278 --- /dev/null +++ b/extensions/migrate-claude/plan.ts @@ -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 { + const source = await discoverClaudeSource(ctx.source); + if (!hasClaudeSource(source)) { + throw new Error( + `Claude state was not found at ${source.root}. Pass --from 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 }, + }; +} diff --git a/extensions/migrate-claude/provider.test.ts b/extensions/migrate-claude/provider.test.ts new file mode 100644 index 00000000000..29ecc4d154a --- /dev/null +++ b/extensions/migrate-claude/provider.test.ts @@ -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 } }).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(); + }); +}); diff --git a/extensions/migrate-claude/provider.ts b/extensions/migrate-claude/provider.ts new file mode 100644 index 00000000000..acb86d2cbf2 --- /dev/null +++ b/extensions/migrate-claude/provider.ts @@ -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 }); + }, + }; +} diff --git a/extensions/migrate-claude/skills.ts b/extensions/migrate-claude/skills.ts new file mode 100644 index 00000000000..c4e9754a3be --- /dev/null +++ b/extensions/migrate-claude/skills.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + 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", + "---", + "", + ``, + "", + params.commandContent.trimEnd(), + "", + ].join("\n"); +} + +export async function applyGeneratedSkillItem( + item: MigrationItem, + opts: { overwrite?: boolean } = {}, +): Promise { + 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)); + } +} diff --git a/extensions/migrate-claude/source.ts b/extensions/migrate-claude/source.ts new file mode 100644 index 00000000000..0e38385b18c --- /dev/null +++ b/extensions/migrate-claude/source.ts @@ -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 { + if ((await exists(candidate)) || (await isDirectory(candidate))) { + archivePaths.push({ id, path: candidate, relativePath }); + } +} + +export async function discoverClaudeSource(input?: string): Promise { + 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"; +} diff --git a/extensions/migrate-claude/targets.ts b/extensions/migrate-claude/targets.ts new file mode 100644 index 00000000000..c0e1923f821 --- /dev/null +++ b/extensions/migrate-claude/targets.ts @@ -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, + }; +} diff --git a/extensions/migrate-claude/test/provider-helpers.ts b/extensions/migrate-claude/test/provider-helpers.ts new file mode 100644 index 00000000000..c13acecd739 --- /dev/null +++ b/extensions/migrate-claude/test/provider-helpers.ts @@ -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(); + +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 { + return { + config: { + loadConfig: () => config, + writeConfigFile: async (next: OpenClawConfig) => { + for (const key of Object.keys(config) as Array) { + delete config[key]; + } + Object.assign(config, next); + onWrite?.(next); + }, + }, + } as NonNullable; +} + +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, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffb1a1e1c9b..b2f9ca31983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: