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