agent_config.py
python
| 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 set --adapters NAME,... [--global] [--json] |
| 34 | Persist which adapters sync will generate. Use ``--global`` to write |
| 35 | to ``~/.muse/config.toml`` so the setting applies to every repo on |
| 36 | this machine and survives branch switches and merges (like ~/.gitconfig). |
| 37 | Without ``--global`` the setting is saved to the repo's .muse/config.toml. |
| 38 | sync will error if neither source has an [agent-config] section. |
| 39 | |
| 40 | muse agent-config sync [--adapters NAME,...] [--dry-run] [--force] [--json] |
| 41 | Generate IDE adapter files from .muse/agent.md. Requires an adapter |
| 42 | list configured via ``muse agent-config set`` (repo or global level). |
| 43 | Pass ``--adapters`` to override for a single invocation. |
| 44 | |
| 45 | muse agent-config read [--scope repo|workspace|merged] [--json] |
| 46 | Print the agent.md content. |
| 47 | |
| 48 | muse agent-config status [--json] |
| 49 | Show which adapter files exist and whether they are in sync. |
| 50 | |
| 51 | muse agent-config inspect [--json] |
| 52 | Single-call bootstrap: context, merged rules, adapter status, ready flag. |
| 53 | |
| 54 | Exit codes:: |
| 55 | |
| 56 | 0 — success |
| 57 | 1 — user error (file exists without --force, missing agent.md, no adapters |
| 58 | configured, etc.) |
| 59 | """ |
| 60 | |
| 61 | import argparse |
| 62 | import json |
| 63 | import logging |
| 64 | import os |
| 65 | import pathlib |
| 66 | import re |
| 67 | import sys |
| 68 | from collections.abc import Callable |
| 69 | from typing import TypedDict |
| 70 | |
| 71 | from muse.core.envelope import EnvelopeJson, make_envelope |
| 72 | from muse.core.paths import agent_md_path as _agent_md_path, config_toml_path as _config_toml_path, workspace_toml_path as _workspace_toml_path |
| 73 | from muse.core.errors import ExitCode |
| 74 | from muse.core.io import write_text_atomic |
| 75 | from muse.core.timing import start_timer |
| 76 | from muse.core.workspace import ( |
| 77 | WorkspaceMemberDict, |
| 78 | WorkspaceManifestDict, |
| 79 | find_workspace_root, |
| 80 | ) |
| 81 | |
| 82 | logger = logging.getLogger(__name__) |
| 83 | |
| 84 | # --------------------------------------------------------------------------- |
| 85 | # Adapter registry |
| 86 | # --------------------------------------------------------------------------- |
| 87 | |
| 88 | class AdapterSpec(TypedDict): |
| 89 | """Specification for one IDE/agent adapter file. |
| 90 | |
| 91 | ``name`` — short identifier (e.g. ``"claude"``) |
| 92 | ``filename`` — path relative to repo root (e.g. ``"CLAUDE.md"``) |
| 93 | ``style`` — ``"include"`` uses ``@path`` reference syntax; |
| 94 | ``"embed"`` inlines the full content |
| 95 | """ |
| 96 | |
| 97 | name: str |
| 98 | filename: str |
| 99 | style: str |
| 100 | |
| 101 | class SyncAdapterResult(TypedDict): |
| 102 | """One entry in the ``muse agent-config sync --json`` output.""" |
| 103 | |
| 104 | name: str |
| 105 | path: str |
| 106 | written: bool |
| 107 | skipped: bool |
| 108 | |
| 109 | class StatusAdapterEntry(TypedDict): |
| 110 | """One entry in the ``muse agent-config status --json`` output.""" |
| 111 | |
| 112 | name: str |
| 113 | filename: str |
| 114 | exists: bool |
| 115 | in_sync: bool |
| 116 | |
| 117 | class InspectResult(TypedDict): |
| 118 | """Output of ``muse agent-config inspect --json``. |
| 119 | |
| 120 | Single-call bootstrap payload for agents entering a new repository. |
| 121 | Contains everything needed to understand the repo context, active rules, |
| 122 | and adapter sync state without making multiple separate calls. |
| 123 | """ |
| 124 | |
| 125 | context: str # "standalone" | "workspace_root" | "workspace_member" |
| 126 | workspace_root: str | None |
| 127 | repo_name: str |
| 128 | agent_md_exists: bool |
| 129 | merged_content: str | None # workspace rules + repo rules concatenated |
| 130 | adapters: list[StatusAdapterEntry] |
| 131 | ready: bool # agent_md_exists AND at least one adapter exists AND all existing in sync |
| 132 | |
| 133 | class _InitJson(EnvelopeJson): |
| 134 | """JSON envelope for ``muse agent-config init --json``.""" |
| 135 | |
| 136 | path: str # absolute path to agent.md |
| 137 | scope: str # "standalone" | "workspace_root" | "workspace_member" |
| 138 | created: bool |
| 139 | |
| 140 | class _SyncJson(EnvelopeJson): |
| 141 | """JSON envelope for ``muse agent-config sync --json``.""" |
| 142 | |
| 143 | adapters: list[SyncAdapterResult] |
| 144 | |
| 145 | class _ReadJson(EnvelopeJson): |
| 146 | """JSON envelope for ``muse agent-config read --json``.""" |
| 147 | |
| 148 | content: str # agent.md content |
| 149 | path: str # absolute path read |
| 150 | scope: str # "repo" | "workspace" | "merged" |
| 151 | |
| 152 | class _StatusJson(EnvelopeJson): |
| 153 | """JSON envelope for ``muse agent-config status --json``.""" |
| 154 | |
| 155 | agent_md: str # absolute path to agent.md |
| 156 | agent_md_exists: bool |
| 157 | ready: bool # true when agent.md + adapters are in sync |
| 158 | in_sync_count: int |
| 159 | missing_count: int |
| 160 | out_of_sync_count: int |
| 161 | adapters: list[StatusAdapterEntry] |
| 162 | |
| 163 | class _InspectJson(EnvelopeJson): |
| 164 | """JSON envelope for ``muse agent-config inspect --json``.""" |
| 165 | |
| 166 | context: str # "standalone" | "workspace_root" | "workspace_member" |
| 167 | workspace_root: str | None |
| 168 | repo_name: str |
| 169 | agent_md_exists: bool |
| 170 | merged_content: str | None |
| 171 | adapters: list[StatusAdapterEntry] |
| 172 | ready: bool |
| 173 | |
| 174 | class _SetJson(EnvelopeJson): |
| 175 | """JSON envelope for ``muse agent-config set --json``.""" |
| 176 | |
| 177 | adapters: list[str] |
| 178 | path: str # absolute path to config.toml written |
| 179 | |
| 180 | AdapterRegistry = dict[str, AdapterSpec] |
| 181 | |
| 182 | _ADAPTERS: AdapterRegistry = { |
| 183 | "claude": AdapterSpec(name="claude", filename="CLAUDE.md", style="include"), |
| 184 | "codex": AdapterSpec(name="codex", filename="AGENTS.md", style="embed"), |
| 185 | "cursor": AdapterSpec(name="cursor", filename=".cursorrules", style="embed"), |
| 186 | "windsurf": AdapterSpec(name="windsurf", filename=".windsurfrules", style="embed"), |
| 187 | } |
| 188 | |
| 189 | # --------------------------------------------------------------------------- |
| 190 | # Templates |
| 191 | # --------------------------------------------------------------------------- |
| 192 | |
| 193 | _STANDALONE_TEMPLATE = """\ |
| 194 | # {repo_name} — Agent Configuration |
| 195 | |
| 196 | This file is the canonical agent context for the **{repo_name}** repository. |
| 197 | It is managed by `muse agent-config` — regenerate adapter files at any time |
| 198 | with `muse agent-config sync`. |
| 199 | |
| 200 | --- |
| 201 | |
| 202 | ## Version Control |
| 203 | |
| 204 | This repository uses **Muse** for version control — not git, not GitHub. |
| 205 | |
| 206 | - Never run `git`, `gh`, or any git subcommand. |
| 207 | - Never reference GitHub, GitHub Actions, or GitHub URLs. |
| 208 | - All VCS operations use `muse` commands. |
| 209 | |
| 210 | If you find a `git` or `gh` call anywhere — delete it in the same commit. |
| 211 | |
| 212 | --- |
| 213 | |
| 214 | ## Branch Flow |
| 215 | |
| 216 | Always work on a feature branch — never commit directly to `main` or `dev`. |
| 217 | |
| 218 | ```bash |
| 219 | muse checkout dev |
| 220 | muse checkout -b task/my-thing # start work |
| 221 | |
| 222 | muse code add . |
| 223 | muse commit -m "feat: ..." |
| 224 | |
| 225 | muse checkout dev |
| 226 | muse merge task/my-thing |
| 227 | muse branch -d task/my-thing |
| 228 | muse push local dev |
| 229 | ``` |
| 230 | |
| 231 | --- |
| 232 | |
| 233 | ## Code Intelligence |
| 234 | |
| 235 | Use `muse code` commands for navigation — never raw grep or file reads. |
| 236 | |
| 237 | | Task | Command | |
| 238 | |------|---------| |
| 239 | | Find symbol declaration | `muse code grep "Name" --json` | |
| 240 | | Read one symbol | `muse code cat "file.py::Symbol" --json` | |
| 241 | | File structure | `muse code symbols --file file.py --json` | |
| 242 | | Blast radius | `muse code impact "file.py::Symbol" --json` | |
| 243 | | Dependencies | `muse code deps "file.py" --json` | |
| 244 | | Tests for changed code | `muse code test --json` | |
| 245 | |
| 246 | --- |
| 247 | |
| 248 | ## Testing Rules |
| 249 | |
| 250 | **Never run the full test suite.** It is slow; only the owner runs it when ready. |
| 251 | |
| 252 | - Use `muse code test --json` first — it runs only the tests relevant to changed files. |
| 253 | - When fixing a specific failure, run only that file: |
| 254 | `python3 -m pytest tests/test_foo.py -q --tb=short` |
| 255 | - When verifying a single fix, run only that test by name: |
| 256 | `python3 -m pytest tests/test_foo.py::test_bar -q --tb=short` |
| 257 | - Never run `python3 -m pytest tests/` or any whole-suite invocation. |
| 258 | |
| 259 | --- |
| 260 | |
| 261 | ## Status |
| 262 | |
| 263 | Always verify a clean state before switching branches: |
| 264 | |
| 265 | ```bash |
| 266 | muse status --json # must show "clean": true before muse checkout |
| 267 | ``` |
| 268 | |
| 269 | --- |
| 270 | |
| 271 | ## HARD RULE — No Destructive Actions Without Explicit Permission |
| 272 | |
| 273 | Under no circumstances take any destructive or irreversible action without the |
| 274 | owner's express permission in that conversation. This includes but is not limited to: |
| 275 | |
| 276 | - `muse merge --abort` — wipes all uncommitted working tree changes |
| 277 | - `muse reset --hard` — discards commits and working tree changes |
| 278 | - `muse branch -D` — force-deletes branches |
| 279 | - `muse rm` / `muse rm --force` — deletes tracked files |
| 280 | - `muse checkout --force` / `muse checkout --ours` / `muse checkout --theirs` |
| 281 | - Any `--force` flag on any muse command |
| 282 | - Deleting, overwriting, or resetting any file or object store entry |
| 283 | - `muse code migrate` — rewrites object store in place |
| 284 | |
| 285 | If you encounter a conflict, stale merge state, dirty working tree, or any other |
| 286 | unexpected state — STOP and ask the owner what to do. Do not resolve it yourself. |
| 287 | Losing uncommitted work is catastrophic and unrecoverable. |
| 288 | """ |
| 289 | |
| 290 | _WORKSPACE_ROOT_TEMPLATE = """\ |
| 291 | # Workspace — Shared Agent Configuration |
| 292 | |
| 293 | This file contains shared rules for all repositories in this workspace. |
| 294 | Each member repository may have its own ``.muse/agent.md`` with repo-specific |
| 295 | additions. |
| 296 | |
| 297 | Managed by `muse agent-config` — regenerate adapters with `muse agent-config sync`. |
| 298 | |
| 299 | --- |
| 300 | |
| 301 | ## Workspace Members |
| 302 | |
| 303 | {members_table} |
| 304 | |
| 305 | --- |
| 306 | |
| 307 | ## Version Control |
| 308 | |
| 309 | This workspace uses **Muse** for version control — not git, not GitHub. |
| 310 | |
| 311 | - Never run `git`, `gh`, or any git subcommand. |
| 312 | - Never reference GitHub, GitHub Actions, or GitHub URLs. |
| 313 | - Use `muse -C ~/path/to/repo <command>` when CWD differs from the target repo. |
| 314 | |
| 315 | If you find a `git` or `gh` call anywhere — delete it in the same commit. |
| 316 | |
| 317 | --- |
| 318 | |
| 319 | ## Branch Flow |
| 320 | |
| 321 | Always work on a feature branch — never commit directly to `main` or `dev`. |
| 322 | |
| 323 | ```bash |
| 324 | muse -C ~/path/to/repo checkout dev |
| 325 | muse -C ~/path/to/repo checkout -b task/my-thing |
| 326 | |
| 327 | muse code add . |
| 328 | muse commit -m "feat: ..." |
| 329 | |
| 330 | muse -C ~/path/to/repo checkout dev |
| 331 | muse -C ~/path/to/repo merge task/my-thing |
| 332 | muse -C ~/path/to/repo branch -d task/my-thing |
| 333 | muse -C ~/path/to/repo push local dev |
| 334 | ``` |
| 335 | |
| 336 | --- |
| 337 | |
| 338 | ## Code Intelligence |
| 339 | |
| 340 | | Task | Command | |
| 341 | |------|---------| |
| 342 | | Find symbol declaration | `muse code grep "Name" --json` | |
| 343 | | Read one symbol | `muse code cat "file.py::Symbol" --json` | |
| 344 | | File structure | `muse code symbols --file file.py --json` | |
| 345 | | Blast radius | `muse code impact "file.py::Symbol" --json` | |
| 346 | | Dependencies | `muse code deps "file.py" --json` | |
| 347 | |
| 348 | --- |
| 349 | |
| 350 | ## HARD RULE — No Destructive Actions Without Explicit Permission |
| 351 | |
| 352 | Under no circumstances take any destructive or irreversible action without the |
| 353 | owner's express permission in that conversation. This includes but is not limited to: |
| 354 | |
| 355 | - `muse merge --abort` — wipes all uncommitted working tree changes |
| 356 | - `muse reset --hard` — discards commits and working tree changes |
| 357 | - `muse branch -D` — force-deletes branches |
| 358 | - `muse rm` / `muse rm --force` — deletes tracked files |
| 359 | - `muse checkout --force` / `muse checkout --ours` / `muse checkout --theirs` |
| 360 | - Any `--force` flag on any muse command |
| 361 | - Deleting, overwriting, or resetting any file or object store entry |
| 362 | - `muse code migrate` — rewrites object store in place |
| 363 | |
| 364 | If you encounter a conflict, stale merge state, dirty working tree, or any other |
| 365 | unexpected state — STOP and ask the owner what to do. Do not resolve it yourself. |
| 366 | Losing uncommitted work is catastrophic and unrecoverable. |
| 367 | """ |
| 368 | |
| 369 | _WORKSPACE_MEMBER_TEMPLATE = """\ |
| 370 | # {repo_name} — Agent Configuration |
| 371 | |
| 372 | This repository is a member of a workspace. |
| 373 | Shared workspace rules live in the parent ``.muse/agent.md``. |
| 374 | This file contains only {repo_name}-specific additions. |
| 375 | |
| 376 | Managed by `muse agent-config` — regenerate adapters with `muse agent-config sync`. |
| 377 | |
| 378 | --- |
| 379 | |
| 380 | ## Repo-Specific Notes |
| 381 | |
| 382 | Add {repo_name}-specific agent rules below this line. |
| 383 | """ |
| 384 | |
| 385 | # --------------------------------------------------------------------------- |
| 386 | # Core helpers |
| 387 | # --------------------------------------------------------------------------- |
| 388 | |
| 389 | def _detect_context(root: pathlib.Path) -> tuple[str, pathlib.Path | None]: |
| 390 | """Classify *root* as ``standalone``, ``workspace_root``, or ``workspace_member``. |
| 391 | |
| 392 | Returns a ``(kind, workspace_root)`` pair. ``workspace_root`` is ``None`` |
| 393 | for standalone repos and the workspace directory for members. |
| 394 | |
| 395 | Detection rules |
| 396 | --------------- |
| 397 | 1. If ``root/.muse/workspace.toml`` exists → ``workspace_root``. |
| 398 | 2. If a ``workspace.toml`` exists in any parent → ``workspace_member``. |
| 399 | 3. Otherwise → ``standalone``. |
| 400 | """ |
| 401 | if (_workspace_toml_path(root)).exists(): |
| 402 | return "workspace_root", root |
| 403 | ws = find_workspace_root(root) |
| 404 | if ws is not None and ws != root: |
| 405 | return "workspace_member", ws |
| 406 | return "standalone", None |
| 407 | |
| 408 | def _compute_rel_path(repo: pathlib.Path, ws: pathlib.Path) -> str: |
| 409 | """Return the relative path from *repo* to *ws*. |
| 410 | |
| 411 | Examples:: |
| 412 | |
| 413 | _compute_rel_path(ws / "core", ws) → ".." |
| 414 | _compute_rel_path(ws / "packages" / "foo", ws) → "../.." |
| 415 | _compute_rel_path(ws, ws) → "." |
| 416 | """ |
| 417 | return str(pathlib.Path(os.path.relpath(ws, repo))) |
| 418 | |
| 419 | def _render_adapter( |
| 420 | spec: AdapterSpec, |
| 421 | repo_agent_md: str, |
| 422 | ws_agent_md: str | None, |
| 423 | repo_agent_content: str | None = None, |
| 424 | ws_agent_content: str | None = None, |
| 425 | ) -> str: |
| 426 | """Render the content for one IDE adapter file. |
| 427 | |
| 428 | Include-style adapters (Claude) use ``@path`` reference syntax so Claude |
| 429 | resolves the content at read time. Embed-style adapters (all others) inline |
| 430 | the full content so the tool sees it without following references. |
| 431 | |
| 432 | When *ws_agent_md* / *ws_agent_content* are provided, workspace-level rules |
| 433 | are prepended so they take precedence over repo-level rules. |
| 434 | """ |
| 435 | if spec["style"] == "include": |
| 436 | lines: list[str] = [] |
| 437 | if ws_agent_md is not None: |
| 438 | lines.append(f"@{ws_agent_md}") |
| 439 | lines.append(f"@{repo_agent_md}") |
| 440 | return "\n".join(lines) + "\n" |
| 441 | else: |
| 442 | parts: list[str] = [] |
| 443 | if ws_agent_content is not None: |
| 444 | parts.append(ws_agent_content.rstrip()) |
| 445 | if repo_agent_content is not None: |
| 446 | parts.append(repo_agent_content.rstrip()) |
| 447 | return "\n\n".join(parts) + "\n" if parts else "" |
| 448 | |
| 449 | def _load_workspace_manifest(ws_root: pathlib.Path) -> WorkspaceManifestDict | None: |
| 450 | """Load the workspace manifest from *ws_root*/.muse/workspace.toml.""" |
| 451 | try: |
| 452 | import tomllib |
| 453 | path = _workspace_toml_path(ws_root) |
| 454 | if not path.exists(): |
| 455 | return None |
| 456 | raw = tomllib.loads(path.read_text(encoding="utf-8")) |
| 457 | members: list[WorkspaceMemberDict] = [] |
| 458 | for m in raw.get("members", []): |
| 459 | if isinstance(m, dict): |
| 460 | members.append( |
| 461 | WorkspaceMemberDict( |
| 462 | name=str(m.get("name", "")), |
| 463 | url=str(m.get("url", "")), |
| 464 | path=str(m.get("path", "")), |
| 465 | branch=str(m.get("branch", "main")), |
| 466 | ) |
| 467 | ) |
| 468 | return WorkspaceManifestDict(members=members) |
| 469 | except Exception as exc: |
| 470 | logger.warning("Could not load workspace manifest: %s", exc) |
| 471 | return None |
| 472 | |
| 473 | def _user_muse_dir() -> pathlib.Path: |
| 474 | """Return the user-level muse config directory. |
| 475 | |
| 476 | Defaults to ``~/.muse``. Override with ``MUSE_USER_CONFIG_DIR`` for |
| 477 | testing or CI environments — the same pattern git uses with ``$HOME``. |
| 478 | """ |
| 479 | override = os.environ.get("MUSE_USER_CONFIG_DIR") |
| 480 | if override: |
| 481 | return pathlib.Path(override) |
| 482 | return pathlib.Path.home() / ".muse" |
| 483 | |
| 484 | |
| 485 | def _load_configured_adapters(root: pathlib.Path) -> list[str] | None: |
| 486 | """Read ``[agent-config] adapters`` from config. Two sources, repo wins: |
| 487 | |
| 488 | 1. ``<repo>/.muse/config.toml`` — repo-level (takes priority) |
| 489 | 2. ``~/.muse/config.toml`` — user-level fallback (like ~/.gitconfig) |
| 490 | |
| 491 | Set ``MUSE_USER_CONFIG_DIR`` to override the user-config directory in tests. |
| 492 | |
| 493 | Returns the list of adapter names if configured in either source, or |
| 494 | ``None`` if absent from both. |
| 495 | """ |
| 496 | import tomllib |
| 497 | |
| 498 | def _read(path: pathlib.Path) -> list[str] | None: |
| 499 | if not path.is_file(): |
| 500 | return None |
| 501 | try: |
| 502 | raw = tomllib.loads(path.read_text(encoding="utf-8")) |
| 503 | section = raw.get("agent-config", {}) |
| 504 | adapters = section.get("adapters") |
| 505 | if isinstance(adapters, list) and all(isinstance(a, str) for a in adapters): |
| 506 | return [str(a) for a in adapters] |
| 507 | except Exception as exc: |
| 508 | logger.warning("Could not read [agent-config] from %s: %s", path, exc) |
| 509 | return None |
| 510 | |
| 511 | # Repo-level takes priority over user-level |
| 512 | repo_result = _read(_config_toml_path(root)) |
| 513 | if repo_result is not None: |
| 514 | return repo_result |
| 515 | |
| 516 | return _read(_user_muse_dir() / "config.toml") |
| 517 | |
| 518 | def _build_members_table(manifest: WorkspaceManifestDict) -> str: |
| 519 | """Render a Markdown table of workspace members.""" |
| 520 | lines = ["| Repo | Path | Branch |", "|------|------|--------|"] |
| 521 | for m in manifest.get("members", []): |
| 522 | lines.append(f"| **{m['name']}** | `{m['path']}` | `{m['branch']}` |") |
| 523 | return "\n".join(lines) |
| 524 | |
| 525 | def _find_operation_root() -> pathlib.Path: |
| 526 | """Return the directory that agent-config should operate on. |
| 527 | |
| 528 | For workspace roots (``cwd/.muse/workspace.toml`` exists) the CWD is |
| 529 | returned directly — there is no repo to require. |
| 530 | |
| 531 | For everything else, ``require_repo()`` is called so a clear error is |
| 532 | shown if the CWD is not inside a Muse repository. |
| 533 | """ |
| 534 | from muse.core.repo import require_repo |
| 535 | |
| 536 | cwd = pathlib.Path.cwd() |
| 537 | if (_workspace_toml_path(cwd)).exists(): |
| 538 | return cwd |
| 539 | return require_repo() |
| 540 | |
| 541 | # --------------------------------------------------------------------------- |
| 542 | # Subcommand: init |
| 543 | # --------------------------------------------------------------------------- |
| 544 | |
| 545 | def run_init(args: argparse.Namespace) -> None: |
| 546 | """Create ``.muse/agent.md`` with sane defaults. |
| 547 | |
| 548 | Detects whether the current directory is a standalone repo, a workspace |
| 549 | root, or a workspace member and generates the appropriate template. |
| 550 | |
| 551 | Agent quickstart |
| 552 | ---------------- |
| 553 | :: |
| 554 | |
| 555 | muse agent-config init --json |
| 556 | muse agent-config init --force --json # overwrite existing |
| 557 | |
| 558 | Exit codes |
| 559 | ---------- |
| 560 | 0 Created (or already exists when ``--force`` not given). |
| 561 | 1 File exists without ``--force``. |
| 562 | """ |
| 563 | elapsed = start_timer() |
| 564 | json_out: bool = args.json_out |
| 565 | force: bool = args.force |
| 566 | root = _find_operation_root() |
| 567 | kind, ws = _detect_context(root) |
| 568 | |
| 569 | agent_md_path = _agent_md_path(root) |
| 570 | |
| 571 | if agent_md_path.exists() and not force: |
| 572 | msg = ( |
| 573 | f"❌ {agent_md_path} already exists.\n" |
| 574 | " Use --force to overwrite it." |
| 575 | ) |
| 576 | if json_out: |
| 577 | print( |
| 578 | json.dumps( |
| 579 | {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} |
| 580 | ) |
| 581 | ) |
| 582 | else: |
| 583 | print(msg, file=sys.stderr) |
| 584 | raise SystemExit(ExitCode.USER_ERROR) |
| 585 | |
| 586 | if kind == "workspace_root": |
| 587 | manifest = _load_workspace_manifest(root) |
| 588 | if manifest and manifest["members"]: |
| 589 | members_table = _build_members_table(manifest) |
| 590 | else: |
| 591 | members_table = "_No members registered yet._" |
| 592 | content = _WORKSPACE_ROOT_TEMPLATE.format(members_table=members_table) |
| 593 | elif kind == "workspace_member": |
| 594 | content = _WORKSPACE_MEMBER_TEMPLATE.format(repo_name=root.name) |
| 595 | else: |
| 596 | content = _STANDALONE_TEMPLATE.format(repo_name=root.name) |
| 597 | |
| 598 | write_text_atomic(agent_md_path, content) |
| 599 | created = True |
| 600 | |
| 601 | if json_out: |
| 602 | out = _InitJson(**make_envelope(elapsed), path=str(agent_md_path), scope=kind, created=created) |
| 603 | print(json.dumps(out)) |
| 604 | else: |
| 605 | print(f"✅ Created {agent_md_path} (scope: {kind})") |
| 606 | |
| 607 | # --------------------------------------------------------------------------- |
| 608 | # Subcommand: sync |
| 609 | # --------------------------------------------------------------------------- |
| 610 | |
| 611 | def run_sync(args: argparse.Namespace) -> None: |
| 612 | """Generate IDE adapter files from ``.muse/agent.md``. |
| 613 | |
| 614 | Reads the canonical ``.muse/agent.md`` (and the workspace-level one if |
| 615 | inside a workspace) and writes adapter files for each configured IDE/agent |
| 616 | tool. |
| 617 | |
| 618 | Adapter selection — three sources in priority order: |
| 619 | |
| 620 | 1. ``--adapters`` CLI flag (one-shot override) |
| 621 | 2. ``[agent-config] adapters`` in ``<repo>/.muse/config.toml`` |
| 622 | 3. ``[agent-config] adapters`` in ``~/.muse/config.toml`` (user-global) |
| 623 | |
| 624 | If none of the above are configured, sync exits with an error. |
| 625 | Use ``muse agent-config set --adapters claude`` (or ``--global``) to configure. |
| 626 | |
| 627 | Agent quickstart |
| 628 | ---------------- |
| 629 | :: |
| 630 | |
| 631 | muse agent-config set --global --adapters claude # once, for all repos |
| 632 | muse agent-config sync --json |
| 633 | muse agent-config sync --adapters claude,codex --json # one-shot override |
| 634 | muse agent-config sync --dry-run --json # preview without writing |
| 635 | |
| 636 | Exit codes |
| 637 | ---------- |
| 638 | 0 Adapters generated or already in sync. |
| 639 | 1 No agent.md found, or no adapter configured. |
| 640 | """ |
| 641 | elapsed = start_timer() |
| 642 | json_out: bool = args.json_out |
| 643 | force: bool = args.force |
| 644 | dry_run: bool = args.dry_run |
| 645 | adapters_filter: list[str] | None = ( |
| 646 | [a.strip() for a in args.adapters.split(",")] |
| 647 | if args.adapters |
| 648 | else None |
| 649 | ) |
| 650 | |
| 651 | root = _find_operation_root() |
| 652 | kind, ws = _detect_context(root) |
| 653 | |
| 654 | agent_md_path = _agent_md_path(root) |
| 655 | if not agent_md_path.exists(): |
| 656 | msg = ( |
| 657 | f"❌ No agent.md found at {agent_md_path}.\n" |
| 658 | " Run `muse agent-config init` first." |
| 659 | ) |
| 660 | if json_out: |
| 661 | print( |
| 662 | json.dumps( |
| 663 | {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} |
| 664 | ) |
| 665 | ) |
| 666 | else: |
| 667 | print(msg, file=sys.stderr) |
| 668 | raise SystemExit(ExitCode.USER_ERROR) |
| 669 | |
| 670 | repo_agent_content = agent_md_path.read_text(encoding="utf-8") |
| 671 | |
| 672 | ws_agent_md: str | None = None |
| 673 | ws_agent_content: str | None = None |
| 674 | if kind == "workspace_member" and ws is not None: |
| 675 | rel = _compute_rel_path(root, ws) |
| 676 | ws_agent_md = f"{rel}/.muse/agent.md" |
| 677 | ws_path = _agent_md_path(ws) |
| 678 | if ws_path.exists(): |
| 679 | ws_agent_content = ws_path.read_text(encoding="utf-8") |
| 680 | |
| 681 | repo_agent_md = ".muse/agent.md" |
| 682 | |
| 683 | # Resolve which adapters to generate. Priority (highest → lowest): |
| 684 | # 1. --adapters flag (CLI override) |
| 685 | # 2. [agent-config] adapters in .muse/config.toml (persistent preference) |
| 686 | # There is no "all adapters" default — if neither is set, sync exits with an |
| 687 | # error directing the user to run `muse agent-config set --adapters <names>`. |
| 688 | effective_filter: list[str] | None = adapters_filter |
| 689 | if effective_filter is None: |
| 690 | effective_filter = _load_configured_adapters(root) |
| 691 | |
| 692 | if effective_filter is None: |
| 693 | msg = ( |
| 694 | "❌ No adapter configured.\n" |
| 695 | " Run `muse agent-config set --adapters <names>` to configure which\n" |
| 696 | f" adapters to generate (available: {', '.join(_ADAPTERS)}).\n" |
| 697 | " Example: muse agent-config set --adapters claude" |
| 698 | ) |
| 699 | if json_out: |
| 700 | print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR})) |
| 701 | else: |
| 702 | print(msg, file=sys.stderr) |
| 703 | raise SystemExit(ExitCode.USER_ERROR) |
| 704 | |
| 705 | selected_adapters = { |
| 706 | k: v for k, v in _ADAPTERS.items() |
| 707 | if k in effective_filter |
| 708 | } |
| 709 | |
| 710 | results: list[SyncAdapterResult] = [] |
| 711 | for spec in selected_adapters.values(): |
| 712 | target = root / spec["filename"] |
| 713 | rendered = _render_adapter( |
| 714 | spec, |
| 715 | repo_agent_md=repo_agent_md, |
| 716 | ws_agent_md=ws_agent_md, |
| 717 | repo_agent_content=repo_agent_content, |
| 718 | ws_agent_content=ws_agent_content, |
| 719 | ) |
| 720 | |
| 721 | # Smart skip: if the file already contains the exact content we would |
| 722 | # write and --force is not set, leave it untouched. This makes sync |
| 723 | # idempotent and agent-friendly — no --force required after every edit. |
| 724 | already_in_sync = ( |
| 725 | not force |
| 726 | and not dry_run |
| 727 | and target.exists() |
| 728 | and target.read_text(encoding="utf-8") == rendered |
| 729 | ) |
| 730 | |
| 731 | written = False |
| 732 | skipped = False |
| 733 | if already_in_sync: |
| 734 | skipped = True |
| 735 | elif not dry_run: |
| 736 | target.parent.mkdir(parents=True, exist_ok=True) |
| 737 | write_text_atomic(target, rendered) |
| 738 | written = True |
| 739 | |
| 740 | results.append( |
| 741 | SyncAdapterResult( |
| 742 | name=spec["name"], |
| 743 | path=str(target), |
| 744 | written=written, |
| 745 | skipped=skipped, |
| 746 | ) |
| 747 | ) |
| 748 | |
| 749 | if json_out: |
| 750 | out = _SyncJson(**make_envelope(elapsed), adapters=results) |
| 751 | print(json.dumps(out)) |
| 752 | else: |
| 753 | for entry in results: |
| 754 | if dry_run: |
| 755 | print(f"[dry-run] would write {entry['path']}") |
| 756 | elif entry["skipped"]: |
| 757 | print(f"✓ already in sync {entry['path']}") |
| 758 | else: |
| 759 | print(f"✅ wrote {entry['path']}") |
| 760 | |
| 761 | # --------------------------------------------------------------------------- |
| 762 | # Subcommand: read |
| 763 | # --------------------------------------------------------------------------- |
| 764 | |
| 765 | def run_read(args: argparse.Namespace) -> None: |
| 766 | """Read the agent.md content for this repo or workspace. |
| 767 | |
| 768 | ``--scope repo`` — repo-level agent.md only (default). |
| 769 | ``--scope workspace`` — workspace-level agent.md only. |
| 770 | ``--scope merged`` — workspace + repo content concatenated. |
| 771 | |
| 772 | Agent quickstart |
| 773 | ---------------- |
| 774 | :: |
| 775 | |
| 776 | muse agent-config read --json |
| 777 | muse agent-config read --scope merged --json |
| 778 | |
| 779 | JSON fields |
| 780 | ----------- |
| 781 | scope Scope used (``"repo"`` | ``"workspace"`` | ``"merged"``). |
| 782 | content Full text of agent.md (or null if file does not exist). |
| 783 | path Absolute path to the agent.md file read. |
| 784 | |
| 785 | Exit codes |
| 786 | ---------- |
| 787 | 0 Success. |
| 788 | 1 No agent.md found for the requested scope. |
| 789 | """ |
| 790 | elapsed = start_timer() |
| 791 | json_out: bool = args.json_out |
| 792 | scope: str = getattr(args, "scope", "repo") or "repo" |
| 793 | |
| 794 | root = _find_operation_root() |
| 795 | kind, ws = _detect_context(root) |
| 796 | |
| 797 | agent_md_path = _agent_md_path(root) |
| 798 | |
| 799 | if scope == "workspace": |
| 800 | if kind == "workspace_root": |
| 801 | target_path = agent_md_path |
| 802 | elif kind == "workspace_member" and ws is not None: |
| 803 | target_path = _agent_md_path(ws) |
| 804 | else: |
| 805 | target_path = agent_md_path |
| 806 | else: |
| 807 | target_path = agent_md_path |
| 808 | |
| 809 | if not target_path.exists(): |
| 810 | msg = f"❌ No agent.md found at {target_path}." |
| 811 | if json_out: |
| 812 | print( |
| 813 | json.dumps( |
| 814 | {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} |
| 815 | ) |
| 816 | ) |
| 817 | else: |
| 818 | print(msg, file=sys.stderr) |
| 819 | raise SystemExit(ExitCode.USER_ERROR) |
| 820 | |
| 821 | if scope == "merged" and kind == "workspace_member" and ws is not None: |
| 822 | ws_path = _agent_md_path(ws) |
| 823 | parts: list[str] = [] |
| 824 | if ws_path.exists(): |
| 825 | parts.append(ws_path.read_text(encoding="utf-8").rstrip()) |
| 826 | parts.append(target_path.read_text(encoding="utf-8").rstrip()) |
| 827 | content = "\n\n".join(parts) + "\n" |
| 828 | display_path = str(target_path) |
| 829 | display_scope = "merged" |
| 830 | else: |
| 831 | content = target_path.read_text(encoding="utf-8") |
| 832 | display_path = str(target_path) |
| 833 | display_scope = scope |
| 834 | |
| 835 | if json_out: |
| 836 | out = _ReadJson(**make_envelope(elapsed), content=content, path=display_path, scope=display_scope) |
| 837 | print(json.dumps(out)) |
| 838 | else: |
| 839 | print(content, end="") |
| 840 | |
| 841 | # --------------------------------------------------------------------------- |
| 842 | # Subcommand: status |
| 843 | # --------------------------------------------------------------------------- |
| 844 | |
| 845 | def run_status(args: argparse.Namespace) -> None: |
| 846 | """Report which adapter files exist and whether they are in sync. |
| 847 | |
| 848 | An adapter is *in sync* when its on-disk content matches what |
| 849 | ``muse agent-config sync`` would generate from the current ``agent.md``. |
| 850 | |
| 851 | Agent quickstart |
| 852 | ---------------- |
| 853 | :: |
| 854 | |
| 855 | muse agent-config status --json |
| 856 | muse agent-config status --json | jq '.adapters[] | select(.in_sync == false)' |
| 857 | |
| 858 | JSON fields |
| 859 | ----------- |
| 860 | agent_md_exists Whether ``.muse/agent.md`` exists. |
| 861 | adapters List of adapter status entries. |
| 862 | |
| 863 | Each adapter entry: ``name``, ``path``, ``exists``, ``in_sync``. |
| 864 | |
| 865 | Exit codes |
| 866 | ---------- |
| 867 | 0 Always. |
| 868 | """ |
| 869 | elapsed = start_timer() |
| 870 | json_out: bool = args.json_out |
| 871 | |
| 872 | root = _find_operation_root() |
| 873 | kind, ws = _detect_context(root) |
| 874 | |
| 875 | agent_md_path = _agent_md_path(root) |
| 876 | agent_md_exists = agent_md_path.exists() |
| 877 | |
| 878 | repo_agent_content: str | None = None |
| 879 | ws_agent_md: str | None = None |
| 880 | ws_agent_content: str | None = None |
| 881 | |
| 882 | if agent_md_exists: |
| 883 | repo_agent_content = agent_md_path.read_text(encoding="utf-8") |
| 884 | |
| 885 | if kind == "workspace_member" and ws is not None: |
| 886 | rel = _compute_rel_path(root, ws) |
| 887 | ws_agent_md = f"{rel}/.muse/agent.md" |
| 888 | ws_path = _agent_md_path(ws) |
| 889 | if ws_path.exists(): |
| 890 | ws_agent_content = ws_path.read_text(encoding="utf-8") |
| 891 | |
| 892 | adapter_statuses: list[StatusAdapterEntry] = [] |
| 893 | for spec in _ADAPTERS.values(): |
| 894 | target = root / spec["filename"] |
| 895 | exists = target.exists() |
| 896 | in_sync = False |
| 897 | if exists and repo_agent_content is not None: |
| 898 | expected = _render_adapter( |
| 899 | spec, |
| 900 | repo_agent_md=".muse/agent.md", |
| 901 | ws_agent_md=ws_agent_md, |
| 902 | repo_agent_content=repo_agent_content, |
| 903 | ws_agent_content=ws_agent_content, |
| 904 | ) |
| 905 | actual = target.read_text(encoding="utf-8") |
| 906 | in_sync = actual == expected |
| 907 | adapter_statuses.append( |
| 908 | StatusAdapterEntry( |
| 909 | name=spec["name"], |
| 910 | filename=spec["filename"], |
| 911 | exists=exists, |
| 912 | in_sync=in_sync, |
| 913 | ) |
| 914 | ) |
| 915 | |
| 916 | any_adapter_exists = any(a["exists"] for a in adapter_statuses) |
| 917 | all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"]) |
| 918 | ready = agent_md_exists and any_adapter_exists and all_existing_in_sync |
| 919 | in_sync_count = sum(1 for a in adapter_statuses if a["in_sync"]) |
| 920 | missing_count = sum(1 for a in adapter_statuses if not a["exists"]) |
| 921 | out_of_sync_count = sum(1 for a in adapter_statuses if a["exists"] and not a["in_sync"]) |
| 922 | |
| 923 | if json_out: |
| 924 | out = _StatusJson( |
| 925 | **make_envelope(elapsed), |
| 926 | agent_md=str(agent_md_path), |
| 927 | agent_md_exists=agent_md_exists, |
| 928 | ready=ready, |
| 929 | in_sync_count=in_sync_count, |
| 930 | missing_count=missing_count, |
| 931 | out_of_sync_count=out_of_sync_count, |
| 932 | adapters=adapter_statuses, |
| 933 | ) |
| 934 | print(json.dumps(out)) |
| 935 | else: |
| 936 | agent_label = "✅" if agent_md_exists else "❌" |
| 937 | print(f"{agent_label} agent.md: {agent_md_path}") |
| 938 | ready_label = "✅ ready" if ready else "⚠️ not ready" |
| 939 | print(f" {ready_label} ({in_sync_count} in sync, {out_of_sync_count} out of sync, {missing_count} missing)") |
| 940 | print() |
| 941 | for entry in adapter_statuses: |
| 942 | if entry["exists"]: |
| 943 | sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync" |
| 944 | else: |
| 945 | sync_label = "❌ missing" |
| 946 | print(f" {entry['filename']:<42} {sync_label}") |
| 947 | |
| 948 | # --------------------------------------------------------------------------- |
| 949 | # Subcommand: inspect |
| 950 | # --------------------------------------------------------------------------- |
| 951 | |
| 952 | def run_inspect(args: argparse.Namespace) -> None: |
| 953 | """Single-call agent bootstrap: full context, merged rules, adapter status. |
| 954 | |
| 955 | Designed for agents entering a new repository. Returns everything needed |
| 956 | to understand the repo context, active rules, and adapter sync state in a |
| 957 | single call — replacing separate ``read``, ``status``, and |
| 958 | context-detection calls. |
| 959 | |
| 960 | Agent quickstart |
| 961 | ---------------- |
| 962 | :: |
| 963 | |
| 964 | muse agent-config inspect --json |
| 965 | muse agent-config inspect --json | jq '{context, ready, agent_md_exists}' |
| 966 | |
| 967 | JSON fields |
| 968 | ----------- |
| 969 | context ``"standalone"`` | ``"workspace_root"`` | ``"workspace_member"``. |
| 970 | workspace_root Absolute path to workspace root, or ``null``. |
| 971 | repo_name Name of the repository directory. |
| 972 | agent_md_exists Whether ``.muse/agent.md`` is present. |
| 973 | merged_content Workspace rules + repo rules concatenated; ``null`` if no agent.md. |
| 974 | adapters List of adapter sync entries (same schema as ``status --json``). |
| 975 | ready ``true`` when agent.md exists, at least one adapter is present, |
| 976 | and all present adapters are in sync. |
| 977 | |
| 978 | Exit codes |
| 979 | ---------- |
| 980 | 0 Always. |
| 981 | """ |
| 982 | elapsed = start_timer() |
| 983 | json_out: bool = args.json_out |
| 984 | |
| 985 | root = _find_operation_root() |
| 986 | kind, ws = _detect_context(root) |
| 987 | |
| 988 | agent_md_path = _agent_md_path(root) |
| 989 | agent_md_exists = agent_md_path.exists() |
| 990 | |
| 991 | repo_agent_content: str | None = None |
| 992 | ws_agent_md: str | None = None |
| 993 | ws_agent_content: str | None = None |
| 994 | |
| 995 | if agent_md_exists: |
| 996 | repo_agent_content = agent_md_path.read_text(encoding="utf-8") |
| 997 | |
| 998 | if kind == "workspace_member" and ws is not None: |
| 999 | rel = _compute_rel_path(root, ws) |
| 1000 | ws_agent_md = f"{rel}/.muse/agent.md" |
| 1001 | ws_path = _agent_md_path(ws) |
| 1002 | if ws_path.exists(): |
| 1003 | ws_agent_content = ws_path.read_text(encoding="utf-8") |
| 1004 | |
| 1005 | # Build merged content (workspace first, repo second). |
| 1006 | merged_content: str | None = None |
| 1007 | if repo_agent_content is not None: |
| 1008 | parts: list[str] = [] |
| 1009 | if ws_agent_content is not None: |
| 1010 | parts.append(ws_agent_content.rstrip()) |
| 1011 | parts.append(repo_agent_content.rstrip()) |
| 1012 | merged_content = "\n\n".join(parts) + "\n" |
| 1013 | |
| 1014 | # Adapter sync statuses. |
| 1015 | adapter_statuses: list[StatusAdapterEntry] = [] |
| 1016 | for spec in _ADAPTERS.values(): |
| 1017 | target = root / spec["filename"] |
| 1018 | exists = target.exists() |
| 1019 | in_sync = False |
| 1020 | if exists and repo_agent_content is not None: |
| 1021 | expected = _render_adapter( |
| 1022 | spec, |
| 1023 | repo_agent_md=".muse/agent.md", |
| 1024 | ws_agent_md=ws_agent_md, |
| 1025 | repo_agent_content=repo_agent_content, |
| 1026 | ws_agent_content=ws_agent_content, |
| 1027 | ) |
| 1028 | in_sync = target.read_text(encoding="utf-8") == expected |
| 1029 | adapter_statuses.append( |
| 1030 | StatusAdapterEntry( |
| 1031 | name=spec["name"], |
| 1032 | filename=spec["filename"], |
| 1033 | exists=exists, |
| 1034 | in_sync=in_sync, |
| 1035 | ) |
| 1036 | ) |
| 1037 | |
| 1038 | any_adapter_exists = any(a["exists"] for a in adapter_statuses) |
| 1039 | all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"]) |
| 1040 | ready = agent_md_exists and any_adapter_exists and all_existing_in_sync |
| 1041 | |
| 1042 | if json_out: |
| 1043 | out = _InspectJson( |
| 1044 | **make_envelope(elapsed), |
| 1045 | context=kind, |
| 1046 | workspace_root=str(ws) if ws else None, |
| 1047 | repo_name=root.name, |
| 1048 | agent_md_exists=agent_md_exists, |
| 1049 | merged_content=merged_content, |
| 1050 | adapters=adapter_statuses, |
| 1051 | ready=ready, |
| 1052 | ) |
| 1053 | print(json.dumps(out)) |
| 1054 | else: |
| 1055 | _context_labels = { |
| 1056 | "standalone": "standalone repo", |
| 1057 | "workspace_root": "workspace root", |
| 1058 | "workspace_member": "workspace member", |
| 1059 | } |
| 1060 | print(f"Context: {_context_labels.get(kind, kind)}") |
| 1061 | if ws: |
| 1062 | print(f"Workspace: {ws}") |
| 1063 | print(f"Repo: {root.name}") |
| 1064 | print(f"agent.md: {'✅ exists' if agent_md_exists else '❌ missing'}") |
| 1065 | ready_label = "✅ ready" if ready else "⚠️ not ready" |
| 1066 | print(f"Status: {ready_label}") |
| 1067 | print() |
| 1068 | for entry in adapter_statuses: |
| 1069 | if entry["exists"]: |
| 1070 | sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync" |
| 1071 | else: |
| 1072 | sync_label = "❌ missing" |
| 1073 | print(f" {entry['filename']:<42} {sync_label}") |
| 1074 | if merged_content: |
| 1075 | print() |
| 1076 | print("─" * 60) |
| 1077 | print(merged_content, end="") |
| 1078 | |
| 1079 | # --------------------------------------------------------------------------- |
| 1080 | # Registration |
| 1081 | # --------------------------------------------------------------------------- |
| 1082 | |
| 1083 | def run_set(args: argparse.Namespace) -> None: |
| 1084 | """Persist adapter preferences for ``muse agent-config sync``. |
| 1085 | |
| 1086 | Without ``--global``, writes to ``<repo>/.muse/config.toml`` (repo scope). |
| 1087 | With ``--global``, writes to ``~/.muse/config.toml`` (user scope — applies |
| 1088 | to every repo on this machine, survives branch switches and merges, exactly |
| 1089 | like ``~/.gitconfig``). |
| 1090 | |
| 1091 | Priority when sync runs: repo config > user config > error. |
| 1092 | |
| 1093 | Quickstart |
| 1094 | ---------- |
| 1095 | :: |
| 1096 | |
| 1097 | # Set once globally — never have to think about it again: |
| 1098 | muse agent-config set --global --adapters claude |
| 1099 | |
| 1100 | # Set per-repo (overrides global for this repo only): |
| 1101 | muse agent-config set --adapters claude,codex |
| 1102 | |
| 1103 | JSON fields |
| 1104 | ----------- |
| 1105 | adapters List of adapter names now configured. |
| 1106 | path Config file that was written. |
| 1107 | |
| 1108 | Exit codes |
| 1109 | ---------- |
| 1110 | 0 Settings saved. |
| 1111 | 1 Unknown adapter name. |
| 1112 | """ |
| 1113 | elapsed = start_timer() |
| 1114 | json_out: bool = args.json_out |
| 1115 | adapters_raw: str = args.adapters |
| 1116 | global_flag: bool = getattr(args, "global_", False) |
| 1117 | requested = [a.strip() for a in adapters_raw.split(",") if a.strip()] |
| 1118 | |
| 1119 | unknown = [a for a in requested if a not in _ADAPTERS] |
| 1120 | if unknown: |
| 1121 | msg = ( |
| 1122 | f"❌ Unknown adapter(s): {', '.join(unknown)}. " |
| 1123 | f"Available: {', '.join(_ADAPTERS)}" |
| 1124 | ) |
| 1125 | if json_out: |
| 1126 | print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR})) |
| 1127 | else: |
| 1128 | print(msg, file=sys.stderr) |
| 1129 | raise SystemExit(ExitCode.USER_ERROR) |
| 1130 | |
| 1131 | if global_flag: |
| 1132 | user_dir = _user_muse_dir() |
| 1133 | user_dir.mkdir(parents=True, exist_ok=True) |
| 1134 | config_path = user_dir / "config.toml" |
| 1135 | else: |
| 1136 | root = _find_operation_root() |
| 1137 | config_path = _config_toml_path(root) |
| 1138 | |
| 1139 | # Read existing config, update [agent-config] section, write back. |
| 1140 | existing_text = config_path.read_text(encoding="utf-8") if config_path.is_file() else "" |
| 1141 | |
| 1142 | # Build the new [agent-config] block. |
| 1143 | adapters_toml = ", ".join(f'"{a}"' for a in requested) |
| 1144 | new_block = f'[agent-config]\nadapters = [{adapters_toml}]\n' |
| 1145 | |
| 1146 | if "[agent-config]" in existing_text: |
| 1147 | # Replace the existing [agent-config] section up to the next line-initial |
| 1148 | # '[' (next section header) or end of string. Must NOT use [^\[]* because |
| 1149 | # that stops at '[' inside values like adapters = ["claude"]. |
| 1150 | existing_text = re.sub( |
| 1151 | r"^\[agent-config\].*?(?=^\[|\Z)", |
| 1152 | new_block, |
| 1153 | existing_text, |
| 1154 | count=1, |
| 1155 | flags=re.DOTALL | re.MULTILINE, |
| 1156 | ) |
| 1157 | else: |
| 1158 | existing_text = existing_text.rstrip("\n") + ("\n\n" if existing_text else "") + new_block |
| 1159 | |
| 1160 | write_text_atomic(config_path, existing_text) |
| 1161 | |
| 1162 | if json_out: |
| 1163 | out = _SetJson(**make_envelope(elapsed), adapters=requested, path=str(config_path)) |
| 1164 | print(json.dumps(out)) |
| 1165 | else: |
| 1166 | scope = "global (~/.muse)" if global_flag else "repo (.muse)" |
| 1167 | print(f"✅ Saved to {config_path} [{scope}]") |
| 1168 | print(f" adapters = {requested}") |
| 1169 | print(" Run `muse agent-config sync --force` to regenerate adapter files.") |
| 1170 | |
| 1171 | def register( |
| 1172 | subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", |
| 1173 | ) -> None: |
| 1174 | """Register the ``muse agent-config`` subcommand tree and all its flags.""" |
| 1175 | parser = subparsers.add_parser( |
| 1176 | "agent-config", |
| 1177 | help="Manage agent configuration files (CLAUDE.md, AGENTS.md, etc.).", |
| 1178 | description=__doc__, |
| 1179 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1180 | ) |
| 1181 | |
| 1182 | subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") |
| 1183 | |
| 1184 | # ── init ────────────────────────────────────────────────────────── |
| 1185 | init_p = subs.add_parser( |
| 1186 | "init", |
| 1187 | help="Create .muse/agent.md with sane defaults.", |
| 1188 | description=( |
| 1189 | "Create ``.muse/agent.md`` with appropriate default rules for this\n" |
| 1190 | "repo or workspace. Detects standalone, workspace_root, and\n" |
| 1191 | "workspace_member contexts automatically.\n\n" |
| 1192 | "Use ``muse agent-config sync`` afterwards to generate IDE adapter files." |
| 1193 | ), |
| 1194 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1195 | ) |
| 1196 | init_p.add_argument( |
| 1197 | "--force", "-f", action="store_true", |
| 1198 | help="Overwrite .muse/agent.md if it already exists.", |
| 1199 | ) |
| 1200 | init_p.add_argument( |
| 1201 | "--json", "-j", action="store_true", dest="json_out", |
| 1202 | help="Emit machine-readable JSON on stdout.", |
| 1203 | ) |
| 1204 | init_p.set_defaults(func=run_init) |
| 1205 | |
| 1206 | # ── sync ────────────────────────────────────────────────────────── |
| 1207 | sync_p = subs.add_parser( |
| 1208 | "sync", |
| 1209 | help="Generate IDE adapter files from .muse/agent.md.", |
| 1210 | description=( |
| 1211 | "Generate IDE/agent adapter files (CLAUDE.md, AGENTS.md, .cursorrules,\n" |
| 1212 | ".github/copilot-instructions.md, .windsurfrules) from ``.muse/agent.md``.\n\n" |
| 1213 | "Claude's CLAUDE.md uses ``@path`` include syntax and is always minimal.\n" |
| 1214 | "All other adapters embed the full content so each tool sees it directly.\n\n" |
| 1215 | "Inside a workspace, workspace-level rules are automatically prepended." |
| 1216 | ), |
| 1217 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1218 | ) |
| 1219 | sync_p.add_argument( |
| 1220 | "--adapters", default=None, metavar="NAME,...", |
| 1221 | help=( |
| 1222 | "Comma-separated list of adapters to generate (one-shot override). " |
| 1223 | "If omitted, reads from repo or user config set via " |
| 1224 | "`muse agent-config set --adapters`. " |
| 1225 | f"Available: {', '.join(_ADAPTERS)}" |
| 1226 | ), |
| 1227 | ) |
| 1228 | sync_p.add_argument( |
| 1229 | "--dry-run", "-n", action="store_true", dest="dry_run", |
| 1230 | help="Print what would be written without creating any files.", |
| 1231 | ) |
| 1232 | sync_p.add_argument( |
| 1233 | "--force", "-f", action="store_true", |
| 1234 | help="Write all adapters even if already in sync (default: skip in-sync files).", |
| 1235 | ) |
| 1236 | sync_p.add_argument( |
| 1237 | "--json", "-j", action="store_true", dest="json_out", |
| 1238 | help="Emit machine-readable JSON on stdout.", |
| 1239 | ) |
| 1240 | sync_p.set_defaults(func=run_sync) |
| 1241 | |
| 1242 | # ── read ────────────────────────────────────────────────────────── |
| 1243 | read_p = subs.add_parser( |
| 1244 | "read", |
| 1245 | help="Read the agent.md content.", |
| 1246 | description=( |
| 1247 | "Read the ``.muse/agent.md`` content for this repo or workspace.\n\n" |
| 1248 | "Use ``--scope merged`` inside a workspace member to read both\n" |
| 1249 | "workspace and repo rules concatenated." |
| 1250 | ), |
| 1251 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1252 | ) |
| 1253 | read_p.add_argument( |
| 1254 | "--scope", default="repo", |
| 1255 | choices=["repo", "workspace", "merged"], |
| 1256 | help="Which config to read: repo (default), workspace, or merged.", |
| 1257 | ) |
| 1258 | read_p.add_argument( |
| 1259 | "--json", "-j", action="store_true", dest="json_out", |
| 1260 | help="Emit machine-readable JSON on stdout.", |
| 1261 | ) |
| 1262 | read_p.set_defaults(func=run_read) |
| 1263 | |
| 1264 | # ── status ──────────────────────────────────────────────────────── |
| 1265 | status_p = subs.add_parser( |
| 1266 | "status", |
| 1267 | help="Show which adapter files exist and whether they are in sync.", |
| 1268 | description=( |
| 1269 | "Report the sync state of all IDE adapter files.\n\n" |
| 1270 | "An adapter is *in sync* when its content matches what\n" |
| 1271 | "``muse agent-config sync`` would generate from the current ``agent.md``." |
| 1272 | ), |
| 1273 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1274 | ) |
| 1275 | status_p.add_argument( |
| 1276 | "--json", "-j", action="store_true", dest="json_out", |
| 1277 | help="Emit machine-readable JSON on stdout.", |
| 1278 | ) |
| 1279 | status_p.set_defaults(func=run_status) |
| 1280 | |
| 1281 | # ── inspect ─────────────────────────────────────────────────────── |
| 1282 | inspect_p = subs.add_parser( |
| 1283 | "inspect", |
| 1284 | help="Single-call agent bootstrap: context, merged rules, adapter status.", |
| 1285 | description=( |
| 1286 | "Return everything an agent needs when entering a repository: context\n" |
| 1287 | "classification, merged agent rules, and adapter sync state — all in\n" |
| 1288 | "one call.\n\n" |
| 1289 | "JSON output includes:\n" |
| 1290 | " context — standalone / workspace_root / workspace_member\n" |
| 1291 | " workspace_root — path to workspace, or null\n" |
| 1292 | " repo_name — name of this repository\n" |
| 1293 | " agent_md_exists — whether .muse/agent.md is present\n" |
| 1294 | " merged_content — workspace + repo rules concatenated\n" |
| 1295 | " adapters — list of adapter sync entries\n" |
| 1296 | " ready — true when agent.md exists and adapters are in sync\n\n" |
| 1297 | "Use ``--json`` for machine-readable output (recommended for agents)." |
| 1298 | ), |
| 1299 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1300 | ) |
| 1301 | inspect_p.add_argument( |
| 1302 | "--json", "-j", action="store_true", dest="json_out", |
| 1303 | help="Emit machine-readable JSON on stdout.", |
| 1304 | ) |
| 1305 | inspect_p.set_defaults(func=run_inspect) |
| 1306 | |
| 1307 | # ── set ─────────────────────────────────────────────────────────── |
| 1308 | set_p = subs.add_parser( |
| 1309 | "set", |
| 1310 | help="Persist adapter preferences (repo or global).", |
| 1311 | description=( |
| 1312 | "Persist which adapters ``muse agent-config sync`` will generate.\n\n" |
| 1313 | "Without --global: writes to <repo>/.muse/config.toml.\n" |
| 1314 | "With --global: writes to ~/.muse/config.toml — applies to every\n" |
| 1315 | " repo on this machine, survives branch switches and\n" |
| 1316 | " merges (like ~/.gitconfig). Do this once.\n\n" |
| 1317 | f"Available adapters: {', '.join(_ADAPTERS)}\n\n" |
| 1318 | "Examples::\n\n" |
| 1319 | " # Set once globally — never touch it again:\n" |
| 1320 | " muse agent-config set --global --adapters claude\n\n" |
| 1321 | " # Override for one repo only:\n" |
| 1322 | " muse agent-config set --adapters claude,codex\n" |
| 1323 | ), |
| 1324 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 1325 | ) |
| 1326 | set_p.add_argument( |
| 1327 | "--adapters", required=True, metavar="NAME,...", |
| 1328 | help=( |
| 1329 | f"Comma-separated list of adapters to generate on sync. " |
| 1330 | f"Available: {', '.join(_ADAPTERS)}" |
| 1331 | ), |
| 1332 | ) |
| 1333 | set_p.add_argument( |
| 1334 | "--global", action="store_true", dest="global_", |
| 1335 | help=( |
| 1336 | "Write to ~/.muse/config.toml (user-level) instead of the repo config. " |
| 1337 | "Applies to every repo — survives branch switches and merges." |
| 1338 | ), |
| 1339 | ) |
| 1340 | set_p.add_argument( |
| 1341 | "--json", "-j", action="store_true", dest="json_out", |
| 1342 | help="Emit machine-readable JSON on stdout.", |
| 1343 | ) |
| 1344 | set_p.set_defaults(func=run_set) |
| 1345 | |
| 1346 | parser.set_defaults(func=_show_help(parser)) |
| 1347 | |
| 1348 | def _show_help( |
| 1349 | parser: argparse.ArgumentParser, |
| 1350 | ) -> Callable[[argparse.Namespace], None]: |
| 1351 | """Return a callable that prints help and exits.""" |
| 1352 | |
| 1353 | def _help(args: argparse.Namespace) -> None: # noqa: ARG001 |
| 1354 | parser.print_help() |
| 1355 | raise SystemExit(0) |
| 1356 | |
| 1357 | return _help |
| 1358 | |
| 1359 | def run(args: argparse.Namespace) -> None: |
| 1360 | """Dispatch to the correct subcommand handler.""" |
| 1361 | func = getattr(args, "func", None) |
| 1362 | if func is None: |
| 1363 | # No subcommand given — print help |
| 1364 | raise SystemExit(0) |
| 1365 | func(args) |
File History
3 commits
sha256:b5ec4e4a3a73cae0cd08224f32090f2a4836afa0a804cb3231e70c42a3e89295
fix adapter for agent config
Human
patch
10 days ago
sha256:d4fa4f8740ad2b921479054735d7ab932ef361718ec4bb92d32383ac59a693ea
fix adapter
Human
patch
10 days ago