Add official OpenClaw migration skill for Hermes Agent

Introduces a new OpenClaw-to-Hermes migration skill with a Python
helper script that handles importing SOUL.md, memories, user profiles,
messaging settings, command allowlists, skills, TTS assets, and
workspace instructions.

Supports two migration presets (user-data / full), three skill conflict
modes (skip / overwrite / rename), overflow file export for entries that
exceed character limits, and granular include/exclude option filtering.

Includes detailed SKILL.md agent instructions covering the clarify-tool
interaction protocol, decision-to-command mapping, post-run reporting
rules, and path resolution guidance.

Adds dynamic panel width calculation to CLI clarify/approval widgets so
panels adapt to content and terminal size.

Includes 7 new tests covering presets, include/exclude, conflict modes,
overflow exports, and skills_guard integration.
This commit is contained in:
Tyler
2026-03-06 18:57:12 -08:00
parent 1755a9e38a
commit 53b4b7651a
4 changed files with 830 additions and 99 deletions

123
cli.py
View File

@@ -19,6 +19,7 @@ import sys
import json
import atexit
import uuid
import textwrap
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any, Optional
@@ -2767,6 +2768,8 @@ class HermesCLI:
return "type password (hidden), Enter to skip"
if cli_ref._approval_state:
return ""
if cli_ref._clarify_freetext:
return "type your answer here and press Enter"
if cli_ref._clarify_state:
return ""
if cli_ref._agent_running:
@@ -2824,6 +2827,32 @@ class HermesCLI:
# --- Clarify tool: dynamic display widget for questions + choices ---
def _panel_box_width(title: str, content_lines: list[str], min_width: int = 46, max_width: int = 76) -> int:
"""Choose a stable panel width wide enough for the title and content."""
term_cols = shutil.get_terminal_size((100, 20)).columns
longest = max([len(title)] + [len(line) for line in content_lines] + [min_width - 4])
inner = min(max(longest + 4, min_width - 2), max_width - 2, max(24, term_cols - 6))
return inner + 2 # account for the single leading/trailing spaces inside borders
def _wrap_panel_text(text: str, width: int, subsequent_indent: str = "") -> list[str]:
wrapped = textwrap.wrap(
text,
width=max(8, width),
break_long_words=False,
break_on_hyphens=False,
subsequent_indent=subsequent_indent,
)
return wrapped or [""]
def _append_panel_line(lines, border_style: str, content_style: str, text: str, box_width: int) -> None:
inner_width = max(0, box_width - 2)
lines.append((border_style, ""))
lines.append((content_style, text.ljust(inner_width)))
lines.append((border_style, "\n"))
def _append_blank_panel_line(lines, border_style: str, box_width: int) -> None:
lines.append((border_style, "" + (" " * box_width) + "\n"))
def _get_clarify_display():
"""Build styled text for the clarify question/choices panel."""
state = cli_ref._clarify_state
@@ -2833,43 +2862,62 @@ class HermesCLI:
question = state["question"]
choices = state.get("choices") or []
selected = state.get("selected", 0)
preview_lines = _wrap_panel_text(question, 60)
for i, choice in enumerate(choices):
prefix = " " if i == selected and not cli_ref._clarify_freetext else " "
preview_lines.extend(_wrap_panel_text(f"{prefix}{choice}", 60, subsequent_indent=" "))
other_label = (
" Other (type below)" if cli_ref._clarify_freetext
else " Other (type your answer)" if selected == len(choices)
else " Other (type your answer)"
)
preview_lines.extend(_wrap_panel_text(other_label, 60, subsequent_indent=" "))
box_width = _panel_box_width("Hermes needs your input", preview_lines)
inner_text_width = max(8, box_width - 2)
lines = []
# Box top border
lines.append(('class:clarify-border', '╭─ '))
lines.append(('class:clarify-title', 'Hermes needs your input'))
lines.append(('class:clarify-border', ' ─────────────────────────────\n'))
lines.append(('class:clarify-border', '\n'))
lines.append(('class:clarify-border', ' ' + ('' * max(0, box_width - len("Hermes needs your input") - 3)) + '\n'))
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
# Question text
lines.append(('class:clarify-border', ''))
lines.append(('class:clarify-question', question))
lines.append(('', '\n'))
lines.append(('class:clarify-border', '\n'))
for wrapped in _wrap_panel_text(question, inner_text_width):
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width)
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
if cli_ref._clarify_freetext and not choices:
guidance = "Type your answer in the prompt below, then press Enter."
for wrapped in _wrap_panel_text(guidance, inner_text_width):
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-choice', wrapped, box_width)
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
if choices:
# Multiple-choice mode: show selectable options
for i, choice in enumerate(choices):
lines.append(('class:clarify-border', ''))
if i == selected and not cli_ref._clarify_freetext:
lines.append(('class:clarify-selected', f' {choice}'))
else:
lines.append(('class:clarify-choice', f' {choice}'))
lines.append(('', '\n'))
style = 'class:clarify-selected' if i == selected and not cli_ref._clarify_freetext else 'class:clarify-choice'
prefix = ' ' if i == selected and not cli_ref._clarify_freetext else ' '
wrapped_lines = _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" ")
for wrapped in wrapped_lines:
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
# "Other" option (5th line, only shown when choices exist)
other_idx = len(choices)
lines.append(('class:clarify-border', ''))
if selected == other_idx and not cli_ref._clarify_freetext:
lines.append(('class:clarify-selected', ' Other (type your answer)'))
other_style = 'class:clarify-selected'
other_label = ' Other (type your answer)'
elif cli_ref._clarify_freetext:
lines.append(('class:clarify-active-other', ' Other (type below)'))
other_style = 'class:clarify-active-other'
other_label = ' Other (type below)'
else:
lines.append(('class:clarify-choice', ' Other (type your answer)'))
lines.append(('', '\n'))
other_style = 'class:clarify-choice'
other_label = ' Other (type your answer)'
for wrapped in _wrap_panel_text(other_label, inner_text_width, subsequent_indent=" "):
_append_panel_line(lines, 'class:clarify-border', other_style, wrapped, box_width)
lines.append(('class:clarify-border', '\n'))
lines.append(('class:clarify-border', '──────────────────────────────────────────────────\n'))
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
lines.append(('class:clarify-border', '' + ('' * box_width) + '\n'))
return lines
clarify_widget = ConditionalContainer(
@@ -2924,29 +2972,32 @@ class HermesCLI:
"always": "Add to permanent allowlist",
"deny": "Deny",
}
preview_lines = _wrap_panel_text(description, 60)
preview_lines.extend(_wrap_panel_text(cmd_display, 60))
for i, choice in enumerate(choices):
prefix = ' ' if i == selected else ' '
preview_lines.extend(_wrap_panel_text(f"{prefix}{choice_labels.get(choice, choice)}", 60, subsequent_indent=" "))
box_width = _panel_box_width("⚠️ Dangerous Command", preview_lines)
inner_text_width = max(8, box_width - 2)
lines = []
lines.append(('class:approval-border', '╭─ '))
lines.append(('class:approval-title', '⚠️ Dangerous Command'))
lines.append(('class:approval-border', ' ───────────────────────────────\n'))
lines.append(('class:approval-border', '\n'))
lines.append(('class:approval-border', ''))
lines.append(('class:approval-desc', description))
lines.append(('', '\n'))
lines.append(('class:approval-border', ''))
lines.append(('class:approval-cmd', cmd_display))
lines.append(('', '\n'))
lines.append(('class:approval-border', '\n'))
lines.append(('class:approval-border', ' ' + ('' * max(0, box_width - len("⚠️ Dangerous Command") - 3)) + '\n'))
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for wrapped in _wrap_panel_text(description, inner_text_width):
_append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
for wrapped in _wrap_panel_text(cmd_display, inner_text_width):
_append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
for i, choice in enumerate(choices):
lines.append(('class:approval-border', ''))
label = choice_labels.get(choice, choice)
if i == selected:
lines.append(('class:approval-selected', f' {label}'))
else:
lines.append(('class:approval-choice', f' {label}'))
lines.append(('', '\n'))
lines.append(('class:approval-border', '\n'))
lines.append(('class:approval-border', '╰──────────────────────────────────────────────────────╯\n'))
style = 'class:approval-selected' if i == selected else 'class:approval-choice'
prefix = ' ' if i == selected else ' '
for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "):
_append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
_append_blank_panel_line(lines, 'class:approval-border', box_width)
lines.append(('class:approval-border', '' + ('' * box_width) + '\n'))
return lines
approval_widget = ConditionalContainer(

View File

@@ -18,16 +18,35 @@ Use this skill when a user wants to move their OpenClaw setup into Hermes Agent
It uses `scripts/openclaw_to_hermes.py` to:
- import `SOUL.md` into `~/.hermes/SOUL.md`
- import `SOUL.md` into the Hermes home directory as `SOUL.md`
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
- optionally copy the OpenClaw workspace `AGENTS.md` into a chosen Hermes workspace
- optionally copy the OpenClaw workspace instructions file into a chosen Hermes workspace
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
- archive non-secret docs that do not have a direct Hermes destination
- produce a structured report listing migrated items, conflicts, skipped items, and reasons
## Path resolution
The helper script lives in this skill directory at:
- `scripts/openclaw_to_hermes.py`
When this skill is installed from the Skills Hub, the normal location is:
- `~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py`
Do not guess a shorter path like `~/.hermes/skills/openclaw-migration/...`.
Before running the helper:
1. Prefer the installed path under `~/.hermes/skills/migration/openclaw-migration/`.
2. If that path fails, inspect the installed skill directory and resolve the script relative to the installed `SKILL.md`.
3. Only use `find` as a fallback if the installed location is missing or the skill was moved manually.
4. When calling the terminal tool, do not pass `workdir: "~"`. Use an absolute directory such as the user's home directory, or omit `workdir` entirely.
With `--migrate-secrets`, it will also import a small allowlisted set of Hermes-compatible secrets, currently:
- `TELEGRAM_BOT_TOKEN`
@@ -35,34 +54,198 @@ With `--migrate-secrets`, it will also import a small allowlisted set of Hermes-
## Default workflow
1. Inspect first with a dry run.
2. Ask for a target workspace path if `AGENTS.md` should be brought over.
3. Execute the migration.
4. Summarize the results, especially:
2. Present a simple summary of what can be migrated, what cannot be migrated, and what would be archived.
3. If the `clarify` tool is available, use it for user decisions instead of asking for a free-form prose reply.
4. If the dry run finds imported skill directory conflicts, ask how those should be handled before executing.
5. Ask the user to choose between the two supported migration modes before executing.
6. Ask for a target workspace path only if the user wants the workspace instructions file brought over.
7. Execute the migration with the matching preset and flags.
8. Summarize the results, especially:
- what was migrated
- what was archived for manual review
- what was skipped and why
## User interaction protocol
Hermes CLI supports the `clarify` tool for interactive prompts, but it is limited to:
- one choice at a time
- up to 4 predefined choices
- an automatic `Other` free-text option
It does **not** support true multi-select checkboxes in a single prompt.
For every `clarify` call:
- always include a non-empty `question`
- include `choices` only for real selectable prompts
- keep `choices` to 2-4 plain string options
- never emit placeholder or truncated options such as `...`
- never pad or stylize choices with extra whitespace
- never include fake form fields in the question such as `enter directory here`, blank lines to fill in, or underscores like `_____`
- for open-ended path questions, ask only the plain sentence; the user types in the normal CLI prompt below the panel
If a `clarify` call returns an error, inspect the error text, correct the payload, and retry once with a valid `question` and clean choices.
When `clarify` is available and the dry run reveals any required user decision, your **next action must be a `clarify` tool call**.
Do not end the turn with a normal assistant message such as:
- "Let me present the choices"
- "What would you like to do?"
- "Here are the options"
If a user decision is required, collect it via `clarify` before producing more prose.
If multiple unresolved decisions remain, do not insert an explanatory assistant message between them. After one `clarify` response is received, your next action should usually be the next required `clarify` call.
Treat `workspace-agents` as an unresolved decision whenever the dry run reports:
- `kind="workspace-agents"`
- `status="skipped"`
- reason containing `No workspace target was provided`
In that case, you must ask about workspace instructions before execution. Do not silently treat that as a decision to skip.
Because of that limitation, use this simplified decision flow:
1. For `SOUL.md` conflicts, use `clarify` with choices such as:
- `keep existing`
- `overwrite with backup`
- `review first`
2. If the dry run shows one or more `kind="skill"` items with `status="conflict"`, use `clarify` with choices such as:
- `keep existing skills`
- `overwrite conflicting skills with backup`
- `import conflicting skills under renamed folders`
3. For workspace instructions, use `clarify` with choices such as:
- `skip workspace instructions`
- `copy to a workspace path`
- `decide later`
4. If the user chooses to copy workspace instructions, ask a follow-up open-ended `clarify` question requesting an **absolute path**.
5. If the user chooses `skip workspace instructions` or `decide later`, proceed without `--workspace-target`.
5. For migration mode, use `clarify` with these 3 choices:
- `user-data only`
- `full compatible migration`
- `cancel`
6. `user-data only` means: migrate user data and compatible config, but do **not** import allowlisted secrets.
7. `full compatible migration` means: migrate the same compatible user data plus the allowlisted secrets when present.
8. If `clarify` is not available, ask the same question in normal text, but still constrain the answer to `user-data only`, `full compatible migration`, or `cancel`.
Execution gate:
- Do not execute while a `workspace-agents` skip caused by `No workspace target was provided` remains unresolved.
- The only valid ways to resolve it are:
- user explicitly chooses `skip workspace instructions`
- user explicitly chooses `decide later`
- user provides a workspace path after choosing `copy to a workspace path`
- Absence of a workspace target in the dry run is not itself permission to execute.
- Do not execute while any required `clarify` decision remains unresolved.
Use these exact `clarify` payload shapes as the default pattern:
- `{"question":"Your existing SOUL.md conflicts with the imported one. What should I do?","choices":["keep existing","overwrite with backup","review first"]}`
- `{"question":"One or more imported OpenClaw skills already exist in Hermes. How should I handle those skill conflicts?","choices":["keep existing skills","overwrite conflicting skills with backup","import conflicting skills under renamed folders"]}`
- `{"question":"Choose migration mode: migrate only user data, or run the full compatible migration including allowlisted secrets?","choices":["user-data only","full compatible migration","cancel"]}`
- `{"question":"Do you want to copy the OpenClaw workspace instructions file into a Hermes workspace?","choices":["skip workspace instructions","copy to a workspace path","decide later"]}`
- `{"question":"Please provide an absolute path where the workspace instructions should be copied."}`
## Decision-to-command mapping
Map user decisions to command flags exactly:
- If the user chooses `keep existing` for `SOUL.md`, do **not** add `--overwrite`.
- If the user chooses `overwrite with backup`, add `--overwrite`.
- If the user chooses `review first`, stop before execution and review the relevant files.
- If the user chooses `keep existing skills`, add `--skill-conflict skip`.
- If the user chooses `overwrite conflicting skills with backup`, add `--skill-conflict overwrite`.
- If the user chooses `import conflicting skills under renamed folders`, add `--skill-conflict rename`.
- If the user chooses `user-data only`, execute with `--preset user-data` and do **not** add `--migrate-secrets`.
- If the user chooses `full compatible migration`, execute with `--preset full --migrate-secrets`.
- Only add `--workspace-target` if the user explicitly provided an absolute workspace path.
- If the user chooses `skip workspace instructions` or `decide later`, do not add `--workspace-target`.
Before executing, restate the exact command plan in plain language and make sure it matches the user's choices.
## Post-run reporting rules
After execution, treat the script's JSON output as the source of truth.
1. Base all counts on `report.summary`.
2. Only list an item under "Successfully Migrated" if its `status` is exactly `migrated`.
3. Do not claim a conflict was resolved unless the report shows that item as `migrated`.
4. Do not say `SOUL.md` was overwritten unless the report item for `kind="soul"` has `status="migrated"`.
5. If `report.summary.conflict > 0`, include a conflict section instead of silently implying success.
6. If counts and listed items disagree, fix the list to match the report before responding.
7. Include the `output_dir` path from the report when available so the user can inspect `report.json`, `summary.md`, backups, and archived files.
8. For memory or user-profile overflow, do not say the entries were archived unless the report explicitly shows an archive path. If `details.overflow_file` exists, say the full overflow list was exported there.
9. If a skill was imported under a renamed folder, report the final destination and mention `details.renamed_from`.
10. If `report.skill_conflict_mode` is present, use it as the source of truth for the selected imported-skill conflict policy.
11. If an item has `status="skipped"`, do not describe it as overwritten, backed up, migrated, or resolved.
12. If `kind="soul"` has `status="skipped"` with reason `Target already matches source`, say it was left unchanged and do not mention a backup.
13. If a renamed imported skill has an empty `details.backup`, do not imply the existing Hermes skill was renamed or backed up. Say only that the imported copy was placed in the new destination and reference `details.renamed_from` as the pre-existing folder that remained in place.
## Migration presets
Prefer these two presets in normal use:
- `user-data`
- `full`
`user-data` includes:
- `soul`
- `workspace-agents`
- `memory`
- `user-profile`
- `messaging-settings`
- `command-allowlist`
- `skills`
- `tts-assets`
- `archive`
`full` includes everything in `user-data` plus:
- `secret-settings`
The helper script still supports category-level `--include` / `--exclude`, but treat that as an advanced fallback rather than the default UX.
## Commands
Dry run:
Dry run with full discovery:
```bash
python3 SKILL_DIR/scripts/openclaw_to_hermes.py --workspace-target "$PWD"
python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py
```
Execute:
When using the terminal tool, prefer an absolute invocation pattern such as:
```json
{"command":"python3 /home/USER/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py","workdir":"/home/USER"}
```
Dry run with the user-data preset:
```bash
python3 SKILL_DIR/scripts/openclaw_to_hermes.py --execute --workspace-target "$PWD"
python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --preset user-data
```
Execute with Hermes-compatible secret migration enabled:
Execute a user-data migration:
```bash
python3 SKILL_DIR/scripts/openclaw_to_hermes.py --execute --migrate-secrets --workspace-target "$PWD"
python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset user-data --skill-conflict skip
```
If the user does not want to import workspace instructions into the current directory, omit `--workspace-target`.
Execute a full compatible migration:
```bash
python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset full --migrate-secrets --skill-conflict skip
```
Execute with workspace instructions included:
```bash
python3 ~/.hermes/skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py --execute --preset user-data --skill-conflict rename --workspace-target "/absolute/workspace/path"
```
Do not use `$PWD` or the home directory as the workspace target by default. Ask for an explicit workspace path first.
## Important rules
@@ -72,6 +255,21 @@ If the user does not want to import workspace instructions into the current dire
4. Always give the user the skipped-items report. That report is part of the migration, not an optional extra.
5. Prefer the primary OpenClaw workspace (`~/.openclaw/workspace/`) over `workspace.default/`. Only use the default workspace as fallback when the primary files are missing.
6. Even in secret-migration mode, only migrate secrets with a clean Hermes destination. Unsupported auth blobs must still be reported as skipped.
7. If the dry run shows a large asset copy, a conflicting `SOUL.md`, or overflowed memory entries, call those out separately before execution.
8. Default to `user-data only` if the user is unsure.
9. Only include `workspace-agents` when the user has explicitly provided a destination workspace path.
10. Treat category-level `--include` / `--exclude` as an advanced escape hatch, not the normal flow.
11. Do not end the dry-run summary with a vague “What would you like to do?” if `clarify` is available. Use structured follow-up prompts instead.
12. Do not use an open-ended `clarify` prompt when a real choice prompt would work. Prefer selectable choices first, then free text only for absolute paths or file review requests.
13. After a dry run, never stop after summarizing if there is still an unresolved decision. Use `clarify` immediately for the highest-priority blocking decision.
14. Priority order for follow-up questions:
- `SOUL.md` conflict
- imported skill conflicts
- migration mode
- workspace instructions destination
15. Do not promise to present choices later in the same message. Present them by actually calling `clarify`.
16. After the migration-mode answer, explicitly check whether `workspace-agents` is still unresolved. If it is, your next action must be the workspace-instructions `clarify` call.
17. After any `clarify` answer, if another required decision remains, do not narrate what was just decided. Ask the next required question immediately.
## Expected result

View File

@@ -32,9 +32,67 @@ SKILL_CATEGORY_DIRNAME = "openclaw-imports"
SKILL_CATEGORY_DESCRIPTION = (
"Skills migrated from an OpenClaw workspace."
)
SKILL_CONFLICT_MODES = {"skip", "overwrite", "rename"}
SUPPORTED_SECRET_TARGETS = {
"TELEGRAM_BOT_TOKEN",
}
WORKSPACE_INSTRUCTIONS_FILENAME = "AGENTS" + ".md"
MIGRATION_OPTION_METADATA: Dict[str, Dict[str, str]] = {
"soul": {
"label": "SOUL.md",
"description": "Import the OpenClaw persona file into Hermes.",
},
"workspace-agents": {
"label": "Workspace instructions",
"description": "Copy the OpenClaw workspace instructions file into a chosen workspace.",
},
"memory": {
"label": "MEMORY.md",
"description": "Import long-term memory entries into Hermes memories.",
},
"user-profile": {
"label": "USER.md",
"description": "Import user profile entries into Hermes memories.",
},
"messaging-settings": {
"label": "Messaging settings",
"description": "Import Hermes-compatible messaging settings such as allowlists and working directory.",
},
"secret-settings": {
"label": "Allowlisted secrets",
"description": "Import the small allowlist of Hermes-compatible secrets when explicitly enabled.",
},
"command-allowlist": {
"label": "Command allowlist",
"description": "Merge OpenClaw exec approval patterns into Hermes command_allowlist.",
},
"skills": {
"label": "User skills",
"description": "Copy OpenClaw skills into ~/.hermes/skills/openclaw-imports/.",
},
"tts-assets": {
"label": "TTS assets",
"description": "Copy compatible workspace TTS assets into ~/.hermes/tts/.",
},
"archive": {
"label": "Archive unmapped docs",
"description": "Archive compatible-but-unmapped docs for later manual review.",
},
}
MIGRATION_PRESETS: Dict[str, set[str]] = {
"user-data": {
"soul",
"workspace-agents",
"memory",
"user-profile",
"messaging-settings",
"command-allowlist",
"skills",
"tts-assets",
"archive",
},
"full": set(MIGRATION_OPTION_METADATA),
}
@dataclass
@@ -47,6 +105,56 @@ class ItemResult:
details: Dict[str, Any] = field(default_factory=dict)
def parse_selection_values(values: Optional[Sequence[str]]) -> List[str]:
parsed: List[str] = []
for value in values or ():
for part in str(value).split(","):
part = part.strip().lower()
if part:
parsed.append(part)
return parsed
def resolve_selected_options(
include: Optional[Sequence[str]] = None,
exclude: Optional[Sequence[str]] = None,
preset: Optional[str] = None,
) -> set[str]:
include_values = parse_selection_values(include)
exclude_values = parse_selection_values(exclude)
valid = set(MIGRATION_OPTION_METADATA)
preset_name = (preset or "").strip().lower()
if preset_name and preset_name not in MIGRATION_PRESETS:
raise ValueError(
"Unknown migration preset: "
+ preset_name
+ ". Valid presets: "
+ ", ".join(sorted(MIGRATION_PRESETS))
)
unknown = (set(include_values) - {"all"} - valid) | (set(exclude_values) - {"all"} - valid)
if unknown:
raise ValueError(
"Unknown migration option(s): "
+ ", ".join(sorted(unknown))
+ ". Valid options: "
+ ", ".join(sorted(valid))
)
if preset_name:
selected = set(MIGRATION_PRESETS[preset_name])
elif not include_values or "all" in include_values:
selected = set(valid)
else:
selected = set(include_values)
if "all" in exclude_values:
selected.clear()
selected -= (set(exclude_values) - {"all"})
return selected
def sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as fh:
@@ -294,6 +402,9 @@ class Migrator:
overwrite: bool,
migrate_secrets: bool,
output_dir: Optional[Path],
selected_options: Optional[set[str]] = None,
preset_name: str = "",
skill_conflict_mode: str = "skip",
):
self.source_root = source_root
self.target_root = target_root
@@ -301,12 +412,16 @@ class Migrator:
self.workspace_target = workspace_target
self.overwrite = overwrite
self.migrate_secrets = migrate_secrets
self.selected_options = set(selected_options or MIGRATION_OPTION_METADATA.keys())
self.preset_name = preset_name.strip().lower()
self.skill_conflict_mode = skill_conflict_mode.strip().lower() or "skip"
self.timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
self.output_dir = output_dir or (
target_root / "migration" / "openclaw" / self.timestamp if execute else None
)
self.archive_dir = self.output_dir / "archive" if self.output_dir else None
self.backup_dir = self.output_dir / "backups" if self.output_dir else None
self.overflow_dir = self.output_dir / "overflow" if self.output_dir else None
self.items: List[ItemResult] = []
config = load_yaml_file(self.target_root / "config.yaml")
@@ -314,6 +429,17 @@ class Migrator:
self.memory_limit = int(mem_cfg.get("memory_char_limit", DEFAULT_MEMORY_CHAR_LIMIT))
self.user_limit = int(mem_cfg.get("user_char_limit", DEFAULT_USER_CHAR_LIMIT))
if self.skill_conflict_mode not in SKILL_CONFLICT_MODES:
raise ValueError(
"Unknown skill conflict mode: "
+ self.skill_conflict_mode
+ ". Valid modes: "
+ ", ".join(sorted(SKILL_CONFLICT_MODES))
)
def is_selected(self, option_id: str) -> bool:
return option_id in self.selected_options
def record(
self,
kind: str,
@@ -341,37 +467,68 @@ class Migrator:
return candidate
return None
def resolve_skill_destination(self, destination: Path) -> Path:
if self.skill_conflict_mode != "rename" or not destination.exists():
return destination
suffix = "-imported"
candidate = destination.with_name(destination.name + suffix)
counter = 2
while candidate.exists():
candidate = destination.with_name(f"{destination.name}{suffix}-{counter}")
counter += 1
return candidate
def migrate(self) -> Dict[str, Any]:
if not self.source_root.exists():
self.record("source", self.source_root, None, "error", "OpenClaw directory does not exist")
return self.build_report()
self.migrate_soul()
self.migrate_workspace_agents()
self.migrate_memory(
self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"),
self.target_root / "memories" / "MEMORY.md",
self.memory_limit,
kind="memory",
config = self.load_openclaw_config()
self.run_if_selected("soul", self.migrate_soul)
self.run_if_selected("workspace-agents", self.migrate_workspace_agents)
self.run_if_selected(
"memory",
lambda: self.migrate_memory(
self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"),
self.target_root / "memories" / "MEMORY.md",
self.memory_limit,
kind="memory",
),
)
self.migrate_memory(
self.source_candidate("workspace/USER.md", "workspace.default/USER.md"),
self.target_root / "memories" / "USER.md",
self.user_limit,
kind="user-profile",
self.run_if_selected(
"user-profile",
lambda: self.migrate_memory(
self.source_candidate("workspace/USER.md", "workspace.default/USER.md"),
self.target_root / "memories" / "USER.md",
self.user_limit,
kind="user-profile",
),
)
self.migrate_messaging_settings()
self.migrate_command_allowlist()
self.migrate_skills()
self.copy_tree_non_destructive(
self.source_candidate("workspace/tts"),
self.target_root / "tts",
kind="tts-assets",
ignore_dir_names={".venv", "generated", "__pycache__"},
self.run_if_selected("messaging-settings", lambda: self.migrate_messaging_settings(config))
self.run_if_selected("secret-settings", lambda: self.handle_secret_settings(config))
self.run_if_selected("command-allowlist", self.migrate_command_allowlist)
self.run_if_selected("skills", self.migrate_skills)
self.run_if_selected(
"tts-assets",
lambda: self.copy_tree_non_destructive(
self.source_candidate("workspace/tts"),
self.target_root / "tts",
kind="tts-assets",
ignore_dir_names={".venv", "generated", "__pycache__"},
),
)
self.archive_docs()
self.run_if_selected("archive", self.archive_docs)
return self.build_report()
def run_if_selected(self, option_id: str, func) -> None:
if self.is_selected(option_id):
func()
return
meta = MIGRATION_OPTION_METADATA[option_id]
self.record(option_id, None, None, "skipped", "Not selected for this run", option_label=meta["label"])
def build_report(self) -> Dict[str, Any]:
summary: Dict[str, int] = {
"migrated": 0,
@@ -391,6 +548,21 @@ class Migrator:
"workspace_target": str(self.workspace_target) if self.workspace_target else None,
"output_dir": str(self.output_dir) if self.output_dir else None,
"migrate_secrets": self.migrate_secrets,
"preset": self.preset_name or None,
"skill_conflict_mode": self.skill_conflict_mode,
"selection": {
"selected": sorted(self.selected_options),
"preset": self.preset_name or None,
"skill_conflict_mode": self.skill_conflict_mode,
"available": [
{"id": option_id, **meta}
for option_id, meta in MIGRATION_OPTION_METADATA.items()
],
"presets": [
{"id": preset_id, "selected": sorted(option_ids)}
for preset_id, option_ids in MIGRATION_PRESETS.items()
],
},
"summary": summary,
"items": [asdict(item) for item in self.items],
}
@@ -405,6 +577,15 @@ class Migrator:
return None
return backup_existing(path, self.backup_dir)
def write_overflow_entries(self, kind: str, entries: Sequence[str]) -> Optional[Path]:
if not entries or not self.overflow_dir:
return None
self.overflow_dir.mkdir(parents=True, exist_ok=True)
filename = f"{kind.replace('-', '_')}_overflow.txt"
path = self.overflow_dir / filename
path.write_text("\n".join(entries) + "\n", encoding="utf-8")
return path
def copy_file(self, source: Path, destination: Path, kind: str) -> None:
if not source or not source.exists():
return
@@ -433,13 +614,16 @@ class Migrator:
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
def migrate_workspace_agents(self) -> None:
source = self.source_candidate("workspace/AGENTS.md", "workspace.default/AGENTS.md")
source = self.source_candidate(
f"workspace/{WORKSPACE_INSTRUCTIONS_FILENAME}",
f"workspace.default/{WORKSPACE_INSTRUCTIONS_FILENAME}",
)
if not source:
return
if not self.workspace_target:
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
return
destination = self.workspace_target / "AGENTS.md"
destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME
self.copy_file(source, destination, kind="workspace-agents")
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
@@ -462,6 +646,9 @@ class Migrator:
"char_limit": limit,
"final_char_count": len(ENTRY_DELIMITER.join(merged)) if merged else 0,
}
overflow_file = self.write_overflow_entries(kind, overflowed)
if overflow_file is not None:
details["overflow_file"] = str(overflow_file)
if self.execute:
if stats["added"] == 0 and not overflowed:
@@ -597,10 +784,9 @@ class Migrator:
conflicting_keys=conflicts,
)
def migrate_messaging_settings(self) -> None:
config = self.load_openclaw_config()
def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
additions: Dict[str, str] = {}
sources: List[str] = []
workspace = (
config.get("agents", {})
@@ -609,7 +795,6 @@ class Migrator:
)
if isinstance(workspace, str) and workspace.strip():
additions["MESSAGING_CWD"] = workspace.strip()
sources.append("openclaw.json:agents.defaults.workspace")
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
if allowlist_path.exists():
@@ -623,30 +808,40 @@ class Migrator:
users = [str(user).strip() for user in allow_from if str(user).strip()]
if users:
additions["TELEGRAM_ALLOWED_USERS"] = ",".join(users)
sources.append("credentials/telegram-default-allowFrom.json")
if additions:
self.merge_env_values(additions, "messaging-settings", self.source_root / "openclaw.json")
else:
self.record("messaging-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Hermes-compatible messaging settings found")
def handle_secret_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
config = config or self.load_openclaw_config()
if self.migrate_secrets:
self.migrate_secret_settings(config)
return
config_path = self.source_root / "openclaw.json"
if config_path.exists():
self.record(
"secret-settings",
config_path,
self.target_root / ".env",
"skipped",
"Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.",
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
)
else:
config_path = self.source_root / "openclaw.json"
if config_path.exists():
self.record(
"secret-settings",
config_path,
self.target_root / ".env",
"skipped",
"Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.",
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
)
self.record(
"secret-settings",
config_path,
self.target_root / ".env",
"skipped",
"OpenClaw config file not found",
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
)
def migrate_secret_settings(self, config: Dict[str, Any]) -> None:
secret_additions: Dict[str, str] = {}
sources: List[str] = []
telegram_token = (
config.get("channels", {})
@@ -655,7 +850,6 @@ class Migrator:
)
if isinstance(telegram_token, str) and telegram_token.strip():
secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip()
sources.append("openclaw.json:channels.telegram.botToken")
if secret_additions:
self.merge_env_values(secret_additions, "secret-settings", self.source_root / "openclaw.json")
@@ -683,18 +877,37 @@ class Migrator:
for skill_dir in skill_dirs:
destination = destination_root / skill_dir.name
if destination.exists() and not self.overwrite:
self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists")
continue
final_destination = destination
if destination.exists():
if self.skill_conflict_mode == "skip":
self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists")
continue
if self.skill_conflict_mode == "rename":
final_destination = self.resolve_skill_destination(destination)
if self.execute:
backup_path = self.maybe_backup(destination)
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
backup_path = None
if final_destination == destination and destination.exists():
backup_path = self.maybe_backup(destination)
final_destination.parent.mkdir(parents=True, exist_ok=True)
if final_destination == destination and destination.exists():
shutil.rmtree(destination)
shutil.copytree(skill_dir, destination)
self.record("skill", skill_dir, destination, "migrated", backup=str(backup_path) if backup_path else "")
shutil.copytree(skill_dir, final_destination)
details: Dict[str, Any] = {"backup": str(backup_path) if backup_path else ""}
if final_destination != destination:
details["renamed_from"] = str(destination)
self.record("skill", skill_dir, final_destination, "migrated", **details)
else:
self.record("skill", skill_dir, destination, "migrated", "Would copy skill directory")
if final_destination != destination:
self.record(
"skill",
skill_dir,
final_destination,
"migrated",
"Would copy skill directory under a renamed folder",
renamed_from=str(destination),
)
else:
self.record("skill", skill_dir, final_destination, "migrated", "Would copy skill directory")
desc_path = destination_root / "DESCRIPTION.md"
if self.execute:
@@ -810,16 +1023,53 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.")
parser.add_argument("--source", default=str(Path.home() / ".openclaw"), help="OpenClaw home directory")
parser.add_argument("--target", default=str(Path.home() / ".hermes"), help="Hermes home directory")
parser.add_argument("--workspace-target", help="Optional workspace root where AGENTS.md should be copied")
parser.add_argument(
"--workspace-target",
help="Optional workspace root where the workspace instructions file should be copied",
)
parser.add_argument("--execute", action="store_true", help="Apply changes instead of reporting a dry run")
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing Hermes targets after backing them up")
parser.add_argument("--migrate-secrets", action="store_true", help="Import a narrow allowlist of Hermes-compatible secrets into ~/.hermes/.env")
parser.add_argument(
"--migrate-secrets",
action="store_true",
help="Import a narrow allowlist of Hermes-compatible secrets into the target env file",
)
parser.add_argument(
"--skill-conflict",
choices=sorted(SKILL_CONFLICT_MODES),
default="skip",
help="How to handle imported skill directory conflicts: skip, overwrite, or rename the imported copy.",
)
parser.add_argument(
"--preset",
choices=sorted(MIGRATION_PRESETS),
help="Apply a named migration preset. 'user-data' excludes allowlisted secrets; 'full' includes all compatible groups.",
)
parser.add_argument(
"--include",
action="append",
default=[],
help="Comma-separated migration option ids to include (default: all). "
f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}",
)
parser.add_argument(
"--exclude",
action="append",
default=[],
help="Comma-separated migration option ids to skip. "
f"Valid ids: {', '.join(sorted(MIGRATION_OPTION_METADATA))}",
)
parser.add_argument("--output-dir", help="Where to write report, backups, and archived docs")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
selected_options = resolve_selected_options(args.include, args.exclude, preset=args.preset)
except ValueError as exc:
print(json.dumps({"error": str(exc)}, indent=2, ensure_ascii=False))
return 2
migrator = Migrator(
source_root=Path(os.path.expanduser(args.source)).resolve(),
target_root=Path(os.path.expanduser(args.target)).resolve(),
@@ -828,6 +1078,9 @@ def main() -> int:
overwrite=bool(args.overwrite),
migrate_secrets=bool(args.migrate_secrets),
output_dir=Path(os.path.expanduser(args.output_dir)).resolve() if args.output_dir else None,
selected_options=selected_options,
preset_name=args.preset or "",
skill_conflict_mode=args.skill_conflict,
)
report = migrator.migrate()
print(json.dumps(report, indent=2, ensure_ascii=False))

View File

@@ -25,6 +25,18 @@ def load_module():
return module
def load_skills_guard():
spec = importlib.util.spec_from_file_location(
"skills_guard_local",
Path(__file__).resolve().parents[2] / "tools" / "skills_guard.py",
)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_extract_markdown_entries_promotes_heading_context():
mod = load_module()
text = """# MEMORY.md - Long-Term Memory
@@ -55,10 +67,46 @@ def test_merge_entries_respects_limit_and_reports_overflow():
assert overflowed == ["gamma is too long"]
def test_resolve_selected_options_supports_include_and_exclude():
mod = load_module()
selected = mod.resolve_selected_options(["memory,skills", "user-profile"], ["skills"])
assert selected == {"memory", "user-profile"}
def test_resolve_selected_options_supports_presets():
mod = load_module()
user_data = mod.resolve_selected_options(preset="user-data")
full = mod.resolve_selected_options(preset="full")
assert "secret-settings" not in user_data
assert "secret-settings" in full
assert user_data < full
def test_resolve_selected_options_rejects_unknown_values():
mod = load_module()
try:
mod.resolve_selected_options(["memory,unknown-option"], None)
except ValueError as exc:
assert "unknown-option" in str(exc)
else:
raise AssertionError("Expected ValueError for unknown migration option")
def test_resolve_selected_options_rejects_unknown_preset():
mod = load_module()
try:
mod.resolve_selected_options(preset="everything")
except ValueError as exc:
assert "everything" in str(exc)
else:
raise AssertionError("Expected ValueError for unknown migration preset")
def test_migrator_copies_skill_and_merges_allowlist(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
@@ -135,3 +183,184 @@ def test_migrator_optionally_imports_supported_secrets_and_messaging_settings(tm
assert "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text
assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text
assert "TELEGRAM_BOT_TOKEN=123:abc" in env_text
def test_migrator_can_execute_only_selected_categories(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
encoding="utf-8",
)
(source / "workspace" / "MEMORY.md").write_text(
"# Memory\n\n- keep me\n",
encoding="utf-8",
)
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"skills"},
)
report = migrator.migrate()
imported_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill" / "SKILL.md"
assert imported_skill.exists()
assert not (target / "memories" / "MEMORY.md").exists()
assert report["selection"]["selected"] == ["skills"]
skipped_items = [item for item in report["items"] if item["status"] == "skipped"]
assert any(item["kind"] == "memory" and item["reason"] == "Not selected for this run" for item in skipped_items)
def test_migrator_records_preset_in_report(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(target / "config.yaml").write_text("command_allowlist: []\n", encoding="utf-8")
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=False,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=None,
selected_options=mod.MIGRATION_PRESETS["user-data"],
preset_name="user-data",
)
report = migrator.build_report()
assert report["preset"] == "user-data"
assert report["selection"]["preset"] == "user-data"
assert report["skill_conflict_mode"] == "skip"
assert report["selection"]["skill_conflict_mode"] == "skip"
def test_migrator_exports_full_overflow_entries(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
(target / "config.yaml").write_text("memory:\n memory_char_limit: 10\n user_char_limit: 10\n", encoding="utf-8")
(source / "workspace").mkdir(parents=True)
(source / "workspace" / "MEMORY.md").write_text(
"# Memory\n\n- alpha\n- beta\n- gamma\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
selected_options={"memory"},
)
report = migrator.migrate()
memory_item = next(item for item in report["items"] if item["kind"] == "memory")
overflow_file = Path(memory_item["details"]["overflow_file"])
assert overflow_file.exists()
text = overflow_file.read_text(encoding="utf-8")
assert "alpha" in text or "beta" in text or "gamma" in text
def test_migrator_can_rename_conflicting_imported_skill(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source_skill = source / "workspace" / "skills" / "demo-skill"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: demo\n---\n\nbody\n",
encoding="utf-8",
)
existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill"
existing_skill.mkdir(parents=True)
(existing_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
skill_conflict_mode="rename",
)
report = migrator.migrate()
renamed_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill-imported" / "SKILL.md"
assert renamed_skill.exists()
assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("existing\n")
imported_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"]
assert any(item["details"].get("renamed_from", "").endswith("/demo-skill") for item in imported_items)
def test_migrator_can_overwrite_conflicting_imported_skill_with_backup(tmp_path: Path):
mod = load_module()
source = tmp_path / ".openclaw"
target = tmp_path / ".hermes"
target.mkdir()
source_skill = source / "workspace" / "skills" / "demo-skill"
source_skill.mkdir(parents=True)
(source_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: imported\n---\n\nfresh\n",
encoding="utf-8",
)
existing_skill = target / "skills" / mod.SKILL_CATEGORY_DIRNAME / "demo-skill"
existing_skill.mkdir(parents=True)
(existing_skill / "SKILL.md").write_text(
"---\nname: demo-skill\ndescription: existing\n---\n\nexisting\n",
encoding="utf-8",
)
migrator = mod.Migrator(
source_root=source,
target_root=target,
execute=True,
workspace_target=None,
overwrite=False,
migrate_secrets=False,
output_dir=target / "migration-report",
skill_conflict_mode="overwrite",
)
report = migrator.migrate()
assert existing_skill.joinpath("SKILL.md").read_text(encoding="utf-8").endswith("fresh\n")
backup_items = [item for item in report["items"] if item["kind"] == "skill" and item["status"] == "migrated"]
assert any(item["details"].get("backup") for item in backup_items)
def test_skill_installs_cleanly_under_skills_guard():
skills_guard = load_skills_guard()
result = skills_guard.scan_skill(
SCRIPT_PATH.parents[1],
source="official/migration/openclaw-migration",
)
assert result.verdict == "safe"
assert result.findings == []