mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
feat(migrate): add Claude importer
Add a bundled Claude migration provider for Claude Code and Claude Desktop imports.\n\nIncludes source discovery, preview/apply behavior for instructions, MCP servers, skills and command prompts, archive/manual handling for unsafe Claude state, docs, labeler, and tests.
This commit is contained in:
6
.github/labeler.yml
vendored
6
.github/labeler.yml
vendored
@@ -40,6 +40,12 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/migrate-hermes/**"
|
||||
- "docs/cli/migrate.md"
|
||||
"plugin: migrate-claude":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/migrate-claude/**"
|
||||
- "docs/cli/migrate.md"
|
||||
- "docs/install/migrating-claude.md"
|
||||
"plugin: bonjour":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Control UI: polish the quick settings dashboard grid so common cards align across desktop, tablet, and mobile layouts without wasting horizontal space. Thanks @BunsDev.
|
||||
- Matrix/E2EE: add `openclaw matrix encryption setup` to enable Matrix encryption, bootstrap recovery, and print verification status from one setup flow. Thanks @gumadeiras.
|
||||
- Agents/compaction: add an opt-in `agents.defaults.compaction.maxActiveTranscriptBytes` preflight trigger that runs normal local compaction when the active JSONL grows too large, requiring transcript rotation so successful compaction moves future turns onto a smaller successor file instead of raw byte-splitting history. Thanks @vincentkoc.
|
||||
- CLI/migration: add a bundled Claude importer that previews and applies Claude Code and Claude Desktop instructions, MCP servers, skills, command prompts, and safe archive/manual-review state. Thanks @vincentkoc.
|
||||
- CLI/migration: add `openclaw migrate` with plan, dry-run, JSON, pre-migration backup, onboarding detection, archive-only report copies, and a bundled Hermes importer for configuration, memory/plugin hints, model providers, MCP servers, skills, and supported credentials. Thanks @NousResearch.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -451,6 +451,14 @@
|
||||
"source": "Migrating from Hermes",
|
||||
"target": "从 Hermes 迁移"
|
||||
},
|
||||
{
|
||||
"source": "Migrating from Claude",
|
||||
"target": "从 Claude 迁移"
|
||||
},
|
||||
{
|
||||
"source": "Agent workspace",
|
||||
"target": "Agent 工作区"
|
||||
},
|
||||
{
|
||||
"source": "Migration",
|
||||
"target": "迁移"
|
||||
|
||||
@@ -18,11 +18,14 @@ For a user-facing walkthrough of moving from Hermes, see [Migrating from Hermes]
|
||||
|
||||
```bash
|
||||
openclaw migrate list
|
||||
openclaw migrate claude --dry-run
|
||||
openclaw migrate hermes --dry-run
|
||||
openclaw migrate hermes
|
||||
openclaw migrate apply claude --yes
|
||||
openclaw migrate apply hermes --yes
|
||||
openclaw migrate apply hermes --include-secrets --yes
|
||||
openclaw onboard --flow import
|
||||
openclaw onboard --import-from claude --import-source ~/.claude
|
||||
openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
```
|
||||
|
||||
@@ -76,6 +79,26 @@ openclaw onboard --import-from hermes --import-source ~/.hermes
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Claude provider
|
||||
|
||||
The bundled Claude provider detects Claude Code state at `~/.claude` by default. Use `--from <path>` to import a specific Claude Code home or project root.
|
||||
|
||||
<Tip>
|
||||
For a user-facing walkthrough, see [Migrating from Claude](/install/migrating-claude).
|
||||
</Tip>
|
||||
|
||||
### What gets imported
|
||||
|
||||
- Project `CLAUDE.md` and `.claude/CLAUDE.md` into the OpenClaw agent workspace.
|
||||
- User `~/.claude/CLAUDE.md` appended to workspace `USER.md`.
|
||||
- MCP server definitions from project `.mcp.json`, Claude Code `~/.claude.json`, and Claude Desktop `claude_desktop_config.json`.
|
||||
- Claude skill directories that include `SKILL.md`.
|
||||
- Claude command Markdown files converted into OpenClaw skills with manual invocation only.
|
||||
|
||||
### Archive and manual-review state
|
||||
|
||||
Claude hooks, permissions, environment defaults, local memory, path-scoped rules, subagents, caches, plans, and project history are preserved in the migration report or reported as manual-review items. OpenClaw does not execute hooks, copy broad allowlists, or import OAuth/Desktop credential state automatically.
|
||||
|
||||
## Hermes provider
|
||||
|
||||
The bundled Hermes provider detects state at `~/.hermes` by default. Use `--from <path>` when Hermes lives elsewhere.
|
||||
@@ -141,6 +164,7 @@ Onboarding imports require a fresh OpenClaw setup. Reset config, credentials, se
|
||||
## Related
|
||||
|
||||
- [Migrating from Hermes](/install/migrating-hermes): user-facing walkthrough.
|
||||
- [Migrating from Claude](/install/migrating-claude): user-facing walkthrough.
|
||||
- [Migrating](/install/migrating): move OpenClaw to a new machine.
|
||||
- [Doctor](/gateway/doctor): health check after applying a migration.
|
||||
- [Plugins](/tools/plugin): plugin install and registration.
|
||||
|
||||
@@ -1012,6 +1012,7 @@
|
||||
"pages": [
|
||||
"install/updating",
|
||||
"install/migrating",
|
||||
"install/migrating-claude",
|
||||
"install/migrating-hermes",
|
||||
"install/migrating-matrix",
|
||||
"install/uninstall",
|
||||
|
||||
124
docs/install/migrating-claude.md
Normal file
124
docs/install/migrating-claude.md
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
summary: "Move Claude Code and Claude Desktop local state into OpenClaw with a previewed import"
|
||||
read_when:
|
||||
- You are coming from Claude Code or Claude Desktop and want to keep instructions, MCP servers, and skills
|
||||
- You need to understand what OpenClaw imports automatically and what stays archive-only
|
||||
title: "Migrating from Claude"
|
||||
---
|
||||
|
||||
OpenClaw imports local Claude state through the bundled Claude migration provider. The provider previews every item before changing state, redacts secrets in plans and reports, and creates a verified backup before apply.
|
||||
|
||||
<Note>
|
||||
Onboarding imports require a fresh OpenClaw setup. If you already have local OpenClaw state, reset config, credentials, sessions, and the workspace first, or use `openclaw migrate` directly with `--overwrite` after reviewing the plan.
|
||||
</Note>
|
||||
|
||||
## Two ways to import
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Onboarding wizard">
|
||||
The wizard can offer Claude when it detects local Claude state.
|
||||
|
||||
```bash
|
||||
openclaw onboard --flow import
|
||||
```
|
||||
|
||||
Or point at a specific source:
|
||||
|
||||
```bash
|
||||
openclaw onboard --import-from claude --import-source ~/.claude
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab title="CLI">
|
||||
Use `openclaw migrate` for scripted or repeatable runs. See [`openclaw migrate`](/cli/migrate) for the full reference.
|
||||
|
||||
```bash
|
||||
openclaw migrate claude --dry-run
|
||||
openclaw migrate apply claude --yes
|
||||
```
|
||||
|
||||
Add `--from <path>` to import a specific Claude Code home or project root.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## What gets imported
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Instructions and memory">
|
||||
- Project `CLAUDE.md` and `.claude/CLAUDE.md` content is copied or appended into the OpenClaw agent workspace `AGENTS.md`.
|
||||
- User `~/.claude/CLAUDE.md` content is appended into workspace `USER.md`.
|
||||
</Accordion>
|
||||
<Accordion title="MCP servers">
|
||||
MCP server definitions are imported from project `.mcp.json`, Claude Code `~/.claude.json`, and Claude Desktop `claude_desktop_config.json` when present.
|
||||
</Accordion>
|
||||
<Accordion title="Skills and commands">
|
||||
- Claude skills with a `SKILL.md` file are copied into the OpenClaw workspace skills directory.
|
||||
- Claude command Markdown files under `.claude/commands/` or `~/.claude/commands/` are converted into OpenClaw skills with `disable-model-invocation: true`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## What stays archive-only
|
||||
|
||||
The provider copies these into the migration report for manual review, but does **not** load them into live OpenClaw config:
|
||||
|
||||
- Claude hooks
|
||||
- Claude permissions and broad tool allowlists
|
||||
- Claude environment defaults
|
||||
- `CLAUDE.local.md`
|
||||
- `.claude/rules/`
|
||||
- Claude subagents under `.claude/agents/` or `~/.claude/agents/`
|
||||
- Claude Code caches, plans, and project history directories
|
||||
- Claude Desktop extensions and OS-stored credentials
|
||||
|
||||
OpenClaw refuses to execute hooks, trust permission allowlists, or decode opaque OAuth and Desktop credential state automatically.
|
||||
|
||||
## Recommended flow
|
||||
|
||||
<Steps>
|
||||
<Step title="Preview the plan">
|
||||
```bash
|
||||
openclaw migrate claude --dry-run
|
||||
```
|
||||
|
||||
The plan lists everything that will change, including conflicts, skipped items, and sensitive values redacted from nested MCP `env` or `headers` fields.
|
||||
|
||||
</Step>
|
||||
<Step title="Apply with backup">
|
||||
```bash
|
||||
openclaw migrate apply claude --yes
|
||||
```
|
||||
|
||||
OpenClaw creates and verifies a backup before applying.
|
||||
|
||||
</Step>
|
||||
<Step title="Run doctor">
|
||||
```bash
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
[Doctor](/gateway/doctor) checks for config or state issues after the import.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Source selection
|
||||
|
||||
Without `--from`, OpenClaw inspects the default Claude Code home at `~/.claude`, the sampled Claude Code `~/.claude.json` state file, and the Claude Desktop MCP config on macOS.
|
||||
|
||||
When `--from` points at a project root, OpenClaw imports only that project's Claude files such as `CLAUDE.md`, `.claude/settings.json`, `.claude/commands/`, `.claude/skills/`, and `.mcp.json`. It does not read your global Claude home during a project-root import.
|
||||
|
||||
## Conflict handling
|
||||
|
||||
Apply refuses to continue when the plan reports conflicts.
|
||||
|
||||
<Warning>
|
||||
Rerun with `--overwrite` only when replacing the existing target is intentional. Providers may still write item-level backups for overwritten files in the migration report directory.
|
||||
</Warning>
|
||||
|
||||
## Related
|
||||
|
||||
- [`openclaw migrate`](/cli/migrate): full CLI reference, plugin contract, and JSON shapes.
|
||||
- [Onboarding](/cli/onboard): wizard flow and non-interactive flags.
|
||||
- [Doctor](/gateway/doctor): post-migration health check.
|
||||
- [Agent workspace](/concepts/agent-workspace): where `AGENTS.md`, `USER.md`, and skills live.
|
||||
60
extensions/migrate-claude/apply.ts
Normal file
60
extensions/migrate-claude/apply.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import path from "node:path";
|
||||
import { summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import {
|
||||
archiveMigrationItem,
|
||||
copyMigrationFileItem,
|
||||
writeMigrationReport,
|
||||
} from "openclaw/plugin-sdk/migration-runtime";
|
||||
import type {
|
||||
MigrationApplyResult,
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { applyConfigItem, applyManualItem } from "./config.js";
|
||||
import { appendItem } from "./helpers.js";
|
||||
import { buildClaudePlan } from "./plan.js";
|
||||
import { applyGeneratedSkillItem } from "./skills.js";
|
||||
|
||||
export async function applyClaudePlan(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
plan?: MigrationPlan;
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
}): Promise<MigrationApplyResult> {
|
||||
const plan = params.plan ?? (await buildClaudePlan(params.ctx));
|
||||
const reportDir = params.ctx.reportDir ?? path.join(params.ctx.stateDir, "migration", "claude");
|
||||
const items: MigrationItem[] = [];
|
||||
for (const item of plan.items) {
|
||||
if (item.status !== "planned") {
|
||||
items.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.kind === "config") {
|
||||
items.push(
|
||||
await applyConfigItem(
|
||||
{ ...params.ctx, runtime: params.ctx.runtime ?? params.runtime },
|
||||
item,
|
||||
),
|
||||
);
|
||||
} else if (item.kind === "manual") {
|
||||
items.push(applyManualItem(item));
|
||||
} else if (item.action === "archive") {
|
||||
items.push(await archiveMigrationItem(item, reportDir));
|
||||
} else if (item.action === "append") {
|
||||
items.push(await appendItem(item));
|
||||
} else if (item.action === "create" && item.kind === "skill") {
|
||||
items.push(await applyGeneratedSkillItem(item, { overwrite: params.ctx.overwrite }));
|
||||
} else {
|
||||
items.push(await copyMigrationFileItem(item, reportDir, { overwrite: params.ctx.overwrite }));
|
||||
}
|
||||
}
|
||||
const result: MigrationApplyResult = {
|
||||
...plan,
|
||||
items,
|
||||
summary: summarizeMigrationItems(items),
|
||||
backupPath: params.ctx.backupPath,
|
||||
reportDir,
|
||||
};
|
||||
await writeMigrationReport(result, { title: "Claude Migration Report" });
|
||||
return result;
|
||||
}
|
||||
325
extensions/migrate-claude/config.ts
Normal file
325
extensions/migrate-claude/config.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import {
|
||||
createMigrationItem,
|
||||
markMigrationItemConflict,
|
||||
markMigrationItemError,
|
||||
markMigrationItemSkipped,
|
||||
MIGRATION_REASON_TARGET_EXISTS,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import type { MigrationItem, MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { childRecord, isRecord, readJsonObject, sanitizeName } from "./helpers.js";
|
||||
import type { ClaudeSource } from "./source.js";
|
||||
|
||||
type ConfigPatchDetails = {
|
||||
path: string[];
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
type MappedMcpSource = {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
sourcePath: string;
|
||||
servers: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const CONFIG_RUNTIME_UNAVAILABLE = "config runtime unavailable";
|
||||
const MISSING_CONFIG_PATCH = "missing config patch";
|
||||
|
||||
function readPath(root: Record<string, unknown>, path: readonly string[]): unknown {
|
||||
let current: unknown = root;
|
||||
for (const segment of path) {
|
||||
if (!isRecord(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = current[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function mergeValue(left: unknown, right: unknown): unknown {
|
||||
if (!isRecord(left) || !isRecord(right)) {
|
||||
return structuredClone(right);
|
||||
}
|
||||
const next: Record<string, unknown> = { ...left };
|
||||
for (const [key, value] of Object.entries(right)) {
|
||||
next[key] = mergeValue(next[key], value);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function writePath(root: Record<string, unknown>, path: readonly string[], value: unknown): void {
|
||||
let current = root;
|
||||
for (const segment of path.slice(0, -1)) {
|
||||
const existing = current[segment];
|
||||
if (!isRecord(existing)) {
|
||||
current[segment] = {};
|
||||
}
|
||||
current = current[segment] as Record<string, unknown>;
|
||||
}
|
||||
const leaf = path.at(-1);
|
||||
if (!leaf) {
|
||||
return;
|
||||
}
|
||||
current[leaf] = mergeValue(current[leaf], value);
|
||||
}
|
||||
|
||||
function hasPatchConflict(
|
||||
config: MigrationProviderContext["config"],
|
||||
path: readonly string[],
|
||||
value: unknown,
|
||||
): boolean {
|
||||
if (!isRecord(value)) {
|
||||
return readPath(config as Record<string, unknown>, path) !== undefined;
|
||||
}
|
||||
const existing = readPath(config as Record<string, unknown>, path);
|
||||
if (!isRecord(existing)) {
|
||||
return false;
|
||||
}
|
||||
return Object.keys(value).some((key) => existing[key] !== undefined);
|
||||
}
|
||||
|
||||
function createConfigPatchItem(params: {
|
||||
id: string;
|
||||
target: string;
|
||||
path: string[];
|
||||
value: unknown;
|
||||
message: string;
|
||||
conflict?: boolean;
|
||||
reason?: string;
|
||||
source?: string;
|
||||
details?: Record<string, unknown>;
|
||||
}): MigrationItem {
|
||||
return createMigrationItem({
|
||||
id: params.id,
|
||||
kind: "config",
|
||||
action: "merge",
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
status: params.conflict ? "conflict" : "planned",
|
||||
reason: params.conflict ? (params.reason ?? MIGRATION_REASON_TARGET_EXISTS) : undefined,
|
||||
message: params.message,
|
||||
details: { ...params.details, path: params.path, value: params.value },
|
||||
});
|
||||
}
|
||||
|
||||
function createManualItem(params: {
|
||||
id: string;
|
||||
source: string;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
}): MigrationItem {
|
||||
return createMigrationItem({
|
||||
id: params.id,
|
||||
kind: "manual",
|
||||
action: "manual",
|
||||
source: params.source,
|
||||
status: "skipped",
|
||||
message: params.message,
|
||||
reason: params.recommendation,
|
||||
});
|
||||
}
|
||||
|
||||
function mapMcpServers(raw: unknown): Record<string, unknown> | undefined {
|
||||
if (!isRecord(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
const mapped: Record<string, unknown> = {};
|
||||
for (const [name, value] of Object.entries(raw)) {
|
||||
if (!name.trim() || !isRecord(value)) {
|
||||
continue;
|
||||
}
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const key of [
|
||||
"command",
|
||||
"args",
|
||||
"env",
|
||||
"cwd",
|
||||
"workingDirectory",
|
||||
"url",
|
||||
"type",
|
||||
"transport",
|
||||
"headers",
|
||||
"connectionTimeoutMs",
|
||||
]) {
|
||||
if (value[key] !== undefined) {
|
||||
next[key] = value[key];
|
||||
}
|
||||
}
|
||||
if (Object.keys(next).length > 0) {
|
||||
mapped[name] = next;
|
||||
}
|
||||
}
|
||||
return Object.keys(mapped).length > 0 ? mapped : undefined;
|
||||
}
|
||||
|
||||
async function collectMcpSources(source: ClaudeSource): Promise<MappedMcpSource[]> {
|
||||
const sources: MappedMcpSource[] = [];
|
||||
const projectMcp = await readJsonObject(source.projectMcpPath);
|
||||
const projectServers = mapMcpServers(projectMcp.mcpServers ?? projectMcp);
|
||||
if (projectServers && source.projectMcpPath) {
|
||||
sources.push({
|
||||
sourceId: "project-mcp",
|
||||
sourceLabel: "project .mcp.json",
|
||||
sourcePath: source.projectMcpPath,
|
||||
servers: projectServers,
|
||||
});
|
||||
}
|
||||
|
||||
const claudeJson = await readJsonObject(source.userClaudeJsonPath);
|
||||
const userServers = mapMcpServers(claudeJson.mcpServers);
|
||||
if (userServers && source.userClaudeJsonPath) {
|
||||
sources.push({
|
||||
sourceId: "user-claude-json",
|
||||
sourceLabel: "user ~/.claude.json",
|
||||
sourcePath: source.userClaudeJsonPath,
|
||||
servers: userServers,
|
||||
});
|
||||
}
|
||||
|
||||
if (source.projectDir) {
|
||||
const projectRecord = childRecord(childRecord(claudeJson, "projects"), source.projectDir);
|
||||
const projectScopedServers = mapMcpServers(projectRecord.mcpServers);
|
||||
if (projectScopedServers && source.userClaudeJsonPath) {
|
||||
sources.push({
|
||||
sourceId: "user-claude-json-project",
|
||||
sourceLabel: "project entry in ~/.claude.json",
|
||||
sourcePath: source.userClaudeJsonPath,
|
||||
servers: projectScopedServers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const desktopConfig = await readJsonObject(source.desktopConfigPath);
|
||||
const desktopServers = mapMcpServers(desktopConfig.mcpServers);
|
||||
if (desktopServers && source.desktopConfigPath) {
|
||||
sources.push({
|
||||
sourceId: "desktop",
|
||||
sourceLabel: "Claude Desktop config",
|
||||
sourcePath: source.desktopConfigPath,
|
||||
servers: desktopServers,
|
||||
});
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function buildConfigItems(params: {
|
||||
ctx: MigrationProviderContext;
|
||||
source: ClaudeSource;
|
||||
}): Promise<MigrationItem[]> {
|
||||
const items: MigrationItem[] = [];
|
||||
const mcpSources = await collectMcpSources(params.source);
|
||||
const counts = new Map<string, number>();
|
||||
for (const mcpSource of mcpSources) {
|
||||
for (const name of Object.keys(mcpSource.servers)) {
|
||||
counts.set(name, (counts.get(name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
for (const mcpSource of mcpSources) {
|
||||
for (const [name, value] of Object.entries(mcpSource.servers)) {
|
||||
const patch = { [name]: value };
|
||||
const duplicate = (counts.get(name) ?? 0) > 1;
|
||||
const conflict =
|
||||
duplicate ||
|
||||
(!params.ctx.overwrite && hasPatchConflict(params.ctx.config, ["mcp", "servers"], patch));
|
||||
items.push(
|
||||
createConfigPatchItem({
|
||||
id: `config:mcp-server:${sanitizeName(mcpSource.sourceId)}:${sanitizeName(name)}`,
|
||||
source: mcpSource.sourcePath,
|
||||
target: `mcp.servers.${name}`,
|
||||
path: ["mcp", "servers"],
|
||||
value: patch,
|
||||
message: `Import Claude MCP server "${name}" from ${mcpSource.sourceLabel}.`,
|
||||
conflict,
|
||||
reason: duplicate
|
||||
? `multiple Claude MCP sources define "${name}"`
|
||||
: MIGRATION_REASON_TARGET_EXISTS,
|
||||
details: { sourceLabel: mcpSource.sourceLabel },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const settingsPath of [
|
||||
params.source.userSettingsPath,
|
||||
params.source.userLocalSettingsPath,
|
||||
params.source.projectSettingsPath,
|
||||
params.source.projectLocalSettingsPath,
|
||||
]) {
|
||||
const settings = await readJsonObject(settingsPath);
|
||||
if (settingsPath && settings.hooks !== undefined) {
|
||||
items.push(
|
||||
createManualItem({
|
||||
id: `manual:hooks:${sanitizeName(settingsPath)}`,
|
||||
source: settingsPath,
|
||||
message: "Claude hooks were found but are not enabled automatically.",
|
||||
recommendation: "Review hook commands before recreating equivalent OpenClaw automation.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settingsPath && settings.permissions !== undefined) {
|
||||
items.push(
|
||||
createManualItem({
|
||||
id: `manual:permissions:${sanitizeName(settingsPath)}`,
|
||||
source: settingsPath,
|
||||
message: "Claude permission settings were found but are not translated automatically.",
|
||||
recommendation:
|
||||
"Review deny and allow rules manually. Do not import broad allow rules without a policy review.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (settingsPath && settings.env !== undefined) {
|
||||
items.push(
|
||||
createManualItem({
|
||||
id: `manual:env:${sanitizeName(settingsPath)}`,
|
||||
source: settingsPath,
|
||||
message: "Claude environment defaults were found but are not copied automatically.",
|
||||
recommendation:
|
||||
"Move non-secret values manually and store credentials through OpenClaw credential flows.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function readConfigPatchDetails(item: MigrationItem): ConfigPatchDetails | undefined {
|
||||
const path = item.details?.path;
|
||||
if (
|
||||
!Array.isArray(path) ||
|
||||
!path.every((segment): segment is string => typeof segment === "string")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { path, value: item.details?.value };
|
||||
}
|
||||
|
||||
export async function applyConfigItem(
|
||||
ctx: MigrationProviderContext,
|
||||
item: MigrationItem,
|
||||
): Promise<MigrationItem> {
|
||||
if (item.status !== "planned") {
|
||||
return item;
|
||||
}
|
||||
const details = readConfigPatchDetails(item);
|
||||
if (!details) {
|
||||
return markMigrationItemError(item, MISSING_CONFIG_PATCH);
|
||||
}
|
||||
if (!ctx.runtime?.config.writeConfigFile) {
|
||||
return markMigrationItemError(item, CONFIG_RUNTIME_UNAVAILABLE);
|
||||
}
|
||||
try {
|
||||
const nextConfig = structuredClone(ctx.runtime.config.loadConfig?.() ?? ctx.config);
|
||||
if (!ctx.overwrite && hasPatchConflict(nextConfig, details.path, details.value)) {
|
||||
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
|
||||
}
|
||||
writePath(nextConfig as Record<string, unknown>, details.path, details.value);
|
||||
await ctx.runtime.config.writeConfigFile(nextConfig);
|
||||
return { ...item, status: "migrated" };
|
||||
} catch (err) {
|
||||
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
export function applyManualItem(item: MigrationItem): MigrationItem {
|
||||
return markMigrationItemSkipped(item, item.reason ?? "manual follow-up required");
|
||||
}
|
||||
111
extensions/migrate-claude/helpers.ts
Normal file
111
extensions/migrate-claude/helpers.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
markMigrationItemError,
|
||||
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
export function resolveHomePath(input: string): string {
|
||||
if (input === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
if (input.startsWith("~/")) {
|
||||
return path.join(os.homedir(), input.slice(2));
|
||||
}
|
||||
return path.resolve(input);
|
||||
}
|
||||
|
||||
export async function exists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function isDirectory(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
return (await fs.stat(dirPath)).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeName(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9._-]+/g, "-")
|
||||
.replaceAll(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export async function readText(filePath: string | undefined): Promise<string | undefined> {
|
||||
if (!filePath) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await fs.readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJsonObject(
|
||||
filePath: string | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const content = await readText(filePath);
|
||||
if (!content) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content) as unknown;
|
||||
return isRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function childRecord(
|
||||
root: Record<string, unknown> | undefined,
|
||||
key: string,
|
||||
): Record<string, unknown> {
|
||||
const value = root?.[key];
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
export function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function readStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
|
||||
}
|
||||
|
||||
export async function appendItem(item: MigrationItem): Promise<MigrationItem> {
|
||||
if (!item.source || !item.target) {
|
||||
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(item.source, "utf8");
|
||||
const label =
|
||||
typeof item.details?.sourceLabel === "string"
|
||||
? item.details.sourceLabel
|
||||
: path.basename(item.source);
|
||||
const header = `\n\n<!-- Imported from Claude: ${label} -->\n\n`;
|
||||
await fs.mkdir(path.dirname(item.target), { recursive: true });
|
||||
await fs.appendFile(item.target, `${header}${content.trimEnd()}\n`, "utf8");
|
||||
return { ...item, status: "migrated" };
|
||||
} catch (err) {
|
||||
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
11
extensions/migrate-claude/index.ts
Normal file
11
extensions/migrate-claude/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildClaudeMigrationProvider } from "./provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "migrate-claude",
|
||||
name: "Claude Migration",
|
||||
description: "Imports Claude state into OpenClaw.",
|
||||
register(api) {
|
||||
api.registerMigrationProvider(buildClaudeMigrationProvider({ runtime: api.runtime }));
|
||||
},
|
||||
});
|
||||
71
extensions/migrate-claude/memory.ts
Normal file
71
extensions/migrate-claude/memory.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import path from "node:path";
|
||||
import { createMigrationItem, MIGRATION_REASON_TARGET_EXISTS } from "openclaw/plugin-sdk/migration";
|
||||
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { exists } from "./helpers.js";
|
||||
import type { ClaudeSource } from "./source.js";
|
||||
import type { PlannedTargets } from "./targets.js";
|
||||
|
||||
async function addMemoryItem(params: {
|
||||
items: MigrationItem[];
|
||||
id: string;
|
||||
source?: string;
|
||||
target: string;
|
||||
sourceLabel: string;
|
||||
copyWhenMissing?: boolean;
|
||||
overwrite?: boolean;
|
||||
}): Promise<void> {
|
||||
if (!params.source) {
|
||||
return;
|
||||
}
|
||||
const targetExists = await exists(params.target);
|
||||
const action = params.copyWhenMissing && !targetExists ? "copy" : "append";
|
||||
params.items.push(
|
||||
createMigrationItem({
|
||||
id: params.id,
|
||||
kind: params.target.endsWith("AGENTS.md") ? "workspace" : "memory",
|
||||
action,
|
||||
source: params.source,
|
||||
target: params.target,
|
||||
status: action === "copy" && targetExists && !params.overwrite ? "conflict" : "planned",
|
||||
reason:
|
||||
action === "copy" && targetExists && !params.overwrite
|
||||
? MIGRATION_REASON_TARGET_EXISTS
|
||||
: undefined,
|
||||
details: { sourceLabel: params.sourceLabel },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildMemoryItems(params: {
|
||||
source: ClaudeSource;
|
||||
targets: PlannedTargets;
|
||||
overwrite?: boolean;
|
||||
}): Promise<MigrationItem[]> {
|
||||
const items: MigrationItem[] = [];
|
||||
await addMemoryItem({
|
||||
items,
|
||||
id: "workspace:CLAUDE.md",
|
||||
source: params.source.projectMemoryPath,
|
||||
target: path.join(params.targets.workspaceDir, "AGENTS.md"),
|
||||
sourceLabel: "project CLAUDE.md",
|
||||
copyWhenMissing: true,
|
||||
overwrite: params.overwrite,
|
||||
});
|
||||
await addMemoryItem({
|
||||
items,
|
||||
id: "workspace:.claude/CLAUDE.md",
|
||||
source: params.source.projectDotClaudeMemoryPath,
|
||||
target: path.join(params.targets.workspaceDir, "AGENTS.md"),
|
||||
sourceLabel: "project .claude/CLAUDE.md",
|
||||
overwrite: params.overwrite,
|
||||
});
|
||||
await addMemoryItem({
|
||||
items,
|
||||
id: "memory:user-CLAUDE.md",
|
||||
source: params.source.userMemoryPath,
|
||||
target: path.join(params.targets.workspaceDir, "USER.md"),
|
||||
sourceLabel: "user ~/.claude/CLAUDE.md",
|
||||
overwrite: params.overwrite,
|
||||
});
|
||||
return items;
|
||||
}
|
||||
13
extensions/migrate-claude/openclaw.plugin.json
Normal file
13
extensions/migrate-claude/openclaw.plugin.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "migrate-claude",
|
||||
"name": "Claude Migration",
|
||||
"description": "Imports Claude Code and Claude Desktop instructions, MCP servers, skills, and safe configuration into OpenClaw.",
|
||||
"contracts": {
|
||||
"migrationProviders": ["claude"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
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.4.26",
|
||||
"private": true,
|
||||
"description": "Claude to OpenClaw migration provider",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.26"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
101
extensions/migrate-claude/plan.ts
Normal file
101
extensions/migrate-claude/plan.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { createMigrationItem, summarizeMigrationItems } from "openclaw/plugin-sdk/migration";
|
||||
import type {
|
||||
MigrationItem,
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildConfigItems } from "./config.js";
|
||||
import { buildMemoryItems } from "./memory.js";
|
||||
import { buildSkillItems } from "./skills.js";
|
||||
import { discoverClaudeSource, hasClaudeSource } from "./source.js";
|
||||
import { resolveTargets } from "./targets.js";
|
||||
|
||||
function addArchiveItem(
|
||||
items: MigrationItem[],
|
||||
params: { id: string; source?: string; relativePath: string; message?: string },
|
||||
): void {
|
||||
if (!params.source) {
|
||||
return;
|
||||
}
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: params.id,
|
||||
kind: "archive",
|
||||
action: "archive",
|
||||
source: params.source,
|
||||
message:
|
||||
params.message ??
|
||||
"Archived in the migration report for manual review; not imported into live config.",
|
||||
details: { archiveRelativePath: params.relativePath },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildClaudePlan(ctx: MigrationProviderContext): Promise<MigrationPlan> {
|
||||
const source = await discoverClaudeSource(ctx.source);
|
||||
if (!hasClaudeSource(source)) {
|
||||
throw new Error(
|
||||
`Claude state was not found at ${source.root}. Pass --from <path> if it lives elsewhere.`,
|
||||
);
|
||||
}
|
||||
const targets = resolveTargets(ctx);
|
||||
const items: MigrationItem[] = [];
|
||||
items.push(...(await buildMemoryItems({ source, targets, overwrite: ctx.overwrite })));
|
||||
items.push(...(await buildConfigItems({ ctx, source })));
|
||||
items.push(...(await buildSkillItems({ source, targets, overwrite: ctx.overwrite })));
|
||||
for (const archivePath of source.archivePaths) {
|
||||
addArchiveItem(items, {
|
||||
id: archivePath.id,
|
||||
source: archivePath.path,
|
||||
relativePath: archivePath.relativePath,
|
||||
});
|
||||
}
|
||||
addArchiveItem(items, {
|
||||
id: "archive:CLAUDE.local.md",
|
||||
source: source.projectLocalMemoryPath,
|
||||
relativePath: "CLAUDE.local.md",
|
||||
message:
|
||||
"Claude local project memory is personal machine-local state. It is archived for manual review.",
|
||||
});
|
||||
addArchiveItem(items, {
|
||||
id: "archive:.claude/rules",
|
||||
source: source.projectRulesDir,
|
||||
relativePath: ".claude/rules",
|
||||
});
|
||||
addArchiveItem(items, {
|
||||
id: "archive:user-agents",
|
||||
source: source.userAgentsDir,
|
||||
relativePath: "agents/user",
|
||||
});
|
||||
addArchiveItem(items, {
|
||||
id: "archive:project-agents",
|
||||
source: source.projectAgentsDir,
|
||||
relativePath: "agents/project",
|
||||
});
|
||||
|
||||
const warnings = [
|
||||
...(items.some((item) => item.status === "conflict")
|
||||
? [
|
||||
"Conflicts were found. Re-run with --overwrite to replace conflicting targets after item-level backups.",
|
||||
]
|
||||
: []),
|
||||
...(items.some((item) => item.kind === "archive")
|
||||
? [
|
||||
"Some Claude files are archive-only. They will be copied into the migration report for manual review, not loaded into OpenClaw.",
|
||||
]
|
||||
: []),
|
||||
...(items.some((item) => item.kind === "manual")
|
||||
? ["Some Claude settings require manual review before they can be activated safely."]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
providerId: "claude",
|
||||
source: source.root,
|
||||
target: targets.workspaceDir,
|
||||
summary: summarizeMigrationItems(items),
|
||||
items,
|
||||
warnings,
|
||||
nextSteps: ["Run openclaw doctor after applying the migration."],
|
||||
metadata: { agentDir: targets.agentDir },
|
||||
};
|
||||
}
|
||||
156
extensions/migrate-claude/provider.test.ts
Normal file
156
extensions/migrate-claude/provider.test.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { redactMigrationPlan } from "openclaw/plugin-sdk/migration";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildClaudeMigrationProvider } from "./provider.js";
|
||||
import {
|
||||
cleanupTempRoots,
|
||||
makeConfigRuntime,
|
||||
makeContext,
|
||||
makeTempRoot,
|
||||
writeFile,
|
||||
} from "./test/provider-helpers.js";
|
||||
|
||||
describe("Claude migration provider", () => {
|
||||
afterEach(async () => {
|
||||
await cleanupTempRoots();
|
||||
});
|
||||
|
||||
it("registers a Claude migration provider", async () => {
|
||||
const provider = buildClaudeMigrationProvider();
|
||||
expect(provider.id).toBe("claude");
|
||||
expect(provider.label).toBe("Claude");
|
||||
});
|
||||
|
||||
it("rejects missing Claude sources before planning", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const source = path.join(root, "missing");
|
||||
const provider = buildClaudeMigrationProvider();
|
||||
|
||||
await expect(
|
||||
provider.plan(
|
||||
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir: root }),
|
||||
),
|
||||
).rejects.toThrow("Claude state was not found");
|
||||
});
|
||||
|
||||
it("plans project memory, MCP servers, commands, skills, and manual review items", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const source = path.join(root, "project");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
|
||||
await writeFile(path.join(source, "CLAUDE.local.md"), "local-only\n");
|
||||
await writeFile(
|
||||
path.join(source, ".mcp.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
filesystem: {
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
env: { ANTHROPIC_API_KEY: "short-dev-key" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(source, ".claude", "settings.json"),
|
||||
JSON.stringify({
|
||||
hooks: { PreToolUse: [] },
|
||||
permissions: { allow: ["Bash(*)"] },
|
||||
env: { FOO: "bar" },
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(source, ".claude", "commands", "commit.md"), "Commit $ARGUMENTS\n");
|
||||
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
|
||||
await writeFile(path.join(source, ".claude", "agents", "reviewer.md"), "# Reviewer\n");
|
||||
|
||||
const provider = buildClaudeMigrationProvider();
|
||||
const plan = await provider.plan(
|
||||
makeContext({ source, stateDir: path.join(root, "state"), workspaceDir }),
|
||||
);
|
||||
|
||||
expect(plan.summary.total).toBeGreaterThan(0);
|
||||
expect(plan.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "workspace:CLAUDE.md", kind: "workspace" }),
|
||||
expect.objectContaining({
|
||||
id: "config:mcp-server:project-mcp:filesystem",
|
||||
kind: "config",
|
||||
}),
|
||||
expect.objectContaining({ id: "skill:claude-command-commit", action: "create" }),
|
||||
expect.objectContaining({ id: "skill:review", action: "copy" }),
|
||||
expect.objectContaining({ id: "archive:CLAUDE.local.md", action: "archive" }),
|
||||
expect.objectContaining({ id: "archive:project-agents", action: "archive" }),
|
||||
expect.objectContaining({ id: expect.stringMatching(/^manual:hooks:/u), kind: "manual" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const redacted = JSON.stringify(redactMigrationPlan(plan));
|
||||
expect(redacted).not.toContain("short-dev-key");
|
||||
expect(redacted).toContain("[redacted]");
|
||||
});
|
||||
|
||||
it("applies project imports without reading global Claude state", async () => {
|
||||
const root = await makeTempRoot();
|
||||
const source = path.join(root, "project");
|
||||
const workspaceDir = path.join(root, "workspace");
|
||||
const stateDir = path.join(root, "state");
|
||||
const reportDir = path.join(root, "report");
|
||||
await writeFile(path.join(source, "CLAUDE.md"), "# Project instructions\n");
|
||||
await writeFile(path.join(workspaceDir, "AGENTS.md"), "# Existing agents\n");
|
||||
await writeFile(
|
||||
path.join(source, ".mcp.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
filesystem: {
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(source, ".claude", "commands", "ship.md"), "Ship $ARGUMENTS\n");
|
||||
await writeFile(path.join(source, ".claude", "skills", "Review", "SKILL.md"), "# Review\n");
|
||||
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: workspaceDir,
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
const provider = buildClaudeMigrationProvider();
|
||||
const result = await provider.apply(
|
||||
makeContext({
|
||||
source,
|
||||
stateDir,
|
||||
workspaceDir,
|
||||
reportDir,
|
||||
runtime: makeConfigRuntime(config),
|
||||
config,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.summary.errors).toBe(0);
|
||||
const mcpItem = result.items.find(
|
||||
(item) => item.id === "config:mcp-server:project-mcp:filesystem",
|
||||
);
|
||||
expect(mcpItem?.status).toBe("migrated");
|
||||
expect((config as { mcp?: { servers?: Record<string, unknown> } }).mcp?.servers).toEqual({
|
||||
filesystem: {
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
},
|
||||
});
|
||||
expect(await fs.readFile(path.join(workspaceDir, "AGENTS.md"), "utf8")).toContain(
|
||||
"Imported from Claude: project CLAUDE.md",
|
||||
);
|
||||
await expect(
|
||||
fs.access(path.join(workspaceDir, "skills", "claude-command-ship", "SKILL.md")),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(workspaceDir, "skills", "review", "SKILL.md")),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(fs.access(path.join(reportDir, "summary.md"))).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
35
extensions/migrate-claude/provider.ts
Normal file
35
extensions/migrate-claude/provider.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
MigrationPlan,
|
||||
MigrationProviderContext,
|
||||
MigrationProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { applyClaudePlan } from "./apply.js";
|
||||
import { buildClaudePlan } from "./plan.js";
|
||||
import { discoverClaudeSource, hasClaudeSource } from "./source.js";
|
||||
|
||||
export function buildClaudeMigrationProvider(
|
||||
params: {
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
} = {},
|
||||
): MigrationProviderPlugin {
|
||||
return {
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
description: "Import Claude Code and Claude Desktop instructions, MCP servers, and skills.",
|
||||
async detect(ctx) {
|
||||
const source = await discoverClaudeSource(ctx.source);
|
||||
const found = hasClaudeSource(source);
|
||||
return {
|
||||
found,
|
||||
source: source.root,
|
||||
label: "Claude",
|
||||
confidence: found ? source.confidence : "low",
|
||||
message: found ? "Claude state found." : "Claude state not found.",
|
||||
};
|
||||
},
|
||||
plan: buildClaudePlan,
|
||||
async apply(ctx, plan?: MigrationPlan) {
|
||||
return await applyClaudePlan({ ctx, plan, runtime: params.runtime });
|
||||
},
|
||||
};
|
||||
}
|
||||
194
extensions/migrate-claude/skills.ts
Normal file
194
extensions/migrate-claude/skills.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
createMigrationItem,
|
||||
markMigrationItemConflict,
|
||||
markMigrationItemError,
|
||||
MIGRATION_REASON_MISSING_SOURCE_OR_TARGET,
|
||||
MIGRATION_REASON_TARGET_EXISTS,
|
||||
} from "openclaw/plugin-sdk/migration";
|
||||
import type { MigrationItem } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { exists, readText, sanitizeName } from "./helpers.js";
|
||||
import type { ClaudeSource } from "./source.js";
|
||||
import type { PlannedTargets } from "./targets.js";
|
||||
|
||||
type PlannedSkill = {
|
||||
name: string;
|
||||
source: string;
|
||||
target: string;
|
||||
action: "copy" | "create";
|
||||
sourceLabel: string;
|
||||
};
|
||||
|
||||
async function listMarkdownFiles(root: string): Promise<string[]> {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await listMarkdownFiles(fullPath)));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function collectSkillDirs(
|
||||
planned: PlannedSkill[],
|
||||
dir: string | undefined,
|
||||
targets: PlannedTargets,
|
||||
scope: string,
|
||||
): Promise<void> {
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const source = path.join(dir, entry.name);
|
||||
if (!(await exists(path.join(source, "SKILL.md")))) {
|
||||
continue;
|
||||
}
|
||||
const name = sanitizeName(entry.name);
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
planned.push({
|
||||
name,
|
||||
source,
|
||||
target: path.join(targets.workspaceDir, "skills", name),
|
||||
action: "copy",
|
||||
sourceLabel: `${scope} Claude skill`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCommandFiles(
|
||||
planned: PlannedSkill[],
|
||||
dir: string | undefined,
|
||||
targets: PlannedTargets,
|
||||
scope: string,
|
||||
): Promise<void> {
|
||||
if (!dir) {
|
||||
return;
|
||||
}
|
||||
for (const file of await listMarkdownFiles(dir)) {
|
||||
const relative = path.relative(dir, file);
|
||||
const parsed = path.parse(relative);
|
||||
const namespace = sanitizeName(parsed.dir.replaceAll(path.sep, "-"));
|
||||
const commandName = sanitizeName(parsed.name);
|
||||
const name = sanitizeName(["claude-command", namespace, commandName].filter(Boolean).join("-"));
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
planned.push({
|
||||
name,
|
||||
source: file,
|
||||
target: path.join(targets.workspaceDir, "skills", name),
|
||||
action: "create",
|
||||
sourceLabel: `${scope} Claude command ${relative}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildSkillItems(params: {
|
||||
source: ClaudeSource;
|
||||
targets: PlannedTargets;
|
||||
overwrite?: boolean;
|
||||
}): Promise<MigrationItem[]> {
|
||||
const planned: PlannedSkill[] = [];
|
||||
await collectSkillDirs(planned, params.source.userSkillsDir, params.targets, "user");
|
||||
await collectSkillDirs(planned, params.source.projectSkillsDir, params.targets, "project");
|
||||
await collectCommandFiles(planned, params.source.userCommandsDir, params.targets, "user");
|
||||
await collectCommandFiles(planned, params.source.projectCommandsDir, params.targets, "project");
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const skill of planned) {
|
||||
counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const items: MigrationItem[] = [];
|
||||
for (const skill of planned) {
|
||||
const collides = (counts.get(skill.name) ?? 0) > 1;
|
||||
const targetExists = await exists(skill.target);
|
||||
items.push(
|
||||
createMigrationItem({
|
||||
id: `skill:${skill.name}`,
|
||||
kind: "skill",
|
||||
action: skill.action,
|
||||
source: skill.source,
|
||||
target: skill.target,
|
||||
status: collides ? "conflict" : targetExists && !params.overwrite ? "conflict" : "planned",
|
||||
reason: collides
|
||||
? `multiple Claude skills or commands normalize to "${skill.name}"`
|
||||
: targetExists && !params.overwrite
|
||||
? MIGRATION_REASON_TARGET_EXISTS
|
||||
: undefined,
|
||||
details: { sourceLabel: skill.sourceLabel, skillName: skill.name },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function firstParagraph(content: string): string | undefined {
|
||||
return content
|
||||
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/u, "")
|
||||
.split(/\r?\n\r?\n/u)
|
||||
.map((part) => part.replaceAll(/\s+/g, " ").trim())
|
||||
.find(Boolean);
|
||||
}
|
||||
|
||||
function generatedCommandSkillContent(params: {
|
||||
skillName: string;
|
||||
sourceLabel: string;
|
||||
commandContent: string;
|
||||
}): string {
|
||||
const description =
|
||||
firstParagraph(params.commandContent) ?? `Imported Claude command ${params.skillName}`;
|
||||
return [
|
||||
"---",
|
||||
`name: ${params.skillName}`,
|
||||
`description: ${JSON.stringify(description.slice(0, 180))}`,
|
||||
"disable-model-invocation: true",
|
||||
"---",
|
||||
"",
|
||||
`<!-- Imported from Claude: ${params.sourceLabel} -->`,
|
||||
"",
|
||||
params.commandContent.trimEnd(),
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function applyGeneratedSkillItem(
|
||||
item: MigrationItem,
|
||||
opts: { overwrite?: boolean } = {},
|
||||
): Promise<MigrationItem> {
|
||||
if (!item.source || !item.target) {
|
||||
return markMigrationItemError(item, MIGRATION_REASON_MISSING_SOURCE_OR_TARGET);
|
||||
}
|
||||
try {
|
||||
if ((await exists(item.target)) && !opts.overwrite) {
|
||||
return markMigrationItemConflict(item, MIGRATION_REASON_TARGET_EXISTS);
|
||||
}
|
||||
const sourceLabel =
|
||||
typeof item.details?.sourceLabel === "string"
|
||||
? item.details.sourceLabel
|
||||
: path.basename(item.source);
|
||||
const skillName =
|
||||
typeof item.details?.skillName === "string" ? item.details.skillName : sanitizeName(item.id);
|
||||
const content = generatedCommandSkillContent({
|
||||
skillName,
|
||||
sourceLabel,
|
||||
commandContent: (await readText(item.source)) ?? "",
|
||||
});
|
||||
await fs.mkdir(item.target, { recursive: true });
|
||||
await fs.writeFile(path.join(item.target, "SKILL.md"), content, "utf8");
|
||||
return { ...item, status: "migrated" };
|
||||
} catch (err) {
|
||||
return markMigrationItemError(item, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
174
extensions/migrate-claude/source.ts
Normal file
174
extensions/migrate-claude/source.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { exists, isDirectory, readJsonObject, resolveHomePath } from "./helpers.js";
|
||||
|
||||
export type ClaudeArchivePath = {
|
||||
id: string;
|
||||
path: string;
|
||||
relativePath: string;
|
||||
};
|
||||
|
||||
export type ClaudeSource = {
|
||||
root: string;
|
||||
confidence: "low" | "medium" | "high";
|
||||
homeDir?: string;
|
||||
projectDir?: string;
|
||||
homeProjectsDir?: string;
|
||||
userSettingsPath?: string;
|
||||
userLocalSettingsPath?: string;
|
||||
userClaudeJsonPath?: string;
|
||||
userMemoryPath?: string;
|
||||
projectSettingsPath?: string;
|
||||
projectLocalSettingsPath?: string;
|
||||
projectMcpPath?: string;
|
||||
projectMemoryPath?: string;
|
||||
projectDotClaudeMemoryPath?: string;
|
||||
projectLocalMemoryPath?: string;
|
||||
projectRulesDir?: string;
|
||||
userSkillsDir?: string;
|
||||
projectSkillsDir?: string;
|
||||
userCommandsDir?: string;
|
||||
projectCommandsDir?: string;
|
||||
userAgentsDir?: string;
|
||||
projectAgentsDir?: string;
|
||||
desktopConfigPath?: string;
|
||||
archivePaths: ClaudeArchivePath[];
|
||||
};
|
||||
|
||||
const HOME_ARCHIVE_DIRS = ["projects", "cache", "plans"] as const;
|
||||
const PROJECT_ARCHIVE_FILES = [".claude/scheduled_tasks.json"] as const;
|
||||
|
||||
function defaultClaudeHome(): string {
|
||||
return path.join(os.homedir(), ".claude");
|
||||
}
|
||||
|
||||
function defaultDesktopConfig(): string {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude_desktop_config.json",
|
||||
);
|
||||
}
|
||||
|
||||
async function addArchivePath(
|
||||
archivePaths: ClaudeArchivePath[],
|
||||
id: string,
|
||||
candidate: string,
|
||||
relativePath: string,
|
||||
): Promise<void> {
|
||||
if ((await exists(candidate)) || (await isDirectory(candidate))) {
|
||||
archivePaths.push({ id, path: candidate, relativePath });
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverClaudeSource(input?: string): Promise<ClaudeSource> {
|
||||
const explicitInput = Boolean(input?.trim());
|
||||
const root = resolveHomePath(input?.trim() || defaultClaudeHome());
|
||||
const rootIsHome = path.basename(root) === ".claude";
|
||||
const inspectGlobal = !explicitInput || rootIsHome;
|
||||
const homeDir = inspectGlobal ? (rootIsHome ? root : defaultClaudeHome()) : undefined;
|
||||
const projectDir = rootIsHome ? undefined : root;
|
||||
const archivePaths: ClaudeArchivePath[] = [];
|
||||
|
||||
const userSettingsPath = homeDir ? path.join(homeDir, "settings.json") : undefined;
|
||||
const userLocalSettingsPath = homeDir ? path.join(homeDir, "settings.local.json") : undefined;
|
||||
const userClaudeJsonPath = inspectGlobal ? path.join(os.homedir(), ".claude.json") : undefined;
|
||||
const userMemoryPath = homeDir ? path.join(homeDir, "CLAUDE.md") : undefined;
|
||||
const desktopConfigPath = inspectGlobal ? defaultDesktopConfig() : undefined;
|
||||
const homeProjectsDir = homeDir ? path.join(homeDir, "projects") : undefined;
|
||||
const userSkillsDir = homeDir ? path.join(homeDir, "skills") : undefined;
|
||||
const userCommandsDir = homeDir ? path.join(homeDir, "commands") : undefined;
|
||||
const userAgentsDir = homeDir ? path.join(homeDir, "agents") : undefined;
|
||||
|
||||
if (homeDir) {
|
||||
for (const dir of HOME_ARCHIVE_DIRS) {
|
||||
await addArchivePath(archivePaths, `archive:home:${dir}`, path.join(homeDir, dir), dir);
|
||||
}
|
||||
}
|
||||
|
||||
const source: ClaudeSource = {
|
||||
root,
|
||||
confidence: "low",
|
||||
archivePaths,
|
||||
...(homeDir && (await isDirectory(homeDir)) ? { homeDir } : {}),
|
||||
...(homeProjectsDir && (await isDirectory(homeProjectsDir)) ? { homeProjectsDir } : {}),
|
||||
...(projectDir ? { projectDir } : {}),
|
||||
...(userSettingsPath && (await exists(userSettingsPath)) ? { userSettingsPath } : {}),
|
||||
...(userLocalSettingsPath && (await exists(userLocalSettingsPath))
|
||||
? { userLocalSettingsPath }
|
||||
: {}),
|
||||
...(userClaudeJsonPath && (await exists(userClaudeJsonPath)) ? { userClaudeJsonPath } : {}),
|
||||
...(userMemoryPath && (await exists(userMemoryPath)) ? { userMemoryPath } : {}),
|
||||
...(userSkillsDir && (await isDirectory(userSkillsDir)) ? { userSkillsDir } : {}),
|
||||
...(userCommandsDir && (await isDirectory(userCommandsDir)) ? { userCommandsDir } : {}),
|
||||
...(userAgentsDir && (await isDirectory(userAgentsDir)) ? { userAgentsDir } : {}),
|
||||
...(desktopConfigPath && (await exists(desktopConfigPath)) ? { desktopConfigPath } : {}),
|
||||
};
|
||||
|
||||
if (projectDir) {
|
||||
const projectSettingsPath = path.join(projectDir, ".claude", "settings.json");
|
||||
const projectLocalSettingsPath = path.join(projectDir, ".claude", "settings.local.json");
|
||||
const projectMcpPath = path.join(projectDir, ".mcp.json");
|
||||
const projectMemoryPath = path.join(projectDir, "CLAUDE.md");
|
||||
const projectDotClaudeMemoryPath = path.join(projectDir, ".claude", "CLAUDE.md");
|
||||
const projectLocalMemoryPath = path.join(projectDir, "CLAUDE.local.md");
|
||||
const projectRulesDir = path.join(projectDir, ".claude", "rules");
|
||||
const projectSkillsDir = path.join(projectDir, ".claude", "skills");
|
||||
const projectCommandsDir = path.join(projectDir, ".claude", "commands");
|
||||
const projectAgentsDir = path.join(projectDir, ".claude", "agents");
|
||||
Object.assign(source, {
|
||||
...((await exists(projectSettingsPath)) ? { projectSettingsPath } : {}),
|
||||
...((await exists(projectLocalSettingsPath)) ? { projectLocalSettingsPath } : {}),
|
||||
...((await exists(projectMcpPath)) ? { projectMcpPath } : {}),
|
||||
...((await exists(projectMemoryPath)) ? { projectMemoryPath } : {}),
|
||||
...((await exists(projectDotClaudeMemoryPath)) ? { projectDotClaudeMemoryPath } : {}),
|
||||
...((await exists(projectLocalMemoryPath)) ? { projectLocalMemoryPath } : {}),
|
||||
...((await isDirectory(projectRulesDir)) ? { projectRulesDir } : {}),
|
||||
...((await isDirectory(projectSkillsDir)) ? { projectSkillsDir } : {}),
|
||||
...((await isDirectory(projectCommandsDir)) ? { projectCommandsDir } : {}),
|
||||
...((await isDirectory(projectAgentsDir)) ? { projectAgentsDir } : {}),
|
||||
});
|
||||
for (const file of PROJECT_ARCHIVE_FILES) {
|
||||
await addArchivePath(
|
||||
archivePaths,
|
||||
`archive:project:${file}`,
|
||||
path.join(projectDir, file),
|
||||
file,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const claudeJson = await readJsonObject(source.userClaudeJsonPath);
|
||||
const hasClaudeJsonState = Boolean(claudeJson.mcpServers || claudeJson.projects);
|
||||
const desktopConfig = await readJsonObject(source.desktopConfigPath);
|
||||
const hasDesktopMcp = Boolean(desktopConfig.mcpServers);
|
||||
const high = Boolean(
|
||||
source.userSettingsPath ||
|
||||
source.userMemoryPath ||
|
||||
source.projectSettingsPath ||
|
||||
source.projectMcpPath ||
|
||||
source.projectMemoryPath ||
|
||||
source.projectDotClaudeMemoryPath ||
|
||||
hasClaudeJsonState ||
|
||||
hasDesktopMcp,
|
||||
);
|
||||
const medium = Boolean(
|
||||
source.userSkillsDir ||
|
||||
source.projectSkillsDir ||
|
||||
source.userCommandsDir ||
|
||||
source.projectCommandsDir ||
|
||||
source.userAgentsDir ||
|
||||
source.projectAgentsDir ||
|
||||
source.projectRulesDir ||
|
||||
source.projectLocalMemoryPath ||
|
||||
source.homeProjectsDir,
|
||||
);
|
||||
source.confidence = high ? "high" : medium ? "medium" : "low";
|
||||
return source;
|
||||
}
|
||||
|
||||
export function hasClaudeSource(source: ClaudeSource): boolean {
|
||||
return source.confidence !== "low";
|
||||
}
|
||||
30
extensions/migrate-claude/targets.ts
Normal file
30
extensions/migrate-claude/targets.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import path from "node:path";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { resolveHomePath } from "./helpers.js";
|
||||
|
||||
export type PlannedTargets = {
|
||||
workspaceDir: string;
|
||||
stateDir: string;
|
||||
agentDir: string;
|
||||
};
|
||||
|
||||
export function resolveTargets(ctx: MigrationProviderContext): PlannedTargets {
|
||||
const cfg = ctx.config;
|
||||
const agentId = resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const configuredAgentDir = resolveAgentConfig(cfg, agentId)?.agentDir?.trim();
|
||||
const agentDir =
|
||||
ctx.runtime?.agent?.resolveAgentDir(cfg, agentId) ??
|
||||
(configuredAgentDir ? resolveHomePath(configuredAgentDir) : undefined) ??
|
||||
path.join(ctx.stateDir, "agents", agentId, "agent");
|
||||
return {
|
||||
workspaceDir,
|
||||
stateDir: ctx.stateDir,
|
||||
agentDir,
|
||||
};
|
||||
}
|
||||
83
extensions/migrate-claude/test/provider-helpers.ts
Normal file
83
extensions/migrate-claude/test/provider-helpers.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { MigrationProviderContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
const tempRoots = new Set<string>();
|
||||
|
||||
export const logger = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
debug() {},
|
||||
};
|
||||
|
||||
export async function makeTempRoot() {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-migrate-claude-"),
|
||||
);
|
||||
tempRoots.add(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
export async function cleanupTempRoots() {
|
||||
for (const root of tempRoots) {
|
||||
await fs.rm(root, { force: true, recursive: true });
|
||||
}
|
||||
tempRoots.clear();
|
||||
}
|
||||
|
||||
export async function writeFile(filePath: string, content: string) {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
export function makeConfigRuntime(
|
||||
config: OpenClawConfig,
|
||||
onWrite?: (next: OpenClawConfig) => void,
|
||||
): NonNullable<MigrationProviderContext["runtime"]> {
|
||||
return {
|
||||
config: {
|
||||
loadConfig: () => config,
|
||||
writeConfigFile: async (next: OpenClawConfig) => {
|
||||
for (const key of Object.keys(config) as Array<keyof OpenClawConfig>) {
|
||||
delete config[key];
|
||||
}
|
||||
Object.assign(config, next);
|
||||
onWrite?.(next);
|
||||
},
|
||||
},
|
||||
} as NonNullable<MigrationProviderContext["runtime"]>;
|
||||
}
|
||||
|
||||
export function makeContext(params: {
|
||||
source: string;
|
||||
stateDir: string;
|
||||
workspaceDir: string;
|
||||
config?: OpenClawConfig;
|
||||
includeSecrets?: boolean;
|
||||
overwrite?: boolean;
|
||||
reportDir?: string;
|
||||
runtime?: MigrationProviderContext["runtime"];
|
||||
}): MigrationProviderContext {
|
||||
const config =
|
||||
params.config ??
|
||||
({
|
||||
agents: {
|
||||
defaults: {
|
||||
workspace: params.workspaceDir,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
return {
|
||||
config,
|
||||
stateDir: params.stateDir,
|
||||
source: params.source,
|
||||
includeSecrets: params.includeSecrets,
|
||||
overwrite: params.overwrite,
|
||||
reportDir: params.reportDir,
|
||||
runtime: params.runtime,
|
||||
logger,
|
||||
};
|
||||
}
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -899,6 +899,15 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/migrate-claude:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
openclaw:
|
||||
specifier: workspace:*
|
||||
version: link:../..
|
||||
|
||||
extensions/migrate-hermes:
|
||||
dependencies:
|
||||
yaml:
|
||||
|
||||
Reference in New Issue
Block a user