Download tests/hermes_cli/test_agent_import.py from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 28.2 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/hermes_cli/test_agent_import.py
- Command line
-
hf download hf://SaylorTwift/hermes-agent/tests/hermes_cli/test_agent_import.py
-
curl -L -o test_agent_import.py https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/tests/hermes_cli/test_agent_import.py
28.2 kB
| """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 yaml | |
| from hermes_cli.agent_import import ( | |
| ENTRY_DELIMITER, | |
| AgentImporter, | |
| claude_rule_to_command_pattern, | |
| detect_agents, | |
| extract_markdown_entries, | |
| is_secret_key, | |
| parse_existing_memory_entries, | |
| sanitize_mcp_env, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Shared fixture: redirect Path.home() and HERMES_HOME (profile_env pattern) | |
| # --------------------------------------------------------------------------- | |
| 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 | |
| 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 | |
| """ | |
| 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 | |
| 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: | |
| def test_secret_keys(self, key): | |
| assert is_secret_key(key) | |
| 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: | |
| 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()) | |
| 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: | |
| 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()) | |
| 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()) | |
| 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() | |
| 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()) | |
| 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() == "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() | |
| report = run_import("claude-code", claude_tree, hermes_home, execute=True) | |
| assert (hermes_home / "memories" / "MEMORY.md").read_text() == 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. | |
| """ | |
| 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``. | |
| """ | |
| def config_path(self, hermes_home): | |
| return hermes_home / "config.yaml" | |
| 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 | |
| reason = items[0]["reason"] | |
| assert str(config_path) in reason | |
| assert "not valid YAML" in reason | |
| # Points the user at a way out. | |
| assert "hermes config edit" in reason | |
| 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 | |
| reasons = [i["reason"] for i in self.items_for(report, "command-allowlist")] | |
| assert any("YAML mapping" in r for r in reasons) | |
| 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 == [] | |
| 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_parser_builds_and_parses(self): | |
| import argparse | |
| from hermes_cli.subcommands.import_agent import build_import_agent_parser | |
| parser = argparse.ArgumentParser() | |
| subparsers = parser.add_subparsers(dest="command") | |
| called = {} | |
| build_import_agent_parser( | |
| subparsers, cmd_import_agent=lambda a: called.setdefault("ok", a)) | |
| args = parser.parse_args( | |
| ["import-agent", "claude-code", "--dry-run", "--source", "/tmp/x"]) | |
| assert args.agent == "claude-code" | |
| assert args.dry_run is True | |
| assert args.source == "/tmp/x" | |
| args.func(args) | |
| assert "ok" in called | |
| 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) | |
| out = capsys.readouterr().out | |
| assert "Dry Run Results" in out | |
| assert "command-allowlist" in 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 | |