# Conflicts: # apps/desktop/e2e/archived-hidden-session-recoverable.spec.ts # apps/desktop/e2e/bot-chat-message-agent-friendly-name.spec.ts # apps/desktop/e2e/bot-mailbox-unreadable-ticket.spec.ts # apps/desktop/e2e/bot-mode-roster-localized.spec.ts # apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts # apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts # apps/desktop/e2e/bot-roster-group-row-organisation.spec.ts # apps/desktop/e2e/bot-roster-ignores-infra-dirs.spec.ts # apps/desktop/e2e/bot-roster-timestamp-meta.spec.ts # apps/desktop/e2e/bot-roster-user-sections.spec.ts # apps/desktop/e2e/bot-routines-pane-narrow.spec.ts # apps/desktop/e2e/bot-row-open-recent-session.spec.ts # apps/desktop/e2e/bot-tile-ignores-ambient-composer-model.spec.ts # apps/desktop/e2e/group-composer-auto-grow.spec.ts # apps/desktop/e2e/group-create-gate-remote-roster.spec.ts # apps/desktop/e2e/group-prompt-renamed-primary-handle.spec.ts # apps/desktop/e2e/hosted-room-backend-continuity.spec.ts # apps/desktop/e2e/hosted-room-legacy-store-migration.spec.ts # apps/desktop/e2e/settings-scope-chips-bot-title.spec.ts # apps/desktop/e2e/worktree-branch-status.spec.ts # apps/desktop/electron/backend-probes.test.ts # apps/desktop/electron/connection-apply.test.ts # apps/desktop/electron/desktop-electron-pin.test.ts # apps/desktop/electron/desktop-uninstall.test.ts # apps/desktop/electron/gateway-file-download-transport.test.ts # apps/desktop/electron/gateway-stop-before-update.test.ts # apps/desktop/electron/github-api-auth.test.ts # apps/desktop/electron/registry-primary-profile-scope.test.ts # apps/desktop/electron/update-api-check.test.ts # apps/desktop/electron/update-handoff-marker.test.ts # apps/desktop/electron/venv-blocker-scan.test.ts # apps/desktop/scripts/after-extract.test.mjs # apps/desktop/scripts/local-pack-publish.test.mjs # apps/desktop/scripts/tasks-scroll.test.mjs # apps/desktop/src/app/settings/model-settings.test.tsx # apps/desktop/src/app/updates-overlay.blockers.test.tsx # apps/desktop/src/components/desktop-install-overlay.test.tsx # apps/desktop/src/lib/update-copy.test.ts # scripts/ci/check_os_marker_fakes.py # tests-js/desktop-mac-usage-descriptions.test.ts # tests-js/node-engine-alignment.test.ts # tests/agent/lsp/test_install_and_lint_fixes.py # tests/agent/test_command_token_source.py # tests/agent/test_compression_boundary_hook.py # tests/agent/test_create_openai_client_ssl_verify.py # tests/agent/test_custom_provider_ca_probes.py # tests/agent/test_endpoint_blackhole.py # tests/agent/test_estimator_parity.py # tests/agent/test_in_place_compaction.py # tests/agent/test_moa_loop_mode.py # tests/agent/test_model_metadata.py # tests/agent/test_skill_session_platform_gate.py # tests/agent/test_skill_utils.py # tests/agent/test_ssl_ca_guard.py # tests/computer_use/test_doctor.py # tests/cron/test_codex_execution_paths.py # tests/cron/test_cron_bot_chat_delivery.py # tests/cron/test_cron_script.py # tests/cron/test_media_delivery_parity.py # tests/cron/test_misfire_catchup.py # tests/cron/test_parallel_pool.py # tests/cron/test_recurring_eagain_redispatch.py # tests/gateway/test_choice_picker.py # tests/gateway/test_control_socket_windows_live.py # tests/gateway/test_dingtalk.py # tests/gateway/test_feishu.py # tests/gateway/test_feishu_onboard.py # tests/gateway/test_gateway_shutdown.py # tests/gateway/test_matrix.py # tests/gateway/test_model_command_custom_providers.py # tests/gateway/test_reasoning_command.py # tests/gateway/test_runtime_footer.py # tests/gateway/test_session.py # tests/gateway/test_session_hygiene.py # tests/gateway/test_status.py # tests/gateway/test_teams.py # tests/gateway/test_turn_lease.py # tests/gateway/test_whatsapp_connect.py # tests/hermes_cli/test_approvals_command.py # tests/hermes_cli/test_auth_store_lock_concurrent.py # tests/hermes_cli/test_backup.py # tests/hermes_cli/test_banner_git_state.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_compat_manifest_targets.py # tests/hermes_cli/test_computer_use_cli.py # tests/hermes_cli/test_cpr_local_leak.py # tests/hermes_cli/test_dashboard_auth_gate.py # tests/hermes_cli/test_dashboard_procs_kill_grace.py # tests/hermes_cli/test_desktop_lifecycle_windows_live.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_command_install.py # tests/hermes_cli/test_fleet_config_migration_windows_live.py # tests/hermes_cli/test_gateway.py # tests/hermes_cli/test_gateway_platform_gating.py # tests/hermes_cli/test_gateway_restart_loop.py # tests/hermes_cli/test_gateway_task_probe.py # tests/hermes_cli/test_gateway_wsl.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_install_cua_driver.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_command_exports.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_linux_desktop_entry.py # tests/hermes_cli/test_local_runtime.py # tests/hermes_cli/test_local_runtime_updates.py # tests/hermes_cli/test_managed_uv.py # tests/hermes_cli/test_mcp_reload_confirm_gate.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_npm_engine.py # tests/hermes_cli/test_personality_none.py # tests/hermes_cli/test_pet_toggle.py # tests/hermes_cli/test_plan_reconciliation_windows_live.py # tests/hermes_cli/test_plugin_event_bus.py # tests/hermes_cli/test_plugin_manifest_v2.py # tests/hermes_cli/test_plugin_packs.py # tests/hermes_cli/test_plugins_cmd.py # tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py # tests/hermes_cli/test_process_identity.py # tests/hermes_cli/test_profiles.py # tests/hermes_cli/test_profiles_sidebar_cache.py # tests/hermes_cli/test_pty_bridge.py # tests/hermes_cli/test_resolve_turn_limit.py # tests/hermes_cli/test_serve_runtime_inventory.py # tests/hermes_cli/test_session_vacuum_config.py # tests/hermes_cli/test_set_config_value.py # tests/hermes_cli/test_signal_handler_kanban_worker.py # tests/hermes_cli/test_slash_confirm_windows.py # tests/hermes_cli/test_stale_pid_guard.py # tests/hermes_cli/test_startup_fast_guards.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_telegram_managed_bot.py # tests/hermes_cli/test_tools_config.py # tests/hermes_cli/test_update_apply_shallow_count.py # tests/hermes_cli/test_update_autostash.py # tests/hermes_cli/test_update_concurrent_quarantine.py # tests/hermes_cli/test_update_fetch_failure_classifier.py # tests/hermes_cli/test_update_fleet_probe_resume_token.py # tests/hermes_cli/test_update_handoff_backend_reap.py # tests/hermes_cli/test_update_handoff_desktop_rebuild.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_host_obligation.py # tests/hermes_cli/test_update_import_guard.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_update_inventory.py # tests/hermes_cli/test_update_launchd_unloaded_gateway.py # tests/hermes_cli/test_update_missing_configured_deps.py # tests/hermes_cli/test_update_modified_notice.py # tests/hermes_cli/test_update_multiplex_migration_hook.py # tests/hermes_cli/test_update_no_gateway_restart.py # tests/hermes_cli/test_update_orphan_backend_reap.py # tests/hermes_cli/test_update_parked_branch_guard.py # tests/hermes_cli/test_update_post_pull_syntax_guard.py # tests/hermes_cli/test_update_receipt.py # tests/hermes_cli/test_update_self_lock.py # tests/hermes_cli/test_update_shim_fail_closed.py # tests/hermes_cli/test_update_shim_self_lock.py # tests/hermes_cli/test_update_sqlite_remediation.py # tests/hermes_cli/test_update_stale_dashboard.py # tests/hermes_cli/test_update_stale_virtualenv.py # tests/hermes_cli/test_update_venv_health.py # tests/hermes_cli/test_update_venv_ownership_preflight.py # tests/hermes_cli/test_update_wedged_gateway.py # tests/hermes_cli/test_update_yes_flag.py # tests/hermes_cli/test_update_zip_two_phase.py # tests/hermes_cli/test_urllib_security.py # tests/hermes_cli/test_ux_messages_auth_config.py # tests/hermes_cli/test_ux_messages_startup.py # tests/hermes_cli/test_venv_holder_classifier.py # tests/hermes_cli/test_verify_console_scripts.py # tests/hermes_cli/test_verify_core_dependencies.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_server_console_ws.py # tests/hermes_cli/test_web_server_ws_ping.py # tests/hermes_cli/test_web_ui_build.py # tests/hermes_state/test_fts_rebuild_admission.py # tests/hermes_state/test_hermes_state.py # tests/plugins/memory/test_memory_lazy_install.py # tests/plugins/test_google_meet_plugin.py # tests/plugins/test_langfuse_plugin.py # tests/plugins/test_security_guidance_plugin.py # tests/plugins/test_transform_llm_output_hook.py # tests/scripts/desktop_update/test_desktop_update_windows_gateway_flag.py # tests/scripts/desktop_update/test_desktop_update_windows_python_handoff.py # tests/scripts/desktop_update/test_desktop_update_windows_timestamp.py # tests/scripts/install/test_install_clone_throttle_fallback.py # tests/scripts/install/test_install_lockfile_churn.py # tests/scripts/install/test_install_no_initial_commit.py # tests/scripts/install/test_install_sh_browser_install.py # tests/scripts/install/test_install_sh_node_prerelease.py # tests/scripts/install/test_install_sh_symlink_stomp.py # tests/scripts/install/test_install_sh_uv_lock_config.py # tests/scripts/install/test_install_unmerged_index.py # tests/scripts/test_contributor_map.py # tests/scripts/test_run_tests_parallel.py # tests/skills/test_competitor_news_monitor_skill.py # tests/skills/test_document_to_action_items_skill.py # tests/skills/test_google_workspace_setup.py # tests/skills/test_google_workspace_setup_deps.py # tests/skills/test_grounded_citations_skill.py # tests/skills/test_ip_as_logo_skill.py # tests/skills/test_live_dashboard_skill.py # tests/skills/test_mcp_oauth_remote_gateway_skill.py # tests/skills/test_office_document_skills.py # tests/skills/test_openclaw_migration.py # tests/skills/test_product_price_monitor_skill.py # tests/skills/test_scrollcraft_skill.py # tests/skills/test_setup_wizard_generator_skill.py # tests/skills/test_weekly_review_planning_skill.py # tests/test_engines_satisfiable.py # tests/test_fast_safe_load.py # tests/test_hermes_bootstrap.py # tests/test_hermes_constants.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/test_model_tools_async_bridge.py # tests/test_packaging_build_guard.py # tests/test_packaging_metadata.py # tests/test_yaml_indent_consistency.py # tests/tools/test_approval_timeout_overflow.py # tests/tools/test_base_environment.py # tests/tools/test_bot_mode_dm.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_hardening.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_use_cli.py # tests/tools/test_clipboard.py # tests/tools/test_code_execution.py # tests/tools/test_code_execution_modes.py # tests/tools/test_code_execution_windows_env.py # tests/tools/test_computer_use.py # tests/tools/test_delegate_liveness_timeout.py # tests/tools/test_execute_code_approval_cluster.py # tests/tools/test_execution_flag_detection.py # tests/tools/test_fal_common.py # tests/tools/test_file_operations.py # tests/tools/test_file_tools.py # tests/tools/test_file_tools_cwd_resolution.py # tests/tools/test_file_tools_live.py # tests/tools/test_lazy_deps.py # tests/tools/test_lazy_deps_durable_target.py # tests/tools/test_lazy_deps_managed.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_local_tempdir.py # tests/tools/test_macos_protected_search.py # tests/tools/test_mcp_npx_cached_bin.py # tests/tools/test_oneshot_completion_linger.py # tests/tools/test_process_registry.py # tests/tools/test_read_file_schema_gating.py # tests/tools/test_skill_improvements.py # tests/tools/test_skills_sync.py # tests/tools/test_termux_api_detection.py # tests/tools/test_tirith_security.py # tests/tools/test_transcription_tools.py # tests/tools/test_tts_streaming.py # tests/tools/test_wake_word.py # tests/tui_gateway/test_compute_host_borrowed_lease.py # tests/tui_gateway/test_compute_host_turn_protocol.py # tests/tui_gateway/test_isolated_orphan_activity.py # tests/tui_gateway/test_protocol.py # tests/tui_gateway/test_slash_worker_profile_home.py # tests/tui_gateway/test_subprocess_encoding.py # tests/tui_gateway/test_tui_gateway_server.py # ui-tui/src/__tests__/terminalParity.test.ts # ui-tui/src/__tests__/termuxComposerLayout.test.ts # ui-tui/src/__tests__/textInputFastEcho.test.ts
778 lines
31 KiB
Python
778 lines
31 KiB
Python
"""Tests for hermes_cli.agent_import — ``hermes import-agent``.
|
|
|
|
Covers: source detection, Claude Code and Codex parsing, mapping into the
|
|
real Hermes stores (memories/MEMORY.md, config.yaml command_allowlist /
|
|
approvals.deny / mcp_servers, skills/), dry-run write-nothing guarantees,
|
|
malformed-input skip reports, and the never-import-secrets rule.
|
|
|
|
Uses the profile_env fixture pattern from tests/hermes_cli/test_profiles.py:
|
|
Path.home() and HERMES_HOME are redirected to tmp_path so nothing touches
|
|
the real ~/.hermes.
|
|
"""
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import hermes_yaml as yaml
|
|
|
|
from hermes_cli.agent_import import (
|
|
ENTRY_DELIMITER,
|
|
AgentImporter,
|
|
claude_rule_to_command_pattern,
|
|
detect_agents,
|
|
extract_markdown_entries,
|
|
is_secret_key,
|
|
sanitize_mcp_env,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Shared fixture: redirect Path.home() and HERMES_HOME (profile_env pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture()
|
|
def profile_env(tmp_path, monkeypatch):
|
|
"""Isolated environment: Path.home() -> tmp_path, HERMES_HOME -> tmp/.hermes."""
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
default_home.mkdir(exist_ok=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture()
|
|
def hermes_home(profile_env):
|
|
return profile_env / ".hermes"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fake source trees
|
|
# ---------------------------------------------------------------------------
|
|
|
|
CLAUDE_MD = """# Global instructions
|
|
|
|
## Style
|
|
- Always use type hints
|
|
- Prefer pathlib over os.path
|
|
|
|
Run the linter before committing.
|
|
"""
|
|
|
|
AGENTS_MD = """# Codex rules
|
|
|
|
- Never force-push to main
|
|
- Keep commits atomic
|
|
"""
|
|
|
|
|
|
@pytest.fixture()
|
|
def claude_tree(profile_env):
|
|
"""Build a fake ~/.claude tree (plus sibling ~/.claude.json)."""
|
|
root = profile_env / ".claude"
|
|
root.mkdir()
|
|
(root / "CLAUDE.md").write_text(CLAUDE_MD, encoding="utf-8")
|
|
(root / "settings.json").write_text(json.dumps({
|
|
"permissions": {
|
|
"allow": [
|
|
"Bash(npm run build)",
|
|
"Bash(npm run test:*)",
|
|
"Bash(git diff *)",
|
|
"Read(~/.zshrc)", # non-Bash → unmapped
|
|
],
|
|
"deny": [
|
|
"Bash(rm -rf *)",
|
|
"WebFetch", # non-Bash → dropped
|
|
],
|
|
},
|
|
"mcpServers": {
|
|
"settings-server": {"command": "uvx", "args": ["settings-mcp"]},
|
|
},
|
|
}), encoding="utf-8")
|
|
# mcpServers in the sibling ~/.claude.json (Claude's primary MCP store)
|
|
(profile_env / ".claude.json").write_text(json.dumps({
|
|
"mcpServers": {
|
|
"github": {
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
"env": {
|
|
"GITHUB_TOKEN": "ghp_SECRET123",
|
|
"GITHUB_HOST": "github.example.com",
|
|
},
|
|
},
|
|
"remote": {
|
|
"url": "https://mcp.example.com/sse",
|
|
"headers": {
|
|
"Authorization": "Bearer abc123",
|
|
"X-Region": "us-east",
|
|
},
|
|
},
|
|
},
|
|
}), encoding="utf-8")
|
|
# A credentials file that must never be read/imported
|
|
(root / ".credentials.json").write_text(
|
|
json.dumps({"api_key": "sk-ant-SUPERSECRET"}), encoding="utf-8")
|
|
# Skills
|
|
skill = root / "skills" / "deploy-helper"
|
|
skill.mkdir(parents=True)
|
|
(skill / "SKILL.md").write_text(
|
|
"---\nname: deploy-helper\n---\n\nDeploy things.\n", encoding="utf-8")
|
|
(root / "skills" / "not-a-skill").mkdir() # no SKILL.md → ignored
|
|
# Slash commands (reported as skipped)
|
|
commands = root / "commands"
|
|
commands.mkdir()
|
|
(commands / "review.md").write_text("Review this PR", encoding="utf-8")
|
|
return root
|
|
|
|
|
|
@pytest.fixture()
|
|
def codex_tree(profile_env):
|
|
"""Build a fake ~/.codex tree."""
|
|
root = profile_env / ".codex"
|
|
root.mkdir()
|
|
(root / "AGENTS.md").write_text(AGENTS_MD, encoding="utf-8")
|
|
(root / "config.toml").write_text(
|
|
'model = "gpt-5"\n'
|
|
'approval_policy = "on-request"\n'
|
|
"\n"
|
|
"[mcp_servers.docs]\n"
|
|
'command = "uvx"\n'
|
|
'args = ["docs-mcp"]\n'
|
|
"\n"
|
|
"[mcp_servers.docs.env]\n"
|
|
'DOCS_API_KEY = "secret-value"\n'
|
|
'DOCS_REGION = "eu"\n',
|
|
encoding="utf-8",
|
|
)
|
|
(root / "auth.json").write_text(
|
|
json.dumps({"OPENAI_API_KEY": "sk-SECRET"}), encoding="utf-8")
|
|
memories = root / "memories"
|
|
memories.mkdir()
|
|
(memories / "2026-01-01.md").write_text(
|
|
"- User prefers tabs over spaces\n- Project uses PostgreSQL\n",
|
|
encoding="utf-8",
|
|
)
|
|
skill = root / "skills" / "db-migrate"
|
|
skill.mkdir(parents=True)
|
|
(skill / "SKILL.md").write_text(
|
|
"---\nname: db-migrate\n---\n\nMigrate databases.\n", encoding="utf-8")
|
|
return root
|
|
|
|
|
|
def snapshot_tree(root: Path) -> dict:
|
|
"""Map of relative-path -> bytes for every file under root."""
|
|
return {
|
|
str(p.relative_to(root)): p.read_bytes()
|
|
for p in sorted(root.rglob("*")) if p.is_file()
|
|
}
|
|
|
|
|
|
def run_import(agent, source, hermes_home, execute, overwrite=False):
|
|
return AgentImporter(
|
|
agent=agent,
|
|
source_root=source,
|
|
target_root=hermes_home,
|
|
execute=execute,
|
|
overwrite=overwrite,
|
|
).run()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Detection & helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetection:
|
|
def test_detects_claude_and_codex(self, claude_tree, codex_tree):
|
|
assert detect_agents() == ["claude-code", "codex"]
|
|
|
|
|
|
def test_unsupported_agent_raises(self, hermes_home, tmp_path):
|
|
with pytest.raises(ValueError):
|
|
AgentImporter("cursor", tmp_path, hermes_home)
|
|
|
|
|
|
class TestRuleMapping:
|
|
def test_bash_rule_plain(self):
|
|
assert claude_rule_to_command_pattern("Bash(npm run build)") == "npm run build"
|
|
|
|
|
|
def test_non_bash_rule_is_none(self):
|
|
assert claude_rule_to_command_pattern("Read(~/.zshrc)") is None
|
|
assert claude_rule_to_command_pattern("WebFetch") is None
|
|
|
|
|
|
class TestSecretDetection:
|
|
@pytest.mark.parametrize("key", [
|
|
"GITHUB_TOKEN", "OPENAI_API_KEY", "MY_SECRET", "DB_PASSWORD",
|
|
"AWS_ACCESS_KEY", "AUTH_HEADER", "APIKEY",
|
|
])
|
|
def test_secret_keys(self, key):
|
|
assert is_secret_key(key)
|
|
|
|
@pytest.mark.parametrize("key", ["GITHUB_HOST", "REGION", "DEBUG", "PORT"])
|
|
def test_non_secret_keys(self, key):
|
|
assert not is_secret_key(key)
|
|
|
|
def test_sanitize_env_splits(self):
|
|
kept, stripped = sanitize_mcp_env(
|
|
{"API_TOKEN": "x", "HOST": "h", "MY_KEY": "k"})
|
|
assert kept == {"HOST": "h"}
|
|
assert sorted(stripped) == ["API_TOKEN", "MY_KEY"]
|
|
|
|
|
|
class TestMarkdownEntries:
|
|
def test_extracts_bullets_and_paragraphs(self):
|
|
entries = extract_markdown_entries(CLAUDE_MD)
|
|
assert any("type hints" in e for e in entries)
|
|
assert any("linter" in e for e in entries)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dry run writes NOTHING
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDryRun:
|
|
def test_claude_dry_run_writes_nothing(self, claude_tree, hermes_home):
|
|
before = snapshot_tree(hermes_home)
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
|
assert snapshot_tree(hermes_home) == before
|
|
assert report["dry_run"] is True
|
|
assert report["summary"]["imported"] > 0
|
|
|
|
|
|
def test_dry_run_and_real_run_plan_same_items(self, claude_tree, hermes_home):
|
|
preview = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
|
applied = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
pk = [(i["kind"], i["status"]) for i in preview["items"]]
|
|
ak = [(i["kind"], i["status"]) for i in applied["items"]]
|
|
assert pk == ak
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Claude Code real run — every item lands in the right store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestClaudeCodeImport:
|
|
@pytest.fixture()
|
|
def report(self, claude_tree, hermes_home):
|
|
return run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
|
|
def test_allowlist_lands_in_config_yaml(self, report, hermes_home):
|
|
config = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
|
|
allow = config["command_allowlist"]
|
|
assert "npm run build" in allow
|
|
assert "npm run test*" in allow
|
|
assert "git diff *" in allow
|
|
# non-Bash rules must not leak in
|
|
assert not any("Read(" in p for p in allow)
|
|
|
|
|
|
|
|
|
|
def test_slash_commands_reported_skipped(self, report):
|
|
items = {i["kind"]: i for i in report["items"]}
|
|
assert items["slash-commands"]["status"] == "skipped"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Codex real run
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCodexImport:
|
|
@pytest.fixture()
|
|
def report(self, codex_tree, hermes_home):
|
|
return run_import("codex", codex_tree, hermes_home, execute=True)
|
|
|
|
|
|
def test_mcp_servers_from_config_toml(self, report, hermes_home):
|
|
config = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
|
|
docs = config["mcp_servers"]["docs"]
|
|
assert docs["command"] == "uvx"
|
|
assert docs["args"] == ["docs-mcp"]
|
|
# non-secret env survives, secret is stripped
|
|
assert docs["env"] == {"DOCS_REGION": "eu"}
|
|
|
|
def test_skill_copied(self, report, hermes_home):
|
|
assert (hermes_home / "skills" / "codex-imports" / "db-migrate" / "SKILL.md").exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Secrets are never copied
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSecretsNeverImported:
|
|
def test_no_secret_values_anywhere_claude(self, claude_tree, hermes_home):
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
blob = "".join(
|
|
p.read_text(encoding="utf-8", errors="replace")
|
|
for p in hermes_home.rglob("*") if p.is_file()
|
|
)
|
|
assert "ghp_SECRET123" not in blob
|
|
assert "Bearer abc123" not in blob
|
|
assert "sk-ant-SUPERSECRET" not in blob
|
|
|
|
def test_no_secret_values_anywhere_codex(self, codex_tree, hermes_home):
|
|
run_import("codex", codex_tree, hermes_home, execute=True)
|
|
blob = "".join(
|
|
p.read_text(encoding="utf-8", errors="replace")
|
|
for p in hermes_home.rglob("*") if p.is_file()
|
|
)
|
|
assert "secret-value" not in blob
|
|
assert "sk-SECRET" not in blob
|
|
|
|
def test_stripped_secrets_reported(self, claude_tree, hermes_home):
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
stripped = report.get("stripped_secrets", [])
|
|
assert "mcp_servers.github.env.GITHUB_TOKEN" in stripped
|
|
assert any("Authorization" in s for s in stripped)
|
|
|
|
def test_non_secret_header_kept(self, claude_tree, hermes_home):
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
config = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
|
|
assert config["mcp_servers"]["remote"]["headers"] == {"X-Region": "us-east"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Malformed inputs: per-item skip/error reports, no crashes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMalformedInputs:
|
|
def test_bad_settings_json_reports_error(self, profile_env, hermes_home):
|
|
root = profile_env / ".claude"
|
|
root.mkdir()
|
|
(root / "settings.json").write_text("{not json!!", encoding="utf-8")
|
|
(root / "CLAUDE.md").write_text("- still importable\n", encoding="utf-8")
|
|
report = run_import("claude-code", root, hermes_home, execute=True)
|
|
errors = [i for i in report["items"] if i["status"] == "error"]
|
|
assert any(i["kind"] == "settings" for i in errors)
|
|
# CLAUDE.md still imported despite bad settings.json
|
|
assert "still importable" in (
|
|
hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
|
|
|
|
|
def test_empty_tree_all_skipped(self, profile_env, hermes_home):
|
|
root = profile_env / ".codex"
|
|
root.mkdir()
|
|
report = run_import("codex", root, hermes_home, execute=True)
|
|
assert report["summary"]["imported"] == 0
|
|
assert report["summary"]["error"] == 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Merge semantics & conflicts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestMergeSemantics:
|
|
|
|
def test_existing_mcp_server_conflicts_without_overwrite(
|
|
self, claude_tree, hermes_home):
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"mcp_servers": {"github": {"command": "mine"}}}),
|
|
encoding="utf-8")
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
config = yaml.safe_load((hermes_home / "config.yaml").read_text(encoding="utf-8"))
|
|
assert config["mcp_servers"]["github"]["command"] == "mine"
|
|
assert any(
|
|
i["status"] == "conflict" and i["source"] == "github"
|
|
for i in report["items"]
|
|
)
|
|
|
|
|
|
def test_existing_skill_conflicts_without_overwrite(
|
|
self, claude_tree, hermes_home):
|
|
dest = hermes_home / "skills" / "claude-code-imports" / "deploy-helper"
|
|
dest.mkdir(parents=True)
|
|
(dest / "SKILL.md").write_text("mine\n", encoding="utf-8")
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
assert (dest / "SKILL.md").read_text(encoding="utf-8") == "mine\n"
|
|
assert any(
|
|
i["kind"] == "skill" and i["status"] == "conflict"
|
|
for i in report["items"]
|
|
)
|
|
|
|
def test_reimport_is_idempotent_for_memory(self, claude_tree, hermes_home):
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
first = (hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
assert (hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8") == first
|
|
memory_items = [i for i in report["items"] if i["kind"] == "claude-md"]
|
|
assert memory_items[0]["status"] == "skipped"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The DESTINATION memories/MEMORY.md is a §-delimited store, not a document
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# A realistic hand-edited store: one entry, no "§", with a fenced code block
|
|
# and a markdown table — exactly the content extract_markdown_entries() drops.
|
|
EXISTING_MEMORY = """Homelab runbook. Restart the ingress controller with:
|
|
|
|
```bash
|
|
kubectl -n ingress rollout restart deploy/nginx
|
|
```
|
|
|
|
Escalation ladder:
|
|
|
|
| Severity | Contact | Window |
|
|
|----------|---------|--------|
|
|
| SEV1 | on-call | 15m |
|
|
| SEV2 | #ops | 4h |
|
|
|
|
Never page for SEV3.
|
|
"""
|
|
|
|
|
|
class TestExistingMemoryStorePreserved:
|
|
"""An import must not shred the memory store it merges into.
|
|
|
|
``memories/MEMORY.md`` is the entry-delimited store written by
|
|
``MemoryStore._write_file``; a single-entry or hand-edited store contains
|
|
no ``§`` delimiter. Parsing it with the *source* markdown extractor drops
|
|
code blocks and table rows and splits one entry into fragments, and the
|
|
merged result is written straight back over the file.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def seeded_home(self, hermes_home):
|
|
memory = hermes_home / "memories" / "MEMORY.md"
|
|
memory.parent.mkdir(parents=True, exist_ok=True)
|
|
memory.write_text(EXISTING_MEMORY, encoding="utf-8")
|
|
return hermes_home
|
|
|
|
|
|
|
|
def test_import_preserves_existing_entry_verbatim(
|
|
self, claude_tree, seeded_home):
|
|
path = seeded_home / "memories" / "MEMORY.md"
|
|
run_import("claude-code", claude_tree, seeded_home, execute=True)
|
|
entries = path.read_text(encoding="utf-8").split(ENTRY_DELIMITER)
|
|
# The pre-existing store survives byte-intact as a SINGLE entry ...
|
|
assert entries[0] == EXISTING_MEMORY.strip()
|
|
# ... including the parts the markdown extractor would have dropped.
|
|
assert "kubectl -n ingress rollout restart deploy/nginx" in entries[0]
|
|
assert "| SEV1 | on-call | 15m |" in entries[0]
|
|
# ... and the imported entries are still appended after it.
|
|
assert any("type hints" in e for e in entries[1:])
|
|
|
|
def test_import_backs_up_the_previous_store(self, claude_tree, seeded_home):
|
|
memories = seeded_home / "memories"
|
|
run_import("claude-code", claude_tree, seeded_home, execute=True)
|
|
backups = sorted(memories.glob("MEMORY.md.bak.*"))
|
|
assert len(backups) == 1
|
|
assert backups[0].read_text(encoding="utf-8") == EXISTING_MEMORY
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# config.yaml preservation (sibling of the MEMORY.md case above)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
EXISTING_CONFIG = """\
|
|
model: hermes-4-405b
|
|
api_key_env: OPENROUTER_API_KEY
|
|
command_allowlist:
|
|
- ls *
|
|
- cat *
|
|
approvals:
|
|
deny:
|
|
- shutdown *
|
|
mcp_servers:
|
|
local-notes:
|
|
command: uvx
|
|
args:
|
|
- notes-mcp
|
|
telegram:
|
|
enabled: true
|
|
chat_id: 12345
|
|
"""
|
|
|
|
MALFORMED_CONFIG = """\
|
|
model: hermes-4-405b
|
|
command_allowlist:
|
|
- ls *
|
|
- cat *
|
|
approvals: [unclosed
|
|
"""
|
|
|
|
CONFIG_WRITING_KINDS = ("command-allowlist", "command-denylist", "mcp-servers")
|
|
|
|
|
|
class TestExistingConfigPreserved:
|
|
"""An import must not destroy the config.yaml it merges into.
|
|
|
|
``load_yaml_file`` returned ``{}`` for an absent file AND for a present
|
|
file it could not read or parse. The three importers below read
|
|
config.yaml, merge one section into it, and write the whole mapping back —
|
|
so a YAML syntax error or a permission problem meant every existing
|
|
setting was replaced by just the merged section, reported as ``imported``.
|
|
"""
|
|
|
|
@pytest.fixture()
|
|
def config_path(self, hermes_home):
|
|
return hermes_home / "config.yaml"
|
|
|
|
@staticmethod
|
|
def items_for(report, kind):
|
|
return [i for i in report["items"] if i["kind"] == kind]
|
|
|
|
# -- present but unreadable: refuse, change nothing --------------------
|
|
|
|
def test_malformed_config_is_left_byte_identical(
|
|
self, claude_tree, hermes_home, config_path):
|
|
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
|
before = config_path.read_bytes()
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
assert config_path.read_bytes() == before
|
|
|
|
def test_malformed_config_records_an_error_not_an_import(
|
|
self, claude_tree, hermes_home, config_path):
|
|
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
|
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
for kind in CONFIG_WRITING_KINDS:
|
|
items = self.items_for(report, kind)
|
|
assert items, f"no {kind} item recorded"
|
|
assert [i["status"] for i in items] == ["error"], kind
|
|
|
|
def test_unreadable_config_is_left_byte_identical(
|
|
self, claude_tree, hermes_home, config_path):
|
|
"""A permission problem is the other half of the same root cause."""
|
|
import os
|
|
|
|
if os.name != "posix":
|
|
pytest.skip("chmod-based permission denial is POSIX-only")
|
|
if getattr(os, "geteuid", lambda: 1)() == 0:
|
|
pytest.skip("root bypasses file permissions")
|
|
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
|
before = config_path.read_bytes()
|
|
config_path.chmod(0o000)
|
|
try:
|
|
report = run_import("claude-code", claude_tree, hermes_home,
|
|
execute=True)
|
|
statuses = {
|
|
i["status"]
|
|
for kind in CONFIG_WRITING_KINDS
|
|
for i in self.items_for(report, kind)
|
|
}
|
|
assert statuses == {"error"}
|
|
finally:
|
|
config_path.chmod(0o600)
|
|
assert config_path.read_bytes() == before
|
|
|
|
def test_non_mapping_config_is_refused(
|
|
self, claude_tree, hermes_home, config_path):
|
|
"""Valid YAML that is not a mapping was also collapsed to {}."""
|
|
config_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
|
|
before = config_path.read_bytes()
|
|
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
assert config_path.read_bytes() == before
|
|
assert [i["status"] for i in self.items_for(report, "command-allowlist")] == ["error"]
|
|
|
|
def test_dry_run_reports_the_refusal_rather_than_a_preview(
|
|
self, claude_tree, hermes_home, config_path):
|
|
"""--dry-run must not promise an import that would destroy the file."""
|
|
config_path.write_text(MALFORMED_CONFIG, encoding="utf-8")
|
|
|
|
report = run_import("claude-code", claude_tree, hermes_home, execute=False)
|
|
|
|
for kind in CONFIG_WRITING_KINDS:
|
|
statuses = [i["status"] for i in self.items_for(report, kind)]
|
|
assert statuses == ["error"], kind
|
|
assert "imported" not in statuses
|
|
|
|
# -- the regression that actually pins the data loss -------------------
|
|
|
|
def test_import_preserves_every_pre_existing_config_key(
|
|
self, claude_tree, hermes_home, config_path):
|
|
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
merged = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
# Untouched sections survive verbatim.
|
|
assert merged["model"] == "hermes-4-405b"
|
|
assert merged["api_key_env"] == "OPENROUTER_API_KEY"
|
|
assert merged["telegram"] == {"enabled": True, "chat_id": 12345}
|
|
# Merged-into sections keep their existing members ...
|
|
assert "ls *" in merged["command_allowlist"]
|
|
assert "shutdown *" in merged["approvals"]["deny"]
|
|
assert "local-notes" in merged["mcp_servers"]
|
|
# ... alongside the imported ones.
|
|
assert "npm run build" in merged["command_allowlist"]
|
|
assert "github" in merged["mcp_servers"]
|
|
|
|
def test_absent_config_is_still_created(
|
|
self, claude_tree, hermes_home, config_path):
|
|
"""The guard must not break first-time creation."""
|
|
assert not config_path.exists()
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
created = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
assert "npm run build" in created["command_allowlist"]
|
|
|
|
def test_empty_config_is_treated_as_absent(
|
|
self, claude_tree, hermes_home, config_path):
|
|
config_path.write_text("", encoding="utf-8")
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
created = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
|
assert "npm run build" in created["command_allowlist"]
|
|
|
|
# -- the write itself --------------------------------------------------
|
|
|
|
def test_config_write_is_atomic_and_leaves_no_temp_files(
|
|
self, claude_tree, hermes_home, config_path):
|
|
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
leftovers = [p.name for p in hermes_home.glob(".tmp*")]
|
|
assert leftovers == []
|
|
|
|
@pytest.mark.require_symlinks
|
|
def test_symlinked_config_stays_a_symlink(
|
|
self, claude_tree, hermes_home, tmp_path, config_path):
|
|
"""A non-atomic ``write_text`` would follow it; ``os.replace`` would
|
|
clobber it. ``atomic_replace`` keeps the link and writes the target."""
|
|
real = tmp_path / "real-config.yaml"
|
|
real.write_text(EXISTING_CONFIG, encoding="utf-8")
|
|
config_path.symlink_to(real)
|
|
|
|
run_import("claude-code", claude_tree, hermes_home, execute=True)
|
|
|
|
assert config_path.is_symlink()
|
|
assert "npm run build" in real.read_text(encoding="utf-8")
|
|
|
|
def test_failed_write_does_not_truncate_the_existing_config(
|
|
self, hermes_home, config_path, monkeypatch):
|
|
"""An interrupted dump leaves the previous file complete."""
|
|
from hermes_cli import agent_import
|
|
|
|
config_path.write_text(EXISTING_CONFIG, encoding="utf-8")
|
|
before = config_path.read_bytes()
|
|
|
|
def boom(*_args, **_kwargs):
|
|
raise OSError("no space left on device")
|
|
|
|
monkeypatch.setattr(agent_import, "atomic_yaml_write", boom)
|
|
with pytest.raises(OSError):
|
|
agent_import.dump_yaml_file(config_path, {"model": "replacement"})
|
|
|
|
assert config_path.read_bytes() == before
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# CLI wiring
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCliWiring:
|
|
|
|
def test_rejects_unknown_agent(self):
|
|
import argparse
|
|
from hermes_cli.subcommands.import_agent import build_import_agent_parser
|
|
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
build_import_agent_parser(subparsers, cmd_import_agent=lambda a: None)
|
|
with pytest.raises(SystemExit):
|
|
parser.parse_args(["import-agent", "cursor"])
|
|
|
|
def test_command_dry_run_via_cli_writes_nothing(
|
|
self, claude_tree, hermes_home, capsys):
|
|
"""End-to-end through import_agent_command with --dry-run."""
|
|
import types
|
|
from hermes_cli.agent_import import import_agent_command
|
|
|
|
args = types.SimpleNamespace(
|
|
agent="claude-code", source=str(claude_tree), dry_run=True,
|
|
overwrite=False, yes=False)
|
|
import_agent_command(args)
|
|
assert "command-allowlist" in capsys.readouterr().out
|
|
# Baseline config.yaml/SOUL.md may be seeded by save_config() before
|
|
# the preview runs — but nothing from the IMPORT itself may land:
|
|
assert not (hermes_home / "memories" / "MEMORY.md").exists()
|
|
assert not (hermes_home / "skills" / "claude-code-imports").exists()
|
|
config_text = (hermes_home / "config.yaml").read_text(encoding="utf-8") \
|
|
if (hermes_home / "config.yaml").exists() else ""
|
|
assert "npm run build" not in config_text
|
|
assert "github" not in config_text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync mode (--sync): keep previously imported sources current
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSyncManifest:
|
|
def _run_command(self, agent, source, dry_run=False, sync=False):
|
|
import types
|
|
from hermes_cli.agent_import import import_agent_command
|
|
|
|
import_agent_command(types.SimpleNamespace(
|
|
agent=agent, source=str(source) if source else None,
|
|
dry_run=dry_run, overwrite=False, yes=True, sync=sync))
|
|
|
|
def test_import_registers_source_and_unchanged_sync_is_noop(
|
|
self, claude_tree, hermes_home):
|
|
from hermes_cli.agent_import_sync import load_sync_manifest
|
|
|
|
self._run_command("claude-code", claude_tree)
|
|
entry = load_sync_manifest(hermes_home)["agents"]["claude-code"]
|
|
assert entry["source"] == str(claude_tree.resolve())
|
|
assert "deploy-helper" in entry["imported_skills"] # name → digest of the copy we wrote
|
|
# A token refresh in the credential file is invisible to the digest.
|
|
(claude_tree / ".credentials.json").write_text(
|
|
json.dumps({"api_key": "rotated-token"}), encoding="utf-8")
|
|
before = snapshot_tree(hermes_home)
|
|
self._run_command(None, None, sync=True)
|
|
assert snapshot_tree(hermes_home) == before
|
|
|
|
def test_sync_reimports_changed_source_but_never_clobbers_user_skill(
|
|
self, claude_tree, hermes_home):
|
|
self._run_command("claude-code", claude_tree)
|
|
(claude_tree / "CLAUDE.md").write_text(
|
|
CLAUDE_MD + "\n- Freshly added sync rule\n", encoding="utf-8")
|
|
(claude_tree / "skills" / "deploy-helper" / "SKILL.md").write_text(
|
|
"---\nname: deploy-helper\n---\n\nDeploy v2.\n", encoding="utf-8")
|
|
# A NEW source skill whose destination the user created themselves.
|
|
user_skill = hermes_home / "skills" / "claude-code-imports" / "hand-rolled"
|
|
user_skill.mkdir(parents=True)
|
|
(user_skill / "SKILL.md").write_text("user content", encoding="utf-8")
|
|
(claude_tree / "skills" / "hand-rolled").mkdir()
|
|
(claude_tree / "skills" / "hand-rolled" / "SKILL.md").write_text(
|
|
"---\nname: hand-rolled\n---\n\nsource content\n", encoding="utf-8")
|
|
|
|
self._run_command(None, None, sync=True)
|
|
|
|
imports = hermes_home / "skills" / "claude-code-imports"
|
|
assert "Freshly added sync rule" in (
|
|
hermes_home / "memories" / "MEMORY.md").read_text(encoding="utf-8")
|
|
assert "Deploy v2." in (imports / "deploy-helper" / "SKILL.md").read_text(encoding="utf-8")
|
|
assert (user_skill / "SKILL.md").read_text(encoding="utf-8") == "user content"
|
|
|
|
# An imported skill the user then EDITED locally is no longer Hermes-owned: the next sync
|
|
# records a conflict for it instead of overwriting the edit (the docs promise this).
|
|
(imports / "deploy-helper" / "SKILL.md").write_text("my local tweaks", encoding="utf-8")
|
|
(claude_tree / "skills" / "deploy-helper" / "SKILL.md").write_text(
|
|
"---\nname: deploy-helper\n---\n\nDeploy v3.\n", encoding="utf-8")
|
|
self._run_command(None, None, sync=True)
|
|
assert (imports / "deploy-helper" / "SKILL.md").read_text(encoding="utf-8") == "my local tweaks"
|
|
|
|
def test_sync_dry_run_previews_without_writing(self, claude_tree, hermes_home):
|
|
from hermes_cli.agent_import_sync import load_sync_manifest
|
|
|
|
self._run_command("claude-code", claude_tree)
|
|
old_digest = load_sync_manifest(hermes_home)["agents"]["claude-code"]["digest"]
|
|
(claude_tree / "CLAUDE.md").write_text(
|
|
CLAUDE_MD + "\n- Dry sync entry\n", encoding="utf-8")
|
|
before = snapshot_tree(hermes_home)
|
|
self._run_command(None, None, sync=True, dry_run=True)
|
|
assert snapshot_tree(hermes_home) == before
|
|
assert load_sync_manifest(hermes_home)["agents"]["claude-code"]["digest"] == old_digest
|