gabriel / muse public

test_cmd_agent_config.py file-level

at sha256:9 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:b docs: fix systemic --formatβ†’--json drift and broken plumbing-namespace … · gabriel · Sep 12, 2026
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 .museagent.md with full content
13 workspace_root β€” generates workspace-level .museagent.md with member table
14 workspace_member β€” generates thin repo-level .museagent.md linking to workspace
15 --force β€” overwrites existing .museagent.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 .museagent.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 .museagent.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=".museagent.md", ws_agent_md=None)
195 assert "@.museagent.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=".museagent.md", ws_agent_md="../.museagent.md")
202 assert "@../.museagent.md" in result
203 assert "@.museagent.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=".museagent.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=".museagent.md",
224 ws_agent_md="../.museagent.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 # ACFG β€” canonical source must be a normal tracked file, not inside .muse/
292 #
293 # See muse issue #78: agent_md_path() returned .muse/agent.md, which
294 # _ALWAYS_IGNORE_DIRS excludes from every snapshot regardless of
295 # .museignore -- defeating agent-config's entire "clone, sync, get your
296 # tool's adapter" promise. Fixed by moving the canonical path outside
297 # .muse/ entirely, to .museagent.md at the repo/workspace root.
298 # ---------------------------------------------------------------------------
299
300
301 class TestCanonicalPathIsTracked:
302 def test_ACFG_01_agent_md_path_not_inside_dot_muse(self, tmp_path: pathlib.Path) -> None:
303 """agent_md_path() must never return a path whose parent dir is .muse."""
304 path = agent_md_path(tmp_path)
305 assert path.parent.name != ".muse", (
306 f"agent_md_path() returned {path} -- still inside .muse/, which "
307 "_ALWAYS_IGNORE_DIRS excludes from every snapshot regardless of "
308 ".museignore. The canonical source can never be tracked from here."
309 )
310
311 def test_ACFG_02_created_file_survives_add_and_commit(self, tmp_path: pathlib.Path) -> None:
312 """The real bug: after init + code add + commit, the file must be
313 tracked -- present in `muse ls-files`, not silently excluded."""
314 _init_repo(tmp_path)
315 result = _invoke(tmp_path, ["agent-config", "init"])
316 assert result.exit_code == 0, result.output
317
318 add_result = _invoke(tmp_path, ["code", "add", "."])
319 assert add_result.exit_code == 0, add_result.output
320
321 commit_result = _invoke(
322 tmp_path,
323 ["commit", "-m", "add agent config", "--agent-id", "test", "--model-id", "test"],
324 )
325 assert commit_result.exit_code == 0, commit_result.output
326
327 ls_result = _invoke(tmp_path, ["ls-files", "--json"])
328 assert ls_result.exit_code == 0, ls_result.output
329 tracked = json.loads(ls_result.output)
330 entries = tracked if isinstance(tracked, list) else tracked.get("files", [])
331 tracked_files = [e["path"] if isinstance(e, dict) else e for e in entries]
332 rel_path = agent_md_path(tmp_path).relative_to(tmp_path).as_posix()
333 assert rel_path in tracked_files, (
334 f"{rel_path} not in tracked files after commit: {tracked_files} -- "
335 "the canonical source would not survive a fresh clone."
336 )
337
338 def test_ACFG_03_claude_md_references_new_path(self, tmp_path: pathlib.Path) -> None:
339 """Generated CLAUDE.md must @include .museagent.md, not the old .muse/agent.md."""
340 _init_with_all_adapters(tmp_path)
341 _invoke(tmp_path, ["agent-config", "sync", "--force"])
342 claude_md = (tmp_path / "CLAUDE.md").read_text()
343 assert "@.museagent.md" in claude_md
344 assert ".muse/agent.md" not in claude_md
345
346 def test_ACFG_04_legacy_muse_dir_agent_md_migrated_on_init(self, tmp_path: pathlib.Path) -> None:
347 """A pre-#78 repo with real content at .muse/agent.md must have it
348 migrated (moved, not copied) to .museagent.md on the next init."""
349 _init_repo(tmp_path)
350 legacy = muse_dir(tmp_path) / "agent.md"
351 legacy.write_text("# my real customized rules\n")
352
353 result = _invoke(tmp_path, ["agent-config", "init"])
354 assert result.exit_code == 0, result.output
355
356 assert not legacy.exists(), "legacy .muse/agent.md must not linger after migration"
357 migrated = agent_md_path(tmp_path)
358 assert migrated.exists()
359 assert migrated.read_text() == "# my real customized rules\n"
360
361 def test_ACFG_05_legacy_muse_dir_agent_md_migrated_on_sync(self, tmp_path: pathlib.Path) -> None:
362 """Same migration must trigger from sync too, for a repo that never
363 re-runs init after upgrading."""
364 _init_repo(tmp_path)
365 legacy = muse_dir(tmp_path) / "agent.md"
366 legacy.write_text("# my real customized rules\n")
367 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
368
369 result = _invoke(tmp_path, ["agent-config", "sync"])
370 assert result.exit_code == 0, result.output
371
372 assert not legacy.exists()
373 assert agent_md_path(tmp_path).exists()
374
375
376 # ---------------------------------------------------------------------------
377 # Integration β€” init: workspace root
378 # ---------------------------------------------------------------------------
379
380
381 class TestInitWorkspaceRoot:
382 def test_creates_agent_md_at_workspace_root(self, tmp_path: pathlib.Path) -> None:
383 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
384 result = _invoke(tmp_path, ["agent-config", "init"])
385 assert result.exit_code == 0
386 assert (agent_md_path(tmp_path)).exists()
387
388 def test_workspace_agent_md_lists_members(self, tmp_path: pathlib.Path) -> None:
389 _init_workspace(tmp_path, [("core", "core"), ("api", "api")])
390 _invoke(tmp_path, ["agent-config", "init"])
391 content = (agent_md_path(tmp_path)).read_text()
392 assert "core" in content
393 assert "api" in content
394
395 def test_workspace_agent_md_contains_shared_rules(self, tmp_path: pathlib.Path) -> None:
396 _init_workspace(tmp_path, [("core", "core")])
397 _invoke(tmp_path, ["agent-config", "init"])
398 content = (agent_md_path(tmp_path)).read_text()
399 assert "Muse" in content
400 assert "git" in content.lower()
401
402
403 # ---------------------------------------------------------------------------
404 # Integration β€” init: workspace member
405 # ---------------------------------------------------------------------------
406
407
408 class TestInitWorkspaceMember:
409 def test_creates_repo_level_agent_md(self, tmp_path: pathlib.Path) -> None:
410 _init_workspace(tmp_path, [("core", "core")])
411 repo = tmp_path / "core"
412 repo.mkdir()
413 _init_repo(repo)
414 result = _invoke(repo, ["agent-config", "init"])
415 assert result.exit_code == 0
416 assert (agent_md_path(repo)).exists()
417
418 def test_member_agent_md_references_workspace(self, tmp_path: pathlib.Path) -> None:
419 _init_workspace(tmp_path, [("core", "core")])
420 repo = tmp_path / "core"
421 repo.mkdir()
422 _init_repo(repo)
423 _invoke(repo, ["agent-config", "init"])
424 content = (agent_md_path(repo)).read_text()
425 # Should mention the workspace or link to the parent config
426 assert "workspace" in content.lower() or ".museagent.md" in content
427
428
429 # ---------------------------------------------------------------------------
430 # Integration β€” sync
431 # ---------------------------------------------------------------------------
432
433
434 class TestSync:
435 @pytest.fixture()
436 def standalone(self, tmp_path: pathlib.Path) -> pathlib.Path:
437 _init_repo(tmp_path)
438 _invoke(tmp_path, ["agent-config", "init"])
439 # Explicitly configure all adapters so tests that want specific files work.
440 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex,cursor,windsurf"])
441 return tmp_path
442
443 def test_sync_requires_adapter_config(self, tmp_path: pathlib.Path) -> None:
444 """sync with no [agent-config] section exits with error instead of writing all adapters."""
445 _init_repo(tmp_path)
446 _invoke(tmp_path, ["agent-config", "init"])
447 result = _invoke(tmp_path, ["agent-config", "sync"])
448 assert result.exit_code != 0
449 assert "agent-config set" in result.stderr or "agent-config set" in result.output
450
451 def test_sync_creates_claude_md(self, standalone: pathlib.Path) -> None:
452 result = _invoke(standalone, ["agent-config", "sync"])
453 assert result.exit_code == 0
454 assert (standalone / "CLAUDE.md").exists()
455
456 def test_sync_creates_agents_md(self, standalone: pathlib.Path) -> None:
457 _invoke(standalone, ["agent-config", "sync"])
458 assert (standalone / "AGENTS.md").exists()
459
460 def test_sync_creates_cursorrules(self, standalone: pathlib.Path) -> None:
461 _invoke(standalone, ["agent-config", "sync"])
462 assert (standalone / ".cursorrules").exists()
463
464 def test_sync_creates_windsurfrules(self, standalone: pathlib.Path) -> None:
465 _invoke(standalone, ["agent-config", "sync"])
466 assert (standalone / ".windsurfrules").exists()
467
468 def test_claude_md_uses_include_syntax(self, standalone: pathlib.Path) -> None:
469 _invoke(standalone, ["agent-config", "sync"])
470 content = (standalone / "CLAUDE.md").read_text()
471 assert "@.museagent.md" in content
472
473 def test_agents_md_embeds_content(self, standalone: pathlib.Path) -> None:
474 _invoke(standalone, ["agent-config", "sync"])
475 agent_md_content = (agent_md_path(standalone)).read_text()
476 agents_md_content = (standalone / "AGENTS.md").read_text()
477 # Should contain actual text, not an @ include
478 assert "@" not in agents_md_content.split("\n")[2] # not just an include
479 # Should contain meaningful content from agent.md
480 assert "Muse" in agents_md_content
481
482 def test_sync_adapters_flag_limits_output(self, standalone: pathlib.Path) -> None:
483 result = _invoke(standalone, ["agent-config", "sync", "--adapters", "claude"])
484 assert result.exit_code == 0
485 assert (standalone / "CLAUDE.md").exists()
486 assert not (standalone / "AGENTS.md").exists()
487
488 def test_sync_claude_only_config_writes_only_claude(self, tmp_path: pathlib.Path) -> None:
489 """When adapters = [claude], sync writes ONLY CLAUDE.md β€” nothing else."""
490 _init_repo(tmp_path)
491 _invoke(tmp_path, ["agent-config", "init"])
492 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
493 result = _invoke(tmp_path, ["agent-config", "sync"])
494 assert result.exit_code == 0
495 assert (tmp_path / "CLAUDE.md").exists()
496 assert not (tmp_path / "AGENTS.md").exists()
497 assert not (tmp_path / ".cursorrules").exists()
498 assert not (tmp_path / ".windsurfrules").exists()
499
500 def test_dry_run_creates_no_files(self, standalone: pathlib.Path) -> None:
501 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
502 assert result.exit_code == 0
503 assert not (standalone / "CLAUDE.md").exists()
504 assert not (standalone / "AGENTS.md").exists()
505
506 def test_dry_run_prints_what_would_be_written(self, standalone: pathlib.Path) -> None:
507 result = _invoke(standalone, ["agent-config", "sync", "--dry-run"])
508 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
509
510 def test_sync_already_in_sync_skips_without_error(self, standalone: pathlib.Path) -> None:
511 """Second sync with no changes skips in-sync files and exits 0."""
512 _invoke(standalone, ["agent-config", "sync"])
513 result = _invoke(standalone, ["agent-config", "sync"])
514 assert result.exit_code == 0
515 # Output should indicate files were skipped
516 assert "in sync" in result.output or "skipped" in result.output.lower() or result.exit_code == 0
517
518 def test_force_overwrites_existing(self, standalone: pathlib.Path) -> None:
519 _invoke(standalone, ["agent-config", "sync"])
520 (standalone / "CLAUDE.md").write_text("old content")
521 result = _invoke(standalone, ["agent-config", "sync", "--force"])
522 assert result.exit_code == 0
523 content = (standalone / "CLAUDE.md").read_text()
524 assert content != "old content"
525
526 def test_missing_agent_md_exits_1(self, tmp_path: pathlib.Path) -> None:
527 _init_repo(tmp_path)
528 result = _invoke(tmp_path, ["agent-config", "sync"])
529 assert result.exit_code == 1
530 assert "agent.md" in result.stderr.lower() or "init" in result.stderr.lower()
531
532 def test_json_schema(self, standalone: pathlib.Path) -> None:
533 result = _invoke(standalone, ["agent-config", "sync", "--json"])
534 assert result.exit_code == 0
535 data = json.loads(result.output)
536 assert "adapters" in data
537 assert isinstance(data["adapters"], list)
538 for entry in data["adapters"]:
539 assert "name" in entry
540 assert "path" in entry
541 assert "written" in entry
542
543 def test_workspace_member_claude_includes_both_levels(
544 self, tmp_path: pathlib.Path
545 ) -> None:
546 _init_workspace(tmp_path, [("core", "core")])
547 # Init workspace-level agent.md
548 _invoke(tmp_path, ["agent-config", "init"])
549 # Init and sync repo-level
550 repo = tmp_path / "core"
551 repo.mkdir()
552 _init_with_all_adapters(repo)
553 _invoke(repo, ["agent-config", "sync"])
554 content = (repo / "CLAUDE.md").read_text()
555 # Should include both workspace level and repo level
556 assert "agent.md" in content
557 # Workspace-level reference should be present (parent path)
558 assert ".." in content
559
560
561 # ---------------------------------------------------------------------------
562 # Integration β€” read
563 # ---------------------------------------------------------------------------
564
565
566 class TestRead:
567 def test_read_prints_agent_md_content(self, tmp_path: pathlib.Path) -> None:
568 _init_repo(tmp_path)
569 _invoke(tmp_path, ["agent-config", "init"])
570 result = _invoke(tmp_path, ["agent-config", "read"])
571 assert result.exit_code == 0
572 agent_md = (agent_md_path(tmp_path)).read_text()
573 assert agent_md.strip() in result.output
574
575 def test_read_missing_exits_1(self, tmp_path: pathlib.Path) -> None:
576 _init_repo(tmp_path)
577 result = _invoke(tmp_path, ["agent-config", "read"])
578 assert result.exit_code == 1
579
580 def test_read_json_schema(self, tmp_path: pathlib.Path) -> None:
581 _init_repo(tmp_path)
582 _invoke(tmp_path, ["agent-config", "init"])
583 result = _invoke(tmp_path, ["agent-config", "read", "--json"])
584 assert result.exit_code == 0
585 data = json.loads(result.output)
586 assert "content" in data
587 assert "path" in data
588 assert "scope" in data
589
590 def test_read_merged_workspace(self, tmp_path: pathlib.Path) -> None:
591 _init_workspace(tmp_path, [("core", "core")])
592 _invoke(tmp_path, ["agent-config", "init"])
593 repo = tmp_path / "core"
594 repo.mkdir()
595 _init_repo(repo)
596 _invoke(repo, ["agent-config", "init"])
597 result = _invoke(repo, ["agent-config", "read", "--scope", "merged"])
598 assert result.exit_code == 0
599 # Should include content from both levels
600 ws_content = (agent_md_path(tmp_path)).read_text()
601 repo_content = (agent_md_path(repo)).read_text()
602 assert ws_content[:30] in result.output or repo_content[:30] in result.output
603
604
605 # ---------------------------------------------------------------------------
606 # Integration β€” status
607 # ---------------------------------------------------------------------------
608
609
610 class TestStatus:
611 def test_status_before_sync(self, tmp_path: pathlib.Path) -> None:
612 _init_repo(tmp_path)
613 _invoke(tmp_path, ["agent-config", "init"])
614 result = _invoke(tmp_path, ["agent-config", "status"])
615 assert result.exit_code == 0
616 # All adapters should show as missing
617 assert "CLAUDE.md" in result.output or "claude" in result.output.lower()
618
619 def test_status_after_sync(self, tmp_path: pathlib.Path) -> None:
620 _init_repo(tmp_path)
621 _invoke(tmp_path, ["agent-config", "init"])
622 _invoke(tmp_path, ["agent-config", "sync"])
623 result = _invoke(tmp_path, ["agent-config", "status"])
624 assert result.exit_code == 0
625
626 def test_status_json_schema(self, tmp_path: pathlib.Path) -> None:
627 _init_repo(tmp_path)
628 _invoke(tmp_path, ["agent-config", "init"])
629 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
630 assert result.exit_code == 0
631 data = json.loads(result.output)
632 assert "agent_md" in data
633 assert "adapters" in data
634 for entry in data["adapters"]:
635 assert "name" in entry
636 assert "filename" in entry
637 assert "exists" in entry
638 assert "in_sync" in entry
639
640 def test_status_shows_out_of_sync_after_edit(self, tmp_path: pathlib.Path) -> None:
641 _init_repo(tmp_path)
642 _invoke(tmp_path, ["agent-config", "init"])
643 _invoke(tmp_path, ["agent-config", "sync"])
644 # Modify agent.md without re-syncing
645 agent_md = agent_md_path(tmp_path)
646 agent_md.write_text(f"{agent_md.read_text()}\n# NEW RULE\n")
647 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
648 data = json.loads(result.output)
649 # At least one embed adapter should be out of sync
650 embed_adapters = [a for a in data["adapters"] if a["name"] != "claude"]
651 assert any(not a["in_sync"] for a in embed_adapters)
652
653
654 # ---------------------------------------------------------------------------
655 # --fail-if-out-of-sync β€” musehub#192 Phase 5, HK_40
656 # ---------------------------------------------------------------------------
657
658
659 class TestFailIfOutOfSync:
660 def test_exits_0_when_no_agent_md_at_all(self, tmp_path: pathlib.Path) -> None:
661 _init_repo(tmp_path)
662 result = _invoke(tmp_path, ["agent-config", "status", "--fail-if-out-of-sync"])
663 assert result.exit_code == 0
664
665 def test_exits_0_when_nothing_configured_yet(self, tmp_path: pathlib.Path) -> None:
666 _init_repo(tmp_path)
667 _invoke(tmp_path, ["agent-config", "init"])
668 result = _invoke(tmp_path, ["agent-config", "status", "--fail-if-out-of-sync"])
669 assert result.exit_code == 0
670
671 def test_exits_0_when_in_sync(self, tmp_path: pathlib.Path) -> None:
672 _init_repo(tmp_path)
673 _invoke(tmp_path, ["agent-config", "init"])
674 _invoke(tmp_path, ["agent-config", "sync"])
675 result = _invoke(tmp_path, ["agent-config", "status", "--fail-if-out-of-sync"])
676 assert result.exit_code == 0
677
678 def test_exits_1_when_out_of_sync(self, tmp_path: pathlib.Path) -> None:
679 # Claude's adapter is an @include β€” its on-disk content never
680 # changes when .museagent.md changes, so a real drift needs an
681 # embed adapter (codex) actually generated on disk.
682 _init_repo(tmp_path)
683 _invoke(tmp_path, ["agent-config", "init"])
684 _invoke(tmp_path, ["agent-config", "sync", "--adapters", "claude,codex"])
685 agent_md = agent_md_path(tmp_path)
686 agent_md.write_text(f"{agent_md.read_text()}\n# NEW RULE\n")
687 result = _invoke(tmp_path, ["agent-config", "status", "--fail-if-out-of-sync"])
688 assert result.exit_code == 1
689
690 def test_json_still_printed_before_failing_exit(self, tmp_path: pathlib.Path) -> None:
691 _init_repo(tmp_path)
692 _invoke(tmp_path, ["agent-config", "init"])
693 _invoke(tmp_path, ["agent-config", "sync", "--adapters", "claude,codex"])
694 agent_md = agent_md_path(tmp_path)
695 agent_md.write_text(f"{agent_md.read_text()}\n# NEW RULE\n")
696 result = _invoke(tmp_path, ["agent-config", "status", "--fail-if-out-of-sync", "--json"])
697 assert result.exit_code == 1
698 data = json.loads(result.output)
699 assert data["out_of_sync_count"] > 0
700
701 def test_flag_absent_never_changes_exit_code(self, tmp_path: pathlib.Path) -> None:
702 # Regression guard: plain `status` (no flag) must stay exit 0 always,
703 # even when out of sync β€” this flag is opt-in.
704 _init_repo(tmp_path)
705 _invoke(tmp_path, ["agent-config", "init"])
706 _invoke(tmp_path, ["agent-config", "sync", "--adapters", "claude,codex"])
707 agent_md = agent_md_path(tmp_path)
708 agent_md.write_text(f"{agent_md.read_text()}\n# NEW RULE\n")
709 result = _invoke(tmp_path, ["agent-config", "status"])
710 assert result.exit_code == 0
711
712
713 # ---------------------------------------------------------------------------
714 # Unit β€” _load_configured_adapters
715 # ---------------------------------------------------------------------------
716
717
718 class TestLoadConfiguredAdapters:
719 """All tests isolate user-level config via MUSE_USER_CONFIG_DIR so the real
720 ~/.muse/config.toml never interferes with the expected result."""
721
722 @pytest.fixture(autouse=True)
723 def isolate_user_config(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
724 """Point MUSE_USER_CONFIG_DIR at a fresh tmp dir β€” no real user config."""
725 user_dir = tmp_path / "user_muse"
726 user_dir.mkdir()
727 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
728
729 def test_returns_none_when_no_config_toml(self, tmp_path: pathlib.Path) -> None:
730 from muse.cli.commands.agent_config import _load_configured_adapters
731 _init_repo(tmp_path)
732 assert _load_configured_adapters(tmp_path) is None
733
734 def test_returns_none_when_no_agent_config_section(self, tmp_path: pathlib.Path) -> None:
735 from muse.cli.commands.agent_config import _load_configured_adapters
736 _init_repo(tmp_path)
737 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
738 assert _load_configured_adapters(tmp_path) is None
739
740 def test_returns_list_when_set(self, tmp_path: pathlib.Path) -> None:
741 from muse.cli.commands.agent_config import _load_configured_adapters
742 _init_repo(tmp_path)
743 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude", "codex"]\n')
744 assert _load_configured_adapters(tmp_path) == ["claude", "codex"]
745
746 def test_returns_none_for_malformed_list(self, tmp_path: pathlib.Path) -> None:
747 from muse.cli.commands.agent_config import _load_configured_adapters
748 _init_repo(tmp_path)
749 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = "not-a-list"\n')
750 assert _load_configured_adapters(tmp_path) is None
751
752 def test_returns_none_for_corrupt_toml(self, tmp_path: pathlib.Path) -> None:
753 from muse.cli.commands.agent_config import _load_configured_adapters
754 _init_repo(tmp_path)
755 (config_toml_path(tmp_path)).write_text("[[[[invalid toml")
756 assert _load_configured_adapters(tmp_path) is None
757
758 def test_falls_back_to_user_config_when_no_repo_config(
759 self, tmp_path: pathlib.Path
760 ) -> None:
761 """When repo has no [agent-config], user-level config is used as fallback."""
762 import os as _os
763 from muse.cli.commands.agent_config import _load_configured_adapters
764 _init_repo(tmp_path)
765 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
766 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["claude"]\n')
767 assert _load_configured_adapters(tmp_path) == ["claude"]
768
769 def test_repo_config_takes_priority_over_user_config(
770 self, tmp_path: pathlib.Path
771 ) -> None:
772 """Repo-level [agent-config] overrides the user-level fallback."""
773 import os as _os
774 from muse.cli.commands.agent_config import _load_configured_adapters
775 _init_repo(tmp_path)
776 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
777 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["codex"]\n')
778 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude"]\n')
779 # Repo says claude; user says codex β€” repo wins
780 assert _load_configured_adapters(tmp_path) == ["claude"]
781
782 def test_user_config_fallback_absent_returns_none(
783 self, tmp_path: pathlib.Path
784 ) -> None:
785 """Both repo and user config absent β†’ None."""
786 from muse.cli.commands.agent_config import _load_configured_adapters
787 _init_repo(tmp_path)
788 assert _load_configured_adapters(tmp_path) is None
789
790
791 # ---------------------------------------------------------------------------
792 # Integration β€” set subcommand
793 # ---------------------------------------------------------------------------
794
795
796 class TestSet:
797 def test_writes_config_toml(self, tmp_path: pathlib.Path) -> None:
798 _init_repo(tmp_path)
799 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
800 assert result.exit_code == 0
801 config = (config_toml_path(tmp_path)).read_text()
802 assert "claude" in config
803 assert "codex" in config
804
805 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
806 _init_repo(tmp_path)
807 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
808 assert result.exit_code == 0
809 data = json.loads(result.output)
810 assert "adapters" in data
811 assert "path" in data
812 assert data["adapters"] == ["claude"]
813
814 def test_updates_existing_section(self, tmp_path: pathlib.Path) -> None:
815 _init_repo(tmp_path)
816 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
817 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
818 config = (config_toml_path(tmp_path)).read_text()
819 # Only one [agent-config] section
820 assert config.count("[agent-config]") == 1
821 # codex no longer present in the adapters list
822 import tomllib
823 raw = tomllib.loads(config)
824 assert raw["agent-config"]["adapters"] == ["claude"]
825
826 def test_preserves_other_config_sections(self, tmp_path: pathlib.Path) -> None:
827 _init_repo(tmp_path)
828 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
829 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
830 config = (config_toml_path(tmp_path)).read_text()
831 assert "[hub]" in config
832 assert "localhost:1337" in config
833 assert "[agent-config]" in config
834
835 def test_unknown_adapter_exits_1(self, tmp_path: pathlib.Path) -> None:
836 _init_repo(tmp_path)
837 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
838 assert result.exit_code == 1
839
840 def test_unknown_adapter_error_message(self, tmp_path: pathlib.Path) -> None:
841 _init_repo(tmp_path)
842 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
843 assert "vscode" in result.stderr.lower() or "unknown" in result.stderr.lower()
844
845 def test_single_adapter(self, tmp_path: pathlib.Path) -> None:
846 _init_repo(tmp_path)
847 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
848 assert result.exit_code == 0
849 assert json.loads(result.output)["adapters"] == ["claude"]
850
851 def test_all_adapters_accepted(self, tmp_path: pathlib.Path) -> None:
852 from muse.cli.commands.agent_config import _ADAPTERS
853 _init_repo(tmp_path)
854 all_names = ",".join(_ADAPTERS.keys())
855 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", all_names])
856 assert result.exit_code == 0
857
858 def test_global_flag_writes_to_user_config(
859 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
860 ) -> None:
861 """--global writes to MUSE_USER_CONFIG_DIR/config.toml, not the repo."""
862 user_dir = tmp_path / "user_muse"
863 user_dir.mkdir()
864 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
865 _init_repo(tmp_path)
866 result = _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
867 assert result.exit_code == 0
868 user_cfg = (user_dir / "config.toml").read_text()
869 assert "claude" in user_cfg
870 # Repo config must NOT have the section
871 repo_cfg = config_toml_path(tmp_path)
872 if repo_cfg.exists():
873 assert "[agent-config]" not in repo_cfg.read_text()
874
875 def test_global_flag_survives_repo_absence(
876 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
877 ) -> None:
878 """--global works even when CWD is not inside a muse repo."""
879 user_dir = tmp_path / "user_muse"
880 user_dir.mkdir()
881 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
882 # Use a directory with no .muse/ β€” repo is NOT required for --global
883 non_repo = tmp_path / "not_a_repo"
884 non_repo.mkdir()
885 _init_repo(non_repo) # init so we have a valid CWD repo context
886 result = _invoke(non_repo, ["agent-config", "set", "--global", "--adapters", "claude"])
887 assert result.exit_code == 0
888 assert "claude" in (user_dir / "config.toml").read_text()
889
890 def test_global_adapters_visible_to_sync(
891 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
892 ) -> None:
893 """sync picks up global config when the repo has no [agent-config] section."""
894 user_dir = tmp_path / "user_muse"
895 user_dir.mkdir()
896 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
897 _init_repo(tmp_path)
898 _invoke(tmp_path, ["agent-config", "init"])
899 _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
900 # Repo has no [agent-config] β€” should fall back to global
901 result = _invoke(tmp_path, ["agent-config", "sync"])
902 assert result.exit_code == 0
903 assert (tmp_path / "CLAUDE.md").exists()
904 assert not (tmp_path / "AGENTS.md").exists()
905 assert not (tmp_path / ".cursorrules").exists()
906 assert not (tmp_path / ".windsurfrules").exists()
907
908
909 # ---------------------------------------------------------------------------
910 # Integration β€” sync priority chain
911 # ---------------------------------------------------------------------------
912
913
914 class TestSyncPriorityChain:
915 def test_config_toml_limits_adapters(self, tmp_path: pathlib.Path) -> None:
916 """[agent-config] adapters in config.toml limits sync without --adapters."""
917 _init_repo(tmp_path)
918 _invoke(tmp_path, ["agent-config", "init"])
919 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
920 result = _invoke(tmp_path, ["agent-config", "sync"])
921 assert result.exit_code == 0
922 assert (tmp_path / "CLAUDE.md").exists()
923 assert not (tmp_path / "AGENTS.md").exists()
924
925 def test_cli_adapters_flag_overrides_config_toml(self, tmp_path: pathlib.Path) -> None:
926 """--adapters on CLI takes priority over config.toml setting."""
927 _init_repo(tmp_path)
928 _invoke(tmp_path, ["agent-config", "init"])
929 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
930 result = _invoke(tmp_path, ["agent-config", "sync", "--adapters", "codex"])
931 assert result.exit_code == 0
932 assert (tmp_path / "AGENTS.md").exists()
933 assert not (tmp_path / "CLAUDE.md").exists()
934
935 def test_no_config_exits_with_error(self, tmp_path: pathlib.Path) -> None:
936 """Without [agent-config] adapters set, sync exits with an actionable error."""
937 _init_repo(tmp_path)
938 _invoke(tmp_path, ["agent-config", "init"])
939 result = _invoke(tmp_path, ["agent-config", "sync"])
940 assert result.exit_code != 0
941 assert "agent-config set" in result.stderr or "agent-config set" in result.output
942
943
944 # ---------------------------------------------------------------------------
945 # E2E β€” full workflow
946 # ---------------------------------------------------------------------------
947
948
949 class TestE2EFullWorkflow:
950 def test_init_set_sync_edit_status_resync(self, tmp_path: pathlib.Path) -> None:
951 """Complete agent-config lifecycle: init β†’ set β†’ sync β†’ edit β†’ out-of-sync β†’ fix."""
952 _init_repo(tmp_path)
953
954 # init
955 r = _invoke(tmp_path, ["agent-config", "init"])
956 assert r.exit_code == 0
957 assert (agent_md_path(tmp_path)).exists()
958
959 # set
960 r = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
961 assert r.exit_code == 0
962
963 # sync
964 r = _invoke(tmp_path, ["agent-config", "sync"])
965 assert r.exit_code == 0
966 assert (tmp_path / "CLAUDE.md").exists()
967 assert (tmp_path / "AGENTS.md").exists()
968
969 # status β€” in sync
970 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
971 data = json.loads(r.output)
972 active = [a for a in data["adapters"] if a["exists"]]
973 assert all(a["in_sync"] for a in active)
974
975 # edit agent.md
976 agent_md = agent_md_path(tmp_path)
977 agent_md.write_text(f"{agent_md.read_text()}\n# EXTRA RULE\n")
978
979 # status β€” codex out of sync (embed adapter)
980 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
981 data = json.loads(r.output)
982 codex = next(a for a in data["adapters"] if a["name"] == "codex")
983 assert not codex["in_sync"]
984
985 # sync --force
986 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
987 assert r.exit_code == 0
988
989 # status β€” back in sync
990 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
991 data = json.loads(r.output)
992 active = [a for a in data["adapters"] if a["exists"]]
993 assert all(a["in_sync"] for a in active)
994
995 # verify new content is in AGENTS.md
996 assert "EXTRA RULE" in (tmp_path / "AGENTS.md").read_text()
997
998 def test_workspace_e2e(self, tmp_path: pathlib.Path) -> None:
999 """Workspace hierarchy: shared rules flow into member CLAUDE.md."""
1000 _init_workspace(tmp_path, [("core", "core")])
1001 _invoke(tmp_path, ["agent-config", "init"])
1002
1003 repo = tmp_path / "core"
1004 repo.mkdir()
1005 _init_with_all_adapters(repo)
1006 _invoke(repo, ["agent-config", "sync"])
1007
1008 claude = (repo / "CLAUDE.md").read_text()
1009 assert "@../.museagent.md" in claude
1010 assert "@.museagent.md" in claude
1011
1012
1013 # ---------------------------------------------------------------------------
1014 # Stress
1015 # ---------------------------------------------------------------------------
1016
1017
1018 class TestStress:
1019 def test_large_agent_md_syncs(self, tmp_path: pathlib.Path) -> None:
1020 """200 KB agent.md embeds correctly into AGENTS.md."""
1021 _init_with_all_adapters(tmp_path)
1022 # Overwrite with 200 KB of content
1023 large = f"# Rule\n{'x' * 200}\n"
1024 large_content = large * 1000 # ~200 KB
1025 (agent_md_path(tmp_path)).write_text(large_content)
1026 result = _invoke(tmp_path, ["agent-config", "sync"])
1027 assert result.exit_code == 0
1028 agents_md = (tmp_path / "AGENTS.md").read_text()
1029 assert len(agents_md) > 100_000
1030
1031 def test_rapid_sequential_syncs(self, tmp_path: pathlib.Path) -> None:
1032 """30 sequential sync --force calls produce consistent output."""
1033 _init_with_all_adapters(tmp_path)
1034 _invoke(tmp_path, ["agent-config", "sync"])
1035 content_before = (tmp_path / "AGENTS.md").read_text()
1036 for _ in range(30):
1037 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
1038 assert r.exit_code == 0
1039 assert (tmp_path / "AGENTS.md").read_text() == content_before
1040
1041 def test_concurrent_sync_no_corruption(self, tmp_path: pathlib.Path) -> None:
1042 """Concurrent sync --force calls never produce a torn file.
1043
1044 Uses write_text_atomic directly to test the atomicity guarantee without
1045 threading through the full CLI (which relies on process-global CWD).
1046 """
1047 from muse.core.io import write_text_atomic
1048 target = tmp_path / "AGENTS.md"
1049 content = "# Agent rules\n" + "x" * 10_000 + "\n"
1050
1051 errors: list[str] = []
1052
1053 def write() -> None:
1054 try:
1055 write_text_atomic(target, content)
1056 except Exception as exc:
1057 errors.append(str(exc))
1058
1059 threads = [threading.Thread(target=write) for _ in range(8)]
1060 for t in threads:
1061 t.start()
1062 for t in threads:
1063 t.join()
1064
1065 assert not errors
1066 result = target.read_text()
1067 assert len(result) > 0
1068 # File must be complete β€” never a partial write
1069 assert result == content
1070
1071
1072 # ---------------------------------------------------------------------------
1073 # Data Integrity
1074 # ---------------------------------------------------------------------------
1075
1076
1077 class TestDataIntegrity:
1078 def test_corrupt_config_toml_exits_with_error(
1079 self, tmp_path: pathlib.Path
1080 ) -> None:
1081 """Corrupt config.toml causes sync to fail β€” no files are silently generated."""
1082 _init_repo(tmp_path)
1083 _invoke(tmp_path, ["agent-config", "init"])
1084 (config_toml_path(tmp_path)).write_text("[[[[not valid toml")
1085 result = _invoke(tmp_path, ["agent-config", "sync"])
1086 assert result.exit_code != 0
1087
1088 def test_adapter_file_not_empty_after_sync(self, tmp_path: pathlib.Path) -> None:
1089 """Every generated adapter file has non-zero content."""
1090 from muse.cli.commands.agent_config import _ADAPTERS
1091 _init_with_all_adapters(tmp_path)
1092 _invoke(tmp_path, ["agent-config", "sync"])
1093 for spec in _ADAPTERS.values():
1094 p = tmp_path / spec["filename"]
1095 assert p.stat().st_size > 0, f"{spec['filename']} is empty"
1096
1097 def test_sync_write_is_atomic(self, tmp_path: pathlib.Path) -> None:
1098 """After sync, AGENTS.md is a complete file β€” not truncated mid-write."""
1099 _init_with_all_adapters(tmp_path)
1100 _invoke(tmp_path, ["agent-config", "sync"])
1101 content = (tmp_path / "AGENTS.md").read_text()
1102 # Content should end with a newline, not be truncated mid-line
1103 assert content.endswith("\n")
1104
1105 def test_set_preserves_existing_config_integrity(
1106 self, tmp_path: pathlib.Path
1107 ) -> None:
1108 """set writes valid TOML that can be re-parsed."""
1109 import tomllib
1110 _init_repo(tmp_path)
1111 (config_toml_path(tmp_path)).write_text(
1112 '[hub]\nurl = "https://localhost:1337"\n\n[limits]\nmax_file_size_mb = 10\n'
1113 )
1114 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
1115 raw = tomllib.loads((config_toml_path(tmp_path)).read_text())
1116 assert raw["hub"]["url"] == "https://localhost:1337"
1117 assert raw["limits"]["max_file_size_mb"] == 10
1118 assert raw["agent-config"]["adapters"] == ["claude", "codex"]
1119
1120
1121 # ---------------------------------------------------------------------------
1122 # Performance
1123 # ---------------------------------------------------------------------------
1124
1125
1126 class TestPerformance:
1127 def test_sync_completes_under_2_seconds(self, tmp_path: pathlib.Path) -> None:
1128 """sync with default adapters completes in under 2 seconds."""
1129 _init_repo(tmp_path)
1130 _invoke(tmp_path, ["agent-config", "init"])
1131 start = time.monotonic()
1132 _invoke(tmp_path, ["agent-config", "sync"])
1133 elapsed = time.monotonic() - start
1134 assert elapsed < 2.0, f"sync took {elapsed:.2f}s β€” too slow"
1135
1136 def test_status_completes_under_1_second(self, tmp_path: pathlib.Path) -> None:
1137 """status check completes in under 1 second."""
1138 _init_repo(tmp_path)
1139 _invoke(tmp_path, ["agent-config", "init"])
1140 _invoke(tmp_path, ["agent-config", "sync"])
1141 start = time.monotonic()
1142 _invoke(tmp_path, ["agent-config", "status", "--json"])
1143 elapsed = time.monotonic() - start
1144 assert elapsed < 1.0, f"status took {elapsed:.2f}s β€” too slow"
1145
1146
1147 # ---------------------------------------------------------------------------
1148 # Security
1149 # ---------------------------------------------------------------------------
1150
1151
1152 class TestSecurity:
1153 def test_set_rejects_path_traversal_in_adapter_name(
1154 self, tmp_path: pathlib.Path
1155 ) -> None:
1156 """set does not accept adapter names containing path separators."""
1157 _init_repo(tmp_path)
1158 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "../traversal"])
1159 assert result.exit_code == 1
1160
1161 def test_set_rejects_adapter_with_null_byte(
1162 self, tmp_path: pathlib.Path
1163 ) -> None:
1164 """set rejects adapter names containing null bytes."""
1165 _init_repo(tmp_path)
1166 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude\x00malicious"])
1167 assert result.exit_code == 1
1168
1169 def test_agent_md_with_null_bytes_does_not_crash_sync(
1170 self, tmp_path: pathlib.Path
1171 ) -> None:
1172 """agent.md containing null bytes is handled without an unhandled exception."""
1173 _init_repo(tmp_path)
1174 _invoke(tmp_path, ["agent-config", "init"])
1175 # Write null bytes into agent.md
1176 agent_md = agent_md_path(tmp_path)
1177 agent_md.write_bytes(agent_md.read_bytes() + b"\x00\x00malicious\x00")
1178 # Should not raise β€” exit code may be 0 or 1 but must not be an unhandled exception
1179 result = _invoke(tmp_path, ["agent-config", "sync"])
1180 assert result.exit_code in (0, 1)
1181
1182 def test_toml_injection_in_config_does_not_escape_section(
1183 self, tmp_path: pathlib.Path
1184 ) -> None:
1185 """A crafted adapter name cannot inject extra TOML sections."""
1186 import tomllib
1187 _init_repo(tmp_path)
1188 # Attempt to inject a new TOML section via adapter name
1189 result = _invoke(
1190 tmp_path,
1191 ["agent-config", "set", "--adapters", 'claude"]\n[injected'],
1192 )
1193 # Should fail with unknown adapter error, not write injected TOML
1194 assert result.exit_code == 1
1195 config_path = config_toml_path(tmp_path)
1196 if config_path.exists():
1197 raw = tomllib.loads(config_path.read_text())
1198 assert "injected" not in raw
1199
1200
1201 # ---------------------------------------------------------------------------
1202 # Integration β€” smart sync (skip in-sync files)
1203 # ---------------------------------------------------------------------------
1204
1205
1206 class TestSmartSync:
1207 def test_second_sync_skips_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1208 """Repeated sync without changes exits 0 and reports skipped."""
1209 _init_with_all_adapters(tmp_path)
1210 _invoke(tmp_path, ["agent-config", "sync"])
1211 result = _invoke(tmp_path, ["agent-config", "sync"])
1212 assert result.exit_code == 0
1213 assert "in sync" in result.output
1214
1215 def test_second_sync_json_skipped_true(self, tmp_path: pathlib.Path) -> None:
1216 """sync --json shows skipped=True for already-in-sync files."""
1217 _init_with_all_adapters(tmp_path)
1218 _invoke(tmp_path, ["agent-config", "sync"])
1219 result = _invoke(tmp_path, ["agent-config", "sync", "--json"])
1220 assert result.exit_code == 0
1221 data = json.loads(result.output)
1222 for entry in data["adapters"]:
1223 assert entry["skipped"] is True
1224 assert entry["written"] is False
1225
1226 def test_out_of_sync_file_is_updated_without_force(self, tmp_path: pathlib.Path) -> None:
1227 """An adapter that is out of sync is updated even without --force."""
1228 _init_with_all_adapters(tmp_path)
1229 _invoke(tmp_path, ["agent-config", "sync"])
1230 # Corrupt AGENTS.md content
1231 (tmp_path / "AGENTS.md").write_text("old content")
1232 result = _invoke(tmp_path, ["agent-config", "sync"])
1233 assert result.exit_code == 0
1234 assert "old content" not in (tmp_path / "AGENTS.md").read_text()
1235 assert "Muse" in (tmp_path / "AGENTS.md").read_text()
1236
1237 def test_force_rewrites_even_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1238 """--force writes all files even when they are already in sync."""
1239 _init_with_all_adapters(tmp_path)
1240 _invoke(tmp_path, ["agent-config", "sync"])
1241 result = _invoke(tmp_path, ["agent-config", "sync", "--force", "--json"])
1242 assert result.exit_code == 0
1243 data = json.loads(result.output)
1244 for entry in data["adapters"]:
1245 assert entry["written"] is True
1246 assert entry["skipped"] is False
1247
1248 def test_sync_idempotent_across_multiple_runs(self, tmp_path: pathlib.Path) -> None:
1249 """Running sync N times produces identical output each time."""
1250 _init_with_all_adapters(tmp_path)
1251 _invoke(tmp_path, ["agent-config", "sync"])
1252 content_after_first = (tmp_path / "AGENTS.md").read_text()
1253 for _ in range(5):
1254 r = _invoke(tmp_path, ["agent-config", "sync"])
1255 assert r.exit_code == 0
1256 assert (tmp_path / "AGENTS.md").read_text() == content_after_first
1257
1258
1259 # ---------------------------------------------------------------------------
1260 # Integration β€” inspect
1261 # ---------------------------------------------------------------------------
1262
1263
1264 class TestInspect:
1265 def test_inspect_json_schema_standalone(self, tmp_path: pathlib.Path) -> None:
1266 """inspect --json returns all required fields for a standalone repo."""
1267 _init_repo(tmp_path)
1268 _invoke(tmp_path, ["agent-config", "init"])
1269 _invoke(tmp_path, ["agent-config", "sync"])
1270 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1271 assert result.exit_code == 0
1272 data = json.loads(result.output)
1273 assert data["context"] == "standalone"
1274 assert data["workspace_root"] is None
1275 assert data["repo_name"] == tmp_path.name
1276 assert data["agent_md_exists"] is True
1277 assert data["merged_content"] is not None
1278 assert "adapters" in data
1279 assert isinstance(data["ready"], bool)
1280
1281 def test_inspect_ready_true_when_in_sync(self, tmp_path: pathlib.Path) -> None:
1282 """ready is True when agent.md exists and adapters are in sync."""
1283 _init_with_all_adapters(tmp_path)
1284 _invoke(tmp_path, ["agent-config", "sync"])
1285 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1286 data = json.loads(result.output)
1287 assert data["ready"] is True
1288
1289 def test_inspect_ready_false_without_adapters(self, tmp_path: pathlib.Path) -> None:
1290 """ready is False when agent.md exists but no adapters have been synced."""
1291 _init_repo(tmp_path)
1292 _invoke(tmp_path, ["agent-config", "init"])
1293 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1294 data = json.loads(result.output)
1295 assert data["ready"] is False
1296
1297 def test_inspect_ready_false_without_agent_md(self, tmp_path: pathlib.Path) -> None:
1298 """ready is False when agent.md does not exist."""
1299 _init_repo(tmp_path)
1300 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1301 data = json.loads(result.output)
1302 assert data["ready"] is False
1303 assert data["agent_md_exists"] is False
1304 assert data["merged_content"] is None
1305
1306 def test_inspect_merged_content_contains_rules(self, tmp_path: pathlib.Path) -> None:
1307 """merged_content includes the actual rules from agent.md."""
1308 _init_repo(tmp_path)
1309 _invoke(tmp_path, ["agent-config", "init"])
1310 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1311 data = json.loads(result.output)
1312 assert "Muse" in data["merged_content"]
1313 assert "git" in data["merged_content"].lower()
1314
1315 def test_inspect_adapter_entries_schema(self, tmp_path: pathlib.Path) -> None:
1316 """Each adapter entry in inspect output has the expected fields."""
1317 _init_repo(tmp_path)
1318 _invoke(tmp_path, ["agent-config", "init"])
1319 _invoke(tmp_path, ["agent-config", "sync"])
1320 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1321 data = json.loads(result.output)
1322 for entry in data["adapters"]:
1323 assert "name" in entry
1324 assert "filename" in entry
1325 assert "exists" in entry
1326 assert "in_sync" in entry
1327
1328 def test_inspect_workspace_member_context(self, tmp_path: pathlib.Path) -> None:
1329 """inspect reports workspace_member context and non-null workspace_root."""
1330 _init_workspace(tmp_path, [("core", "core")])
1331 _invoke(tmp_path, ["agent-config", "init"])
1332 repo = tmp_path / "core"
1333 repo.mkdir()
1334 _init_repo(repo)
1335 _invoke(repo, ["agent-config", "init"])
1336 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1337 assert result.exit_code == 0
1338 data = json.loads(result.output)
1339 assert data["context"] == "workspace_member"
1340 assert data["workspace_root"] is not None
1341 assert data["repo_name"] == "core"
1342
1343 def test_inspect_workspace_merged_content_includes_both_levels(
1344 self, tmp_path: pathlib.Path
1345 ) -> None:
1346 """merged_content in a workspace member includes both WS and repo rules."""
1347 _init_workspace(tmp_path, [("core", "core")])
1348 _invoke(tmp_path, ["agent-config", "init"])
1349 # Add a unique marker to the workspace-level agent.md
1350 ws_agent = agent_md_path(tmp_path)
1351 ws_agent.write_text(f"{ws_agent.read_text()}\n# WS_MARKER\n")
1352 repo = tmp_path / "core"
1353 repo.mkdir()
1354 _init_repo(repo)
1355 _invoke(repo, ["agent-config", "init"])
1356 # Add a unique marker to the repo-level agent.md
1357 repo_agent = agent_md_path(repo)
1358 repo_agent.write_text(f"{repo_agent.read_text()}\n# REPO_MARKER\n")
1359 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1360 data = json.loads(result.output)
1361 assert "WS_MARKER" in data["merged_content"]
1362 assert "REPO_MARKER" in data["merged_content"]
1363
1364 def test_inspect_text_output_exits_0(self, tmp_path: pathlib.Path) -> None:
1365 """inspect without --json exits 0 and prints context info."""
1366 _init_repo(tmp_path)
1367 _invoke(tmp_path, ["agent-config", "init"])
1368 result = _invoke(tmp_path, ["agent-config", "inspect"])
1369 assert result.exit_code == 0
1370 assert "Context" in result.output or "standalone" in result.output
1371
1372
1373 # ---------------------------------------------------------------------------
1374 # Integration β€” status extra fields
1375 # ---------------------------------------------------------------------------
1376
1377
1378 class TestStatusExtraFields:
1379 def test_status_json_includes_agent_md_exists(self, tmp_path: pathlib.Path) -> None:
1380 _init_repo(tmp_path)
1381 _invoke(tmp_path, ["agent-config", "init"])
1382 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1383 data = json.loads(result.output)
1384 assert "agent_md_exists" in data
1385 assert data["agent_md_exists"] is True
1386
1387 def test_status_json_includes_ready(self, tmp_path: pathlib.Path) -> None:
1388 _init_with_all_adapters(tmp_path)
1389 _invoke(tmp_path, ["agent-config", "sync"])
1390 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1391 data = json.loads(result.output)
1392 assert "ready" in data
1393 assert data["ready"] is True
1394
1395 def test_status_json_ready_false_before_sync(self, tmp_path: pathlib.Path) -> None:
1396 _init_repo(tmp_path)
1397 _invoke(tmp_path, ["agent-config", "init"])
1398 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1399 data = json.loads(result.output)
1400 assert data["ready"] is False
1401
1402 def test_status_json_summary_counts(self, tmp_path: pathlib.Path) -> None:
1403 from muse.cli.commands.agent_config import _ADAPTERS
1404 _init_repo(tmp_path)
1405 _invoke(tmp_path, ["agent-config", "init"])
1406 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1407 data = json.loads(result.output)
1408 assert "in_sync_count" in data
1409 assert "missing_count" in data
1410 assert "out_of_sync_count" in data
1411 # Before sync, all adapters are missing
1412 assert data["missing_count"] == len(_ADAPTERS)
1413 assert data["in_sync_count"] == 0
1414
1415 def test_status_json_counts_after_sync(self, tmp_path: pathlib.Path) -> None:
1416 from muse.cli.commands.agent_config import _ADAPTERS
1417 _init_with_all_adapters(tmp_path)
1418 _invoke(tmp_path, ["agent-config", "sync"])
1419 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1420 data = json.loads(result.output)
1421 assert data["in_sync_count"] == len(_ADAPTERS)
1422 assert data["missing_count"] == 0
1423 assert data["out_of_sync_count"] == 0
1424
1425
1426 # ---------------------------------------------------------------------------
1427 # Integration β€” template content
1428 # ---------------------------------------------------------------------------
1429
1430
1431 class TestTemplateContent:
1432 def test_standalone_template_includes_testing_rules(
1433 self, tmp_path: pathlib.Path
1434 ) -> None:
1435 """Standalone template includes the no-full-test-suite rule."""
1436 _init_repo(tmp_path)
1437 _invoke(tmp_path, ["agent-config", "init"])
1438 content = (agent_md_path(tmp_path)).read_text()
1439 assert "full test suite" in content.lower() or "full" in content.lower()
1440 assert "muse code test" in content
1441
1442 def test_standalone_template_includes_muse_code_test(
1443 self, tmp_path: pathlib.Path
1444 ) -> None:
1445 """Standalone template lists muse code test in the code intelligence table."""
1446 _init_repo(tmp_path)
1447 _invoke(tmp_path, ["agent-config", "init"])
1448 content = (agent_md_path(tmp_path)).read_text()
1449 assert "muse code test" in content
1450
1451
1452 class TestRegisterFlags:
1453 """Argparse registration tests for ``muse agent-config`` subcommands."""
1454
1455 def _parse(self, *args: str) -> argparse.Namespace:
1456 from muse.cli.commands.agent_config import register
1457 p = argparse.ArgumentParser()
1458 sub = p.add_subparsers()
1459 register(sub)
1460 return p.parse_args(["agent-config", *args])
1461
1462 # init
1463 def test_init_default_json_out_is_false(self) -> None:
1464 ns = self._parse("init")
1465 assert ns.json_out is False
1466
1467 def test_init_json_flag_sets_json_out(self) -> None:
1468 ns = self._parse("init", "--json")
1469 assert ns.json_out is True
1470
1471 def test_init_j_shorthand_sets_json_out(self) -> None:
1472 ns = self._parse("init", "-j")
1473 assert ns.json_out is True
1474
1475 def test_init_force_default(self) -> None:
1476 ns = self._parse("init")
1477 assert ns.force is False
1478
1479 def test_init_force_flag(self) -> None:
1480 ns = self._parse("init", "--force")
1481 assert ns.force is True
1482
1483 def test_init_force_shorthand(self) -> None:
1484 ns = self._parse("init", "-f")
1485 assert ns.force is True
1486
1487 # sync
1488 def test_sync_default_json_out_is_false(self) -> None:
1489 ns = self._parse("sync")
1490 assert ns.json_out is False
1491
1492 def test_sync_json_flag_sets_json_out(self) -> None:
1493 ns = self._parse("sync", "--json")
1494 assert ns.json_out is True
1495
1496 def test_sync_j_shorthand_sets_json_out(self) -> None:
1497 ns = self._parse("sync", "-j")
1498 assert ns.json_out is True
1499
1500 def test_sync_dry_run_default(self) -> None:
1501 ns = self._parse("sync")
1502 assert ns.dry_run is False
1503
1504 def test_sync_dry_run_flag(self) -> None:
1505 ns = self._parse("sync", "--dry-run")
1506 assert ns.dry_run is True
1507
1508 def test_sync_dry_run_shorthand(self) -> None:
1509 ns = self._parse("sync", "-n")
1510 assert ns.dry_run is True
1511
1512 def test_sync_force_default(self) -> None:
1513 ns = self._parse("sync")
1514 assert ns.force is False
1515
1516 def test_sync_force_flag(self) -> None:
1517 ns = self._parse("sync", "--force")
1518 assert ns.force is True
1519
1520 def test_sync_force_shorthand(self) -> None:
1521 ns = self._parse("sync", "-f")
1522 assert ns.force is True
1523
1524 # read
1525 def test_read_default_json_out_is_false(self) -> None:
1526 ns = self._parse("read")
1527 assert ns.json_out is False
1528
1529 def test_read_json_flag_sets_json_out(self) -> None:
1530 ns = self._parse("read", "--json")
1531 assert ns.json_out is True
1532
1533 def test_read_j_shorthand_sets_json_out(self) -> None:
1534 ns = self._parse("read", "-j")
1535 assert ns.json_out is True
1536
1537 # status
1538 def test_status_default_json_out_is_false(self) -> None:
1539 ns = self._parse("status")
1540 assert ns.json_out is False
1541
1542 def test_status_json_flag_sets_json_out(self) -> None:
1543 ns = self._parse("status", "--json")
1544 assert ns.json_out is True
1545
1546 # inspect
1547 def test_inspect_default_json_out_is_false(self) -> None:
1548 ns = self._parse("inspect")
1549 assert ns.json_out is False
1550
1551 def test_inspect_json_flag_sets_json_out(self) -> None:
1552 ns = self._parse("inspect", "--json")
1553 assert ns.json_out is True
1554
1555 # set
1556 def test_set_default_json_out_is_false(self) -> None:
1557 ns = self._parse("set", "--adapters", "claude")
1558 assert ns.json_out is False
1559
1560 def test_set_json_flag_sets_json_out(self) -> None:
1561 ns = self._parse("set", "--adapters", "claude", "--json")
1562 assert ns.json_out is True