agent_config.py file-level

at main · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
1 """``muse agent-config`` β€” manage per-repo and workspace agent configuration.
2
3 Generates and syncs the canonical ``.muse/agent.md`` file and IDE-specific
4 adapter files so every AI tool gets consistent, up-to-date rules without
5 duplication.
6
7 Architecture
8 ------------
9 There is one **canonical source** per level:
10
11 - ``<repo>/.muse/agent.md`` β€” repo-specific rules.
12 - ``<workspace>/.muse/agent.md`` β€” shared workspace rules (if inside a workspace).
13
14 IDE adapter files (CLAUDE.md, AGENTS.md, .cursorrules, etc.) are **derived
15 outputs** β€” regenerate them any time with ``muse agent-config sync``.
16
17 Context modes::
18
19 standalone β€” repo with no parent workspace
20 workspace_root β€” directory with .muse/workspace.toml (shared rules only)
21 workspace_member β€” repo nested inside a workspace; inherits workspace rules
22
23 Adapter styles::
24
25 include (Claude) β€” ``@.muse/agent.md`` reference; Claude resolves at read time
26 embed (others) β€” full content inlined so the tool sees it immediately
27
28 Subcommands::
29
30 muse agent-config init [--force] [--json]
31 Create .muse/agent.md with sane defaults for this repo or workspace.
32
33 muse agent-config sync [--adapters NAME,...] [--dry-run] [--force] [--json]
34 Generate IDE adapter files from .muse/agent.md.
35
36 muse agent-config show [--scope repo|workspace|merged] [--json]
37 Print the agent.md content.
38
39 muse agent-config status [--json]
40 Show which adapter files exist and whether they are in sync.
41
42 Exit codes::
43
44 0 β€” success
45 1 β€” user error (file exists without --force, missing agent.md, etc.)
46 """
47
48 from __future__ import annotations
49
50 import argparse
51 import json
52 import logging
53 import os
54 import pathlib
55 import re
56 import sys
57 from collections.abc import Callable
58 from typing import TypedDict
59
60 from muse.core.errors import ExitCode
61 from muse.core.store import write_text_atomic
62 from muse.core.workspace import (
63 WorkspaceMemberDict,
64 WorkspaceManifestDict,
65 find_workspace_root,
66 )
67
68 logger = logging.getLogger(__name__)
69
70 # ---------------------------------------------------------------------------
71 # Adapter registry
72 # ---------------------------------------------------------------------------
73
74
75 class AdapterSpec(TypedDict):
76 """Specification for one IDE/agent adapter file.
77
78 ``name`` β€” short identifier (e.g. ``"claude"``)
79 ``filename`` β€” path relative to repo root (e.g. ``"CLAUDE.md"``)
80 ``style`` β€” ``"include"`` uses ``@path`` reference syntax;
81 ``"embed"`` inlines the full content
82 """
83
84 name: str
85 filename: str
86 style: str
87
88
89 class SyncAdapterResult(TypedDict):
90 """One entry in the ``muse agent-config sync --json`` output."""
91
92 name: str
93 path: str
94 written: bool
95
96
97 class StatusAdapterEntry(TypedDict):
98 """One entry in the ``muse agent-config status --json`` output."""
99
100 name: str
101 filename: str
102 exists: bool
103 in_sync: bool
104
105
106 AdapterRegistry = dict[str, AdapterSpec]
107
108 _ADAPTERS: AdapterRegistry = {
109 "claude": AdapterSpec(name="claude", filename="CLAUDE.md", style="include"),
110 "codex": AdapterSpec(name="codex", filename="AGENTS.md", style="embed"),
111 "cursor": AdapterSpec(name="cursor", filename=".cursorrules", style="embed"),
112 "windsurf": AdapterSpec(name="windsurf", filename=".windsurfrules", style="embed"),
113 }
114
115 # ---------------------------------------------------------------------------
116 # Templates
117 # ---------------------------------------------------------------------------
118
119 _STANDALONE_TEMPLATE = """\
120 # {repo_name} β€” Agent Configuration
121
122 This file is the canonical agent context for the **{repo_name}** repository.
123 It is managed by `muse agent-config` β€” regenerate adapter files at any time
124 with `muse agent-config sync`.
125
126 ---
127
128 ## Version Control
129
130 This repository uses **Muse** for version control β€” not git, not GitHub.
131
132 - Never run `git`, `gh`, or any git subcommand.
133 - Never reference GitHub, GitHub Actions, or GitHub URLs.
134 - All VCS operations use `muse` commands.
135
136 If you find a `git` or `gh` call anywhere β€” delete it in the same commit.
137
138 ---
139
140 ## Branch Flow
141
142 Always work on a feature branch β€” never commit directly to `main` or `dev`.
143
144 ```bash
145 muse checkout dev
146 muse checkout -b task/my-thing # start work
147
148 muse code add .
149 muse commit -m "feat: ..."
150
151 muse checkout dev
152 muse merge task/my-thing
153 muse branch -d task/my-thing
154 muse push local dev
155 ```
156
157 ---
158
159 ## Code Intelligence
160
161 Use `muse code` commands for navigation β€” never raw grep or file reads.
162
163 | Task | Command |
164 |------|---------|
165 | Find symbol declaration | `muse code grep "Name" --json` |
166 | Read one symbol | `muse code cat "file.py::Symbol" --json` |
167 | File structure | `muse code symbols --file file.py --json` |
168 | Blast radius | `muse code impact "file.py::Symbol" --json` |
169 | Dependencies | `muse code deps "file.py" --json` |
170
171 ---
172
173 ## Status
174
175 Always verify a clean state before switching branches:
176
177 ```bash
178 muse status --json # must show "clean": true before muse checkout
179 ```
180 """
181
182 _WORKSPACE_ROOT_TEMPLATE = """\
183 # Workspace β€” Shared Agent Configuration
184
185 This file contains shared rules for all repositories in this workspace.
186 Each member repository may have its own ``.muse/agent.md`` with repo-specific
187 additions.
188
189 Managed by `muse agent-config` β€” regenerate adapters with `muse agent-config sync`.
190
191 ---
192
193 ## Workspace Members
194
195 {members_table}
196
197 ---
198
199 ## Version Control
200
201 This workspace uses **Muse** for version control β€” not git, not GitHub.
202
203 - Never run `git`, `gh`, or any git subcommand.
204 - Never reference GitHub, GitHub Actions, or GitHub URLs.
205 - Use `muse -C ~/path/to/repo <command>` when CWD differs from the target repo.
206
207 If you find a `git` or `gh` call anywhere β€” delete it in the same commit.
208
209 ---
210
211 ## Branch Flow
212
213 Always work on a feature branch β€” never commit directly to `main` or `dev`.
214
215 ```bash
216 muse -C ~/path/to/repo checkout dev
217 muse -C ~/path/to/repo checkout -b task/my-thing
218
219 muse code add .
220 muse commit -m "feat: ..."
221
222 muse -C ~/path/to/repo checkout dev
223 muse -C ~/path/to/repo merge task/my-thing
224 muse -C ~/path/to/repo branch -d task/my-thing
225 muse -C ~/path/to/repo push local dev
226 ```
227
228 ---
229
230 ## Code Intelligence
231
232 | Task | Command |
233 |------|---------|
234 | Find symbol declaration | `muse code grep "Name" --json` |
235 | Read one symbol | `muse code cat "file.py::Symbol" --json` |
236 | File structure | `muse code symbols --file file.py --json` |
237 | Blast radius | `muse code impact "file.py::Symbol" --json` |
238 | Dependencies | `muse code deps "file.py" --json` |
239 """
240
241 _WORKSPACE_MEMBER_TEMPLATE = """\
242 # {repo_name} β€” Agent Configuration
243
244 This repository is a member of a workspace.
245 Shared workspace rules live in the parent ``.muse/agent.md``.
246 This file contains only {repo_name}-specific additions.
247
248 Managed by `muse agent-config` β€” regenerate adapters with `muse agent-config sync`.
249
250 ---
251
252 ## Repo-Specific Notes
253
254 Add {repo_name}-specific agent rules below this line.
255 """
256
257
258 # ---------------------------------------------------------------------------
259 # Core helpers
260 # ---------------------------------------------------------------------------
261
262
263 def _detect_context(root: pathlib.Path) -> tuple[str, pathlib.Path | None]:
264 """Classify *root* as ``standalone``, ``workspace_root``, or ``workspace_member``.
265
266 Returns a ``(kind, workspace_root)`` pair. ``workspace_root`` is ``None``
267 for standalone repos and the workspace directory for members.
268
269 Detection rules
270 ---------------
271 1. If ``root/.muse/workspace.toml`` exists β†’ ``workspace_root``.
272 2. If a ``workspace.toml`` exists in any parent β†’ ``workspace_member``.
273 3. Otherwise β†’ ``standalone``.
274 """
275 if (root / ".muse" / "workspace.toml").exists():
276 return "workspace_root", root
277 ws = find_workspace_root(root)
278 if ws is not None and ws != root:
279 return "workspace_member", ws
280 return "standalone", None
281
282
283 def _compute_rel_path(repo: pathlib.Path, ws: pathlib.Path) -> str:
284 """Return the relative path from *repo* to *ws*.
285
286 Examples::
287
288 _compute_rel_path(ws / "core", ws) β†’ ".."
289 _compute_rel_path(ws / "packages" / "foo", ws) β†’ "../.."
290 _compute_rel_path(ws, ws) β†’ "."
291 """
292 return str(pathlib.Path(os.path.relpath(ws, repo)))
293
294
295 def _render_adapter(
296 spec: AdapterSpec,
297 repo_agent_md: str,
298 ws_agent_md: str | None,
299 repo_agent_content: str | None = None,
300 ws_agent_content: str | None = None,
301 ) -> str:
302 """Render the content for one IDE adapter file.
303
304 Include-style adapters (Claude) use ``@path`` reference syntax so Claude
305 resolves the content at read time. Embed-style adapters (all others) inline
306 the full content so the tool sees it without following references.
307
308 When *ws_agent_md* / *ws_agent_content* are provided, workspace-level rules
309 are prepended so they take precedence over repo-level rules.
310 """
311 if spec["style"] == "include":
312 lines: list[str] = []
313 if ws_agent_md is not None:
314 lines.append(f"@{ws_agent_md}")
315 lines.append(f"@{repo_agent_md}")
316 return "\n".join(lines) + "\n"
317 else:
318 parts: list[str] = []
319 if ws_agent_content is not None:
320 parts.append(ws_agent_content.rstrip())
321 if repo_agent_content is not None:
322 parts.append(repo_agent_content.rstrip())
323 return "\n\n".join(parts) + "\n" if parts else ""
324
325
326 def _load_workspace_manifest(ws_root: pathlib.Path) -> WorkspaceManifestDict | None:
327 """Load the workspace manifest from *ws_root*/.muse/workspace.toml."""
328 try:
329 import tomllib
330 path = ws_root / ".muse" / "workspace.toml"
331 if not path.exists():
332 return None
333 raw = tomllib.loads(path.read_text(encoding="utf-8"))
334 members: list[WorkspaceMemberDict] = []
335 for m in raw.get("members", []):
336 if isinstance(m, dict):
337 members.append(
338 WorkspaceMemberDict(
339 name=str(m.get("name", "")),
340 url=str(m.get("url", "")),
341 path=str(m.get("path", "")),
342 branch=str(m.get("branch", "main")),
343 )
344 )
345 return WorkspaceManifestDict(members=members)
346 except Exception as exc:
347 logger.warning("Could not load workspace manifest: %s", exc)
348 return None
349
350
351 def _load_configured_adapters(root: pathlib.Path) -> list[str] | None:
352 """Read ``[agent-config] adapters`` from ``.muse/config.toml``.
353
354 Returns the list of adapter names if configured, or ``None`` if the key is
355 absent (meaning "all adapters").
356
357 Example ``.muse/config.toml``::
358
359 [agent-config]
360 adapters = ["claude", "codex"]
361 """
362 config_path = root / ".muse" / "config.toml"
363 if not config_path.is_file():
364 return None
365 try:
366 import tomllib
367 raw = tomllib.loads(config_path.read_text(encoding="utf-8"))
368 section = raw.get("agent-config", {})
369 adapters = section.get("adapters")
370 if isinstance(adapters, list) and all(isinstance(a, str) for a in adapters):
371 return [str(a) for a in adapters]
372 except Exception as exc:
373 logger.warning("Could not read [agent-config] from config.toml: %s", exc)
374 return None
375
376
377 def _build_members_table(manifest: WorkspaceManifestDict) -> str:
378 """Render a Markdown table of workspace members."""
379 lines = ["| Repo | Path | Branch |", "|------|------|--------|"]
380 for m in manifest.get("members", []):
381 lines.append(f"| **{m['name']}** | `{m['path']}` | `{m['branch']}` |")
382 return "\n".join(lines)
383
384
385 def _find_operation_root() -> pathlib.Path:
386 """Return the directory that agent-config should operate on.
387
388 For workspace roots (``cwd/.muse/workspace.toml`` exists) the CWD is
389 returned directly β€” there is no repo to require.
390
391 For everything else, ``require_repo()`` is called so a clear error is
392 shown if the CWD is not inside a Muse repository.
393 """
394 from muse.core.repo import require_repo
395
396 cwd = pathlib.Path.cwd()
397 if (cwd / ".muse" / "workspace.toml").exists():
398 return cwd
399 return require_repo()
400
401
402 # ---------------------------------------------------------------------------
403 # Subcommand: init
404 # ---------------------------------------------------------------------------
405
406
407 def run_init(args: argparse.Namespace) -> None:
408 """Create ``.muse/agent.md`` with sane defaults.
409
410 Detects whether the current directory is a standalone repo, a workspace
411 root, or a workspace member and generates the appropriate template.
412 """
413 fmt: str = getattr(args, "fmt", "text")
414 force: bool = args.force
415 root = _find_operation_root()
416 kind, ws = _detect_context(root)
417
418 agent_md_path = root / ".muse" / "agent.md"
419
420 if agent_md_path.exists() and not force:
421 msg = (
422 f"❌ {agent_md_path} already exists.\n"
423 " Use --force to overwrite it."
424 )
425 if fmt == "json":
426 print(
427 json.dumps(
428 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
429 )
430 )
431 else:
432 print(msg, file=sys.stderr)
433 raise SystemExit(ExitCode.USER_ERROR)
434
435 if kind == "workspace_root":
436 manifest = _load_workspace_manifest(root)
437 if manifest and manifest["members"]:
438 members_table = _build_members_table(manifest)
439 else:
440 members_table = "_No members registered yet._"
441 content = _WORKSPACE_ROOT_TEMPLATE.format(members_table=members_table)
442 elif kind == "workspace_member":
443 content = _WORKSPACE_MEMBER_TEMPLATE.format(repo_name=root.name)
444 else:
445 content = _STANDALONE_TEMPLATE.format(repo_name=root.name)
446
447 write_text_atomic(agent_md_path, content)
448 created = True
449
450 if fmt == "json":
451 print(
452 json.dumps(
453 {
454 "path": str(agent_md_path),
455 "scope": kind,
456 "created": created,
457 }
458 )
459 )
460 else:
461 print(f"βœ… Created {agent_md_path} (scope: {kind})")
462
463
464 # ---------------------------------------------------------------------------
465 # Subcommand: sync
466 # ---------------------------------------------------------------------------
467
468
469 def run_sync(args: argparse.Namespace) -> None:
470 """Generate IDE adapter files from ``.muse/agent.md``.
471
472 Reads the canonical ``.muse/agent.md`` (and the workspace-level one if
473 inside a workspace) and writes adapter files for each IDE/agent tool.
474 """
475 fmt: str = getattr(args, "fmt", "text")
476 force: bool = args.force
477 dry_run: bool = args.dry_run
478 adapters_filter: list[str] | None = (
479 [a.strip() for a in args.adapters.split(",")]
480 if args.adapters
481 else None
482 )
483
484 root = _find_operation_root()
485 kind, ws = _detect_context(root)
486
487 agent_md_path = root / ".muse" / "agent.md"
488 if not agent_md_path.exists():
489 msg = (
490 f"❌ No agent.md found at {agent_md_path}.\n"
491 " Run `muse agent-config init` first."
492 )
493 if fmt == "json":
494 print(
495 json.dumps(
496 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
497 )
498 )
499 else:
500 print(msg, file=sys.stderr)
501 raise SystemExit(ExitCode.USER_ERROR)
502
503 repo_agent_content = agent_md_path.read_text(encoding="utf-8")
504
505 ws_agent_md: str | None = None
506 ws_agent_content: str | None = None
507 if kind == "workspace_member" and ws is not None:
508 rel = _compute_rel_path(root, ws)
509 ws_agent_md = f"{rel}/.muse/agent.md"
510 ws_path = ws / ".muse" / "agent.md"
511 if ws_path.exists():
512 ws_agent_content = ws_path.read_text(encoding="utf-8")
513
514 repo_agent_md = ".muse/agent.md"
515
516 # Resolve which adapters to generate. Priority (highest β†’ lowest):
517 # 1. --adapters flag (CLI override)
518 # 2. [agent-config] adapters in .muse/config.toml (persistent preference)
519 # 3. All adapters (default)
520 effective_filter: list[str] | None = adapters_filter
521 if effective_filter is None:
522 effective_filter = _load_configured_adapters(root)
523
524 selected_adapters = {
525 k: v for k, v in _ADAPTERS.items()
526 if effective_filter is None or k in effective_filter
527 }
528
529 # Check for existing files before writing anything (fail-fast)
530 if not force and not dry_run:
531 conflicts = []
532 for spec in selected_adapters.values():
533 target = root / spec["filename"]
534 if target.exists():
535 conflicts.append(str(target))
536 if conflicts:
537 msg = (
538 "❌ Adapter files already exist. Use --force to overwrite:\n"
539 + "\n".join(f" {p}" for p in conflicts)
540 )
541 if fmt == "json":
542 print(
543 json.dumps(
544 {
545 "error": msg.replace("❌ ", ""),
546 "exit_code": ExitCode.USER_ERROR,
547 }
548 )
549 )
550 else:
551 print(msg, file=sys.stderr)
552 raise SystemExit(ExitCode.USER_ERROR)
553
554 results: list[SyncAdapterResult] = []
555 for spec in selected_adapters.values():
556 target = root / spec["filename"]
557 rendered = _render_adapter(
558 spec,
559 repo_agent_md=repo_agent_md,
560 ws_agent_md=ws_agent_md,
561 repo_agent_content=repo_agent_content,
562 ws_agent_content=ws_agent_content,
563 )
564 written = False
565 if not dry_run:
566 target.parent.mkdir(parents=True, exist_ok=True)
567 write_text_atomic(target, rendered)
568 written = True
569
570 results.append(
571 SyncAdapterResult(
572 name=spec["name"],
573 path=str(target),
574 written=written,
575 )
576 )
577
578 if fmt == "json":
579 print(json.dumps({"adapters": results}))
580 else:
581 for entry in results:
582 action = "would write" if dry_run else "wrote"
583 print(f"{'[dry-run] ' if dry_run else ''}βœ… {action} {entry['path']}")
584
585
586 # ---------------------------------------------------------------------------
587 # Subcommand: show
588 # ---------------------------------------------------------------------------
589
590
591 def run_show(args: argparse.Namespace) -> None:
592 """Print the agent.md content for this repo or workspace.
593
594 ``--scope repo`` β€” repo-level agent.md only (default)
595 ``--scope workspace`` β€” workspace-level agent.md only
596 ``--scope merged`` β€” workspace + repo content concatenated
597 """
598 fmt: str = getattr(args, "fmt", "text")
599 scope: str = getattr(args, "scope", "repo") or "repo"
600
601 root = _find_operation_root()
602 kind, ws = _detect_context(root)
603
604 agent_md_path = root / ".muse" / "agent.md"
605
606 if scope == "workspace":
607 if kind == "workspace_root":
608 target_path = agent_md_path
609 elif kind == "workspace_member" and ws is not None:
610 target_path = ws / ".muse" / "agent.md"
611 else:
612 target_path = agent_md_path
613 else:
614 target_path = agent_md_path
615
616 if not target_path.exists():
617 msg = f"❌ No agent.md found at {target_path}."
618 if fmt == "json":
619 print(
620 json.dumps(
621 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
622 )
623 )
624 else:
625 print(msg, file=sys.stderr)
626 raise SystemExit(ExitCode.USER_ERROR)
627
628 if scope == "merged" and kind == "workspace_member" and ws is not None:
629 ws_path = ws / ".muse" / "agent.md"
630 parts: list[str] = []
631 if ws_path.exists():
632 parts.append(ws_path.read_text(encoding="utf-8").rstrip())
633 parts.append(target_path.read_text(encoding="utf-8").rstrip())
634 content = "\n\n".join(parts) + "\n"
635 display_path = str(target_path)
636 display_scope = "merged"
637 else:
638 content = target_path.read_text(encoding="utf-8")
639 display_path = str(target_path)
640 display_scope = scope
641
642 if fmt == "json":
643 print(
644 json.dumps(
645 {
646 "content": content,
647 "path": display_path,
648 "scope": display_scope,
649 }
650 )
651 )
652 else:
653 print(content, end="")
654
655
656 # ---------------------------------------------------------------------------
657 # Subcommand: status
658 # ---------------------------------------------------------------------------
659
660
661 def run_status(args: argparse.Namespace) -> None:
662 """Report which adapter files exist and whether they are in sync.
663
664 An adapter is *in sync* when its on-disk content matches what
665 ``muse agent-config sync`` would generate from the current ``agent.md``.
666 """
667 fmt: str = getattr(args, "fmt", "text")
668
669 root = _find_operation_root()
670 kind, ws = _detect_context(root)
671
672 agent_md_path = root / ".muse" / "agent.md"
673 agent_md_exists = agent_md_path.exists()
674
675 repo_agent_content: str | None = None
676 ws_agent_md: str | None = None
677 ws_agent_content: str | None = None
678
679 if agent_md_exists:
680 repo_agent_content = agent_md_path.read_text(encoding="utf-8")
681
682 if kind == "workspace_member" and ws is not None:
683 rel = _compute_rel_path(root, ws)
684 ws_agent_md = f"{rel}/.muse/agent.md"
685 ws_path = ws / ".muse" / "agent.md"
686 if ws_path.exists():
687 ws_agent_content = ws_path.read_text(encoding="utf-8")
688
689 adapter_statuses: list[StatusAdapterEntry] = []
690 for spec in _ADAPTERS.values():
691 target = root / spec["filename"]
692 exists = target.exists()
693 in_sync = False
694 if exists and repo_agent_content is not None:
695 expected = _render_adapter(
696 spec,
697 repo_agent_md=".muse/agent.md",
698 ws_agent_md=ws_agent_md,
699 repo_agent_content=repo_agent_content,
700 ws_agent_content=ws_agent_content,
701 )
702 actual = target.read_text(encoding="utf-8")
703 in_sync = actual == expected
704 adapter_statuses.append(
705 StatusAdapterEntry(
706 name=spec["name"],
707 filename=spec["filename"],
708 exists=exists,
709 in_sync=in_sync,
710 )
711 )
712
713 if fmt == "json":
714 print(
715 json.dumps(
716 {
717 "agent_md": str(agent_md_path),
718 "adapters": adapter_statuses,
719 }
720 )
721 )
722 else:
723 agent_label = "βœ…" if agent_md_exists else "❌"
724 print(f"{agent_label} agent.md: {agent_md_path}")
725 print()
726 for entry in adapter_statuses:
727 if entry["exists"]:
728 sync_label = "βœ… in sync" if entry["in_sync"] else "⚠️ out of sync"
729 else:
730 sync_label = "❌ missing"
731 print(f" {entry['filename']:<42} {sync_label}")
732
733
734 # ---------------------------------------------------------------------------
735 # Registration
736 # ---------------------------------------------------------------------------
737
738
739 def run_set(args: argparse.Namespace) -> None:
740 """Write persistent agent-config settings to ``.muse/config.toml``.
741
742 Currently supports setting the default adapter list so that
743 ``muse agent-config sync`` only generates the tools you actually use,
744 without requiring ``--adapters`` on every invocation.
745 """
746 fmt: str = getattr(args, "fmt", "text")
747 adapters_raw: str = args.adapters
748 requested = [a.strip() for a in adapters_raw.split(",") if a.strip()]
749
750 unknown = [a for a in requested if a not in _ADAPTERS]
751 if unknown:
752 msg = (
753 f"❌ Unknown adapter(s): {', '.join(unknown)}. "
754 f"Available: {', '.join(_ADAPTERS)}"
755 )
756 if fmt == "json":
757 print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}))
758 else:
759 print(msg, file=sys.stderr)
760 raise SystemExit(ExitCode.USER_ERROR)
761
762 root = _find_operation_root()
763 config_path = root / ".muse" / "config.toml"
764
765 # Read existing config, update [agent-config] section, write back.
766 existing_text = config_path.read_text(encoding="utf-8") if config_path.is_file() else ""
767
768 # Build the new [agent-config] block.
769 adapters_toml = ", ".join(f'"{a}"' for a in requested)
770 new_block = f'[agent-config]\nadapters = [{adapters_toml}]\n'
771
772 if "[agent-config]" in existing_text:
773 # Replace the existing [agent-config] section up to the next line-initial
774 # '[' (next section header) or end of string. Must NOT use [^\[]* because
775 # that stops at '[' inside values like adapters = ["claude"].
776 existing_text = re.sub(
777 r"^\[agent-config\].*?(?=^\[|\Z)",
778 new_block,
779 existing_text,
780 count=1,
781 flags=re.DOTALL | re.MULTILINE,
782 )
783 else:
784 existing_text = existing_text.rstrip("\n") + ("\n\n" if existing_text else "") + new_block
785
786 write_text_atomic(config_path, existing_text)
787
788 if fmt == "json":
789 print(json.dumps({"adapters": requested, "path": str(config_path)}))
790 else:
791 print(f"βœ… Saved to {config_path}")
792 print(f" adapters = {requested}")
793 print(" Run `muse agent-config sync --force` to regenerate adapter files.")
794
795
796 def register(
797 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
798 ) -> None:
799 """Register the ``muse agent-config`` subcommand tree and all its flags."""
800 parser = subparsers.add_parser(
801 "agent-config",
802 help="Manage agent configuration files (CLAUDE.md, AGENTS.md, etc.).",
803 description=__doc__,
804 formatter_class=argparse.RawDescriptionHelpFormatter,
805 )
806 parser.add_argument(
807 "--format", "-f", default="text", dest="fmt",
808 help="Output format: text or json.",
809 )
810 parser.add_argument(
811 "--json", "-j", action="store_const", const="json", dest="fmt",
812 help="Shorthand for --format json.",
813 )
814
815 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
816
817 # ── init ──────────────────────────────────────────────────────────
818 init_p = subs.add_parser(
819 "init",
820 help="Create .muse/agent.md with sane defaults.",
821 description=(
822 "Create ``.muse/agent.md`` with appropriate default rules for this\n"
823 "repo or workspace. Detects standalone, workspace_root, and\n"
824 "workspace_member contexts automatically.\n\n"
825 "Use ``muse agent-config sync`` afterwards to generate IDE adapter files."
826 ),
827 formatter_class=argparse.RawDescriptionHelpFormatter,
828 )
829 init_p.add_argument(
830 "--force", action="store_true",
831 help="Overwrite .muse/agent.md if it already exists.",
832 )
833 init_p.add_argument(
834 "--format", "-f", default="text", dest="fmt",
835 help="Output format: text or json.",
836 )
837 init_p.add_argument(
838 "--json", "-j", action="store_const", const="json", dest="fmt",
839 help="Shorthand for --format json.",
840 )
841 init_p.set_defaults(func=run_init)
842
843 # ── sync ──────────────────────────────────────────────────────────
844 sync_p = subs.add_parser(
845 "sync",
846 help="Generate IDE adapter files from .muse/agent.md.",
847 description=(
848 "Generate IDE/agent adapter files (CLAUDE.md, AGENTS.md, .cursorrules,\n"
849 ".github/copilot-instructions.md, .windsurfrules) from ``.muse/agent.md``.\n\n"
850 "Claude's CLAUDE.md uses ``@path`` include syntax and is always minimal.\n"
851 "All other adapters embed the full content so each tool sees it directly.\n\n"
852 "Inside a workspace, workspace-level rules are automatically prepended."
853 ),
854 formatter_class=argparse.RawDescriptionHelpFormatter,
855 )
856 sync_p.add_argument(
857 "--adapters", default=None, metavar="NAME,...",
858 help="Comma-separated list of adapters to sync (default: all).",
859 )
860 sync_p.add_argument(
861 "--dry-run", action="store_true", dest="dry_run",
862 help="Print what would be written without creating any files.",
863 )
864 sync_p.add_argument(
865 "--force", action="store_true",
866 help="Overwrite existing adapter files.",
867 )
868 sync_p.add_argument(
869 "--format", "-f", default="text", dest="fmt",
870 help="Output format: text or json.",
871 )
872 sync_p.add_argument(
873 "--json", "-j", action="store_const", const="json", dest="fmt",
874 help="Shorthand for --format json.",
875 )
876 sync_p.set_defaults(func=run_sync)
877
878 # ── show ──────────────────────────────────────────────────────────
879 show_p = subs.add_parser(
880 "show",
881 help="Print the agent.md content.",
882 description=(
883 "Print the ``.muse/agent.md`` content for this repo or workspace.\n\n"
884 "Use ``--scope merged`` inside a workspace member to see both\n"
885 "workspace and repo rules concatenated."
886 ),
887 formatter_class=argparse.RawDescriptionHelpFormatter,
888 )
889 show_p.add_argument(
890 "--scope", default="repo",
891 choices=["repo", "workspace", "merged"],
892 help="Which config to show: repo (default), workspace, or merged.",
893 )
894 show_p.add_argument(
895 "--format", "-f", default="text", dest="fmt",
896 help="Output format: text or json.",
897 )
898 show_p.add_argument(
899 "--json", "-j", action="store_const", const="json", dest="fmt",
900 help="Shorthand for --format json.",
901 )
902 show_p.set_defaults(func=run_show)
903
904 # ── status ────────────────────────────────────────────────────────
905 status_p = subs.add_parser(
906 "status",
907 help="Show which adapter files exist and whether they are in sync.",
908 description=(
909 "Report the sync state of all IDE adapter files.\n\n"
910 "An adapter is *in sync* when its content matches what\n"
911 "``muse agent-config sync`` would generate from the current ``agent.md``."
912 ),
913 formatter_class=argparse.RawDescriptionHelpFormatter,
914 )
915 status_p.add_argument(
916 "--format", "-f", default="text", dest="fmt",
917 help="Output format: text or json.",
918 )
919 status_p.add_argument(
920 "--json", "-j", action="store_const", const="json", dest="fmt",
921 help="Shorthand for --format json.",
922 )
923 status_p.set_defaults(func=run_status)
924
925 # ── set ───────────────────────────────────────────────────────────
926 set_p = subs.add_parser(
927 "set",
928 help="Persist agent-config preferences in .muse/config.toml.",
929 description=(
930 "Write persistent agent-config settings to ``.muse/config.toml``.\n\n"
931 "Settings written here become the default for every subsequent\n"
932 "``muse agent-config sync`` invocation in this repo without\n"
933 "needing to pass flags each time.\n\n"
934 "Available adapters: " + ", ".join(_ADAPTERS) + "\n\n"
935 "Examples::\n\n"
936 " muse agent-config set --adapters claude,codex\n"
937 " muse agent-config set --adapters claude\n"
938 ),
939 formatter_class=argparse.RawDescriptionHelpFormatter,
940 )
941 set_p.add_argument(
942 "--adapters", required=True, metavar="NAME,...",
943 help=(
944 "Comma-separated list of adapters to generate on sync. "
945 "Available: " + ", ".join(_ADAPTERS)
946 ),
947 )
948 set_p.add_argument(
949 "--format", "-f", default="text", dest="fmt",
950 help="Output format: text or json.",
951 )
952 set_p.add_argument(
953 "--json", "-j", action="store_const", const="json", dest="fmt",
954 help="Shorthand for --format json.",
955 )
956 set_p.set_defaults(func=run_set)
957
958 parser.set_defaults(func=_show_help(parser))
959
960
961 def _show_help(
962 parser: argparse.ArgumentParser,
963 ) -> Callable[[argparse.Namespace], None]:
964 """Return a callable that prints help and exits."""
965
966 def _help(args: argparse.Namespace) -> None: # noqa: ARG001
967 parser.print_help()
968 raise SystemExit(0)
969
970 return _help
971
972
973 def run(args: argparse.Namespace) -> None:
974 """Dispatch to the correct subcommand handler."""
975 func = getattr(args, "func", None)
976 if func is None:
977 # No subcommand given β€” print help
978 raise SystemExit(0)
979 func(args)