gabriel / muse public
test_cmd_agent_config.py python
1,503 lines 63.5 KB
Raw
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295 fix adapter for agent config Human patch 44 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 .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 # Unit — _load_configured_adapters
656 # ---------------------------------------------------------------------------
657
658
659 class TestLoadConfiguredAdapters:
660 """All tests isolate user-level config via MUSE_USER_CONFIG_DIR so the real
661 ~/.muse/config.toml never interferes with the expected result."""
662
663 @pytest.fixture(autouse=True)
664 def isolate_user_config(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
665 """Point MUSE_USER_CONFIG_DIR at a fresh tmp dir — no real user config."""
666 user_dir = tmp_path / "user_muse"
667 user_dir.mkdir()
668 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
669
670 def test_returns_none_when_no_config_toml(self, tmp_path: pathlib.Path) -> None:
671 from muse.cli.commands.agent_config import _load_configured_adapters
672 _init_repo(tmp_path)
673 assert _load_configured_adapters(tmp_path) is None
674
675 def test_returns_none_when_no_agent_config_section(self, tmp_path: pathlib.Path) -> None:
676 from muse.cli.commands.agent_config import _load_configured_adapters
677 _init_repo(tmp_path)
678 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
679 assert _load_configured_adapters(tmp_path) is None
680
681 def test_returns_list_when_set(self, tmp_path: pathlib.Path) -> None:
682 from muse.cli.commands.agent_config import _load_configured_adapters
683 _init_repo(tmp_path)
684 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude", "codex"]\n')
685 assert _load_configured_adapters(tmp_path) == ["claude", "codex"]
686
687 def test_returns_none_for_malformed_list(self, tmp_path: pathlib.Path) -> None:
688 from muse.cli.commands.agent_config import _load_configured_adapters
689 _init_repo(tmp_path)
690 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = "not-a-list"\n')
691 assert _load_configured_adapters(tmp_path) is None
692
693 def test_returns_none_for_corrupt_toml(self, tmp_path: pathlib.Path) -> None:
694 from muse.cli.commands.agent_config import _load_configured_adapters
695 _init_repo(tmp_path)
696 (config_toml_path(tmp_path)).write_text("[[[[invalid toml")
697 assert _load_configured_adapters(tmp_path) is None
698
699 def test_falls_back_to_user_config_when_no_repo_config(
700 self, tmp_path: pathlib.Path
701 ) -> None:
702 """When repo has no [agent-config], user-level config is used as fallback."""
703 import os as _os
704 from muse.cli.commands.agent_config import _load_configured_adapters
705 _init_repo(tmp_path)
706 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
707 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["claude"]\n')
708 assert _load_configured_adapters(tmp_path) == ["claude"]
709
710 def test_repo_config_takes_priority_over_user_config(
711 self, tmp_path: pathlib.Path
712 ) -> None:
713 """Repo-level [agent-config] overrides the user-level fallback."""
714 import os as _os
715 from muse.cli.commands.agent_config import _load_configured_adapters
716 _init_repo(tmp_path)
717 user_dir = pathlib.Path(_os.environ["MUSE_USER_CONFIG_DIR"])
718 (user_dir / "config.toml").write_text('[agent-config]\nadapters = ["codex"]\n')
719 (config_toml_path(tmp_path)).write_text('[agent-config]\nadapters = ["claude"]\n')
720 # Repo says claude; user says codex — repo wins
721 assert _load_configured_adapters(tmp_path) == ["claude"]
722
723 def test_user_config_fallback_absent_returns_none(
724 self, tmp_path: pathlib.Path
725 ) -> None:
726 """Both repo and user config absent → None."""
727 from muse.cli.commands.agent_config import _load_configured_adapters
728 _init_repo(tmp_path)
729 assert _load_configured_adapters(tmp_path) is None
730
731
732 # ---------------------------------------------------------------------------
733 # Integration — set subcommand
734 # ---------------------------------------------------------------------------
735
736
737 class TestSet:
738 def test_writes_config_toml(self, tmp_path: pathlib.Path) -> None:
739 _init_repo(tmp_path)
740 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
741 assert result.exit_code == 0
742 config = (config_toml_path(tmp_path)).read_text()
743 assert "claude" in config
744 assert "codex" in config
745
746 def test_json_schema(self, tmp_path: pathlib.Path) -> None:
747 _init_repo(tmp_path)
748 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
749 assert result.exit_code == 0
750 data = json.loads(result.output)
751 assert "adapters" in data
752 assert "path" in data
753 assert data["adapters"] == ["claude"]
754
755 def test_updates_existing_section(self, tmp_path: pathlib.Path) -> None:
756 _init_repo(tmp_path)
757 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
758 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
759 config = (config_toml_path(tmp_path)).read_text()
760 # Only one [agent-config] section
761 assert config.count("[agent-config]") == 1
762 # codex no longer present in the adapters list
763 import tomllib
764 raw = tomllib.loads(config)
765 assert raw["agent-config"]["adapters"] == ["claude"]
766
767 def test_preserves_other_config_sections(self, tmp_path: pathlib.Path) -> None:
768 _init_repo(tmp_path)
769 (config_toml_path(tmp_path)).write_text('[hub]\nurl = "https://localhost:1337"\n')
770 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
771 config = (config_toml_path(tmp_path)).read_text()
772 assert "[hub]" in config
773 assert "localhost:1337" in config
774 assert "[agent-config]" in config
775
776 def test_unknown_adapter_exits_1(self, tmp_path: pathlib.Path) -> None:
777 _init_repo(tmp_path)
778 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
779 assert result.exit_code == 1
780
781 def test_unknown_adapter_error_message(self, tmp_path: pathlib.Path) -> None:
782 _init_repo(tmp_path)
783 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "vscode"])
784 assert "vscode" in result.stderr.lower() or "unknown" in result.stderr.lower()
785
786 def test_single_adapter(self, tmp_path: pathlib.Path) -> None:
787 _init_repo(tmp_path)
788 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude", "--json"])
789 assert result.exit_code == 0
790 assert json.loads(result.output)["adapters"] == ["claude"]
791
792 def test_all_adapters_accepted(self, tmp_path: pathlib.Path) -> None:
793 from muse.cli.commands.agent_config import _ADAPTERS
794 _init_repo(tmp_path)
795 all_names = ",".join(_ADAPTERS.keys())
796 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", all_names])
797 assert result.exit_code == 0
798
799 def test_global_flag_writes_to_user_config(
800 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
801 ) -> None:
802 """--global writes to MUSE_USER_CONFIG_DIR/config.toml, not the repo."""
803 user_dir = tmp_path / "user_muse"
804 user_dir.mkdir()
805 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
806 _init_repo(tmp_path)
807 result = _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
808 assert result.exit_code == 0
809 user_cfg = (user_dir / "config.toml").read_text()
810 assert "claude" in user_cfg
811 # Repo config must NOT have the section
812 repo_cfg = config_toml_path(tmp_path)
813 if repo_cfg.exists():
814 assert "[agent-config]" not in repo_cfg.read_text()
815
816 def test_global_flag_survives_repo_absence(
817 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
818 ) -> None:
819 """--global works even when CWD is not inside a muse repo."""
820 user_dir = tmp_path / "user_muse"
821 user_dir.mkdir()
822 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
823 # Use a directory with no .muse/ — repo is NOT required for --global
824 non_repo = tmp_path / "not_a_repo"
825 non_repo.mkdir()
826 _init_repo(non_repo) # init so we have a valid CWD repo context
827 result = _invoke(non_repo, ["agent-config", "set", "--global", "--adapters", "claude"])
828 assert result.exit_code == 0
829 assert "claude" in (user_dir / "config.toml").read_text()
830
831 def test_global_adapters_visible_to_sync(
832 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
833 ) -> None:
834 """sync picks up global config when the repo has no [agent-config] section."""
835 user_dir = tmp_path / "user_muse"
836 user_dir.mkdir()
837 monkeypatch.setenv("MUSE_USER_CONFIG_DIR", str(user_dir))
838 _init_repo(tmp_path)
839 _invoke(tmp_path, ["agent-config", "init"])
840 _invoke(tmp_path, ["agent-config", "set", "--global", "--adapters", "claude"])
841 # Repo has no [agent-config] — should fall back to global
842 result = _invoke(tmp_path, ["agent-config", "sync"])
843 assert result.exit_code == 0
844 assert (tmp_path / "CLAUDE.md").exists()
845 assert not (tmp_path / "AGENTS.md").exists()
846 assert not (tmp_path / ".cursorrules").exists()
847 assert not (tmp_path / ".windsurfrules").exists()
848
849
850 # ---------------------------------------------------------------------------
851 # Integration — sync priority chain
852 # ---------------------------------------------------------------------------
853
854
855 class TestSyncPriorityChain:
856 def test_config_toml_limits_adapters(self, tmp_path: pathlib.Path) -> None:
857 """[agent-config] adapters in config.toml limits sync without --adapters."""
858 _init_repo(tmp_path)
859 _invoke(tmp_path, ["agent-config", "init"])
860 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
861 result = _invoke(tmp_path, ["agent-config", "sync"])
862 assert result.exit_code == 0
863 assert (tmp_path / "CLAUDE.md").exists()
864 assert not (tmp_path / "AGENTS.md").exists()
865
866 def test_cli_adapters_flag_overrides_config_toml(self, tmp_path: pathlib.Path) -> None:
867 """--adapters on CLI takes priority over config.toml setting."""
868 _init_repo(tmp_path)
869 _invoke(tmp_path, ["agent-config", "init"])
870 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude"])
871 result = _invoke(tmp_path, ["agent-config", "sync", "--adapters", "codex"])
872 assert result.exit_code == 0
873 assert (tmp_path / "AGENTS.md").exists()
874 assert not (tmp_path / "CLAUDE.md").exists()
875
876 def test_no_config_exits_with_error(self, tmp_path: pathlib.Path) -> None:
877 """Without [agent-config] adapters set, sync exits with an actionable error."""
878 _init_repo(tmp_path)
879 _invoke(tmp_path, ["agent-config", "init"])
880 result = _invoke(tmp_path, ["agent-config", "sync"])
881 assert result.exit_code != 0
882 assert "agent-config set" in result.stderr or "agent-config set" in result.output
883
884
885 # ---------------------------------------------------------------------------
886 # E2E — full workflow
887 # ---------------------------------------------------------------------------
888
889
890 class TestE2EFullWorkflow:
891 def test_init_set_sync_edit_status_resync(self, tmp_path: pathlib.Path) -> None:
892 """Complete agent-config lifecycle: init → set → sync → edit → out-of-sync → fix."""
893 _init_repo(tmp_path)
894
895 # init
896 r = _invoke(tmp_path, ["agent-config", "init"])
897 assert r.exit_code == 0
898 assert (agent_md_path(tmp_path)).exists()
899
900 # set
901 r = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
902 assert r.exit_code == 0
903
904 # sync
905 r = _invoke(tmp_path, ["agent-config", "sync"])
906 assert r.exit_code == 0
907 assert (tmp_path / "CLAUDE.md").exists()
908 assert (tmp_path / "AGENTS.md").exists()
909
910 # status — in sync
911 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
912 data = json.loads(r.output)
913 active = [a for a in data["adapters"] if a["exists"]]
914 assert all(a["in_sync"] for a in active)
915
916 # edit agent.md
917 agent_md = agent_md_path(tmp_path)
918 agent_md.write_text(f"{agent_md.read_text()}\n# EXTRA RULE\n")
919
920 # status — codex out of sync (embed adapter)
921 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
922 data = json.loads(r.output)
923 codex = next(a for a in data["adapters"] if a["name"] == "codex")
924 assert not codex["in_sync"]
925
926 # sync --force
927 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
928 assert r.exit_code == 0
929
930 # status — back in sync
931 r = _invoke(tmp_path, ["agent-config", "status", "--json"])
932 data = json.loads(r.output)
933 active = [a for a in data["adapters"] if a["exists"]]
934 assert all(a["in_sync"] for a in active)
935
936 # verify new content is in AGENTS.md
937 assert "EXTRA RULE" in (tmp_path / "AGENTS.md").read_text()
938
939 def test_workspace_e2e(self, tmp_path: pathlib.Path) -> None:
940 """Workspace hierarchy: shared rules flow into member CLAUDE.md."""
941 _init_workspace(tmp_path, [("core", "core")])
942 _invoke(tmp_path, ["agent-config", "init"])
943
944 repo = tmp_path / "core"
945 repo.mkdir()
946 _init_with_all_adapters(repo)
947 _invoke(repo, ["agent-config", "sync"])
948
949 claude = (repo / "CLAUDE.md").read_text()
950 assert "@../.museagent.md" in claude
951 assert "@.museagent.md" in claude
952
953
954 # ---------------------------------------------------------------------------
955 # Stress
956 # ---------------------------------------------------------------------------
957
958
959 class TestStress:
960 def test_large_agent_md_syncs(self, tmp_path: pathlib.Path) -> None:
961 """200 KB agent.md embeds correctly into AGENTS.md."""
962 _init_with_all_adapters(tmp_path)
963 # Overwrite with 200 KB of content
964 large = f"# Rule\n{'x' * 200}\n"
965 large_content = large * 1000 # ~200 KB
966 (agent_md_path(tmp_path)).write_text(large_content)
967 result = _invoke(tmp_path, ["agent-config", "sync"])
968 assert result.exit_code == 0
969 agents_md = (tmp_path / "AGENTS.md").read_text()
970 assert len(agents_md) > 100_000
971
972 def test_rapid_sequential_syncs(self, tmp_path: pathlib.Path) -> None:
973 """30 sequential sync --force calls produce consistent output."""
974 _init_with_all_adapters(tmp_path)
975 _invoke(tmp_path, ["agent-config", "sync"])
976 content_before = (tmp_path / "AGENTS.md").read_text()
977 for _ in range(30):
978 r = _invoke(tmp_path, ["agent-config", "sync", "--force"])
979 assert r.exit_code == 0
980 assert (tmp_path / "AGENTS.md").read_text() == content_before
981
982 def test_concurrent_sync_no_corruption(self, tmp_path: pathlib.Path) -> None:
983 """Concurrent sync --force calls never produce a torn file.
984
985 Uses write_text_atomic directly to test the atomicity guarantee without
986 threading through the full CLI (which relies on process-global CWD).
987 """
988 from muse.core.io import write_text_atomic
989 target = tmp_path / "AGENTS.md"
990 content = "# Agent rules\n" + "x" * 10_000 + "\n"
991
992 errors: list[str] = []
993
994 def write() -> None:
995 try:
996 write_text_atomic(target, content)
997 except Exception as exc:
998 errors.append(str(exc))
999
1000 threads = [threading.Thread(target=write) for _ in range(8)]
1001 for t in threads:
1002 t.start()
1003 for t in threads:
1004 t.join()
1005
1006 assert not errors
1007 result = target.read_text()
1008 assert len(result) > 0
1009 # File must be complete — never a partial write
1010 assert result == content
1011
1012
1013 # ---------------------------------------------------------------------------
1014 # Data Integrity
1015 # ---------------------------------------------------------------------------
1016
1017
1018 class TestDataIntegrity:
1019 def test_corrupt_config_toml_exits_with_error(
1020 self, tmp_path: pathlib.Path
1021 ) -> None:
1022 """Corrupt config.toml causes sync to fail — no files are silently generated."""
1023 _init_repo(tmp_path)
1024 _invoke(tmp_path, ["agent-config", "init"])
1025 (config_toml_path(tmp_path)).write_text("[[[[not valid toml")
1026 result = _invoke(tmp_path, ["agent-config", "sync"])
1027 assert result.exit_code != 0
1028
1029 def test_adapter_file_not_empty_after_sync(self, tmp_path: pathlib.Path) -> None:
1030 """Every generated adapter file has non-zero content."""
1031 from muse.cli.commands.agent_config import _ADAPTERS
1032 _init_with_all_adapters(tmp_path)
1033 _invoke(tmp_path, ["agent-config", "sync"])
1034 for spec in _ADAPTERS.values():
1035 p = tmp_path / spec["filename"]
1036 assert p.stat().st_size > 0, f"{spec['filename']} is empty"
1037
1038 def test_sync_write_is_atomic(self, tmp_path: pathlib.Path) -> None:
1039 """After sync, AGENTS.md is a complete file — not truncated mid-write."""
1040 _init_with_all_adapters(tmp_path)
1041 _invoke(tmp_path, ["agent-config", "sync"])
1042 content = (tmp_path / "AGENTS.md").read_text()
1043 # Content should end with a newline, not be truncated mid-line
1044 assert content.endswith("\n")
1045
1046 def test_set_preserves_existing_config_integrity(
1047 self, tmp_path: pathlib.Path
1048 ) -> None:
1049 """set writes valid TOML that can be re-parsed."""
1050 import tomllib
1051 _init_repo(tmp_path)
1052 (config_toml_path(tmp_path)).write_text(
1053 '[hub]\nurl = "https://localhost:1337"\n\n[limits]\nmax_file_size_mb = 10\n'
1054 )
1055 _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude,codex"])
1056 raw = tomllib.loads((config_toml_path(tmp_path)).read_text())
1057 assert raw["hub"]["url"] == "https://localhost:1337"
1058 assert raw["limits"]["max_file_size_mb"] == 10
1059 assert raw["agent-config"]["adapters"] == ["claude", "codex"]
1060
1061
1062 # ---------------------------------------------------------------------------
1063 # Performance
1064 # ---------------------------------------------------------------------------
1065
1066
1067 class TestPerformance:
1068 def test_sync_completes_under_2_seconds(self, tmp_path: pathlib.Path) -> None:
1069 """sync with default adapters completes in under 2 seconds."""
1070 _init_repo(tmp_path)
1071 _invoke(tmp_path, ["agent-config", "init"])
1072 start = time.monotonic()
1073 _invoke(tmp_path, ["agent-config", "sync"])
1074 elapsed = time.monotonic() - start
1075 assert elapsed < 2.0, f"sync took {elapsed:.2f}s — too slow"
1076
1077 def test_status_completes_under_1_second(self, tmp_path: pathlib.Path) -> None:
1078 """status check completes in under 1 second."""
1079 _init_repo(tmp_path)
1080 _invoke(tmp_path, ["agent-config", "init"])
1081 _invoke(tmp_path, ["agent-config", "sync"])
1082 start = time.monotonic()
1083 _invoke(tmp_path, ["agent-config", "status", "--json"])
1084 elapsed = time.monotonic() - start
1085 assert elapsed < 1.0, f"status took {elapsed:.2f}s — too slow"
1086
1087
1088 # ---------------------------------------------------------------------------
1089 # Security
1090 # ---------------------------------------------------------------------------
1091
1092
1093 class TestSecurity:
1094 def test_set_rejects_path_traversal_in_adapter_name(
1095 self, tmp_path: pathlib.Path
1096 ) -> None:
1097 """set does not accept adapter names containing path separators."""
1098 _init_repo(tmp_path)
1099 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "../traversal"])
1100 assert result.exit_code == 1
1101
1102 def test_set_rejects_adapter_with_null_byte(
1103 self, tmp_path: pathlib.Path
1104 ) -> None:
1105 """set rejects adapter names containing null bytes."""
1106 _init_repo(tmp_path)
1107 result = _invoke(tmp_path, ["agent-config", "set", "--adapters", "claude\x00malicious"])
1108 assert result.exit_code == 1
1109
1110 def test_agent_md_with_null_bytes_does_not_crash_sync(
1111 self, tmp_path: pathlib.Path
1112 ) -> None:
1113 """agent.md containing null bytes is handled without an unhandled exception."""
1114 _init_repo(tmp_path)
1115 _invoke(tmp_path, ["agent-config", "init"])
1116 # Write null bytes into agent.md
1117 agent_md = agent_md_path(tmp_path)
1118 agent_md.write_bytes(agent_md.read_bytes() + b"\x00\x00malicious\x00")
1119 # Should not raise — exit code may be 0 or 1 but must not be an unhandled exception
1120 result = _invoke(tmp_path, ["agent-config", "sync"])
1121 assert result.exit_code in (0, 1)
1122
1123 def test_toml_injection_in_config_does_not_escape_section(
1124 self, tmp_path: pathlib.Path
1125 ) -> None:
1126 """A crafted adapter name cannot inject extra TOML sections."""
1127 import tomllib
1128 _init_repo(tmp_path)
1129 # Attempt to inject a new TOML section via adapter name
1130 result = _invoke(
1131 tmp_path,
1132 ["agent-config", "set", "--adapters", 'claude"]\n[injected'],
1133 )
1134 # Should fail with unknown adapter error, not write injected TOML
1135 assert result.exit_code == 1
1136 config_path = config_toml_path(tmp_path)
1137 if config_path.exists():
1138 raw = tomllib.loads(config_path.read_text())
1139 assert "injected" not in raw
1140
1141
1142 # ---------------------------------------------------------------------------
1143 # Integration — smart sync (skip in-sync files)
1144 # ---------------------------------------------------------------------------
1145
1146
1147 class TestSmartSync:
1148 def test_second_sync_skips_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1149 """Repeated sync without changes exits 0 and reports skipped."""
1150 _init_with_all_adapters(tmp_path)
1151 _invoke(tmp_path, ["agent-config", "sync"])
1152 result = _invoke(tmp_path, ["agent-config", "sync"])
1153 assert result.exit_code == 0
1154 assert "in sync" in result.output
1155
1156 def test_second_sync_json_skipped_true(self, tmp_path: pathlib.Path) -> None:
1157 """sync --json shows skipped=True for already-in-sync files."""
1158 _init_with_all_adapters(tmp_path)
1159 _invoke(tmp_path, ["agent-config", "sync"])
1160 result = _invoke(tmp_path, ["agent-config", "sync", "--json"])
1161 assert result.exit_code == 0
1162 data = json.loads(result.output)
1163 for entry in data["adapters"]:
1164 assert entry["skipped"] is True
1165 assert entry["written"] is False
1166
1167 def test_out_of_sync_file_is_updated_without_force(self, tmp_path: pathlib.Path) -> None:
1168 """An adapter that is out of sync is updated even without --force."""
1169 _init_with_all_adapters(tmp_path)
1170 _invoke(tmp_path, ["agent-config", "sync"])
1171 # Corrupt AGENTS.md content
1172 (tmp_path / "AGENTS.md").write_text("old content")
1173 result = _invoke(tmp_path, ["agent-config", "sync"])
1174 assert result.exit_code == 0
1175 assert "old content" not in (tmp_path / "AGENTS.md").read_text()
1176 assert "Muse" in (tmp_path / "AGENTS.md").read_text()
1177
1178 def test_force_rewrites_even_in_sync_files(self, tmp_path: pathlib.Path) -> None:
1179 """--force writes all files even when they are already in sync."""
1180 _init_with_all_adapters(tmp_path)
1181 _invoke(tmp_path, ["agent-config", "sync"])
1182 result = _invoke(tmp_path, ["agent-config", "sync", "--force", "--json"])
1183 assert result.exit_code == 0
1184 data = json.loads(result.output)
1185 for entry in data["adapters"]:
1186 assert entry["written"] is True
1187 assert entry["skipped"] is False
1188
1189 def test_sync_idempotent_across_multiple_runs(self, tmp_path: pathlib.Path) -> None:
1190 """Running sync N times produces identical output each time."""
1191 _init_with_all_adapters(tmp_path)
1192 _invoke(tmp_path, ["agent-config", "sync"])
1193 content_after_first = (tmp_path / "AGENTS.md").read_text()
1194 for _ in range(5):
1195 r = _invoke(tmp_path, ["agent-config", "sync"])
1196 assert r.exit_code == 0
1197 assert (tmp_path / "AGENTS.md").read_text() == content_after_first
1198
1199
1200 # ---------------------------------------------------------------------------
1201 # Integration — inspect
1202 # ---------------------------------------------------------------------------
1203
1204
1205 class TestInspect:
1206 def test_inspect_json_schema_standalone(self, tmp_path: pathlib.Path) -> None:
1207 """inspect --json returns all required fields for a standalone repo."""
1208 _init_repo(tmp_path)
1209 _invoke(tmp_path, ["agent-config", "init"])
1210 _invoke(tmp_path, ["agent-config", "sync"])
1211 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1212 assert result.exit_code == 0
1213 data = json.loads(result.output)
1214 assert data["context"] == "standalone"
1215 assert data["workspace_root"] is None
1216 assert data["repo_name"] == tmp_path.name
1217 assert data["agent_md_exists"] is True
1218 assert data["merged_content"] is not None
1219 assert "adapters" in data
1220 assert isinstance(data["ready"], bool)
1221
1222 def test_inspect_ready_true_when_in_sync(self, tmp_path: pathlib.Path) -> None:
1223 """ready is True when agent.md exists and adapters are in sync."""
1224 _init_with_all_adapters(tmp_path)
1225 _invoke(tmp_path, ["agent-config", "sync"])
1226 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1227 data = json.loads(result.output)
1228 assert data["ready"] is True
1229
1230 def test_inspect_ready_false_without_adapters(self, tmp_path: pathlib.Path) -> None:
1231 """ready is False when agent.md exists but no adapters have been synced."""
1232 _init_repo(tmp_path)
1233 _invoke(tmp_path, ["agent-config", "init"])
1234 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1235 data = json.loads(result.output)
1236 assert data["ready"] is False
1237
1238 def test_inspect_ready_false_without_agent_md(self, tmp_path: pathlib.Path) -> None:
1239 """ready is False when agent.md does not exist."""
1240 _init_repo(tmp_path)
1241 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1242 data = json.loads(result.output)
1243 assert data["ready"] is False
1244 assert data["agent_md_exists"] is False
1245 assert data["merged_content"] is None
1246
1247 def test_inspect_merged_content_contains_rules(self, tmp_path: pathlib.Path) -> None:
1248 """merged_content includes the actual rules from agent.md."""
1249 _init_repo(tmp_path)
1250 _invoke(tmp_path, ["agent-config", "init"])
1251 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1252 data = json.loads(result.output)
1253 assert "Muse" in data["merged_content"]
1254 assert "git" in data["merged_content"].lower()
1255
1256 def test_inspect_adapter_entries_schema(self, tmp_path: pathlib.Path) -> None:
1257 """Each adapter entry in inspect output has the expected fields."""
1258 _init_repo(tmp_path)
1259 _invoke(tmp_path, ["agent-config", "init"])
1260 _invoke(tmp_path, ["agent-config", "sync"])
1261 result = _invoke(tmp_path, ["agent-config", "inspect", "--json"])
1262 data = json.loads(result.output)
1263 for entry in data["adapters"]:
1264 assert "name" in entry
1265 assert "filename" in entry
1266 assert "exists" in entry
1267 assert "in_sync" in entry
1268
1269 def test_inspect_workspace_member_context(self, tmp_path: pathlib.Path) -> None:
1270 """inspect reports workspace_member context and non-null workspace_root."""
1271 _init_workspace(tmp_path, [("core", "core")])
1272 _invoke(tmp_path, ["agent-config", "init"])
1273 repo = tmp_path / "core"
1274 repo.mkdir()
1275 _init_repo(repo)
1276 _invoke(repo, ["agent-config", "init"])
1277 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1278 assert result.exit_code == 0
1279 data = json.loads(result.output)
1280 assert data["context"] == "workspace_member"
1281 assert data["workspace_root"] is not None
1282 assert data["repo_name"] == "core"
1283
1284 def test_inspect_workspace_merged_content_includes_both_levels(
1285 self, tmp_path: pathlib.Path
1286 ) -> None:
1287 """merged_content in a workspace member includes both WS and repo rules."""
1288 _init_workspace(tmp_path, [("core", "core")])
1289 _invoke(tmp_path, ["agent-config", "init"])
1290 # Add a unique marker to the workspace-level agent.md
1291 ws_agent = agent_md_path(tmp_path)
1292 ws_agent.write_text(f"{ws_agent.read_text()}\n# WS_MARKER\n")
1293 repo = tmp_path / "core"
1294 repo.mkdir()
1295 _init_repo(repo)
1296 _invoke(repo, ["agent-config", "init"])
1297 # Add a unique marker to the repo-level agent.md
1298 repo_agent = agent_md_path(repo)
1299 repo_agent.write_text(f"{repo_agent.read_text()}\n# REPO_MARKER\n")
1300 result = _invoke(repo, ["agent-config", "inspect", "--json"])
1301 data = json.loads(result.output)
1302 assert "WS_MARKER" in data["merged_content"]
1303 assert "REPO_MARKER" in data["merged_content"]
1304
1305 def test_inspect_text_output_exits_0(self, tmp_path: pathlib.Path) -> None:
1306 """inspect without --json exits 0 and prints context info."""
1307 _init_repo(tmp_path)
1308 _invoke(tmp_path, ["agent-config", "init"])
1309 result = _invoke(tmp_path, ["agent-config", "inspect"])
1310 assert result.exit_code == 0
1311 assert "Context" in result.output or "standalone" in result.output
1312
1313
1314 # ---------------------------------------------------------------------------
1315 # Integration — status extra fields
1316 # ---------------------------------------------------------------------------
1317
1318
1319 class TestStatusExtraFields:
1320 def test_status_json_includes_agent_md_exists(self, tmp_path: pathlib.Path) -> None:
1321 _init_repo(tmp_path)
1322 _invoke(tmp_path, ["agent-config", "init"])
1323 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1324 data = json.loads(result.output)
1325 assert "agent_md_exists" in data
1326 assert data["agent_md_exists"] is True
1327
1328 def test_status_json_includes_ready(self, tmp_path: pathlib.Path) -> None:
1329 _init_with_all_adapters(tmp_path)
1330 _invoke(tmp_path, ["agent-config", "sync"])
1331 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1332 data = json.loads(result.output)
1333 assert "ready" in data
1334 assert data["ready"] is True
1335
1336 def test_status_json_ready_false_before_sync(self, tmp_path: pathlib.Path) -> None:
1337 _init_repo(tmp_path)
1338 _invoke(tmp_path, ["agent-config", "init"])
1339 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1340 data = json.loads(result.output)
1341 assert data["ready"] is False
1342
1343 def test_status_json_summary_counts(self, tmp_path: pathlib.Path) -> None:
1344 from muse.cli.commands.agent_config import _ADAPTERS
1345 _init_repo(tmp_path)
1346 _invoke(tmp_path, ["agent-config", "init"])
1347 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1348 data = json.loads(result.output)
1349 assert "in_sync_count" in data
1350 assert "missing_count" in data
1351 assert "out_of_sync_count" in data
1352 # Before sync, all adapters are missing
1353 assert data["missing_count"] == len(_ADAPTERS)
1354 assert data["in_sync_count"] == 0
1355
1356 def test_status_json_counts_after_sync(self, tmp_path: pathlib.Path) -> None:
1357 from muse.cli.commands.agent_config import _ADAPTERS
1358 _init_with_all_adapters(tmp_path)
1359 _invoke(tmp_path, ["agent-config", "sync"])
1360 result = _invoke(tmp_path, ["agent-config", "status", "--json"])
1361 data = json.loads(result.output)
1362 assert data["in_sync_count"] == len(_ADAPTERS)
1363 assert data["missing_count"] == 0
1364 assert data["out_of_sync_count"] == 0
1365
1366
1367 # ---------------------------------------------------------------------------
1368 # Integration — template content
1369 # ---------------------------------------------------------------------------
1370
1371
1372 class TestTemplateContent:
1373 def test_standalone_template_includes_testing_rules(
1374 self, tmp_path: pathlib.Path
1375 ) -> None:
1376 """Standalone template includes the no-full-test-suite rule."""
1377 _init_repo(tmp_path)
1378 _invoke(tmp_path, ["agent-config", "init"])
1379 content = (agent_md_path(tmp_path)).read_text()
1380 assert "full test suite" in content.lower() or "full" in content.lower()
1381 assert "muse code test" in content
1382
1383 def test_standalone_template_includes_muse_code_test(
1384 self, tmp_path: pathlib.Path
1385 ) -> None:
1386 """Standalone template lists muse code test in the code intelligence table."""
1387 _init_repo(tmp_path)
1388 _invoke(tmp_path, ["agent-config", "init"])
1389 content = (agent_md_path(tmp_path)).read_text()
1390 assert "muse code test" in content
1391
1392
1393 class TestRegisterFlags:
1394 """Argparse registration tests for ``muse agent-config`` subcommands."""
1395
1396 def _parse(self, *args: str) -> argparse.Namespace:
1397 from muse.cli.commands.agent_config import register
1398 p = argparse.ArgumentParser()
1399 sub = p.add_subparsers()
1400 register(sub)
1401 return p.parse_args(["agent-config", *args])
1402
1403 # init
1404 def test_init_default_json_out_is_false(self) -> None:
1405 ns = self._parse("init")
1406 assert ns.json_out is False
1407
1408 def test_init_json_flag_sets_json_out(self) -> None:
1409 ns = self._parse("init", "--json")
1410 assert ns.json_out is True
1411
1412 def test_init_j_shorthand_sets_json_out(self) -> None:
1413 ns = self._parse("init", "-j")
1414 assert ns.json_out is True
1415
1416 def test_init_force_default(self) -> None:
1417 ns = self._parse("init")
1418 assert ns.force is False
1419
1420 def test_init_force_flag(self) -> None:
1421 ns = self._parse("init", "--force")
1422 assert ns.force is True
1423
1424 def test_init_force_shorthand(self) -> None:
1425 ns = self._parse("init", "-f")
1426 assert ns.force is True
1427
1428 # sync
1429 def test_sync_default_json_out_is_false(self) -> None:
1430 ns = self._parse("sync")
1431 assert ns.json_out is False
1432
1433 def test_sync_json_flag_sets_json_out(self) -> None:
1434 ns = self._parse("sync", "--json")
1435 assert ns.json_out is True
1436
1437 def test_sync_j_shorthand_sets_json_out(self) -> None:
1438 ns = self._parse("sync", "-j")
1439 assert ns.json_out is True
1440
1441 def test_sync_dry_run_default(self) -> None:
1442 ns = self._parse("sync")
1443 assert ns.dry_run is False
1444
1445 def test_sync_dry_run_flag(self) -> None:
1446 ns = self._parse("sync", "--dry-run")
1447 assert ns.dry_run is True
1448
1449 def test_sync_dry_run_shorthand(self) -> None:
1450 ns = self._parse("sync", "-n")
1451 assert ns.dry_run is True
1452
1453 def test_sync_force_default(self) -> None:
1454 ns = self._parse("sync")
1455 assert ns.force is False
1456
1457 def test_sync_force_flag(self) -> None:
1458 ns = self._parse("sync", "--force")
1459 assert ns.force is True
1460
1461 def test_sync_force_shorthand(self) -> None:
1462 ns = self._parse("sync", "-f")
1463 assert ns.force is True
1464
1465 # read
1466 def test_read_default_json_out_is_false(self) -> None:
1467 ns = self._parse("read")
1468 assert ns.json_out is False
1469
1470 def test_read_json_flag_sets_json_out(self) -> None:
1471 ns = self._parse("read", "--json")
1472 assert ns.json_out is True
1473
1474 def test_read_j_shorthand_sets_json_out(self) -> None:
1475 ns = self._parse("read", "-j")
1476 assert ns.json_out is True
1477
1478 # status
1479 def test_status_default_json_out_is_false(self) -> None:
1480 ns = self._parse("status")
1481 assert ns.json_out is False
1482
1483 def test_status_json_flag_sets_json_out(self) -> None:
1484 ns = self._parse("status", "--json")
1485 assert ns.json_out is True
1486
1487 # inspect
1488 def test_inspect_default_json_out_is_false(self) -> None:
1489 ns = self._parse("inspect")
1490 assert ns.json_out is False
1491
1492 def test_inspect_json_flag_sets_json_out(self) -> None:
1493 ns = self._parse("inspect", "--json")
1494 assert ns.json_out is True
1495
1496 # set
1497 def test_set_default_json_out_is_false(self) -> None:
1498 ns = self._parse("set", "--adapters", "claude")
1499 assert ns.json_out is False
1500
1501 def test_set_json_flag_sets_json_out(self) -> None:
1502 ns = self._parse("set", "--adapters", "claude", "--json")
1503 assert ns.json_out is True
File History 2 commits