Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,62 @@
// Migrate Claude plugin module implements apply behavior.
import path from "node:path";
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
import {
archiveMigrationItem,
copyMigrationFileItem,
withCachedMigrationConfigRuntime,
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 runtime = withCachedMigrationConfigRuntime(
params.ctx.runtime ?? params.runtime,
params.ctx.config,
);
const applyCtx = { ...params.ctx, runtime };
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(applyCtx, 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,195 @@
// Migrate Claude helper module supports config behavior.
import {
applyMigrationConfigPatchItem,
applyMigrationManualItem,
createMigrationConfigPatchItem,
createMigrationManualItem,
hasMigrationConfigPatchConflict,
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 MappedMcpSource = {
sourceId: string;
sourceLabel: string;
sourcePath: string;
servers: Record<string, unknown>;
};
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 &&
hasMigrationConfigPatchConflict(params.ctx.config, ["mcp", "servers"], patch));
items.push(
createMigrationConfigPatchItem({
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(
createMigrationManualItem({
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(
createMigrationManualItem({
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(
createMigrationManualItem({
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;
}
export async function applyConfigItem(
ctx: MigrationProviderContext,
item: MigrationItem,
): Promise<MigrationItem> {
return applyMigrationConfigPatchItem(ctx, item);
}
export function applyManualItem(item: MigrationItem): MigrationItem {
return applyMigrationManualItem(item);
}

View File

@@ -0,0 +1,98 @@
// Migrate Claude helper module supports helpers behavior.
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";
import { appendRegularFile, pathExists } from "openclaw/plugin-sdk/security-runtime";
import { isRecord as sharedIsRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
export function resolveHomePath(input: string): string {
const trimmed = input.trim();
if (!trimmed) {
return trimmed;
}
return path.resolve(trimmed.replace(/^~(?=$|[\\/])/u, os.homedir()));
}
export async function exists(filePath: string): Promise<boolean> {
return await pathExists(filePath);
}
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 const isRecord = sharedIsRecord;
export function childRecord(
root: Record<string, unknown> | undefined,
key: string,
): Record<string, unknown> {
const value = root?.[key];
return isRecord(value) ? value : {};
}
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 appendRegularFile({
filePath: item.target,
content: `${header}${content.trimEnd()}\n`,
rejectSymlinkParents: true,
});
return { ...item, status: "migrated" };
} catch (err) {
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
}
}

View File

@@ -0,0 +1,12 @@
// Migrate Claude plugin entrypoint registers its OpenClaw integration.
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,72 @@
// Migrate Claude plugin module implements memory behavior.
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,16 @@
{
"id": "migrate-claude",
"activation": {
"onStartup": false
},
"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.6.11",
"private": true,
"description": "Claude to OpenClaw migration provider",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.6.11"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,102 @@
// Migrate Claude plugin module implements plan behavior.
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,180 @@
// Migrate Claude tests cover provider plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
import { afterEach, describe, expect, it } from "vitest";
import { resolveHomePath } from "./helpers.js";
import { buildClaudeMigrationProvider } from "./provider.js";
import {
cleanupTempRoots,
makeConfigRuntime,
makeContext,
makeTempRoot,
writeFile,
} from "./test/provider-helpers.js";
function planItemById(
items: readonly { id: string; kind?: string; action?: string }[],
id: string,
) {
const item = items.find((candidate) => candidate.id === id);
if (!item) {
throw new Error(`expected migration plan item ${id}`);
}
return item;
}
describe("Claude migration provider", () => {
afterEach(async () => {
await cleanupTempRoots();
});
it("registers a Claude migration provider", () => {
const provider = buildClaudeMigrationProvider();
expect(provider.id).toBe("claude");
expect(provider.label).toBe("Claude");
});
it("resolves tilde source paths against the OS home when OPENCLAW_HOME is set", () => {
const previous = process.env.OPENCLAW_HOME;
process.env.OPENCLAW_HOME = path.join(path.sep, "tmp", "openclaw-home");
try {
expect(resolveHomePath("~/.claude")).toBe(path.join(os.homedir(), ".claude"));
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_HOME;
} else {
process.env.OPENCLAW_HOME = previous;
}
}
});
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(planItemById(plan.items, "workspace:CLAUDE.md").kind).toBe("workspace");
expect(planItemById(plan.items, "config:mcp-server:project-mcp:filesystem").kind).toBe(
"config",
);
expect(planItemById(plan.items, "skill:claude-command-commit").action).toBe("create");
expect(planItemById(plan.items, "skill:review").action).toBe("copy");
expect(planItemById(plan.items, "archive:CLAUDE.local.md").action).toBe("archive");
expect(planItemById(plan.items, "archive:project-agents").action).toBe("archive");
const manualHooksItem = plan.items.find((item) => item.id.startsWith("manual:hooks:"));
expect(manualHooksItem?.kind).toBe("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,36 @@
// Migrate Claude provider module implements model/runtime integration.
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,195 @@
// Migrate Claude plugin module implements skills behavior.
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,175 @@
// Migrate Claude plugin module implements source behavior.
import os from "node:os";
import path from "node:path";
import { exists, isDirectory, readJsonObject, resolveHomePath } from "./helpers.js";
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,5 @@
// Migrate Claude plugin re-exports the shared migration target resolution.
export {
resolvePlannedMigrationTargets as resolveTargets,
type PlannedMigrationTargets as PlannedTargets,
} from "openclaw/plugin-sdk/migration-runtime";

View File

@@ -0,0 +1,132 @@
// Migrate Claude provider module implements model/runtime integration.
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>();
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"]> {
const commitConfig = (next: OpenClawConfig) => {
for (const key of Object.keys(config) as Array<keyof OpenClawConfig>) {
delete config[key];
}
Object.assign(config, next);
onWrite?.(next);
};
return {
config: {
current: () => config,
mutateConfigFile: async ({
afterWrite,
mutate,
}: {
afterWrite?: unknown;
mutate: (draft: OpenClawConfig, context: unknown) => Promise<unknown> | void;
}) => {
const next = structuredClone(config);
const result = await mutate(next, {
snapshot: {
path: "/tmp/openclaw.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
issues: [],
warnings: [],
legacyIssues: [],
config: next,
resolved: next,
runtimeConfig: next,
sourceConfig: next,
},
previousHash: "test",
});
commitConfig(next);
return {
nextConfig: next,
afterWrite,
followUp: { mode: "auto", requiresRestart: false },
result,
};
},
replaceConfigFile: async ({
afterWrite,
nextConfig,
}: {
afterWrite?: unknown;
nextConfig: OpenClawConfig;
}) => {
commitConfig(nextConfig);
return {
nextConfig,
afterWrite,
followUp: { mode: "auto", requiresRestart: false },
};
},
},
} 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,
};
}