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
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:
62
extensions/migrate-claude/apply.ts
Normal file
62
extensions/migrate-claude/apply.ts
Normal 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;
|
||||
}
|
||||
195
extensions/migrate-claude/config.ts
Normal file
195
extensions/migrate-claude/config.ts
Normal 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);
|
||||
}
|
||||
98
extensions/migrate-claude/helpers.ts
Normal file
98
extensions/migrate-claude/helpers.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
12
extensions/migrate-claude/index.ts
Normal file
12
extensions/migrate-claude/index.ts
Normal 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 }));
|
||||
},
|
||||
});
|
||||
72
extensions/migrate-claude/memory.ts
Normal file
72
extensions/migrate-claude/memory.ts
Normal 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;
|
||||
}
|
||||
16
extensions/migrate-claude/openclaw.plugin.json
Normal file
16
extensions/migrate-claude/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
24
extensions/migrate-claude/package.json
Normal file
24
extensions/migrate-claude/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
102
extensions/migrate-claude/plan.ts
Normal file
102
extensions/migrate-claude/plan.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
180
extensions/migrate-claude/provider.test.ts
Normal file
180
extensions/migrate-claude/provider.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
36
extensions/migrate-claude/provider.ts
Normal file
36
extensions/migrate-claude/provider.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
195
extensions/migrate-claude/skills.ts
Normal file
195
extensions/migrate-claude/skills.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
175
extensions/migrate-claude/source.ts
Normal file
175
extensions/migrate-claude/source.ts
Normal 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";
|
||||
}
|
||||
5
extensions/migrate-claude/targets.ts
Normal file
5
extensions/migrate-claude/targets.ts
Normal 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";
|
||||
132
extensions/migrate-claude/test/provider-helpers.ts
Normal file
132
extensions/migrate-claude/test/provider-helpers.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user