gabriel / muse public
test_cmd_agent_config.py python
1,417 lines 59.3 KB
Raw
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 3 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 argparse
72 import json
73 import pathlib
74 import time
75 import threading
76
77 import pytest
78
79 from muse.core.paths import agent_md_path, config_toml_path, muse_dir
80 from tests.cli_test_helper import CliRunner
81
82 runner = CliRunner()
83
84
85 # ---------------------------------------------------------------------------
86 # Helpers
87 # ---------------------------------------------------------------------------
88
89
90 def _invoke(path: pathlib.Path, args: list[str]) -> "InvokeResult":
91 import os
92 saved = os.getcwd()
93 try:
94 os.chdir(path)
95 return runner.invoke(None, args)
96 finally:
97 os.chdir(saved)
98
99
100 def _init_repo(path: pathlib.Path, domain: str = "code") -> None:
101 r = _invoke(path, ["init", "--domain", domain])
102 assert r.exit_code == 0, r.output
103
104
105 def _init_with_all_adapters(path: pathlib.Path) -> None:
106 """init + set all adapters — use in tests that need all adapter files generated."""
107 _init_repo(path)
108 _invoke(path, ["agent-config", "init"])
109 _invoke(path, ["agent-config", "set", "--adapters", "claude,codex,cursor,windsurf"])
110
111
112 def _init_workspace(path: pathlib.Path, members: list[tuple[str, str]]) -> None:
113 """Create a workspace manifest at path with given (name, rel_path) members."""
114 dot_muse = muse_dir(path)
115 dot_muse.mkdir(parents=True, exist_ok=True)
116 lines = [""]
117 for name, rel in members:
118 lines += [
119 "[[members]]",
120 f'name = "{name}"',
121 f'url = "https://localhost:1337/gabriel/{name}"',
122 f'path = "{rel}"',
123 'branch = "main"',
124 "",
125 ]
126 (dot_muse / "workspace.toml").write_text("\n".join(lines))
127
128
129 # ---------------------------------------------------------------------------
130 # Unit — _detect_context
131 # ---------------------------------------------------------------------------
132
133
134 class TestDetectContext:
135 def test_standalone_repo(self, tmp_path: pathlib.Path) -> None:
136 from muse.cli.commands.agent_config import _detect_context
137 _init_repo(tmp_path)
138 kind, ws = _detect_context(tmp_path)
139 assert kind == "standalone"
140 assert ws is None
141
142 def test_workspace_root(self, tmp_path: pathlib.Path) -> None:
143 from muse.cli.commands.agent_config import _detect_context
144 _init_workspace(tmp_path, [("muse", "muse")])
145 kind, ws = _detect_context(tmp_path)
146 assert kind == "workspace_root"
147 assert ws == tmp_path
148
149 def test_workspace_member(self, tmp_path: pathlib.Path) -> None:
150 from muse.cli.commands.agent_config import _detect_context
151 _init_workspace(tmp_path, [("core", "core")])
152 repo = tmp_path / "core"
153 repo.mkdir()
154 _init_repo(repo)
155 kind, ws = _detect_context(repo)
156 assert kind == "workspace_member"
157 assert ws == tmp_path
158
159
160 # ---------------------------------------------------------------------------
161 # Unit — _compute_rel_path
162 # ---------------------------------------------------------------------------
163
164
165 class TestComputeRelPath:
166 def test_direct_child(self, tmp_path: pathlib.Path) -> None:
167 from muse.cli.commands.agent_config import _compute_rel_path
168 ws = tmp_path / "ws"
169 repo = tmp_path / "ws" / "core"
170 ws.mkdir(), repo.mkdir()
171 assert _compute_rel_path(repo, ws) == ".."
172
173 def test_nested_child(self, tmp_path: pathlib.Path) -> None:
174 from muse.cli.commands.agent_config import _compute_rel_path
175 ws = tmp_path / "ws"
176 repo = tmp_path / "ws" / "packages" / "foo"
177 repo.mkdir(parents=True)
178 assert _compute_rel_path(repo, ws) == "../.."
179
180 def test_same_dir(self, tmp_path: pathlib.Path) -> None:
181 from muse.cli.commands.agent_config import _compute_rel_path
182 assert _compute_rel_path(tmp_path, tmp_path) == "."
183
184
185 # ---------------------------------------------------------------------------
186 # Unit — _render_adapter
187 # ---------------------------------------------------------------------------
188
189
190 class TestRenderAdapter:
191 def test_include_adapter_uses_at_syntax(self) -> None:
192 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
193 spec = _ADAPTERS["claude"]
194 result = _render_adapter(spec, repo_agent_md=".muse/agent.md", ws_agent_md=None)
195 assert "@.muse/agent.md" in result
196 assert "embed" not in result.lower()
197
198 def test_include_adapter_with_workspace(self) -> None:
199 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
200 spec = _ADAPTERS["claude"]
201 result = _render_adapter(spec, repo_agent_md=".muse/agent.md", ws_agent_md="../.muse/agent.md")
202 assert "@../.muse/agent.md" in result
203 assert "@.muse/agent.md" in result
204
205 def test_embed_adapter_contains_content(self) -> None:
206 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
207 spec = _ADAPTERS["codex"]
208 result = _render_adapter(
209 spec,
210 repo_agent_md=".muse/agent.md",
211 ws_agent_md=None,
212 repo_agent_content="# My Agent Config\nsome rules",
213 ws_agent_content=None,
214 )
215 assert "# My Agent Config" in result
216 assert "some rules" in result
217
218 def test_embed_adapter_with_workspace_prepends_ws_content(self) -> None:
219 from muse.cli.commands.agent_config import _render_adapter, _ADAPTERS
220 spec = _ADAPTERS["codex"]
221 result = _render_adapter(
222 spec,
223 repo_agent_md=".muse/agent.md",
224 ws_agent_md="../.muse/agent.md",
225 repo_agent_content="# Repo Config",
226 ws_agent_content="# Workspace Config",
227 )
228 ws_pos = result.index("# Workspace Config")
229 repo_pos = result.index("# Repo Config")
230 assert ws_pos < repo_pos # workspace content comes first
231
232
233 # ---------------------------------------------------------------------------
234 # Integration — init: standalone repo
235 # ---------------------------------------------------------------------------
236
237
238 class TestInitStandalone:
239 def test_creates_agent_md(self, tmp_path: pathlib.Path) -> None:
240 _init_repo(tmp_path)
241 result = _invoke(tmp_path, ["agent-config", "init"])
242 assert result.exit_code == 0
243 assert (agent_md_path(tmp_path)).exists()
244
245 def test_agent_md_contains_muse_rule(self, tmp_path: pathlib.Path) -> None:
246 _init_repo(tmp_path)
247 _invoke(tmp_path, ["agent-config", "init"])
248 content = (agent_md_path(tmp_path)).read_text()
249 assert "Muse" in content
250 assert "git" in content.lower() # the no-git rule mentions "git"
251
252 def test_agent_md_contains_branch_flow(self, tmp_path: pathlib.Path) -> None:
253 _init_repo(tmp_path)
254 _invoke(tmp_path, ["agent-config", "init"])
255 content = (agent_md_path(tmp_path)).read_text()
256 assert "checkout -b" in content
257
258 def test_agent_md_contains_repo_name(self, tmp_path: pathlib.Path) -> None:
259 _init_repo(tmp_path)
260 _invoke(tmp_path, ["agent-config", "init"])
261 content = (agent_md_path(tmp_path)).read_text()
262 assert tmp_path.name in content
263
264 def test_no_force_on_existing_exits_1(self, tmp_path: pathlib.Path) -> None:
265 _init_repo(tmp_path)
266 _invoke(tmp_path, ["agent-config", "init"])
267 result = _invoke(tmp_path, ["agent-config", "init"])
268 assert result.exit_code == 1
269 assert "force" in result.stderr.lower() or "--force" in result.stderr
270
271 def test_force_overwrites(self, tmp_path: pathlib.Path) -> None:
272 _init_repo(tmp_path)
273 _invoke(tmp_path, ["agent-config", "init"])
274 (agent_md_path(tmp_path)).write_text("old content")
275 _invoke(tmp_path, ["agent-config", "init", "--force"])
276 content = (agent_md_path(tmp_path)).read_text()
277 assert content != "old content"
278 assert "Muse" in content
279
280 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
281 _init_repo(tmp_path)
282 result = _invoke(tmp_path, ["agent-config", "init", "--json"])
283 assert result.exit_code == 0
284 data = json.loads(result.output)
285 assert "path" in data
286 assert "scope" in data
287 assert "created" in data
288
289
290 # ---------------------------------------------------------------------------
291 # Integration — init: workspace root
292 # ---------------------------------------------------------------------------
293
294
295 class TestInitWorkspaceRoot:
296 def test_creates_agent_md_at_workspace_root(self, tmp_path: pathlib.Path) -> None:
297 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
298 result = _invoke(tmp_path, ["agent-config", "init"])
299 assert result.exit_code == 0
300 assert (agent_md_path(tmp_path)).exists()
301
302 def test_workspace_agent_md_lists_members(self, tmp_path: pathlib.Path) -> None:
303 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
304 _invoke(tmp_path, ["agent-config", "init"])
305 content = (agent_md_path(tmp_path)).read_text()
306 assert "core" in content
307 assert "api" in content
308
309 def test_workspace_agent_md_contains_shared_rules(self, tmp_path: pathlib.Path) -> None:
310 _init_workspace(tmp_path, [("core", "core")])
311 _invoke(tmp_path, ["agent-config", "init"])
312 content = (agent_md_path(tmp_path)).read_text()
313 assert "Muse" in content
314 assert "git" in content.lower()
315
316
317 # ---------------------------------------------------------------------------
318 # Integration — init: workspace member
319 # ---------------------------------------------------------------------------
320
321
322 class TestInitWorkspaceMember:
323 def test_creates_repo_level_agent_md(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 result = _invoke(repo, ["agent-config", "init"])
329 assert result.exit_code == 0
330 assert (agent_md_path(repo)).exists()
331
332 def test_member_agent_md_references_workspace(self, tmp_path: pathlib.Path) -> None:
333 _init_workspace(tmp_path, [("core", "core")])
334 repo = tmp_path / "core"
335 repo.mkdir()
336 _init_repo(repo)
337 _invoke(repo, ["agent-config", "init"])
338 content = (agent_md_path(repo)).read_text()
339 # Should mention the workspace or link to the parent config
340 assert "workspace" in content.lower() or ".muse/agent.md" in content
341
342
343 # ---------------------------------------------------------------------------
344 # Integration — sync
345 # ---------------------------------------------------------------------------
346
347
348 class TestSync:
349 @pytest.fixture()
350 def standalone(self, tmp_path: pathlib.Path) -> pathlib.Path:
351 _init_repo(tmp_path)
352 _invoke(tmp_path, ["agent-config", "init"])
353 # Explicitly configure all adapters so tests that want specific files work.
354 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex,cursor,windsurf"])
355 return tmp_path
356
357 def test_sync_requires_adapter_config(self, tmp_path: pathlib.Path) -> None:
358 """sync with no [agent-config] section exits with error instead of writing all adapters."""
359 _init_repo(tmp_path)
360 _invoke(tmp_path, ["agent-config", "init"])
361 result = _invoke(tmp_path, ["agent-config", "sync"])
362 assert result.exit_code != 0
363 assert "agent-config set" in result.stderr or "agent-config set" in result.output
364
365 def test_sync_creates_claude_md(self, standalone: pathlib.Path) -> None:
366 result = _invoke(standalone, ["agent-config", "sync"])
367 assert result.exit_code == 0
368 assert (standalone / "CLAUDE.md").exists()
369
370 def test_sync_creates_agents_md(self, standalone: pathlib.Path) -> None:
371 _invoke(standalone, ["agent-config", "sync"])
372 assert (standalone / "AGENTS.md").exists()
373
374 def test_sync_creates_cursorrules(self, standalone: pathlib.Path) -> None:
375 _invoke(standalone, ["agent-config", "sync"])
376 assert (standalone / ".cursorrules").exists()
377
378 def test_sync_creates_windsurfrules(self, standalone: pathlib.Path) -> None:
379 _invoke(standalone, ["agent-config", "sync"])
380 assert (standalone / ".windsurfrules").exists()
381
382 def test_claude_md_uses_include_syntax(self, standalone: pathlib.Path) -> None:
383 _invoke(standalone, ["agent-config", "sync"])
384 content = (standalone / "CLAUDE.md").read_text()
385 assert "@.muse/agent.md" in content
386
387 def test_agents_md_embeds_content(self, standalone: pathlib.Path) -> None:
388 _invoke(standalone, ["agent-config", "sync"])
389 agent_md_content = (agent_md_path(standalone)).read_text()
390 agents_md_content = (standalone / "AGENTS.md").read_text()
391 # Should contain actual text, not an @ include
392 assert "@" not in agents_md_content.split("\n")[2] # not just an include
393 # Should contain meaningful content from agent.md
394 assert "Muse" in agents_md_content
395
396 def test_sync_adapters_flag_limits_output(self, standalone: pathlib.Path) -> None:
397 result = _invoke(standalone, ["agent-config", "sync", "--adapters", "claude"])
398 assert result.exit_code == 0
399 assert (standalone / "CLAUDE.md").exists()
400 assert not (standalone / "AGENTS.md").exists()
401
402 def test_sync_claude_only_config_writes_only_claude(self, tmp_path: pathlib.Path) -> None:
403 """When adapters = [claude], sync writes ONLY CLAUDE.md — nothing else."""
404 _init_repo(tmp_path)
405 _invoke(tmp_path, ["agent-config", "init"])
406 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
407 result = _invoke(tmp_path, ["agent-config", "sync"])
408 assert result.exit_code == 0
409 assert (tmp_path / "CLAUDE.md").exists()
410 assert not (tmp_path / "AGENTS.md").exists()
411 assert not (tmp_path / ".cursorrules").exists()
412 assert not (tmp_path / ".windsurfrules").exists()
413
414 def test_dry_run_creates_no_files(self, standalone: pathlib.Path) -> None:
415 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
416 assert result.exit_code == 0
417 assert not (standalone / "CLAUDE.md").exists()
418 assert not (standalone / "AGENTS.md").exists()
419
420 def test_dry_run_prints_what_would_be_written(self, standalone: pathlib.Path) -> None:
421 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
422 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
423
424 def test_sync_already_in_sync_skips_without_error(self, standalone: pathlib.Path) -> None:
425 """Second sync with no changes skips in-sync files and exits 0."""
426 _invoke(standalone, ["agent-config", "sync"])
427 result = _invoke(standalone, ["agent-config", "sync"])
428 assert result.exit_code == 0
429 # Output should indicate files were skipped
430 assert "in sync" in result.output or "skipped" in result.output.lower() or result.exit_code == 0
431
432 def test_force_overwrites_existing(self, standalone: pathlib.Path) -> None:
433 _invoke(standalone, ["agent-config", "sync"])
434 (standalone / "CLAUDE.md").write_text("old content")
435 result = _invoke(standalone, ["agent-config", "sync", "--force"])
436 assert result.exit_code == 0
437 content = (standalone / "CLAUDE.md").read_text()
438 assert content != "old content"
439
440 def test_missing_agent_md_exits_1(self, tmp_path: pathlib.Path) -> None:
441 _init_repo(tmp_path)
442 result = _invoke(tmp_path, ["agent-config", "sync"])
443 assert result.exit_code == 1
444 assert "agent.md" in result.stderr.lower() or "init" in result.stderr.lower()
445
446 def test_json_schema(self, standalone: pathlib.Path) -> None:
447 result = _invoke(standalone, ["agent-config", "sync", "--json"])
448 assert result.exit_code == 0
449 data = json.loads(result.output)
450 assert "adapters" in data
451 assert isinstance(data["adapters"], list)
452 for entry in data["adapters"]:
453 assert "name" in entry
454 assert "path" in entry
455 assert "written" in entry
456
457 def test_workspace_member_claude_includes_both_levels(
458 self, tmp_path: pathlib.Path
459 ) -> None:
460 _init_workspace(tmp_path, [("core", "core")])
461 # Init workspace-level agent.md
462 _invoke(tmp_path, ["agent-config", "init"])
463 # Init and sync repo-level
464 repo = tmp_path / "core"
465 repo.mkdir()
466 _init_with_all_adapters(repo)
467 _invoke(repo, ["agent-config", "sync"])
468 content = (repo / "CLAUDE.md").read_text()
469 # Should include both workspace level and repo level
470 assert "agent.md" in content
471 # Workspace-level reference should be present (parent path)
472 assert ".." in content
473
474
475 # ---------------------------------------------------------------------------
476 # Integration — read
477 # ---------------------------------------------------------------------------
478
479
480 class TestRead:
481 def test_read_prints_agent_md_content(self, tmp_path: pathlib.Path) -> None:
482 _init_repo(tmp_path)
483 _invoke(tmp_path, ["agent-config", "init"])
484 result = _invoke(tmp_path, ["agent-config", "read"])
485 assert result.exit_code == 0
486 agent_md = (agent_md_path(tmp_path)).read_text()
487 assert agent_md.strip() in result.output
488
489 def test_read_missing_exits_1(self, tmp_path: pathlib.Path) -> None:
490 _init_repo(tmp_path)
491 result = _invoke(tmp_path, ["agent-config", "read"])
492 assert result.exit_code == 1
493
494 def test_read_json_schema(self, tmp_path: pathlib.Path) -> None:
495 _init_repo(tmp_path)
496 _invoke(tmp_path, ["agent-config", "init"])
497 result = _invoke(tmp_path, ["agent-config", "read", "--json"])
498 assert result.exit_code == 0
499 data = json.loads(result.output)
500 assert "content" in data
501 assert "path" in data
502 assert "scope" in data
503
504 def test_read_merged_workspace(self, tmp_path: pathlib.Path) -> None:
505 _init_workspace(tmp_path, [("core", "core")])
506 _invoke(tmp_path, ["agent-config", "init"])
507 repo = tmp_path / "core"
508 repo.mkdir()
509 _init_repo(repo)
510 _invoke(repo, ["agent-config", "init"])
511 result = _invoke(repo, ["agent-config", "read", "--scope", "merged"])
512 assert result.exit_code == 0
513 # Should include content from both levels
514 ws_content = (agent_md_path(tmp_path)).read_text()
515 repo_content = (agent_md_path(repo)).read_text()
516 assert ws_content[:30] in result.output or repo_content[:30] in result.output
517
518
519 # ---------------------------------------------------------------------------
520 # Integration — status
521 # ---------------------------------------------------------------------------
522
523
524 class TestStatus:
525 def test_status_before_sync(self, tmp_path: pathlib.Path) -> None:
526 _init_repo(tmp_path)
527 _invoke(tmp_path, ["agent-config", "init"])
528 result = _invoke(tmp_path, ["agent-config", "status"])
529 assert result.exit_code == 0
530 # All adapters should show as missing
531 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
532
533 def test_status_after_sync(self, tmp_path: pathlib.Path) -> None:
534 _init_repo(tmp_path)
535 _invoke(tmp_path, ["agent-config", "init"])
536 _invoke(tmp_path, ["agent-config", "sync"])
537 result = _invoke(tmp_path, ["agent-config", "status"])
538 assert result.exit_code == 0
539
540 def test_status_json_schema(self, tmp_path: pathlib.Path) -> None:
541 _init_repo(tmp_path)
542 _invoke(tmp_path, ["agent-config", "init"])
543 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
544 assert result.exit_code == 0
545 data = json.loads(result.output)
546 assert "agent_md" in data
547 assert "adapters" in data
548 for entry in data["adapters"]:
549 assert "name" in entry
550 assert "filename" in entry
551 assert "exists" in entry
552 assert "in_sync" in entry
553
554 def test_status_shows_out_of_sync_after_edit(self, tmp_path: pathlib.Path) -> None:
555 _init_repo(tmp_path)
556 _invoke(tmp_path, ["agent-config", "init"])
557 _invoke(tmp_path, ["agent-config", "sync"])
558 # Modify agent.md without re-syncing
559 agent_md = agent_md_path(tmp_path)
560 agent_md.write_text(f"{agent_md.read_text()}\n# NEW RULE\n")
561 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
562 data = json.loads(result.output)
563 # At least one embed adapter should be out of sync
564 embed_adapters = [a for a in data["adapters"] if a["name"] != "claude"]
565 assert any(not a["in_sync"] for a in embed_adapters)
566
567
568 # ---------------------------------------------------------------------------
569 # Unit — _load_configured_adapters
570 # ---------------------------------------------------------------------------
571
572
573 class TestLoadConfiguredAdapters:
574 """All tests isolate user-level config via MUSE_USER_CONFIG_DIR so the real
575 ~/.muse/config.toml never interferes with the expected result."""
576
577 @pytest.fixture(autouse=True)
578 def isolate_user_config(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
579 """Point MUSE_USER_CONFIG_DIR at a fresh tmp dir — no real user config."""
580 user_dir = tmp_path / "user_muse"
581 user_dir.mkdir()
582 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
583
584 def test_returns_none_when_no_config_toml(self, tmp_path: pathlib.Path) -> None:
585 from muse.cli.commands.agent_config import _load_configured_adapters
586 _init_repo(tmp_path)
587 assert _load_configured_adapters(tmp_path) is None
588
589 def test_returns_none_when_no_agent_config_section(self, tmp_path: pathlib.Path) -> None:
590 from muse.cli.commands.agent_config import _load_configured_adapters
591 _init_repo(tmp_path)
592 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
593 assert _load_configured_adapters(tmp_path) is None
594
595 def test_returns_list_when_set(self, tmp_path: pathlib.Path) -> None:
596 from muse.cli.commands.agent_config import _load_configured_adapters
597 _init_repo(tmp_path)
598 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude", "codex"]\n')
599 assert _load_configured_adapters(tmp_path) == ["claude", "codex"]
600
601 def test_returns_none_for_malformed_list(self, tmp_path: pathlib.Path) -> None:
602 from muse.cli.commands.agent_config import _load_configured_adapters
603 _init_repo(tmp_path)
604 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = "not-a-list"\n')
605 assert _load_configured_adapters(tmp_path) is None
606
607 def test_returns_none_for_corrupt_toml(self, tmp_path: pathlib.Path) -> None:
608 from muse.cli.commands.agent_config import _load_configured_adapters
609 _init_repo(tmp_path)
610 (config_toml_path(tmp_path)).write_text("[[[[invalid toml")
611 assert _load_configured_adapters(tmp_path) is None
612
613 def test_falls_back_to_user_config_when_no_repo_config(
614 self, tmp_path: pathlib.Path
615 ) -> None:
616 """When repo has no [agent-config], user-level config is used as fallback."""
617 import os as _os
618 from muse.cli.commands.agent_config import _load_configured_adapters
619 _init_repo(tmp_path)
620 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
621 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["claude"]\n')
622 assert _load_configured_adapters(tmp_path) == ["claude"]
623
624 def test_repo_config_takes_priority_over_user_config(
625 self, tmp_path: pathlib.Path
626 ) -> None:
627 """Repo-level [agent-config] overrides the user-level fallback."""
628 import os as _os
629 from muse.cli.commands.agent_config import _load_configured_adapters
630 _init_repo(tmp_path)
631 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
632 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["codex"]\n')
633 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude"]\n')
634 # Repo says claude; user says codex — repo wins
635 assert _load_configured_adapters(tmp_path) == ["claude"]
636
637 def test_user_config_fallback_absent_returns_none(
638 self, tmp_path: pathlib.Path
639 ) -> None:
640 """Both repo and user config absent → None."""
641 from muse.cli.commands.agent_config import _load_configured_adapters
642 _init_repo(tmp_path)
643 assert _load_configured_adapters(tmp_path) is None
644
645
646 # ---------------------------------------------------------------------------
647 # Integration — set subcommand
648 # ---------------------------------------------------------------------------
649
650
651 class TestSet:
652 def test_writes_config_toml(self, tmp_path: pathlib.Path) -> None:
653 _init_repo(tmp_path)
654 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
655 assert result.exit_code == 0
656 config = (config_toml_path(tmp_path)).read_text()
657 assert "claude" in config
658 assert "codex" in config
659
660 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
661 _init_repo(tmp_path)
662 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
663 assert result.exit_code == 0
664 data = json.loads(result.output)
665 assert "adapters" in data
666 assert "path" in data
667 assert data["adapters"] == ["claude"]
668
669 def test_updates_existing_section(self, tmp_path: pathlib.Path) -> None:
670 _init_repo(tmp_path)
671 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
672 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
673 config = (config_toml_path(tmp_path)).read_text()
674 # Only one [agent-config] section
675 assert config.count("[agent-config]") == 1
676 # codex no longer present in the adapters list
677 import tomllib
678 raw = tomllib.loads(config)
679 assert raw["agent-config"]["adapters"] == ["claude"]
680
681 def test_preserves_other_config_sections(self, tmp_path: pathlib.Path) -> None:
682 _init_repo(tmp_path)
683 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
684 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
685 config = (config_toml_path(tmp_path)).read_text()
686 assert "[hub]" in config
687 assert "localhost:1337" in config
688 assert "[agent-config]" in config
689
690 def test_unknown_adapter_exits_1(self, tmp_path: pathlib.Path) -> None:
691 _init_repo(tmp_path)
692 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
693 assert result.exit_code == 1
694
695 def test_unknown_adapter_error_message(self, tmp_path: pathlib.Path) -> None:
696 _init_repo(tmp_path)
697 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
698 assert "vscode" in result.stderr.lower() or "unknown" in result.stderr.lower()
699
700 def test_single_adapter(self, tmp_path: pathlib.Path) -> None:
701 _init_repo(tmp_path)
702 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
703 assert result.exit_code == 0
704 assert json.loads(result.output)["adapters"] == ["claude"]
705
706 def test_all_adapters_accepted(self, tmp_path: pathlib.Path) -> None:
707 from muse.cli.commands.agent_config import _ADAPTERS
708 _init_repo(tmp_path)
709 all_names = ",".join(_ADAPTERS.keys())
710 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", all_names])
711 assert result.exit_code == 0
712
713 def test_global_flag_writes_to_user_config(
714 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
715 ) -> None:
716 """--global writes to MUSE_USER_CONFIG_DIR/config.toml, not the repo."""
717 user_dir = tmp_path / "user_muse"
718 user_dir.mkdir()
719 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
720 _init_repo(tmp_path)
721 result = _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
722 assert result.exit_code == 0
723 user_cfg = (user_dir / "config.toml").read_text()
724 assert "claude" in user_cfg
725 # Repo config must NOT have the section
726 repo_cfg = config_toml_path(tmp_path)
727 if repo_cfg.exists():
728 assert "[agent-config]" not in repo_cfg.read_text()
729
730 def test_global_flag_survives_repo_absence(
731 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
732 ) -> None:
733 """--global works even when CWD is not inside a muse repo."""
734 user_dir = tmp_path / "user_muse"
735 user_dir.mkdir()
736 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
737 # Use a directory with no .muse/ — repo is NOT required for --global
738 non_repo = tmp_path / "not_a_repo"
739 non_repo.mkdir()
740 _init_repo(non_repo) # init so we have a valid CWD repo context
741 result = _invoke(non_repo, ["agent-config", "set", "--global", "--adapters", "claude"])
742 assert result.exit_code == 0
743 assert "claude" in (user_dir / "config.toml").read_text()
744
745 def test_global_adapters_visible_to_sync(
746 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
747 ) -> None:
748 """sync picks up global config when the repo has no [agent-config] section."""
749 user_dir = tmp_path / "user_muse"
750 user_dir.mkdir()
751 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
752 _init_repo(tmp_path)
753 _invoke(tmp_path, ["agent-config", "init"])
754 _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
755 # Repo has no [agent-config] — should fall back to global
756 result = _invoke(tmp_path, ["agent-config", "sync"])
757 assert result.exit_code == 0
758 assert (tmp_path / "CLAUDE.md").exists()
759 assert not (tmp_path / "AGENTS.md").exists()
760 assert not (tmp_path / ".cursorrules").exists()
761 assert not (tmp_path / ".windsurfrules").exists()
762
763
764 # ---------------------------------------------------------------------------
765 # Integration — sync priority chain
766 # ---------------------------------------------------------------------------
767
768
769 class TestSyncPriorityChain:
770 def test_config_toml_limits_adapters(self, tmp_path: pathlib.Path) -> None:
771 """[agent-config] adapters in config.toml limits sync without --adapters."""
772 _init_repo(tmp_path)
773 _invoke(tmp_path, ["agent-config", "init"])
774 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
775 result = _invoke(tmp_path, ["agent-config", "sync"])
776 assert result.exit_code == 0
777 assert (tmp_path / "CLAUDE.md").exists()
778 assert not (tmp_path / "AGENTS.md").exists()
779
780 def test_cli_adapters_flag_overrides_config_toml(self, tmp_path: pathlib.Path) -> None:
781 """--adapters on CLI takes priority over config.toml setting."""
782 _init_repo(tmp_path)
783 _invoke(tmp_path, ["agent-config", "init"])
784 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
785 result = _invoke(tmp_path, ["agent-config", "sync", "--adapters", "codex"])
786 assert result.exit_code == 0
787 assert (tmp_path / "AGENTS.md").exists()
788 assert not (tmp_path / "CLAUDE.md").exists()
789
790 def test_no_config_exits_with_error(self, tmp_path: pathlib.Path) -> None:
791 """Without [agent-config] adapters set, sync exits with an actionable error."""
792 _init_repo(tmp_path)
793 _invoke(tmp_path, ["agent-config", "init"])
794 result = _invoke(tmp_path, ["agent-config", "sync"])
795 assert result.exit_code != 0
796 assert "agent-config set" in result.stderr or "agent-config set" in result.output
797
798
799 # ---------------------------------------------------------------------------
800 # E2E — full workflow
801 # ---------------------------------------------------------------------------
802
803
804 class TestE2EFullWorkflow:
805 def test_init_set_sync_edit_status_resync(self, tmp_path: pathlib.Path) -> None:
806 """Complete agent-config lifecycle: init → set → sync → edit → out-of-sync → fix."""
807 _init_repo(tmp_path)
808
809 # init
810 r = _invoke(tmp_path, ["agent-config", "init"])
811 assert r.exit_code == 0
812 assert (agent_md_path(tmp_path)).exists()
813
814 # set
815 r = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
816 assert r.exit_code == 0
817
818 # sync
819 r = _invoke(tmp_path, ["agent-config", "sync"])
820 assert r.exit_code == 0
821 assert (tmp_path / "CLAUDE.md").exists()
822 assert (tmp_path / "AGENTS.md").exists()
823
824 # status — in sync
825 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
826 data = json.loads(r.output)
827 active = [a for a in data["adapters"] if a["exists"]]
828 assert all(a["in_sync"] for a in active)
829
830 # edit agent.md
831 agent_md = agent_md_path(tmp_path)
832 agent_md.write_text(f"{agent_md.read_text()}\n# EXTRA RULE\n")
833
834 # status — codex out of sync (embed adapter)
835 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
836 data = json.loads(r.output)
837 codex = next(a for a in data["adapters"] if a["name"] == "codex")
838 assert not codex["in_sync"]
839
840 # sync --force
841 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
842 assert r.exit_code == 0
843
844 # status — back in sync
845 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
846 data = json.loads(r.output)
847 active = [a for a in data["adapters"] if a["exists"]]
848 assert all(a["in_sync"] for a in active)
849
850 # verify new content is in AGENTS.md
851 assert "EXTRA RULE" in (tmp_path / "AGENTS.md").read_text()
852
853 def test_workspace_e2e(self, tmp_path: pathlib.Path) -> None:
854 """Workspace hierarchy: shared rules flow into member CLAUDE.md."""
855 _init_workspace(tmp_path, [("core", "core")])
856 _invoke(tmp_path, ["agent-config", "init"])
857
858 repo = tmp_path / "core"
859 repo.mkdir()
860 _init_with_all_adapters(repo)
861 _invoke(repo, ["agent-config", "sync"])
862
863 claude = (repo / "CLAUDE.md").read_text()
864 assert "@../.muse/agent.md" in claude
865 assert "@.muse/agent.md" in claude
866
867
868 # ---------------------------------------------------------------------------
869 # Stress
870 # ---------------------------------------------------------------------------
871
872
873 class TestStress:
874 def test_large_agent_md_syncs(self, tmp_path: pathlib.Path) -> None:
875 """200 KB agent.md embeds correctly into AGENTS.md."""
876 _init_with_all_adapters(tmp_path)
877 # Overwrite with 200 KB of content
878 large = f"# Rule\n{'x' * 200}\n"
879 large_content = large * 1000 # ~200 KB
880 (agent_md_path(tmp_path)).write_text(large_content)
881 result = _invoke(tmp_path, ["agent-config", "sync"])
882 assert result.exit_code == 0
883 agents_md = (tmp_path / "AGENTS.md").read_text()
884 assert len(agents_md) > 100_000
885
886 def test_rapid_sequential_syncs(self, tmp_path: pathlib.Path) -> None:
887 """30 sequential sync --force calls produce consistent output."""
888 _init_with_all_adapters(tmp_path)
889 _invoke(tmp_path, ["agent-config", "sync"])
890 content_before = (tmp_path / "AGENTS.md").read_text()
891 for _ in range(30):
892 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
893 assert r.exit_code == 0
894 assert (tmp_path / "AGENTS.md").read_text() == content_before
895
896 def test_concurrent_sync_no_corruption(self, tmp_path: pathlib.Path) -> None:
897 """Concurrent sync --force calls never produce a torn file.
898
899 Uses write_text_atomic directly to test the atomicity guarantee without
900 threading through the full CLI (which relies on process-global CWD).
901 """
902 from muse.core.io import write_text_atomic
903 target = tmp_path / "AGENTS.md"
904 content = "# Agent rules\n" + "x" * 10_000 + "\n"
905
906 errors: list[str] = []
907
908 def write() -> None:
909 try:
910 write_text_atomic(target, content)
911 except Exception as exc:
912 errors.append(str(exc))
913
914 threads = [threading.Thread(target=write) for _ in range(8)]
915 for t in threads:
916 t.start()
917 for t in threads:
918 t.join()
919
920 assert not errors
921 result = target.read_text()
922 assert len(result) > 0
923 # File must be complete — never a partial write
924 assert result == content
925
926
927 # ---------------------------------------------------------------------------
928 # Data Integrity
929 # ---------------------------------------------------------------------------
930
931
932 class TestDataIntegrity:
933 def test_corrupt_config_toml_exits_with_error(
934 self, tmp_path: pathlib.Path
935 ) -> None:
936 """Corrupt config.toml causes sync to fail — no files are silently generated."""
937 _init_repo(tmp_path)
938 _invoke(tmp_path, ["agent-config", "init"])
939 (config_toml_path(tmp_path)).write_text("[[[[not valid toml")
940 result = _invoke(tmp_path, ["agent-config", "sync"])
941 assert result.exit_code != 0
942
943 def test_adapter_file_not_empty_after_sync(self, tmp_path: pathlib.Path) -> None:
944 """Every generated adapter file has non-zero content."""
945 from muse.cli.commands.agent_config import _ADAPTERS
946 _init_with_all_adapters(tmp_path)
947 _invoke(tmp_path, ["agent-config", "sync"])
948 for spec in _ADAPTERS.values():
949 p = tmp_path / spec["filename"]
950 assert p.stat().st_size > 0, f"{spec['filename']} is empty"
951
952 def test_sync_write_is_atomic(self, tmp_path: pathlib.Path) -> None:
953 """After sync, AGENTS.md is a complete file — not truncated mid-write."""
954 _init_with_all_adapters(tmp_path)
955 _invoke(tmp_path, ["agent-config", "sync"])
956 content = (tmp_path / "AGENTS.md").read_text()
957 # Content should end with a newline, not be truncated mid-line
958 assert content.endswith("\n")
959
960 def test_set_preserves_existing_config_integrity(
961 self, tmp_path: pathlib.Path
962 ) -> None:
963 """set writes valid TOML that can be re-parsed."""
964 import tomllib
965 _init_repo(tmp_path)
966 (config_toml_path(tmp_path)).write_text(
967 '[hub]\nurl = "https://localhost:1337"\n\n[limits]\nmax_file_size_mb = 10\n'
968 )
969 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
970 raw = tomllib.loads((config_toml_path(tmp_path)).read_text())
971 assert raw["hub"]["url"] == "https://localhost:1337"
972 assert raw["limits"]["max_file_size_mb"] == 10
973 assert raw["agent-config"]["adapters"] == ["claude", "codex"]
974
975
976 # ---------------------------------------------------------------------------
977 # Performance
978 # ---------------------------------------------------------------------------
979
980
981 class TestPerformance:
982 def test_sync_completes_under_2_seconds(self, tmp_path: pathlib.Path) -> None:
983 """sync with default adapters completes in under 2 seconds."""
984 _init_repo(tmp_path)
985 _invoke(tmp_path, ["agent-config", "init"])
986 start = time.monotonic()
987 _invoke(tmp_path, ["agent-config", "sync"])
988 elapsed = time.monotonic() - start
989 assert elapsed < 2.0, f"sync took {elapsed:.2f}s — too slow"
990
991 def test_status_completes_under_1_second(self, tmp_path: pathlib.Path) -> None:
992 """status check completes in under 1 second."""
993 _init_repo(tmp_path)
994 _invoke(tmp_path, ["agent-config", "init"])
995 _invoke(tmp_path, ["agent-config", "sync"])
996 start = time.monotonic()
997 _invoke(tmp_path, ["agent-config", "status", "--json"])
998 elapsed = time.monotonic() - start
999 assert elapsed < 1.0, f"status took {elapsed:.2f}s — too slow"
1000
1001
1002 # ---------------------------------------------------------------------------
1003 # Security
1004 # ---------------------------------------------------------------------------
1005
1006
1007 class TestSecurity:
1008 def test_set_rejects_path_traversal_in_adapter_name(
1009 self, tmp_path: pathlib.Path
1010 ) -> None:
1011 """set does not accept adapter names containing path separators."""
1012 _init_repo(tmp_path)
1013 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "../traversal"])
1014 assert result.exit_code == 1
1015
1016 def test_set_rejects_adapter_with_null_byte(
1017 self, tmp_path: pathlib.Path
1018 ) -> None:
1019 """set rejects adapter names containing null bytes."""
1020 _init_repo(tmp_path)
1021 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude\x00malicious"])
1022 assert result.exit_code == 1
1023
1024 def test_agent_md_with_null_bytes_does_not_crash_sync(
1025 self, tmp_path: pathlib.Path
1026 ) -> None:
1027 """agent.md containing null bytes is handled without an unhandled exception."""
1028 _init_repo(tmp_path)
1029 _invoke(tmp_path, ["agent-config", "init"])
1030 # Write null bytes into agent.md
1031 agent_md = agent_md_path(tmp_path)
1032 agent_md.write_bytes(agent_md.read_bytes() + b"\x00\x00malicious\x00")
1033 # Should not raise — exit code may be 0 or 1 but must not be an unhandled exception
1034 result = _invoke(tmp_path, ["agent-config", "sync"])
1035 assert result.exit_code in (0, 1)
1036
1037 def test_toml_injection_in_config_does_not_escape_section(
1038 self, tmp_path: pathlib.Path
1039 ) -> None:
1040 """A crafted adapter name cannot inject extra TOML sections."""
1041 import tomllib
1042 _init_repo(tmp_path)
1043 # Attempt to inject a new TOML section via adapter name
1044 result = _invoke(
1045 tmp_path,
1046 ["agent-config", "set", "--adapters", 'claude"]\n[injected'],
1047 )
1048 # Should fail with unknown adapter error, not write injected TOML
1049 assert result.exit_code == 1
1050 config_path = config_toml_path(tmp_path)
1051 if config_path.exists():
1052 raw = tomllib.loads(config_path.read_text())
1053 assert "injected" not in raw
1054
1055
1056 # ---------------------------------------------------------------------------
1057 # Integration — smart sync (skip in-sync files)
1058 # ---------------------------------------------------------------------------
1059
1060
1061 class TestSmartSync:
1062 def test_second_sync_skips_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1063 """Repeated sync without changes exits 0 and reports skipped."""
1064 _init_with_all_adapters(tmp_path)
1065 _invoke(tmp_path, ["agent-config", "sync"])
1066 result = _invoke(tmp_path, ["agent-config", "sync"])
1067 assert result.exit_code == 0
1068 assert "in sync" in result.output
1069
1070 def test_second_sync_json_skipped_true(self, tmp_path: pathlib.Path) -> None:
1071 """sync --json shows skipped=True for already-in-sync files."""
1072 _init_with_all_adapters(tmp_path)
1073 _invoke(tmp_path, ["agent-config", "sync"])
1074 result = _invoke(tmp_path, ["agent-config", "sync", "--json"])
1075 assert result.exit_code == 0
1076 data = json.loads(result.output)
1077 for entry in data["adapters"]:
1078 assert entry["skipped"] is True
1079 assert entry["written"] is False
1080
1081 def test_out_of_sync_file_is_updated_without_force(self, tmp_path: pathlib.Path) -> None:
1082 """An adapter that is out of sync is updated even without --force."""
1083 _init_with_all_adapters(tmp_path)
1084 _invoke(tmp_path, ["agent-config", "sync"])
1085 # Corrupt AGENTS.md content
1086 (tmp_path / "AGENTS.md").write_text("old content")
1087 result = _invoke(tmp_path, ["agent-config", "sync"])
1088 assert result.exit_code == 0
1089 assert "old content" not in (tmp_path / "AGENTS.md").read_text()
1090 assert "Muse" in (tmp_path / "AGENTS.md").read_text()
1091
1092 def test_force_rewrites_even_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1093 """--force writes all files even when they are already in sync."""
1094 _init_with_all_adapters(tmp_path)
1095 _invoke(tmp_path, ["agent-config", "sync"])
1096 result = _invoke(tmp_path, ["agent-config", "sync", "--force", "--json"])
1097 assert result.exit_code == 0
1098 data = json.loads(result.output)
1099 for entry in data["adapters"]:
1100 assert entry["written"] is True
1101 assert entry["skipped"] is False
1102
1103 def test_sync_idempotent_across_multiple_runs(self, tmp_path: pathlib.Path) -> None:
1104 """Running sync N times produces identical output each time."""
1105 _init_with_all_adapters(tmp_path)
1106 _invoke(tmp_path, ["agent-config", "sync"])
1107 content_after_first = (tmp_path / "AGENTS.md").read_text()
1108 for _ in range(5):
1109 r = _invoke(tmp_path, ["agent-config", "sync"])
1110 assert r.exit_code == 0
1111 assert (tmp_path / "AGENTS.md").read_text() == content_after_first
1112
1113
1114 # ---------------------------------------------------------------------------
1115 # Integration — inspect
1116 # ---------------------------------------------------------------------------
1117
1118
1119 class TestInspect:
1120 def test_inspect_json_schema_standalone(self, tmp_path: pathlib.Path) -> None:
1121 """inspect --json returns all required fields for a standalone repo."""
1122 _init_repo(tmp_path)
1123 _invoke(tmp_path, ["agent-config", "init"])
1124 _invoke(tmp_path, ["agent-config", "sync"])
1125 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1126 assert result.exit_code == 0
1127 data = json.loads(result.output)
1128 assert data["context"] == "standalone"
1129 assert data["workspace_root"] is None
1130 assert data["repo_name"] == tmp_path.name
1131 assert data["agent_md_exists"] is True
1132 assert data["merged_content"] is not None
1133 assert "adapters" in data
1134 assert isinstance(data["ready"], bool)
1135
1136 def test_inspect_ready_true_when_in_sync(self, tmp_path: pathlib.Path) -> None:
1137 """ready is True when agent.md exists and adapters are in sync."""
1138 _init_with_all_adapters(tmp_path)
1139 _invoke(tmp_path, ["agent-config", "sync"])
1140 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1141 data = json.loads(result.output)
1142 assert data["ready"] is True
1143
1144 def test_inspect_ready_false_without_adapters(self, tmp_path: pathlib.Path) -> None:
1145 """ready is False when agent.md exists but no adapters have been synced."""
1146 _init_repo(tmp_path)
1147 _invoke(tmp_path, ["agent-config", "init"])
1148 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1149 data = json.loads(result.output)
1150 assert data["ready"] is False
1151
1152 def test_inspect_ready_false_without_agent_md(self, tmp_path: pathlib.Path) -> None:
1153 """ready is False when agent.md does not exist."""
1154 _init_repo(tmp_path)
1155 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1156 data = json.loads(result.output)
1157 assert data["ready"] is False
1158 assert data["agent_md_exists"] is False
1159 assert data["merged_content"] is None
1160
1161 def test_inspect_merged_content_contains_rules(self, tmp_path: pathlib.Path) -> None:
1162 """merged_content includes the actual rules from agent.md."""
1163 _init_repo(tmp_path)
1164 _invoke(tmp_path, ["agent-config", "init"])
1165 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1166 data = json.loads(result.output)
1167 assert "Muse" in data["merged_content"]
1168 assert "git" in data["merged_content"].lower()
1169
1170 def test_inspect_adapter_entries_schema(self, tmp_path: pathlib.Path) -> None:
1171 """Each adapter entry in inspect output has the expected fields."""
1172 _init_repo(tmp_path)
1173 _invoke(tmp_path, ["agent-config", "init"])
1174 _invoke(tmp_path, ["agent-config", "sync"])
1175 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1176 data = json.loads(result.output)
1177 for entry in data["adapters"]:
1178 assert "name" in entry
1179 assert "filename" in entry
1180 assert "exists" in entry
1181 assert "in_sync" in entry
1182
1183 def test_inspect_workspace_member_context(self, tmp_path: pathlib.Path) -> None:
1184 """inspect reports workspace_member context and non-null workspace_root."""
1185 _init_workspace(tmp_path, [("core", "core")])
1186 _invoke(tmp_path, ["agent-config", "init"])
1187 repo = tmp_path / "core"
1188 repo.mkdir()
1189 _init_repo(repo)
1190 _invoke(repo, ["agent-config", "init"])
1191 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1192 assert result.exit_code == 0
1193 data = json.loads(result.output)
1194 assert data["context"] == "workspace_member"
1195 assert data["workspace_root"] is not None
1196 assert data["repo_name"] == "core"
1197
1198 def test_inspect_workspace_merged_content_includes_both_levels(
1199 self, tmp_path: pathlib.Path
1200 ) -> None:
1201 """merged_content in a workspace member includes both WS and repo rules."""
1202 _init_workspace(tmp_path, [("core", "core")])
1203 _invoke(tmp_path, ["agent-config", "init"])
1204 # Add a unique marker to the workspace-level agent.md
1205 ws_agent = agent_md_path(tmp_path)
1206 ws_agent.write_text(f"{ws_agent.read_text()}\n# WS_MARKER\n")
1207 repo = tmp_path / "core"
1208 repo.mkdir()
1209 _init_repo(repo)
1210 _invoke(repo, ["agent-config", "init"])
1211 # Add a unique marker to the repo-level agent.md
1212 repo_agent = agent_md_path(repo)
1213 repo_agent.write_text(f"{repo_agent.read_text()}\n# REPO_MARKER\n")
1214 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1215 data = json.loads(result.output)
1216 assert "WS_MARKER" in data["merged_content"]
1217 assert "REPO_MARKER" in data["merged_content"]
1218
1219 def test_inspect_text_output_exits_0(self, tmp_path: pathlib.Path) -> None:
1220 """inspect without --json exits 0 and prints context info."""
1221 _init_repo(tmp_path)
1222 _invoke(tmp_path, ["agent-config", "init"])
1223 result = _invoke(tmp_path, ["agent-config", "inspect"])
1224 assert result.exit_code == 0
1225 assert "Context" in result.output or "standalone" in result.output
1226
1227
1228 # ---------------------------------------------------------------------------
1229 # Integration — status extra fields
1230 # ---------------------------------------------------------------------------
1231
1232
1233 class TestStatusExtraFields:
1234 def test_status_json_includes_agent_md_exists(self, tmp_path: pathlib.Path) -> None:
1235 _init_repo(tmp_path)
1236 _invoke(tmp_path, ["agent-config", "init"])
1237 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1238 data = json.loads(result.output)
1239 assert "agent_md_exists" in data
1240 assert data["agent_md_exists"] is True
1241
1242 def test_status_json_includes_ready(self, tmp_path: pathlib.Path) -> None:
1243 _init_with_all_adapters(tmp_path)
1244 _invoke(tmp_path, ["agent-config", "sync"])
1245 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1246 data = json.loads(result.output)
1247 assert "ready" in data
1248 assert data["ready"] is True
1249
1250 def test_status_json_ready_false_before_sync(self, tmp_path: pathlib.Path) -> None:
1251 _init_repo(tmp_path)
1252 _invoke(tmp_path, ["agent-config", "init"])
1253 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1254 data = json.loads(result.output)
1255 assert data["ready"] is False
1256
1257 def test_status_json_summary_counts(self, tmp_path: pathlib.Path) -> None:
1258 from muse.cli.commands.agent_config import _ADAPTERS
1259 _init_repo(tmp_path)
1260 _invoke(tmp_path, ["agent-config", "init"])
1261 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1262 data = json.loads(result.output)
1263 assert "in_sync_count" in data
1264 assert "missing_count" in data
1265 assert "out_of_sync_count" in data
1266 # Before sync, all adapters are missing
1267 assert data["missing_count"] == len(_ADAPTERS)
1268 assert data["in_sync_count"] == 0
1269
1270 def test_status_json_counts_after_sync(self, tmp_path: pathlib.Path) -> None:
1271 from muse.cli.commands.agent_config import _ADAPTERS
1272 _init_with_all_adapters(tmp_path)
1273 _invoke(tmp_path, ["agent-config", "sync"])
1274 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1275 data = json.loads(result.output)
1276 assert data["in_sync_count"] == len(_ADAPTERS)
1277 assert data["missing_count"] == 0
1278 assert data["out_of_sync_count"] == 0
1279
1280
1281 # ---------------------------------------------------------------------------
1282 # Integration — template content
1283 # ---------------------------------------------------------------------------
1284
1285
1286 class TestTemplateContent:
1287 def test_standalone_template_includes_testing_rules(
1288 self, tmp_path: pathlib.Path
1289 ) -> None:
1290 """Standalone template includes the no-full-test-suite rule."""
1291 _init_repo(tmp_path)
1292 _invoke(tmp_path, ["agent-config", "init"])
1293 content = (agent_md_path(tmp_path)).read_text()
1294 assert "full test suite" in content.lower() or "full" in content.lower()
1295 assert "muse code test" in content
1296
1297 def test_standalone_template_includes_muse_code_test(
1298 self, tmp_path: pathlib.Path
1299 ) -> None:
1300 """Standalone template lists muse code test in the code intelligence table."""
1301 _init_repo(tmp_path)
1302 _invoke(tmp_path, ["agent-config", "init"])
1303 content = (agent_md_path(tmp_path)).read_text()
1304 assert "muse code test" in content
1305
1306
1307 class TestRegisterFlags:
1308 """Argparse registration tests for ``muse agent-config`` subcommands."""
1309
1310 def _parse(self, *args: str) -> argparse.Namespace:
1311 from muse.cli.commands.agent_config import register
1312 p = argparse.ArgumentParser()
1313 sub = p.add_subparsers()
1314 register(sub)
1315 return p.parse_args(["agent-config", *args])
1316
1317 # init
1318 def test_init_default_json_out_is_false(self) -> None:
1319 ns = self._parse("init")
1320 assert ns.json_out is False
1321
1322 def test_init_json_flag_sets_json_out(self) -> None:
1323 ns = self._parse("init", "--json")
1324 assert ns.json_out is True
1325
1326 def test_init_j_shorthand_sets_json_out(self) -> None:
1327 ns = self._parse("init", "-j")
1328 assert ns.json_out is True
1329
1330 def test_init_force_default(self) -> None:
1331 ns = self._parse("init")
1332 assert ns.force is False
1333
1334 def test_init_force_flag(self) -> None:
1335 ns = self._parse("init", "--force")
1336 assert ns.force is True
1337
1338 def test_init_force_shorthand(self) -> None:
1339 ns = self._parse("init", "-f")
1340 assert ns.force is True
1341
1342 # sync
1343 def test_sync_default_json_out_is_false(self) -> None:
1344 ns = self._parse("sync")
1345 assert ns.json_out is False
1346
1347 def test_sync_json_flag_sets_json_out(self) -> None:
1348 ns = self._parse("sync", "--json")
1349 assert ns.json_out is True
1350
1351 def test_sync_j_shorthand_sets_json_out(self) -> None:
1352 ns = self._parse("sync", "-j")
1353 assert ns.json_out is True
1354
1355 def test_sync_dry_run_default(self) -> None:
1356 ns = self._parse("sync")
1357 assert ns.dry_run is False
1358
1359 def test_sync_dry_run_flag(self) -> None:
1360 ns = self._parse("sync", "--dry-run")
1361 assert ns.dry_run is True
1362
1363 def test_sync_dry_run_shorthand(self) -> None:
1364 ns = self._parse("sync", "-n")
1365 assert ns.dry_run is True
1366
1367 def test_sync_force_default(self) -> None:
1368 ns = self._parse("sync")
1369 assert ns.force is False
1370
1371 def test_sync_force_flag(self) -> None:
1372 ns = self._parse("sync", "--force")
1373 assert ns.force is True
1374
1375 def test_sync_force_shorthand(self) -> None:
1376 ns = self._parse("sync", "-f")
1377 assert ns.force is True
1378
1379 # read
1380 def test_read_default_json_out_is_false(self) -> None:
1381 ns = self._parse("read")
1382 assert ns.json_out is False
1383
1384 def test_read_json_flag_sets_json_out(self) -> None:
1385 ns = self._parse("read", "--json")
1386 assert ns.json_out is True
1387
1388 def test_read_j_shorthand_sets_json_out(self) -> None:
1389 ns = self._parse("read", "-j")
1390 assert ns.json_out is True
1391
1392 # status
1393 def test_status_default_json_out_is_false(self) -> None:
1394 ns = self._parse("status")
1395 assert ns.json_out is False
1396
1397 def test_status_json_flag_sets_json_out(self) -> None:
1398 ns = self._parse("status", "--json")
1399 assert ns.json_out is True
1400
1401 # inspect
1402 def test_inspect_default_json_out_is_false(self) -> None:
1403 ns = self._parse("inspect")
1404 assert ns.json_out is False
1405
1406 def test_inspect_json_flag_sets_json_out(self) -> None:
1407 ns = self._parse("inspect", "--json")
1408 assert ns.json_out is True
1409
1410 # set
1411 def test_set_default_json_out_is_false(self) -> None:
1412 ns = self._parse("set", "--adapters", "claude")
1413 assert ns.json_out is False
1414
1415 def test_set_json_flag_sets_json_out(self) -> None:
1416 ns = self._parse("set", "--adapters", "claude", "--json")
1417 assert ns.json_out is True
File History 2 commits