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:
parent
1755a9e38a
commit
53b4b7651a
4 changed files with 830 additions and 99 deletions
123
cli.py
123
cli.py
|
|
@ -19,6 +19,7 @@ import sys
|
||||||
import json
|
import json
|
||||||
import atexit
|
import atexit
|
||||||
import uuid
|
import uuid
|
||||||
|
import textwrap
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
@ -2767,6 +2768,8 @@ class HermesCLI:
|
||||||
return "type password (hidden), Enter to skip"
|
return "type password (hidden), Enter to skip"
|
||||||
if cli_ref._approval_state:
|
if cli_ref._approval_state:
|
||||||
return ""
|
return ""
|
||||||
|
if cli_ref._clarify_freetext:
|
||||||
|
return "type your answer here and press Enter"
|
||||||
if cli_ref._clarify_state:
|
if cli_ref._clarify_state:
|
||||||
return ""
|
return ""
|
||||||
if cli_ref._agent_running:
|
if cli_ref._agent_running:
|
||||||
|
|
@ -2824,6 +2827,32 @@ class HermesCLI:
|
||||||
|
|
||||||
# --- Clarify tool: dynamic display widget for questions + choices ---
|
# --- 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():
|
def _get_clarify_display():
|
||||||
"""Build styled text for the clarify question/choices panel."""
|
"""Build styled text for the clarify question/choices panel."""
|
||||||
state = cli_ref._clarify_state
|
state = cli_ref._clarify_state
|
||||||
|
|
@ -2833,43 +2862,62 @@ class HermesCLI:
|
||||||
question = state["question"]
|
question = state["question"]
|
||||||
choices = state.get("choices") or []
|
choices = state.get("choices") or []
|
||||||
selected = state.get("selected", 0)
|
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 = []
|
lines = []
|
||||||
# Box top border
|
# Box top border
|
||||||
lines.append(('class:clarify-border', '╭─ '))
|
lines.append(('class:clarify-border', '╭─ '))
|
||||||
lines.append(('class:clarify-title', 'Hermes needs your input'))
|
lines.append(('class:clarify-title', 'Hermes needs your input'))
|
||||||
lines.append(('class:clarify-border', ' ─────────────────────────────╮\n'))
|
lines.append(('class:clarify-border', ' ' + ('─' * max(0, box_width - len("Hermes needs your input") - 3)) + '╮\n'))
|
||||||
lines.append(('class:clarify-border', '│\n'))
|
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||||
|
|
||||||
# Question text
|
# Question text
|
||||||
lines.append(('class:clarify-border', '│ '))
|
for wrapped in _wrap_panel_text(question, inner_text_width):
|
||||||
lines.append(('class:clarify-question', question))
|
_append_panel_line(lines, 'class:clarify-border', 'class:clarify-question', wrapped, box_width)
|
||||||
lines.append(('', '\n'))
|
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||||
lines.append(('class:clarify-border', '│\n'))
|
|
||||||
|
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:
|
if choices:
|
||||||
# Multiple-choice mode: show selectable options
|
# Multiple-choice mode: show selectable options
|
||||||
for i, choice in enumerate(choices):
|
for i, choice in enumerate(choices):
|
||||||
lines.append(('class:clarify-border', '│ '))
|
style = 'class:clarify-selected' if i == selected and not cli_ref._clarify_freetext else 'class:clarify-choice'
|
||||||
if i == selected and not cli_ref._clarify_freetext:
|
prefix = '❯ ' if i == selected and not cli_ref._clarify_freetext else ' '
|
||||||
lines.append(('class:clarify-selected', f'❯ {choice}'))
|
wrapped_lines = _wrap_panel_text(f"{prefix}{choice}", inner_text_width, subsequent_indent=" ")
|
||||||
else:
|
for wrapped in wrapped_lines:
|
||||||
lines.append(('class:clarify-choice', f' {choice}'))
|
_append_panel_line(lines, 'class:clarify-border', style, wrapped, box_width)
|
||||||
lines.append(('', '\n'))
|
|
||||||
|
|
||||||
# "Other" option (5th line, only shown when choices exist)
|
# "Other" option (5th line, only shown when choices exist)
|
||||||
other_idx = len(choices)
|
other_idx = len(choices)
|
||||||
lines.append(('class:clarify-border', '│ '))
|
|
||||||
if selected == other_idx and not cli_ref._clarify_freetext:
|
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:
|
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:
|
else:
|
||||||
lines.append(('class:clarify-choice', ' Other (type your answer)'))
|
other_style = 'class:clarify-choice'
|
||||||
lines.append(('', '\n'))
|
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'))
|
_append_blank_panel_line(lines, 'class:clarify-border', box_width)
|
||||||
lines.append(('class:clarify-border', '╰──────────────────────────────────────────────────╯\n'))
|
lines.append(('class:clarify-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
clarify_widget = ConditionalContainer(
|
clarify_widget = ConditionalContainer(
|
||||||
|
|
@ -2924,29 +2972,32 @@ class HermesCLI:
|
||||||
"always": "Add to permanent allowlist",
|
"always": "Add to permanent allowlist",
|
||||||
"deny": "Deny",
|
"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 = []
|
||||||
lines.append(('class:approval-border', '╭─ '))
|
lines.append(('class:approval-border', '╭─ '))
|
||||||
lines.append(('class:approval-title', '⚠️ Dangerous Command'))
|
lines.append(('class:approval-title', '⚠️ Dangerous Command'))
|
||||||
lines.append(('class:approval-border', ' ───────────────────────────────╮\n'))
|
lines.append(('class:approval-border', ' ' + ('─' * max(0, box_width - len("⚠️ Dangerous Command") - 3)) + '╮\n'))
|
||||||
lines.append(('class:approval-border', '│\n'))
|
_append_blank_panel_line(lines, 'class:approval-border', box_width)
|
||||||
lines.append(('class:approval-border', '│ '))
|
for wrapped in _wrap_panel_text(description, inner_text_width):
|
||||||
lines.append(('class:approval-desc', description))
|
_append_panel_line(lines, 'class:approval-border', 'class:approval-desc', wrapped, box_width)
|
||||||
lines.append(('', '\n'))
|
for wrapped in _wrap_panel_text(cmd_display, inner_text_width):
|
||||||
lines.append(('class:approval-border', '│ '))
|
_append_panel_line(lines, 'class:approval-border', 'class:approval-cmd', wrapped, box_width)
|
||||||
lines.append(('class:approval-cmd', cmd_display))
|
_append_blank_panel_line(lines, 'class:approval-border', box_width)
|
||||||
lines.append(('', '\n'))
|
|
||||||
lines.append(('class:approval-border', '│\n'))
|
|
||||||
for i, choice in enumerate(choices):
|
for i, choice in enumerate(choices):
|
||||||
lines.append(('class:approval-border', '│ '))
|
|
||||||
label = choice_labels.get(choice, choice)
|
label = choice_labels.get(choice, choice)
|
||||||
if i == selected:
|
style = 'class:approval-selected' if i == selected else 'class:approval-choice'
|
||||||
lines.append(('class:approval-selected', f'❯ {label}'))
|
prefix = '❯ ' if i == selected else ' '
|
||||||
else:
|
for wrapped in _wrap_panel_text(f"{prefix}{label}", inner_text_width, subsequent_indent=" "):
|
||||||
lines.append(('class:approval-choice', f' {label}'))
|
_append_panel_line(lines, 'class:approval-border', style, wrapped, box_width)
|
||||||
lines.append(('', '\n'))
|
_append_blank_panel_line(lines, 'class:approval-border', box_width)
|
||||||
lines.append(('class:approval-border', '│\n'))
|
lines.append(('class:approval-border', '╰' + ('─' * box_width) + '╯\n'))
|
||||||
lines.append(('class:approval-border', '╰──────────────────────────────────────────────────────╯\n'))
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
approval_widget = ConditionalContainer(
|
approval_widget = ConditionalContainer(
|
||||||
|
|
|
||||||
|
|
@ -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:
|
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
|
- transform OpenClaw `MEMORY.md` and `USER.md` into Hermes memory entries
|
||||||
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
- merge OpenClaw command approval patterns into Hermes `command_allowlist`
|
||||||
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
- migrate Hermes-compatible messaging settings such as `TELEGRAM_ALLOWED_USERS` and `MESSAGING_CWD`
|
||||||
- copy OpenClaw skills into `~/.hermes/skills/openclaw-imports/`
|
- 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/`
|
- mirror compatible workspace assets such as `workspace/tts/` into `~/.hermes/tts/`
|
||||||
- archive non-secret docs that do not have a direct Hermes destination
|
- archive non-secret docs that do not have a direct Hermes destination
|
||||||
- produce a structured report listing migrated items, conflicts, skipped items, and reasons
|
- 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:
|
With `--migrate-secrets`, it will also import a small allowlisted set of Hermes-compatible secrets, currently:
|
||||||
|
|
||||||
- `TELEGRAM_BOT_TOKEN`
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
|
@ -35,34 +54,198 @@ With `--migrate-secrets`, it will also import a small allowlisted set of Hermes-
|
||||||
## Default workflow
|
## Default workflow
|
||||||
|
|
||||||
1. Inspect first with a dry run.
|
1. Inspect first with a dry run.
|
||||||
2. Ask for a target workspace path if `AGENTS.md` should be brought over.
|
2. Present a simple summary of what can be migrated, what cannot be migrated, and what would be archived.
|
||||||
3. Execute the migration.
|
3. If the `clarify` tool is available, use it for user decisions instead of asking for a free-form prose reply.
|
||||||
4. Summarize the results, especially:
|
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 migrated
|
||||||
- what was archived for manual review
|
- what was archived for manual review
|
||||||
- what was skipped and why
|
- 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
|
## Commands
|
||||||
|
|
||||||
Dry run:
|
Dry run with full discovery:
|
||||||
|
|
||||||
```bash
|
```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
|
```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
|
```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
|
## 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.
|
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.
|
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.
|
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
|
## Expected result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,67 @@ SKILL_CATEGORY_DIRNAME = "openclaw-imports"
|
||||||
SKILL_CATEGORY_DESCRIPTION = (
|
SKILL_CATEGORY_DESCRIPTION = (
|
||||||
"Skills migrated from an OpenClaw workspace."
|
"Skills migrated from an OpenClaw workspace."
|
||||||
)
|
)
|
||||||
|
SKILL_CONFLICT_MODES = {"skip", "overwrite", "rename"}
|
||||||
SUPPORTED_SECRET_TARGETS = {
|
SUPPORTED_SECRET_TARGETS = {
|
||||||
"TELEGRAM_BOT_TOKEN",
|
"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
|
@dataclass
|
||||||
|
|
@ -47,6 +105,56 @@ class ItemResult:
|
||||||
details: Dict[str, Any] = field(default_factory=dict)
|
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:
|
def sha256_file(path: Path) -> str:
|
||||||
h = hashlib.sha256()
|
h = hashlib.sha256()
|
||||||
with path.open("rb") as fh:
|
with path.open("rb") as fh:
|
||||||
|
|
@ -294,6 +402,9 @@ class Migrator:
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
migrate_secrets: bool,
|
migrate_secrets: bool,
|
||||||
output_dir: Optional[Path],
|
output_dir: Optional[Path],
|
||||||
|
selected_options: Optional[set[str]] = None,
|
||||||
|
preset_name: str = "",
|
||||||
|
skill_conflict_mode: str = "skip",
|
||||||
):
|
):
|
||||||
self.source_root = source_root
|
self.source_root = source_root
|
||||||
self.target_root = target_root
|
self.target_root = target_root
|
||||||
|
|
@ -301,12 +412,16 @@ class Migrator:
|
||||||
self.workspace_target = workspace_target
|
self.workspace_target = workspace_target
|
||||||
self.overwrite = overwrite
|
self.overwrite = overwrite
|
||||||
self.migrate_secrets = migrate_secrets
|
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.timestamp = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||||
self.output_dir = output_dir or (
|
self.output_dir = output_dir or (
|
||||||
target_root / "migration" / "openclaw" / self.timestamp if execute else None
|
target_root / "migration" / "openclaw" / self.timestamp if execute else None
|
||||||
)
|
)
|
||||||
self.archive_dir = self.output_dir / "archive" if self.output_dir 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.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] = []
|
self.items: List[ItemResult] = []
|
||||||
|
|
||||||
config = load_yaml_file(self.target_root / "config.yaml")
|
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.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))
|
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(
|
def record(
|
||||||
self,
|
self,
|
||||||
kind: str,
|
kind: str,
|
||||||
|
|
@ -341,37 +467,68 @@ class Migrator:
|
||||||
return candidate
|
return candidate
|
||||||
return None
|
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]:
|
def migrate(self) -> Dict[str, Any]:
|
||||||
if not self.source_root.exists():
|
if not self.source_root.exists():
|
||||||
self.record("source", self.source_root, None, "error", "OpenClaw directory does not exist")
|
self.record("source", self.source_root, None, "error", "OpenClaw directory does not exist")
|
||||||
return self.build_report()
|
return self.build_report()
|
||||||
|
|
||||||
self.migrate_soul()
|
config = self.load_openclaw_config()
|
||||||
self.migrate_workspace_agents()
|
|
||||||
self.migrate_memory(
|
self.run_if_selected("soul", self.migrate_soul)
|
||||||
self.source_candidate("workspace/MEMORY.md", "workspace.default/MEMORY.md"),
|
self.run_if_selected("workspace-agents", self.migrate_workspace_agents)
|
||||||
self.target_root / "memories" / "MEMORY.md",
|
self.run_if_selected(
|
||||||
self.memory_limit,
|
"memory",
|
||||||
kind="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.run_if_selected(
|
||||||
self.source_candidate("workspace/USER.md", "workspace.default/USER.md"),
|
"user-profile",
|
||||||
self.target_root / "memories" / "USER.md",
|
lambda: self.migrate_memory(
|
||||||
self.user_limit,
|
self.source_candidate("workspace/USER.md", "workspace.default/USER.md"),
|
||||||
kind="user-profile",
|
self.target_root / "memories" / "USER.md",
|
||||||
|
self.user_limit,
|
||||||
|
kind="user-profile",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
self.migrate_messaging_settings()
|
self.run_if_selected("messaging-settings", lambda: self.migrate_messaging_settings(config))
|
||||||
self.migrate_command_allowlist()
|
self.run_if_selected("secret-settings", lambda: self.handle_secret_settings(config))
|
||||||
self.migrate_skills()
|
self.run_if_selected("command-allowlist", self.migrate_command_allowlist)
|
||||||
self.copy_tree_non_destructive(
|
self.run_if_selected("skills", self.migrate_skills)
|
||||||
self.source_candidate("workspace/tts"),
|
self.run_if_selected(
|
||||||
self.target_root / "tts",
|
"tts-assets",
|
||||||
kind="tts-assets",
|
lambda: self.copy_tree_non_destructive(
|
||||||
ignore_dir_names={".venv", "generated", "__pycache__"},
|
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()
|
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]:
|
def build_report(self) -> Dict[str, Any]:
|
||||||
summary: Dict[str, int] = {
|
summary: Dict[str, int] = {
|
||||||
"migrated": 0,
|
"migrated": 0,
|
||||||
|
|
@ -391,6 +548,21 @@ class Migrator:
|
||||||
"workspace_target": str(self.workspace_target) if self.workspace_target else None,
|
"workspace_target": str(self.workspace_target) if self.workspace_target else None,
|
||||||
"output_dir": str(self.output_dir) if self.output_dir else None,
|
"output_dir": str(self.output_dir) if self.output_dir else None,
|
||||||
"migrate_secrets": self.migrate_secrets,
|
"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,
|
"summary": summary,
|
||||||
"items": [asdict(item) for item in self.items],
|
"items": [asdict(item) for item in self.items],
|
||||||
}
|
}
|
||||||
|
|
@ -405,6 +577,15 @@ class Migrator:
|
||||||
return None
|
return None
|
||||||
return backup_existing(path, self.backup_dir)
|
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:
|
def copy_file(self, source: Path, destination: Path, kind: str) -> None:
|
||||||
if not source or not source.exists():
|
if not source or not source.exists():
|
||||||
return
|
return
|
||||||
|
|
@ -433,13 +614,16 @@ class Migrator:
|
||||||
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
|
self.copy_file(source, self.target_root / "SOUL.md", kind="soul")
|
||||||
|
|
||||||
def migrate_workspace_agents(self) -> None:
|
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:
|
if not source:
|
||||||
return
|
return
|
||||||
if not self.workspace_target:
|
if not self.workspace_target:
|
||||||
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
|
self.record("workspace-agents", source, None, "skipped", "No workspace target was provided")
|
||||||
return
|
return
|
||||||
destination = self.workspace_target / "AGENTS.md"
|
destination = self.workspace_target / WORKSPACE_INSTRUCTIONS_FILENAME
|
||||||
self.copy_file(source, destination, kind="workspace-agents")
|
self.copy_file(source, destination, kind="workspace-agents")
|
||||||
|
|
||||||
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
|
def migrate_memory(self, source: Optional[Path], destination: Path, limit: int, kind: str) -> None:
|
||||||
|
|
@ -462,6 +646,9 @@ class Migrator:
|
||||||
"char_limit": limit,
|
"char_limit": limit,
|
||||||
"final_char_count": len(ENTRY_DELIMITER.join(merged)) if merged else 0,
|
"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 self.execute:
|
||||||
if stats["added"] == 0 and not overflowed:
|
if stats["added"] == 0 and not overflowed:
|
||||||
|
|
@ -597,10 +784,9 @@ class Migrator:
|
||||||
conflicting_keys=conflicts,
|
conflicting_keys=conflicts,
|
||||||
)
|
)
|
||||||
|
|
||||||
def migrate_messaging_settings(self) -> None:
|
def migrate_messaging_settings(self, config: Optional[Dict[str, Any]] = None) -> None:
|
||||||
config = self.load_openclaw_config()
|
config = config or self.load_openclaw_config()
|
||||||
additions: Dict[str, str] = {}
|
additions: Dict[str, str] = {}
|
||||||
sources: List[str] = []
|
|
||||||
|
|
||||||
workspace = (
|
workspace = (
|
||||||
config.get("agents", {})
|
config.get("agents", {})
|
||||||
|
|
@ -609,7 +795,6 @@ class Migrator:
|
||||||
)
|
)
|
||||||
if isinstance(workspace, str) and workspace.strip():
|
if isinstance(workspace, str) and workspace.strip():
|
||||||
additions["MESSAGING_CWD"] = workspace.strip()
|
additions["MESSAGING_CWD"] = workspace.strip()
|
||||||
sources.append("openclaw.json:agents.defaults.workspace")
|
|
||||||
|
|
||||||
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
|
allowlist_path = self.source_root / "credentials" / "telegram-default-allowFrom.json"
|
||||||
if allowlist_path.exists():
|
if allowlist_path.exists():
|
||||||
|
|
@ -623,30 +808,40 @@ class Migrator:
|
||||||
users = [str(user).strip() for user in allow_from if str(user).strip()]
|
users = [str(user).strip() for user in allow_from if str(user).strip()]
|
||||||
if users:
|
if users:
|
||||||
additions["TELEGRAM_ALLOWED_USERS"] = ",".join(users)
|
additions["TELEGRAM_ALLOWED_USERS"] = ",".join(users)
|
||||||
sources.append("credentials/telegram-default-allowFrom.json")
|
|
||||||
|
|
||||||
if additions:
|
if additions:
|
||||||
self.merge_env_values(additions, "messaging-settings", self.source_root / "openclaw.json")
|
self.merge_env_values(additions, "messaging-settings", self.source_root / "openclaw.json")
|
||||||
else:
|
else:
|
||||||
self.record("messaging-settings", self.source_root / "openclaw.json", self.target_root / ".env", "skipped", "No Hermes-compatible messaging settings found")
|
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:
|
if self.migrate_secrets:
|
||||||
self.migrate_secret_settings(config)
|
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:
|
else:
|
||||||
config_path = self.source_root / "openclaw.json"
|
self.record(
|
||||||
if config_path.exists():
|
"secret-settings",
|
||||||
self.record(
|
config_path,
|
||||||
"secret-settings",
|
self.target_root / ".env",
|
||||||
config_path,
|
"skipped",
|
||||||
self.target_root / ".env",
|
"OpenClaw config file not found",
|
||||||
"skipped",
|
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
|
||||||
"Secret migration disabled. Re-run with --migrate-secrets to import allowlisted secrets.",
|
)
|
||||||
supported_targets=sorted(SUPPORTED_SECRET_TARGETS),
|
|
||||||
)
|
|
||||||
|
|
||||||
def migrate_secret_settings(self, config: Dict[str, Any]) -> None:
|
def migrate_secret_settings(self, config: Dict[str, Any]) -> None:
|
||||||
secret_additions: Dict[str, str] = {}
|
secret_additions: Dict[str, str] = {}
|
||||||
sources: List[str] = []
|
|
||||||
|
|
||||||
telegram_token = (
|
telegram_token = (
|
||||||
config.get("channels", {})
|
config.get("channels", {})
|
||||||
|
|
@ -655,7 +850,6 @@ class Migrator:
|
||||||
)
|
)
|
||||||
if isinstance(telegram_token, str) and telegram_token.strip():
|
if isinstance(telegram_token, str) and telegram_token.strip():
|
||||||
secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip()
|
secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip()
|
||||||
sources.append("openclaw.json:channels.telegram.botToken")
|
|
||||||
|
|
||||||
if secret_additions:
|
if secret_additions:
|
||||||
self.merge_env_values(secret_additions, "secret-settings", self.source_root / "openclaw.json")
|
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:
|
for skill_dir in skill_dirs:
|
||||||
destination = destination_root / skill_dir.name
|
destination = destination_root / skill_dir.name
|
||||||
if destination.exists() and not self.overwrite:
|
final_destination = destination
|
||||||
self.record("skill", skill_dir, destination, "conflict", "Destination skill already exists")
|
if destination.exists():
|
||||||
continue
|
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:
|
if self.execute:
|
||||||
backup_path = self.maybe_backup(destination)
|
backup_path = None
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
if final_destination == destination and destination.exists():
|
||||||
if 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.rmtree(destination)
|
||||||
shutil.copytree(skill_dir, destination)
|
shutil.copytree(skill_dir, final_destination)
|
||||||
self.record("skill", skill_dir, destination, "migrated", backup=str(backup_path) if backup_path else "")
|
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:
|
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"
|
desc_path = destination_root / "DESCRIPTION.md"
|
||||||
if self.execute:
|
if self.execute:
|
||||||
|
|
@ -810,16 +1023,53 @@ def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(description="Migrate OpenClaw user state into Hermes Agent.")
|
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("--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("--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("--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("--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")
|
parser.add_argument("--output-dir", help="Where to write report, backups, and archived docs")
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
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(
|
migrator = Migrator(
|
||||||
source_root=Path(os.path.expanduser(args.source)).resolve(),
|
source_root=Path(os.path.expanduser(args.source)).resolve(),
|
||||||
target_root=Path(os.path.expanduser(args.target)).resolve(),
|
target_root=Path(os.path.expanduser(args.target)).resolve(),
|
||||||
|
|
@ -828,6 +1078,9 @@ def main() -> int:
|
||||||
overwrite=bool(args.overwrite),
|
overwrite=bool(args.overwrite),
|
||||||
migrate_secrets=bool(args.migrate_secrets),
|
migrate_secrets=bool(args.migrate_secrets),
|
||||||
output_dir=Path(os.path.expanduser(args.output_dir)).resolve() if args.output_dir else None,
|
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()
|
report = migrator.migrate()
|
||||||
print(json.dumps(report, indent=2, ensure_ascii=False))
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,18 @@ def load_module():
|
||||||
return 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():
|
def test_extract_markdown_entries_promotes_heading_context():
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
text = """# MEMORY.md - Long-Term Memory
|
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"]
|
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):
|
def test_migrator_copies_skill_and_merges_allowlist(tmp_path: Path):
|
||||||
mod = load_module()
|
mod = load_module()
|
||||||
source = tmp_path / ".openclaw"
|
source = tmp_path / ".openclaw"
|
||||||
target = tmp_path / ".hermes"
|
target = tmp_path / ".hermes"
|
||||||
|
target.mkdir()
|
||||||
|
|
||||||
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
|
(source / "workspace" / "skills" / "demo-skill").mkdir(parents=True)
|
||||||
(source / "workspace" / "skills" / "demo-skill" / "SKILL.md").write_text(
|
(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 "MESSAGING_CWD=/tmp/openclaw-workspace" in env_text
|
||||||
assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text
|
assert "TELEGRAM_ALLOWED_USERS=111,222" in env_text
|
||||||
assert "TELEGRAM_BOT_TOKEN=123:abc" 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 == []
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue