gabriel / muse public
test_cmd_agent_config.py python
934 lines 38.7 KB
Raw
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 42 days ago
1 """Tests for ``muse agent-config``.
2
3 Coverage
4 --------
5 Unit
6 _detect_context — standalone, workspace_root, workspace_member
7 _compute_rel_path — relative path from repo to workspace root
8 _render_adapter — include syntax (Claude) vs embedded (Codex/Cursor/etc.)
9 _load_configured_adapters — reads [agent-config] adapters from config.toml
10
11 Integration — init
12 standalone — generates .muse/agent.md with full content
13 workspace_root — generates workspace-level .muse/agent.md with member table
14 workspace_member — generates thin repo-level .muse/agent.md linking to workspace
15 --force — overwrites existing .muse/agent.md
16 no --force on existing — exits 1 with clear message
17 --json schema — all fields present
18
19 Integration — sync
20 standalone — generates all adapter files from .muse/agent.md
21 workspace_member — Claude adapter includes both workspace + repo level
22 embed adapters — non-Claude adapters embed full content
23 --adapters claude — only generates CLAUDE.md
24 --dry-run — prints what would be written, no files created
25 --force — overwrites existing adapter files
26 no --force on existing — exits 1
27 missing agent.md — exits 1 with helpful message
28 --json schema — all fields present
29 config.toml adapters — sync respects [agent-config] adapters setting
30 --adapters overrides — CLI flag takes priority over config.toml
31
32 Integration — show
33 standalone — prints .muse/agent.md content
34 merged workspace — prints workspace + repo content concatenated
35 --json — content field present
36
37 Integration — status
38 all adapters present — reports in_sync correctly
39 some missing — reports missing
40 --json schema — all fields present
41
42 Integration — set
43 writes config.toml — [agent-config] adapters persisted correctly
44 updates existing — existing [agent-config] section replaced
45 preserves other keys — other config.toml sections untouched
46 unknown adapter exits1 — invalid adapter name exits with code 1
47 --json schema — {adapters, path} present
48
49 E2E — full workflow
50 init → set → sync → edit → status out-of-sync → sync --force → status clean
51
52 Stress
53 large agent.md — 200 KB content syncs without error
54 rapid sequential syncs — 30 iterations stable
55
56 Data Integrity
57 sync is atomic — adapter file is never partially written
58 corrupt config.toml — falls back to all adapters gracefully
59
60 Performance
61 sync completes quickly — wall time < 2 s
62
63 Security
64 set rejects path traversal in adapter name
65 malformed config.toml — TOML injection attempt doesn't crash
66 agent.md with null bytes — handled without crash
67 """
68
69 from __future__ import annotations
70
71 import json
72 import pathlib
73 import time
74 import threading
75
76 import pytest
77
78 from tests.cli_test_helper import CliRunner
79
80 runner = CliRunner()
81
82
83 # ---------------------------------------------------------------------------
84 # Helpers
85 # ---------------------------------------------------------------------------
86
87
88 def _invoke(path: pathlib.Path, args: list[str]) -> object:
89 import os
90 saved = os.getcwd()
91 try:
92 os.chdir(path)
93 return runner.invoke(None, args)
94 finally:
95 os.chdir(saved)
96
97
98 def _init_repo(path: pathlib.Path, domain: str = "code") -> None:
99 r = _invoke(path, ["init", "--domain", domain])
100 assert r.exit_code == 0, r.output
101
102
103 def _init_workspace(path: pathlib.Path, members: list[tuple[str, str]]) -> None:
104 """Create a workspace manifest at path with given (name, rel_path) members."""
105 muse_dir = path / ".muse"
106 muse_dir.mkdir(parents=True, exist_ok=True)
107 lines = [""]
108 for name, rel in members:
109 lines += [
110 "[[members]]",
111 f'name = "{name}"',
112 f'url = "http://localhost:10003/gabriel/{name}"',
113 f'path = "{rel}"',
114 'branch = "main"',
115 "",
116 ]
117 (muse_dir / "workspace.toml").write_text("\n".join(lines))
118
119
120 # ---------------------------------------------------------------------------
121 # Unit — _detect_context
122 # ---------------------------------------------------------------------------
123
124
125 class TestDetectContext:
126 def test_standalone_repo(self, tmp_path: pathlib.Path) -> None:
127 from muse.cli.commands.agent_config import _detect_context
128 _init_repo(tmp_path)
129 kind, ws = _detect_context(tmp_path)
130 assert kind == "standalone"
131 assert ws is None
132
133 def test_workspace_root(self, tmp_path: pathlib.Path) -> None:
134 from muse.cli.commands.agent_config import _detect_context
135 _init_workspace(tmp_path, [("muse", "muse")])
136 kind, ws = _detect_context(tmp_path)
137 assert kind == "workspace_root"
138 assert ws == tmp_path
139
140 def test_workspace_member(self, tmp_path: pathlib.Path) -> None:
141 from muse.cli.commands.agent_config import _detect_context
142 _init_workspace(tmp_path, [("core", "core")])
143 repo = tmp_path / "core"
144 repo.mkdir()
145 _init_repo(repo)
146 kind, ws = _detect_context(repo)
147 assert kind == "workspace_member"
148 assert ws == tmp_path
149
150
151 # ---------------------------------------------------------------------------
152 # Unit — _compute_rel_path
153 # ---------------------------------------------------------------------------
154
155
156 class TestComputeRelPath:
157 def test_direct_child(self, tmp_path: pathlib.Path) -> None:
158 from muse.cli.commands.agent_config import _compute_rel_path
159 ws = tmp_path / "ws"
160 repo = tmp_path / "ws" / "core"
161 ws.mkdir(), repo.mkdir()
162 assert _compute_rel_path(repo, ws) == ".."
163
164 def test_nested_child(self, tmp_path: pathlib.Path) -> None:
165 from muse.cli.commands.agent_config import _compute_rel_path
166 ws = tmp_path / "ws"
167 repo = tmp_path / "ws" / "packages" / "foo"
168 repo.mkdir(parents=True)
169 assert _compute_rel_path(repo, ws) == "../.."
170
171 def test_same_dir(self, tmp_path: pathlib.Path) -> None:
172 from muse.cli.commands.agent_config import _compute_rel_path
173 assert _compute_rel_path(tmp_path, tmp_path) == "."
174
175
176 # ---------------------------------------------------------------------------
177 # Unit — _render_adapter
178 # ---------------------------------------------------------------------------
179
180
181 class TestRenderAdapter:
182 def test_include_adapter_uses_at_syntax(self) -> None:
183 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
184 spec = _ADAPTERS["claude"]
185 result = _render_adapter(spec, repo_agent_md=".muse/agent.md", ws_agent_md=None)
186 assert "@.muse/agent.md" in result
187 assert "embed" not in result.lower()
188
189 def test_include_adapter_with_workspace(self) -> None:
190 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
191 spec = _ADAPTERS["claude"]
192 result = _render_adapter(spec, repo_agent_md=".muse/agent.md", ws_agent_md="../.muse/agent.md")
193 assert "@../.muse/agent.md" in result
194 assert "@.muse/agent.md" in result
195
196 def test_embed_adapter_contains_content(self) -> None:
197 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
198 spec = _ADAPTERS["codex"]
199 result = _render_adapter(
200 spec,
201 repo_agent_md=".muse/agent.md",
202 ws_agent_md=None,
203 repo_agent_content="# My Agent Config\nsome rules",
204 ws_agent_content=None,
205 )
206 assert "# My Agent Config" in result
207 assert "some rules" in result
208
209 def test_embed_adapter_with_workspace_prepends_ws_content(self) -> None:
210 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
211 spec = _ADAPTERS["codex"]
212 result = _render_adapter(
213 spec,
214 repo_agent_md=".muse/agent.md",
215 ws_agent_md="../.muse/agent.md",
216 repo_agent_content="# Repo Config",
217 ws_agent_content="# Workspace Config",
218 )
219 ws_pos = result.index("# Workspace Config")
220 repo_pos = result.index("# Repo Config")
221 assert ws_pos < repo_pos # workspace content comes first
222
223
224 # ---------------------------------------------------------------------------
225 # Integration — init: standalone repo
226 # ---------------------------------------------------------------------------
227
228
229 class TestInitStandalone:
230 def test_creates_agent_md(self, tmp_path: pathlib.Path) -> None:
231 _init_repo(tmp_path)
232 result = _invoke(tmp_path, ["agent-config", "init"])
233 assert result.exit_code == 0
234 assert (tmp_path / ".muse" / "agent.md").exists()
235
236 def test_agent_md_contains_muse_rule(self, tmp_path: pathlib.Path) -> None:
237 _init_repo(tmp_path)
238 _invoke(tmp_path, ["agent-config", "init"])
239 content = (tmp_path / ".muse" / "agent.md").read_text()
240 assert "Muse" in content
241 assert "git" in content.lower() # the no-git rule mentions "git"
242
243 def test_agent_md_contains_branch_flow(self, tmp_path: pathlib.Path) -> None:
244 _init_repo(tmp_path)
245 _invoke(tmp_path, ["agent-config", "init"])
246 content = (tmp_path / ".muse" / "agent.md").read_text()
247 assert "checkout -b" in content
248
249 def test_agent_md_contains_repo_name(self, tmp_path: pathlib.Path) -> None:
250 _init_repo(tmp_path)
251 _invoke(tmp_path, ["agent-config", "init"])
252 content = (tmp_path / ".muse" / "agent.md").read_text()
253 assert tmp_path.name in content
254
255 def test_no_force_on_existing_exits_1(self, tmp_path: pathlib.Path) -> None:
256 _init_repo(tmp_path)
257 _invoke(tmp_path, ["agent-config", "init"])
258 result = _invoke(tmp_path, ["agent-config", "init"])
259 assert result.exit_code == 1
260 assert "force" in result.output.lower() or "--force" in result.output
261
262 def test_force_overwrites(self, tmp_path: pathlib.Path) -> None:
263 _init_repo(tmp_path)
264 _invoke(tmp_path, ["agent-config", "init"])
265 (tmp_path / ".muse" / "agent.md").write_text("old content")
266 _invoke(tmp_path, ["agent-config", "init", "--force"])
267 content = (tmp_path / ".muse" / "agent.md").read_text()
268 assert content != "old content"
269 assert "Muse" in content
270
271 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
272 _init_repo(tmp_path)
273 result = _invoke(tmp_path, ["agent-config", "init", "--json"])
274 assert result.exit_code == 0
275 data = json.loads(result.output)
276 assert "path" in data
277 assert "scope" in data
278 assert "created" in data
279
280
281 # ---------------------------------------------------------------------------
282 # Integration — init: workspace root
283 # ---------------------------------------------------------------------------
284
285
286 class TestInitWorkspaceRoot:
287 def test_creates_agent_md_at_workspace_root(self, tmp_path: pathlib.Path) -> None:
288 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
289 result = _invoke(tmp_path, ["agent-config", "init"])
290 assert result.exit_code == 0
291 assert (tmp_path / ".muse" / "agent.md").exists()
292
293 def test_workspace_agent_md_lists_members(self, tmp_path: pathlib.Path) -> None:
294 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
295 _invoke(tmp_path, ["agent-config", "init"])
296 content = (tmp_path / ".muse" / "agent.md").read_text()
297 assert "core" in content
298 assert "api" in content
299
300 def test_workspace_agent_md_contains_shared_rules(self, tmp_path: pathlib.Path) -> None:
301 _init_workspace(tmp_path, [("core", "core")])
302 _invoke(tmp_path, ["agent-config", "init"])
303 content = (tmp_path / ".muse" / "agent.md").read_text()
304 assert "Muse" in content
305 assert "git" in content.lower()
306
307
308 # ---------------------------------------------------------------------------
309 # Integration — init: workspace member
310 # ---------------------------------------------------------------------------
311
312
313 class TestInitWorkspaceMember:
314 def test_creates_repo_level_agent_md(self, tmp_path: pathlib.Path) -> None:
315 _init_workspace(tmp_path, [("core", "core")])
316 repo = tmp_path / "core"
317 repo.mkdir()
318 _init_repo(repo)
319 result = _invoke(repo, ["agent-config", "init"])
320 assert result.exit_code == 0
321 assert (repo / ".muse" / "agent.md").exists()
322
323 def test_member_agent_md_references_workspace(self, tmp_path: pathlib.Path) -> None:
324 _init_workspace(tmp_path, [("core", "core")])
325 repo = tmp_path / "core"
326 repo.mkdir()
327 _init_repo(repo)
328 _invoke(repo, ["agent-config", "init"])
329 content = (repo / ".muse" / "agent.md").read_text()
330 # Should mention the workspace or link to the parent config
331 assert "workspace" in content.lower() or ".muse/agent.md" in content
332
333
334 # ---------------------------------------------------------------------------
335 # Integration — sync
336 # ---------------------------------------------------------------------------
337
338
339 class TestSync:
340 @pytest.fixture()
341 def standalone(self, tmp_path: pathlib.Path) -> pathlib.Path:
342 _init_repo(tmp_path)
343 _invoke(tmp_path, ["agent-config", "init"])
344 return tmp_path
345
346 def test_sync_creates_claude_md(self, standalone: pathlib.Path) -> None:
347 result = _invoke(standalone, ["agent-config", "sync"])
348 assert result.exit_code == 0
349 assert (standalone / "CLAUDE.md").exists()
350
351 def test_sync_creates_agents_md(self, standalone: pathlib.Path) -> None:
352 _invoke(standalone, ["agent-config", "sync"])
353 assert (standalone / "AGENTS.md").exists()
354
355 def test_sync_creates_cursorrules(self, standalone: pathlib.Path) -> None:
356 _invoke(standalone, ["agent-config", "sync"])
357 assert (standalone / ".cursorrules").exists()
358
359
360 def test_sync_creates_windsurfrules(self, standalone: pathlib.Path) -> None:
361 _invoke(standalone, ["agent-config", "sync"])
362 assert (standalone / ".windsurfrules").exists()
363
364 def test_claude_md_uses_include_syntax(self, standalone: pathlib.Path) -> None:
365 _invoke(standalone, ["agent-config", "sync"])
366 content = (standalone / "CLAUDE.md").read_text()
367 assert "@.muse/agent.md" in content
368
369 def test_agents_md_embeds_content(self, standalone: pathlib.Path) -> None:
370 _invoke(standalone, ["agent-config", "sync"])
371 agent_md_content = (standalone / ".muse" / "agent.md").read_text()
372 agents_md_content = (standalone / "AGENTS.md").read_text()
373 # Should contain actual text, not an @ include
374 assert "@" not in agents_md_content.split("\n")[2] # not just an include
375 # Should contain meaningful content from agent.md
376 assert "Muse" in agents_md_content
377
378 def test_sync_adapters_flag_limits_output(self, standalone: pathlib.Path) -> None:
379 result = _invoke(standalone, ["agent-config", "sync", "--adapters", "claude"])
380 assert result.exit_code == 0
381 assert (standalone / "CLAUDE.md").exists()
382 assert not (standalone / "AGENTS.md").exists()
383
384 def test_dry_run_creates_no_files(self, standalone: pathlib.Path) -> None:
385 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
386 assert result.exit_code == 0
387 assert not (standalone / "CLAUDE.md").exists()
388 assert not (standalone / "AGENTS.md").exists()
389
390 def test_dry_run_prints_what_would_be_written(self, standalone: pathlib.Path) -> None:
391 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
392 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
393
394 def test_no_force_on_existing_exits_1(self, standalone: pathlib.Path) -> None:
395 _invoke(standalone, ["agent-config", "sync"])
396 result = _invoke(standalone, ["agent-config", "sync"])
397 assert result.exit_code == 1
398
399 def test_force_overwrites_existing(self, standalone: pathlib.Path) -> None:
400 _invoke(standalone, ["agent-config", "sync"])
401 (standalone / "CLAUDE.md").write_text("old content")
402 result = _invoke(standalone, ["agent-config", "sync", "--force"])
403 assert result.exit_code == 0
404 content = (standalone / "CLAUDE.md").read_text()
405 assert content != "old content"
406
407 def test_missing_agent_md_exits_1(self, tmp_path: pathlib.Path) -> None:
408 _init_repo(tmp_path)
409 result = _invoke(tmp_path, ["agent-config", "sync"])
410 assert result.exit_code == 1
411 assert "agent.md" in result.output.lower() or "init" in result.output.lower()
412
413 def test_json_schema(self, standalone: pathlib.Path) -> None:
414 result = _invoke(standalone, ["agent-config", "sync", "--json"])
415 assert result.exit_code == 0
416 data = json.loads(result.output)
417 assert "adapters" in data
418 assert isinstance(data["adapters"], list)
419 for entry in data["adapters"]:
420 assert "name" in entry
421 assert "path" in entry
422 assert "written" in entry
423
424 def test_workspace_member_claude_includes_both_levels(
425 self, tmp_path: pathlib.Path
426 ) -> None:
427 _init_workspace(tmp_path, [("core", "core")])
428 # Init workspace-level agent.md
429 _invoke(tmp_path, ["agent-config", "init"])
430 # Init and sync repo-level
431 repo = tmp_path / "core"
432 repo.mkdir()
433 _init_repo(repo)
434 _invoke(repo, ["agent-config", "init"])
435 _invoke(repo, ["agent-config", "sync"])
436 content = (repo / "CLAUDE.md").read_text()
437 # Should include both workspace level and repo level
438 assert "agent.md" in content
439 # Workspace-level reference should be present (parent path)
440 assert ".." in content
441
442
443 # ---------------------------------------------------------------------------
444 # Integration — show
445 # ---------------------------------------------------------------------------
446
447
448 class TestShow:
449 def test_show_prints_agent_md_content(self, tmp_path: pathlib.Path) -> None:
450 _init_repo(tmp_path)
451 _invoke(tmp_path, ["agent-config", "init"])
452 result = _invoke(tmp_path, ["agent-config", "show"])
453 assert result.exit_code == 0
454 agent_md = (tmp_path / ".muse" / "agent.md").read_text()
455 assert agent_md.strip() in result.output
456
457 def test_show_missing_exits_1(self, tmp_path: pathlib.Path) -> None:
458 _init_repo(tmp_path)
459 result = _invoke(tmp_path, ["agent-config", "show"])
460 assert result.exit_code == 1
461
462 def test_show_json_schema(self, tmp_path: pathlib.Path) -> None:
463 _init_repo(tmp_path)
464 _invoke(tmp_path, ["agent-config", "init"])
465 result = _invoke(tmp_path, ["agent-config", "show", "--json"])
466 assert result.exit_code == 0
467 data = json.loads(result.output)
468 assert "content" in data
469 assert "path" in data
470 assert "scope" in data
471
472 def test_show_merged_workspace(self, tmp_path: pathlib.Path) -> None:
473 _init_workspace(tmp_path, [("core", "core")])
474 _invoke(tmp_path, ["agent-config", "init"])
475 repo = tmp_path / "core"
476 repo.mkdir()
477 _init_repo(repo)
478 _invoke(repo, ["agent-config", "init"])
479 result = _invoke(repo, ["agent-config", "show", "--scope", "merged"])
480 assert result.exit_code == 0
481 # Should include content from both levels
482 ws_content = (tmp_path / ".muse" / "agent.md").read_text()
483 repo_content = (repo / ".muse" / "agent.md").read_text()
484 assert ws_content[:30] in result.output or repo_content[:30] in result.output
485
486
487 # ---------------------------------------------------------------------------
488 # Integration — status
489 # ---------------------------------------------------------------------------
490
491
492 class TestStatus:
493 def test_status_before_sync(self, tmp_path: pathlib.Path) -> None:
494 _init_repo(tmp_path)
495 _invoke(tmp_path, ["agent-config", "init"])
496 result = _invoke(tmp_path, ["agent-config", "status"])
497 assert result.exit_code == 0
498 # All adapters should show as missing
499 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
500
501 def test_status_after_sync(self, tmp_path: pathlib.Path) -> None:
502 _init_repo(tmp_path)
503 _invoke(tmp_path, ["agent-config", "init"])
504 _invoke(tmp_path, ["agent-config", "sync"])
505 result = _invoke(tmp_path, ["agent-config", "status"])
506 assert result.exit_code == 0
507
508 def test_status_json_schema(self, tmp_path: pathlib.Path) -> None:
509 _init_repo(tmp_path)
510 _invoke(tmp_path, ["agent-config", "init"])
511 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
512 assert result.exit_code == 0
513 data = json.loads(result.output)
514 assert "agent_md" in data
515 assert "adapters" in data
516 for entry in data["adapters"]:
517 assert "name" in entry
518 assert "filename" in entry
519 assert "exists" in entry
520 assert "in_sync" in entry
521
522 def test_status_shows_out_of_sync_after_edit(self, tmp_path: pathlib.Path) -> None:
523 _init_repo(tmp_path)
524 _invoke(tmp_path, ["agent-config", "init"])
525 _invoke(tmp_path, ["agent-config", "sync"])
526 # Modify agent.md without re-syncing
527 agent_md = tmp_path / ".muse" / "agent.md"
528 agent_md.write_text(agent_md.read_text() + "\n# NEW RULE\n")
529 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
530 data = json.loads(result.output)
531 # At least one embed adapter should be out of sync
532 embed_adapters = [a for a in data["adapters"] if a["name"] != "claude"]
533 assert any(not a["in_sync"] for a in embed_adapters)
534
535
536 # ---------------------------------------------------------------------------
537 # Unit — _load_configured_adapters
538 # ---------------------------------------------------------------------------
539
540
541 class TestLoadConfiguredAdapters:
542 def test_returns_none_when_no_config_toml(self, tmp_path: pathlib.Path) -> None:
543 from muse.cli.commands.agent_config import _load_configured_adapters
544 _init_repo(tmp_path)
545 assert _load_configured_adapters(tmp_path) is None
546
547 def test_returns_none_when_no_agent_config_section(self, tmp_path: pathlib.Path) -> None:
548 from muse.cli.commands.agent_config import _load_configured_adapters
549 _init_repo(tmp_path)
550 (tmp_path / ".muse" / "config.toml").write_text('[hub]\nurl = "http://localhost:10003"\n')
551 assert _load_configured_adapters(tmp_path) is None
552
553 def test_returns_list_when_set(self, tmp_path: pathlib.Path) -> None:
554 from muse.cli.commands.agent_config import _load_configured_adapters
555 _init_repo(tmp_path)
556 (tmp_path / ".muse" / "config.toml").write_text('[agent-config]\nadapters = ["claude", "codex"]\n')
557 assert _load_configured_adapters(tmp_path) == ["claude", "codex"]
558
559 def test_returns_none_for_malformed_list(self, tmp_path: pathlib.Path) -> None:
560 from muse.cli.commands.agent_config import _load_configured_adapters
561 _init_repo(tmp_path)
562 (tmp_path / ".muse" / "config.toml").write_text('[agent-config]\nadapters = "not-a-list"\n')
563 assert _load_configured_adapters(tmp_path) is None
564
565 def test_returns_none_for_corrupt_toml(self, tmp_path: pathlib.Path) -> None:
566 from muse.cli.commands.agent_config import _load_configured_adapters
567 _init_repo(tmp_path)
568 (tmp_path / ".muse" / "config.toml").write_text("[[[[invalid toml")
569 assert _load_configured_adapters(tmp_path) is None
570
571
572 # ---------------------------------------------------------------------------
573 # Integration — set subcommand
574 # ---------------------------------------------------------------------------
575
576
577 class TestSet:
578 def test_writes_config_toml(self, tmp_path: pathlib.Path) -> None:
579 _init_repo(tmp_path)
580 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
581 assert result.exit_code == 0
582 config = (tmp_path / ".muse" / "config.toml").read_text()
583 assert "claude" in config
584 assert "codex" in config
585
586 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
587 _init_repo(tmp_path)
588 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
589 assert result.exit_code == 0
590 data = json.loads(result.output)
591 assert "adapters" in data
592 assert "path" in data
593 assert data["adapters"] == ["claude"]
594
595 def test_updates_existing_section(self, tmp_path: pathlib.Path) -> None:
596 _init_repo(tmp_path)
597 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
598 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
599 config = (tmp_path / ".muse" / "config.toml").read_text()
600 # Only one [agent-config] section
601 assert config.count("[agent-config]") == 1
602 # codex no longer present in the adapters list
603 import tomllib
604 raw = tomllib.loads(config)
605 assert raw["agent-config"]["adapters"] == ["claude"]
606
607 def test_preserves_other_config_sections(self, tmp_path: pathlib.Path) -> None:
608 _init_repo(tmp_path)
609 (tmp_path / ".muse" / "config.toml").write_text('[hub]\nurl = "http://localhost:10003"\n')
610 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
611 config = (tmp_path / ".muse" / "config.toml").read_text()
612 assert "[hub]" in config
613 assert "localhost:10003" in config
614 assert "[agent-config]" in config
615
616 def test_unknown_adapter_exits_1(self, tmp_path: pathlib.Path) -> None:
617 _init_repo(tmp_path)
618 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
619 assert result.exit_code == 1
620
621 def test_unknown_adapter_error_message(self, tmp_path: pathlib.Path) -> None:
622 _init_repo(tmp_path)
623 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
624 assert "vscode" in result.output.lower() or "unknown" in result.output.lower()
625
626 def test_single_adapter(self, tmp_path: pathlib.Path) -> None:
627 _init_repo(tmp_path)
628 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
629 assert result.exit_code == 0
630 assert json.loads(result.output)["adapters"] == ["claude"]
631
632 def test_all_adapters_accepted(self, tmp_path: pathlib.Path) -> None:
633 from muse.cli.commands.agent_config import _ADAPTERS
634 _init_repo(tmp_path)
635 all_names = ",".join(_ADAPTERS.keys())
636 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", all_names])
637 assert result.exit_code == 0
638
639
640 # ---------------------------------------------------------------------------
641 # Integration — sync priority chain
642 # ---------------------------------------------------------------------------
643
644
645 class TestSyncPriorityChain:
646 def test_config_toml_limits_adapters(self, tmp_path: pathlib.Path) -> None:
647 """[agent-config] adapters in config.toml limits sync without --adapters."""
648 _init_repo(tmp_path)
649 _invoke(tmp_path, ["agent-config", "init"])
650 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
651 result = _invoke(tmp_path, ["agent-config", "sync"])
652 assert result.exit_code == 0
653 assert (tmp_path / "CLAUDE.md").exists()
654 assert not (tmp_path / "AGENTS.md").exists()
655
656 def test_cli_adapters_flag_overrides_config_toml(self, tmp_path: pathlib.Path) -> None:
657 """--adapters on CLI takes priority over config.toml setting."""
658 _init_repo(tmp_path)
659 _invoke(tmp_path, ["agent-config", "init"])
660 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
661 result = _invoke(tmp_path, ["agent-config", "sync", "--adapters", "codex"])
662 assert result.exit_code == 0
663 assert (tmp_path / "AGENTS.md").exists()
664 assert not (tmp_path / "CLAUDE.md").exists()
665
666 def test_no_config_generates_all_adapters(self, tmp_path: pathlib.Path) -> None:
667 """Without config.toml setting, all adapters are generated."""
668 from muse.cli.commands.agent_config import _ADAPTERS
669 _init_repo(tmp_path)
670 _invoke(tmp_path, ["agent-config", "init"])
671 _invoke(tmp_path, ["agent-config", "sync"])
672 for spec in _ADAPTERS.values():
673 assert (tmp_path / spec["filename"]).exists(), f"missing {spec['filename']}"
674
675
676 # ---------------------------------------------------------------------------
677 # E2E — full workflow
678 # ---------------------------------------------------------------------------
679
680
681 class TestE2EFullWorkflow:
682 def test_init_set_sync_edit_status_resync(self, tmp_path: pathlib.Path) -> None:
683 """Complete agent-config lifecycle: init → set → sync → edit → out-of-sync → fix."""
684 _init_repo(tmp_path)
685
686 # init
687 r = _invoke(tmp_path, ["agent-config", "init"])
688 assert r.exit_code == 0
689 assert (tmp_path / ".muse" / "agent.md").exists()
690
691 # set
692 r = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
693 assert r.exit_code == 0
694
695 # sync
696 r = _invoke(tmp_path, ["agent-config", "sync"])
697 assert r.exit_code == 0
698 assert (tmp_path / "CLAUDE.md").exists()
699 assert (tmp_path / "AGENTS.md").exists()
700
701 # status — in sync
702 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
703 data = json.loads(r.output)
704 active = [a for a in data["adapters"] if a["exists"]]
705 assert all(a["in_sync"] for a in active)
706
707 # edit agent.md
708 agent_md = tmp_path / ".muse" / "agent.md"
709 agent_md.write_text(agent_md.read_text() + "\n# EXTRA RULE\n")
710
711 # status — codex out of sync (embed adapter)
712 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
713 data = json.loads(r.output)
714 codex = next(a for a in data["adapters"] if a["name"] == "codex")
715 assert not codex["in_sync"]
716
717 # sync --force
718 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
719 assert r.exit_code == 0
720
721 # status — back in sync
722 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
723 data = json.loads(r.output)
724 active = [a for a in data["adapters"] if a["exists"]]
725 assert all(a["in_sync"] for a in active)
726
727 # verify new content is in AGENTS.md
728 assert "EXTRA RULE" in (tmp_path / "AGENTS.md").read_text()
729
730 def test_workspace_e2e(self, tmp_path: pathlib.Path) -> None:
731 """Workspace hierarchy: shared rules flow into member CLAUDE.md."""
732 _init_workspace(tmp_path, [("core", "core")])
733 _invoke(tmp_path, ["agent-config", "init"])
734
735 repo = tmp_path / "core"
736 repo.mkdir()
737 _init_repo(repo)
738 _invoke(repo, ["agent-config", "init"])
739 _invoke(repo, ["agent-config", "sync"])
740
741 claude = (repo / "CLAUDE.md").read_text()
742 assert "@../.muse/agent.md" in claude
743 assert "@.muse/agent.md" in claude
744
745
746 # ---------------------------------------------------------------------------
747 # Stress
748 # ---------------------------------------------------------------------------
749
750
751 class TestStress:
752 def test_large_agent_md_syncs(self, tmp_path: pathlib.Path) -> None:
753 """200 KB agent.md embeds correctly into AGENTS.md."""
754 _init_repo(tmp_path)
755 _invoke(tmp_path, ["agent-config", "init"])
756 # Overwrite with 200 KB of content
757 large = "# Rule\n" + ("x" * 200) + "\n"
758 large_content = large * 1000 # ~200 KB
759 (tmp_path / ".muse" / "agent.md").write_text(large_content)
760 result = _invoke(tmp_path, ["agent-config", "sync"])
761 assert result.exit_code == 0
762 agents_md = (tmp_path / "AGENTS.md").read_text()
763 assert len(agents_md) > 100_000
764
765 def test_rapid_sequential_syncs(self, tmp_path: pathlib.Path) -> None:
766 """30 sequential sync --force calls produce consistent output."""
767 _init_repo(tmp_path)
768 _invoke(tmp_path, ["agent-config", "init"])
769 _invoke(tmp_path, ["agent-config", "sync"])
770 content_before = (tmp_path / "AGENTS.md").read_text()
771 for _ in range(30):
772 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
773 assert r.exit_code == 0
774 assert (tmp_path / "AGENTS.md").read_text() == content_before
775
776 def test_concurrent_sync_no_corruption(self, tmp_path: pathlib.Path) -> None:
777 """Concurrent sync --force calls never produce a torn file."""
778 _init_repo(tmp_path)
779 _invoke(tmp_path, ["agent-config", "init"])
780 _invoke(tmp_path, ["agent-config", "sync"])
781 expected = (tmp_path / "AGENTS.md").read_text()
782
783 errors: list[str] = []
784
785 def sync() -> None:
786 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
787 if r.exit_code != 0:
788 errors.append(r.output)
789
790 threads = [threading.Thread(target=sync) for _ in range(8)]
791 for t in threads:
792 t.start()
793 for t in threads:
794 t.join()
795
796 assert not errors
797 # File is never empty or partial — must be valid content
798 result = (tmp_path / "AGENTS.md").read_text()
799 assert len(result) > 0
800 assert "Muse" in result
801
802
803 # ---------------------------------------------------------------------------
804 # Data Integrity
805 # ---------------------------------------------------------------------------
806
807
808 class TestDataIntegrity:
809 def test_corrupt_config_toml_falls_back_to_all_adapters(
810 self, tmp_path: pathlib.Path
811 ) -> None:
812 """Corrupt config.toml is silently ignored — all adapters generated."""
813 from muse.cli.commands.agent_config import _ADAPTERS
814 _init_repo(tmp_path)
815 _invoke(tmp_path, ["agent-config", "init"])
816 (tmp_path / ".muse" / "config.toml").write_text("[[[[not valid toml")
817 result = _invoke(tmp_path, ["agent-config", "sync"])
818 assert result.exit_code == 0
819 for spec in _ADAPTERS.values():
820 assert (tmp_path / spec["filename"]).exists()
821
822 def test_adapter_file_not_empty_after_sync(self, tmp_path: pathlib.Path) -> None:
823 """Every generated adapter file has non-zero content."""
824 from muse.cli.commands.agent_config import _ADAPTERS
825 _init_repo(tmp_path)
826 _invoke(tmp_path, ["agent-config", "init"])
827 _invoke(tmp_path, ["agent-config", "sync"])
828 for spec in _ADAPTERS.values():
829 p = tmp_path / spec["filename"]
830 assert p.stat().st_size > 0, f"{spec['filename']} is empty"
831
832 def test_sync_write_is_atomic(self, tmp_path: pathlib.Path) -> None:
833 """After sync, AGENTS.md is a complete file — not truncated mid-write."""
834 _init_repo(tmp_path)
835 _invoke(tmp_path, ["agent-config", "init"])
836 _invoke(tmp_path, ["agent-config", "sync"])
837 content = (tmp_path / "AGENTS.md").read_text()
838 # Content should end with a newline, not be truncated mid-line
839 assert content.endswith("\n")
840
841 def test_set_preserves_existing_config_integrity(
842 self, tmp_path: pathlib.Path
843 ) -> None:
844 """set writes valid TOML that can be re-parsed."""
845 import tomllib
846 _init_repo(tmp_path)
847 (tmp_path / ".muse" / "config.toml").write_text(
848 '[hub]\nurl = "http://localhost:10003"\n\n[limits]\nmax_file_size_mb = 10\n'
849 )
850 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
851 raw = tomllib.loads((tmp_path / ".muse" / "config.toml").read_text())
852 assert raw["hub"]["url"] == "http://localhost:10003"
853 assert raw["limits"]["max_file_size_mb"] == 10
854 assert raw["agent-config"]["adapters"] == ["claude", "codex"]
855
856
857 # ---------------------------------------------------------------------------
858 # Performance
859 # ---------------------------------------------------------------------------
860
861
862 class TestPerformance:
863 def test_sync_completes_under_2_seconds(self, tmp_path: pathlib.Path) -> None:
864 """sync with default adapters completes in under 2 seconds."""
865 _init_repo(tmp_path)
866 _invoke(tmp_path, ["agent-config", "init"])
867 start = time.monotonic()
868 _invoke(tmp_path, ["agent-config", "sync"])
869 elapsed = time.monotonic() - start
870 assert elapsed < 2.0, f"sync took {elapsed:.2f}s — too slow"
871
872 def test_status_completes_under_1_second(self, tmp_path: pathlib.Path) -> None:
873 """status check completes in under 1 second."""
874 _init_repo(tmp_path)
875 _invoke(tmp_path, ["agent-config", "init"])
876 _invoke(tmp_path, ["agent-config", "sync"])
877 start = time.monotonic()
878 _invoke(tmp_path, ["agent-config", "status", "--json"])
879 elapsed = time.monotonic() - start
880 assert elapsed < 1.0, f"status took {elapsed:.2f}s — too slow"
881
882
883 # ---------------------------------------------------------------------------
884 # Security
885 # ---------------------------------------------------------------------------
886
887
888 class TestSecurity:
889 def test_set_rejects_path_traversal_in_adapter_name(
890 self, tmp_path: pathlib.Path
891 ) -> None:
892 """set does not accept adapter names containing path separators."""
893 _init_repo(tmp_path)
894 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "../evil"])
895 assert result.exit_code == 1
896
897 def test_set_rejects_adapter_with_null_byte(
898 self, tmp_path: pathlib.Path
899 ) -> None:
900 """set rejects adapter names containing null bytes."""
901 _init_repo(tmp_path)
902 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude\x00evil"])
903 assert result.exit_code == 1
904
905 def test_agent_md_with_null_bytes_does_not_crash_sync(
906 self, tmp_path: pathlib.Path
907 ) -> None:
908 """agent.md containing null bytes is handled without an unhandled exception."""
909 _init_repo(tmp_path)
910 _invoke(tmp_path, ["agent-config", "init"])
911 # Write null bytes into agent.md
912 agent_md = tmp_path / ".muse" / "agent.md"
913 agent_md.write_bytes(agent_md.read_bytes() + b"\x00\x00malicious\x00")
914 # Should not raise — exit code may be 0 or 1 but must not be an unhandled exception
915 result = _invoke(tmp_path, ["agent-config", "sync"])
916 assert result.exit_code in (0, 1)
917
918 def test_toml_injection_in_config_does_not_escape_section(
919 self, tmp_path: pathlib.Path
920 ) -> None:
921 """A crafted adapter name cannot inject extra TOML sections."""
922 import tomllib
923 _init_repo(tmp_path)
924 # Attempt to inject a new TOML section via adapter name
925 result = _invoke(
926 tmp_path,
927 ["agent-config", "set", "--adapters", 'claude"]\n[injected'],
928 )
929 # Should fail with unknown adapter error, not write injected TOML
930 assert result.exit_code == 1
931 config_path = tmp_path / ".muse" / "config.toml"
932 if config_path.exists():
933 raw = tomllib.loads(config_path.read_text())
934 assert "injected" not in raw
File History 2 commits