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