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