"""``muse agent-config`` — manage per-repo and workspace agent configuration. Generates and syncs the canonical ``.muse/agent.md`` file and IDE-specific adapter files so every AI tool gets consistent, up-to-date rules without duplication. Architecture ------------ There is one **canonical source** per level: - ``/.muse/agent.md`` — repo-specific rules. - ``/.muse/agent.md`` — shared workspace rules (if inside a workspace). IDE adapter files (CLAUDE.md, AGENTS.md, .cursorrules, etc.) are **derived outputs** — regenerate them any time with ``muse agent-config sync``. Context modes:: standalone — repo with no parent workspace workspace_root — directory with .muse/workspace.toml (shared rules only) workspace_member — repo nested inside a workspace; inherits workspace rules Adapter styles:: include (Claude) — ``@.muse/agent.md`` reference; Claude resolves at read time embed (others) — full content inlined so the tool sees it immediately Subcommands:: muse agent-config init [--force] [--json] Create .muse/agent.md with sane defaults for this repo or workspace. muse agent-config set --adapters NAME,... [--global] [--json] Persist which adapters sync will generate. Use ``--global`` to write to ``~/.muse/config.toml`` so the setting applies to every repo on this machine and survives branch switches and merges (like ~/.gitconfig). Without ``--global`` the setting is saved to the repo's .muse/config.toml. sync will error if neither source has an [agent-config] section. muse agent-config sync [--adapters NAME,...] [--dry-run] [--force] [--json] Generate IDE adapter files from .muse/agent.md. Requires an adapter list configured via ``muse agent-config set`` (repo or global level). Pass ``--adapters`` to override for a single invocation. muse agent-config read [--scope repo|workspace|merged] [--json] Print the agent.md content. muse agent-config status [--json] Show which adapter files exist and whether they are in sync. muse agent-config inspect [--json] Single-call bootstrap: context, merged rules, adapter status, ready flag. Exit codes:: 0 — success 1 — user error (file exists without --force, missing agent.md, no adapters configured, etc.) """ import argparse import json import logging import os import pathlib import re import sys from collections.abc import Callable from typing import TypedDict from muse.core.envelope import EnvelopeJson, make_envelope 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 from muse.core.errors import ExitCode from muse.core.io import write_text_atomic from muse.core.timing import start_timer from muse.core.workspace import ( WorkspaceMemberDict, WorkspaceManifestDict, find_workspace_root, ) logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Adapter registry # --------------------------------------------------------------------------- class AdapterSpec(TypedDict): """Specification for one IDE/agent adapter file. ``name`` — short identifier (e.g. ``"claude"``) ``filename`` — path relative to repo root (e.g. ``"CLAUDE.md"``) ``style`` — ``"include"`` uses ``@path`` reference syntax; ``"embed"`` inlines the full content """ name: str filename: str style: str class SyncAdapterResult(TypedDict): """One entry in the ``muse agent-config sync --json`` output.""" name: str path: str written: bool skipped: bool class StatusAdapterEntry(TypedDict): """One entry in the ``muse agent-config status --json`` output.""" name: str filename: str exists: bool in_sync: bool class InspectResult(TypedDict): """Output of ``muse agent-config inspect --json``. Single-call bootstrap payload for agents entering a new repository. Contains everything needed to understand the repo context, active rules, and adapter sync state without making multiple separate calls. """ context: str # "standalone" | "workspace_root" | "workspace_member" workspace_root: str | None repo_name: str agent_md_exists: bool merged_content: str | None # workspace rules + repo rules concatenated adapters: list[StatusAdapterEntry] ready: bool # agent_md_exists AND at least one adapter exists AND all existing in sync class _InitJson(EnvelopeJson): """JSON envelope for ``muse agent-config init --json``.""" path: str # absolute path to agent.md scope: str # "standalone" | "workspace_root" | "workspace_member" created: bool class _SyncJson(EnvelopeJson): """JSON envelope for ``muse agent-config sync --json``.""" adapters: list[SyncAdapterResult] class _ReadJson(EnvelopeJson): """JSON envelope for ``muse agent-config read --json``.""" content: str # agent.md content path: str # absolute path read scope: str # "repo" | "workspace" | "merged" class _StatusJson(EnvelopeJson): """JSON envelope for ``muse agent-config status --json``.""" agent_md: str # absolute path to agent.md agent_md_exists: bool ready: bool # true when agent.md + adapters are in sync in_sync_count: int missing_count: int out_of_sync_count: int adapters: list[StatusAdapterEntry] class _InspectJson(EnvelopeJson): """JSON envelope for ``muse agent-config inspect --json``.""" context: str # "standalone" | "workspace_root" | "workspace_member" workspace_root: str | None repo_name: str agent_md_exists: bool merged_content: str | None adapters: list[StatusAdapterEntry] ready: bool class _SetJson(EnvelopeJson): """JSON envelope for ``muse agent-config set --json``.""" adapters: list[str] path: str # absolute path to config.toml written AdapterRegistry = dict[str, AdapterSpec] _ADAPTERS: AdapterRegistry = { "claude": AdapterSpec(name="claude", filename="CLAUDE.md", style="include"), "codex": AdapterSpec(name="codex", filename="AGENTS.md", style="embed"), "cursor": AdapterSpec(name="cursor", filename=".cursorrules", style="embed"), "windsurf": AdapterSpec(name="windsurf", filename=".windsurfrules", style="embed"), } # --------------------------------------------------------------------------- # Templates # --------------------------------------------------------------------------- _STANDALONE_TEMPLATE = """\ # {repo_name} — Agent Configuration This file is the canonical agent context for the **{repo_name}** repository. It is managed by `muse agent-config` — regenerate adapter files at any time with `muse agent-config sync`. --- ## Version Control This repository uses **Muse** for version control — not git, not GitHub. - Never run `git`, `gh`, or any git subcommand. - Never reference GitHub, GitHub Actions, or GitHub URLs. - All VCS operations use `muse` commands. If you find a `git` or `gh` call anywhere — delete it in the same commit. --- ## Branch Flow Always work on a feature branch — never commit directly to `main` or `dev`. ```bash muse checkout dev muse checkout -b task/my-thing # start work muse code add . muse commit -m "feat: ..." muse checkout dev muse merge task/my-thing muse branch -d task/my-thing muse push local dev ``` --- ## Code Intelligence Use `muse code` commands for navigation — never raw grep or file reads. | Task | Command | |------|---------| | Find symbol declaration | `muse code grep "Name" --json` | | Read one symbol | `muse code cat "file.py::Symbol" --json` | | File structure | `muse code symbols --file file.py --json` | | Blast radius | `muse code impact "file.py::Symbol" --json` | | Dependencies | `muse code deps "file.py" --json` | | Tests for changed code | `muse code test --json` | --- ## Testing Rules **Never run the full test suite.** It is slow; only the owner runs it when ready. - Use `muse code test --json` first — it runs only the tests relevant to changed files. - When fixing a specific failure, run only that file: `python3 -m pytest tests/test_foo.py -q --tb=short` - When verifying a single fix, run only that test by name: `python3 -m pytest tests/test_foo.py::test_bar -q --tb=short` - Never run `python3 -m pytest tests/` or any whole-suite invocation. --- ## Status Always verify a clean state before switching branches: ```bash muse status --json # must show "clean": true before muse checkout ``` --- ## HARD RULE — No Destructive Actions Without Explicit Permission Under no circumstances take any destructive or irreversible action without the owner's express permission in that conversation. This includes but is not limited to: - `muse merge --abort` — wipes all uncommitted working tree changes - `muse reset --hard` — discards commits and working tree changes - `muse branch -D` — force-deletes branches - `muse rm` / `muse rm --force` — deletes tracked files - `muse checkout --force` / `muse checkout --ours` / `muse checkout --theirs` - Any `--force` flag on any muse command - Deleting, overwriting, or resetting any file or object store entry - `muse code migrate` — rewrites object store in place If you encounter a conflict, stale merge state, dirty working tree, or any other unexpected state — STOP and ask the owner what to do. Do not resolve it yourself. Losing uncommitted work is catastrophic and unrecoverable. """ _WORKSPACE_ROOT_TEMPLATE = """\ # Workspace — Shared Agent Configuration This file contains shared rules for all repositories in this workspace. Each member repository may have its own ``.muse/agent.md`` with repo-specific additions. Managed by `muse agent-config` — regenerate adapters with `muse agent-config sync`. --- ## Workspace Members {members_table} --- ## Version Control This workspace uses **Muse** for version control — not git, not GitHub. - Never run `git`, `gh`, or any git subcommand. - Never reference GitHub, GitHub Actions, or GitHub URLs. - Use `muse -C ~/path/to/repo ` when CWD differs from the target repo. If you find a `git` or `gh` call anywhere — delete it in the same commit. --- ## Branch Flow Always work on a feature branch — never commit directly to `main` or `dev`. ```bash muse -C ~/path/to/repo checkout dev muse -C ~/path/to/repo checkout -b task/my-thing muse code add . muse commit -m "feat: ..." muse -C ~/path/to/repo checkout dev muse -C ~/path/to/repo merge task/my-thing muse -C ~/path/to/repo branch -d task/my-thing muse -C ~/path/to/repo push local dev ``` --- ## Code Intelligence | Task | Command | |------|---------| | Find symbol declaration | `muse code grep "Name" --json` | | Read one symbol | `muse code cat "file.py::Symbol" --json` | | File structure | `muse code symbols --file file.py --json` | | Blast radius | `muse code impact "file.py::Symbol" --json` | | Dependencies | `muse code deps "file.py" --json` | --- ## HARD RULE — No Destructive Actions Without Explicit Permission Under no circumstances take any destructive or irreversible action without the owner's express permission in that conversation. This includes but is not limited to: - `muse merge --abort` — wipes all uncommitted working tree changes - `muse reset --hard` — discards commits and working tree changes - `muse branch -D` — force-deletes branches - `muse rm` / `muse rm --force` — deletes tracked files - `muse checkout --force` / `muse checkout --ours` / `muse checkout --theirs` - Any `--force` flag on any muse command - Deleting, overwriting, or resetting any file or object store entry - `muse code migrate` — rewrites object store in place If you encounter a conflict, stale merge state, dirty working tree, or any other unexpected state — STOP and ask the owner what to do. Do not resolve it yourself. Losing uncommitted work is catastrophic and unrecoverable. """ _WORKSPACE_MEMBER_TEMPLATE = """\ # {repo_name} — Agent Configuration This repository is a member of a workspace. Shared workspace rules live in the parent ``.muse/agent.md``. This file contains only {repo_name}-specific additions. Managed by `muse agent-config` — regenerate adapters with `muse agent-config sync`. --- ## Repo-Specific Notes Add {repo_name}-specific agent rules below this line. """ # --------------------------------------------------------------------------- # Core helpers # --------------------------------------------------------------------------- def _detect_context(root: pathlib.Path) -> tuple[str, pathlib.Path | None]: """Classify *root* as ``standalone``, ``workspace_root``, or ``workspace_member``. Returns a ``(kind, workspace_root)`` pair. ``workspace_root`` is ``None`` for standalone repos and the workspace directory for members. Detection rules --------------- 1. If ``root/.muse/workspace.toml`` exists → ``workspace_root``. 2. If a ``workspace.toml`` exists in any parent → ``workspace_member``. 3. Otherwise → ``standalone``. """ if (_workspace_toml_path(root)).exists(): return "workspace_root", root ws = find_workspace_root(root) if ws is not None and ws != root: return "workspace_member", ws return "standalone", None def _compute_rel_path(repo: pathlib.Path, ws: pathlib.Path) -> str: """Return the relative path from *repo* to *ws*. Examples:: _compute_rel_path(ws / "core", ws) → ".." _compute_rel_path(ws / "packages" / "foo", ws) → "../.." _compute_rel_path(ws, ws) → "." """ return str(pathlib.Path(os.path.relpath(ws, repo))) def _render_adapter( spec: AdapterSpec, repo_agent_md: str, ws_agent_md: str | None, repo_agent_content: str | None = None, ws_agent_content: str | None = None, ) -> str: """Render the content for one IDE adapter file. Include-style adapters (Claude) use ``@path`` reference syntax so Claude resolves the content at read time. Embed-style adapters (all others) inline the full content so the tool sees it without following references. When *ws_agent_md* / *ws_agent_content* are provided, workspace-level rules are prepended so they take precedence over repo-level rules. """ if spec["style"] == "include": lines: list[str] = [] if ws_agent_md is not None: lines.append(f"@{ws_agent_md}") lines.append(f"@{repo_agent_md}") return "\n".join(lines) + "\n" else: parts: list[str] = [] if ws_agent_content is not None: parts.append(ws_agent_content.rstrip()) if repo_agent_content is not None: parts.append(repo_agent_content.rstrip()) return "\n\n".join(parts) + "\n" if parts else "" def _load_workspace_manifest(ws_root: pathlib.Path) -> WorkspaceManifestDict | None: """Load the workspace manifest from *ws_root*/.muse/workspace.toml.""" try: import tomllib path = _workspace_toml_path(ws_root) if not path.exists(): return None raw = tomllib.loads(path.read_text(encoding="utf-8")) members: list[WorkspaceMemberDict] = [] for m in raw.get("members", []): if isinstance(m, dict): members.append( WorkspaceMemberDict( name=str(m.get("name", "")), url=str(m.get("url", "")), path=str(m.get("path", "")), branch=str(m.get("branch", "main")), ) ) return WorkspaceManifestDict(members=members) except Exception as exc: logger.warning("Could not load workspace manifest: %s", exc) return None def _user_muse_dir() -> pathlib.Path: """Return the user-level muse config directory. Defaults to ``~/.muse``. Override with ``MUSE_USER_CONFIG_DIR`` for testing or CI environments — the same pattern git uses with ``$HOME``. """ override = os.environ.get("MUSE_USER_CONFIG_DIR") if override: return pathlib.Path(override) return pathlib.Path.home() / ".muse" def _load_configured_adapters(root: pathlib.Path) -> list[str] | None: """Read ``[agent-config] adapters`` from config. Two sources, repo wins: 1. ``/.muse/config.toml`` — repo-level (takes priority) 2. ``~/.muse/config.toml`` — user-level fallback (like ~/.gitconfig) Set ``MUSE_USER_CONFIG_DIR`` to override the user-config directory in tests. Returns the list of adapter names if configured in either source, or ``None`` if absent from both. """ import tomllib def _read(path: pathlib.Path) -> list[str] | None: if not path.is_file(): return None try: raw = tomllib.loads(path.read_text(encoding="utf-8")) section = raw.get("agent-config", {}) adapters = section.get("adapters") if isinstance(adapters, list) and all(isinstance(a, str) for a in adapters): return [str(a) for a in adapters] except Exception as exc: logger.warning("Could not read [agent-config] from %s: %s", path, exc) return None # Repo-level takes priority over user-level repo_result = _read(_config_toml_path(root)) if repo_result is not None: return repo_result return _read(_user_muse_dir() / "config.toml") def _build_members_table(manifest: WorkspaceManifestDict) -> str: """Render a Markdown table of workspace members.""" lines = ["| Repo | Path | Branch |", "|------|------|--------|"] for m in manifest.get("members", []): lines.append(f"| **{m['name']}** | `{m['path']}` | `{m['branch']}` |") return "\n".join(lines) def _find_operation_root() -> pathlib.Path: """Return the directory that agent-config should operate on. For workspace roots (``cwd/.muse/workspace.toml`` exists) the CWD is returned directly — there is no repo to require. For everything else, ``require_repo()`` is called so a clear error is shown if the CWD is not inside a Muse repository. """ from muse.core.repo import require_repo cwd = pathlib.Path.cwd() if (_workspace_toml_path(cwd)).exists(): return cwd return require_repo() # --------------------------------------------------------------------------- # Subcommand: init # --------------------------------------------------------------------------- def run_init(args: argparse.Namespace) -> None: """Create ``.muse/agent.md`` with sane defaults. Detects whether the current directory is a standalone repo, a workspace root, or a workspace member and generates the appropriate template. Agent quickstart ---------------- :: muse agent-config init --json muse agent-config init --force --json # overwrite existing Exit codes ---------- 0 Created (or already exists when ``--force`` not given). 1 File exists without ``--force``. """ elapsed = start_timer() json_out: bool = args.json_out force: bool = args.force root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = _agent_md_path(root) if agent_md_path.exists() and not force: msg = ( f"❌ {agent_md_path} already exists.\n" " Use --force to overwrite it." ) if json_out: print( json.dumps( {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} ) ) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if kind == "workspace_root": manifest = _load_workspace_manifest(root) if manifest and manifest["members"]: members_table = _build_members_table(manifest) else: members_table = "_No members registered yet._" content = _WORKSPACE_ROOT_TEMPLATE.format(members_table=members_table) elif kind == "workspace_member": content = _WORKSPACE_MEMBER_TEMPLATE.format(repo_name=root.name) else: content = _STANDALONE_TEMPLATE.format(repo_name=root.name) write_text_atomic(agent_md_path, content) created = True if json_out: out = _InitJson(**make_envelope(elapsed), path=str(agent_md_path), scope=kind, created=created) print(json.dumps(out)) else: print(f"✅ Created {agent_md_path} (scope: {kind})") # --------------------------------------------------------------------------- # Subcommand: sync # --------------------------------------------------------------------------- def run_sync(args: argparse.Namespace) -> None: """Generate IDE adapter files from ``.muse/agent.md``. Reads the canonical ``.muse/agent.md`` (and the workspace-level one if inside a workspace) and writes adapter files for each configured IDE/agent tool. Adapter selection — three sources in priority order: 1. ``--adapters`` CLI flag (one-shot override) 2. ``[agent-config] adapters`` in ``/.muse/config.toml`` 3. ``[agent-config] adapters`` in ``~/.muse/config.toml`` (user-global) If none of the above are configured, sync exits with an error. Use ``muse agent-config set --adapters claude`` (or ``--global``) to configure. Agent quickstart ---------------- :: muse agent-config set --global --adapters claude # once, for all repos muse agent-config sync --json muse agent-config sync --adapters claude,codex --json # one-shot override muse agent-config sync --dry-run --json # preview without writing Exit codes ---------- 0 Adapters generated or already in sync. 1 No agent.md found, or no adapter configured. """ elapsed = start_timer() json_out: bool = args.json_out force: bool = args.force dry_run: bool = args.dry_run adapters_filter: list[str] | None = ( [a.strip() for a in args.adapters.split(",")] if args.adapters else None ) root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = _agent_md_path(root) if not agent_md_path.exists(): msg = ( f"❌ No agent.md found at {agent_md_path}.\n" " Run `muse agent-config init` first." ) if json_out: print( json.dumps( {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} ) ) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) repo_agent_content = agent_md_path.read_text(encoding="utf-8") ws_agent_md: str | None = None ws_agent_content: str | None = None if kind == "workspace_member" and ws is not None: rel = _compute_rel_path(root, ws) ws_agent_md = f"{rel}/.muse/agent.md" ws_path = _agent_md_path(ws) if ws_path.exists(): ws_agent_content = ws_path.read_text(encoding="utf-8") repo_agent_md = ".muse/agent.md" # Resolve which adapters to generate. Priority (highest → lowest): # 1. --adapters flag (CLI override) # 2. [agent-config] adapters in .muse/config.toml (persistent preference) # There is no "all adapters" default — if neither is set, sync exits with an # error directing the user to run `muse agent-config set --adapters `. effective_filter: list[str] | None = adapters_filter if effective_filter is None: effective_filter = _load_configured_adapters(root) if effective_filter is None: msg = ( "❌ No adapter configured.\n" " Run `muse agent-config set --adapters ` to configure which\n" f" adapters to generate (available: {', '.join(_ADAPTERS)}).\n" " Example: muse agent-config set --adapters claude" ) if json_out: print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR})) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) selected_adapters = { k: v for k, v in _ADAPTERS.items() if k in effective_filter } results: list[SyncAdapterResult] = [] for spec in selected_adapters.values(): target = root / spec["filename"] rendered = _render_adapter( spec, repo_agent_md=repo_agent_md, ws_agent_md=ws_agent_md, repo_agent_content=repo_agent_content, ws_agent_content=ws_agent_content, ) # Smart skip: if the file already contains the exact content we would # write and --force is not set, leave it untouched. This makes sync # idempotent and agent-friendly — no --force required after every edit. already_in_sync = ( not force and not dry_run and target.exists() and target.read_text(encoding="utf-8") == rendered ) written = False skipped = False if already_in_sync: skipped = True elif not dry_run: target.parent.mkdir(parents=True, exist_ok=True) write_text_atomic(target, rendered) written = True results.append( SyncAdapterResult( name=spec["name"], path=str(target), written=written, skipped=skipped, ) ) if json_out: out = _SyncJson(**make_envelope(elapsed), adapters=results) print(json.dumps(out)) else: for entry in results: if dry_run: print(f"[dry-run] would write {entry['path']}") elif entry["skipped"]: print(f"✓ already in sync {entry['path']}") else: print(f"✅ wrote {entry['path']}") # --------------------------------------------------------------------------- # Subcommand: read # --------------------------------------------------------------------------- def run_read(args: argparse.Namespace) -> None: """Read the agent.md content for this repo or workspace. ``--scope repo`` — repo-level agent.md only (default). ``--scope workspace`` — workspace-level agent.md only. ``--scope merged`` — workspace + repo content concatenated. Agent quickstart ---------------- :: muse agent-config read --json muse agent-config read --scope merged --json JSON fields ----------- scope Scope used (``"repo"`` | ``"workspace"`` | ``"merged"``). content Full text of agent.md (or null if file does not exist). path Absolute path to the agent.md file read. Exit codes ---------- 0 Success. 1 No agent.md found for the requested scope. """ elapsed = start_timer() json_out: bool = args.json_out scope: str = getattr(args, "scope", "repo") or "repo" root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = _agent_md_path(root) if scope == "workspace": if kind == "workspace_root": target_path = agent_md_path elif kind == "workspace_member" and ws is not None: target_path = _agent_md_path(ws) else: target_path = agent_md_path else: target_path = agent_md_path if not target_path.exists(): msg = f"❌ No agent.md found at {target_path}." if json_out: print( json.dumps( {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR} ) ) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if scope == "merged" and kind == "workspace_member" and ws is not None: ws_path = _agent_md_path(ws) parts: list[str] = [] if ws_path.exists(): parts.append(ws_path.read_text(encoding="utf-8").rstrip()) parts.append(target_path.read_text(encoding="utf-8").rstrip()) content = "\n\n".join(parts) + "\n" display_path = str(target_path) display_scope = "merged" else: content = target_path.read_text(encoding="utf-8") display_path = str(target_path) display_scope = scope if json_out: out = _ReadJson(**make_envelope(elapsed), content=content, path=display_path, scope=display_scope) print(json.dumps(out)) else: print(content, end="") # --------------------------------------------------------------------------- # Subcommand: status # --------------------------------------------------------------------------- def run_status(args: argparse.Namespace) -> None: """Report which adapter files exist and whether they are in sync. An adapter is *in sync* when its on-disk content matches what ``muse agent-config sync`` would generate from the current ``agent.md``. Agent quickstart ---------------- :: muse agent-config status --json muse agent-config status --json | jq '.adapters[] | select(.in_sync == false)' JSON fields ----------- agent_md_exists Whether ``.muse/agent.md`` exists. adapters List of adapter status entries. Each adapter entry: ``name``, ``path``, ``exists``, ``in_sync``. Exit codes ---------- 0 Always. """ elapsed = start_timer() json_out: bool = args.json_out root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = _agent_md_path(root) agent_md_exists = agent_md_path.exists() repo_agent_content: str | None = None ws_agent_md: str | None = None ws_agent_content: str | None = None if agent_md_exists: repo_agent_content = agent_md_path.read_text(encoding="utf-8") if kind == "workspace_member" and ws is not None: rel = _compute_rel_path(root, ws) ws_agent_md = f"{rel}/.muse/agent.md" ws_path = _agent_md_path(ws) if ws_path.exists(): ws_agent_content = ws_path.read_text(encoding="utf-8") adapter_statuses: list[StatusAdapterEntry] = [] for spec in _ADAPTERS.values(): target = root / spec["filename"] exists = target.exists() in_sync = False if exists and repo_agent_content is not None: expected = _render_adapter( spec, repo_agent_md=".muse/agent.md", ws_agent_md=ws_agent_md, repo_agent_content=repo_agent_content, ws_agent_content=ws_agent_content, ) actual = target.read_text(encoding="utf-8") in_sync = actual == expected adapter_statuses.append( StatusAdapterEntry( name=spec["name"], filename=spec["filename"], exists=exists, in_sync=in_sync, ) ) any_adapter_exists = any(a["exists"] for a in adapter_statuses) all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"]) ready = agent_md_exists and any_adapter_exists and all_existing_in_sync in_sync_count = sum(1 for a in adapter_statuses if a["in_sync"]) missing_count = sum(1 for a in adapter_statuses if not a["exists"]) out_of_sync_count = sum(1 for a in adapter_statuses if a["exists"] and not a["in_sync"]) if json_out: out = _StatusJson( **make_envelope(elapsed), agent_md=str(agent_md_path), agent_md_exists=agent_md_exists, ready=ready, in_sync_count=in_sync_count, missing_count=missing_count, out_of_sync_count=out_of_sync_count, adapters=adapter_statuses, ) print(json.dumps(out)) else: agent_label = "✅" if agent_md_exists else "❌" print(f"{agent_label} agent.md: {agent_md_path}") ready_label = "✅ ready" if ready else "⚠️ not ready" print(f" {ready_label} ({in_sync_count} in sync, {out_of_sync_count} out of sync, {missing_count} missing)") print() for entry in adapter_statuses: if entry["exists"]: sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync" else: sync_label = "❌ missing" print(f" {entry['filename']:<42} {sync_label}") # --------------------------------------------------------------------------- # Subcommand: inspect # --------------------------------------------------------------------------- def run_inspect(args: argparse.Namespace) -> None: """Single-call agent bootstrap: full context, merged rules, adapter status. Designed for agents entering a new repository. Returns everything needed to understand the repo context, active rules, and adapter sync state in a single call — replacing separate ``read``, ``status``, and context-detection calls. Agent quickstart ---------------- :: muse agent-config inspect --json muse agent-config inspect --json | jq '{context, ready, agent_md_exists}' JSON fields ----------- context ``"standalone"`` | ``"workspace_root"`` | ``"workspace_member"``. workspace_root Absolute path to workspace root, or ``null``. repo_name Name of the repository directory. agent_md_exists Whether ``.muse/agent.md`` is present. merged_content Workspace rules + repo rules concatenated; ``null`` if no agent.md. adapters List of adapter sync entries (same schema as ``status --json``). ready ``true`` when agent.md exists, at least one adapter is present, and all present adapters are in sync. Exit codes ---------- 0 Always. """ elapsed = start_timer() json_out: bool = args.json_out root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = _agent_md_path(root) agent_md_exists = agent_md_path.exists() repo_agent_content: str | None = None ws_agent_md: str | None = None ws_agent_content: str | None = None if agent_md_exists: repo_agent_content = agent_md_path.read_text(encoding="utf-8") if kind == "workspace_member" and ws is not None: rel = _compute_rel_path(root, ws) ws_agent_md = f"{rel}/.muse/agent.md" ws_path = _agent_md_path(ws) if ws_path.exists(): ws_agent_content = ws_path.read_text(encoding="utf-8") # Build merged content (workspace first, repo second). merged_content: str | None = None if repo_agent_content is not None: parts: list[str] = [] if ws_agent_content is not None: parts.append(ws_agent_content.rstrip()) parts.append(repo_agent_content.rstrip()) merged_content = "\n\n".join(parts) + "\n" # Adapter sync statuses. adapter_statuses: list[StatusAdapterEntry] = [] for spec in _ADAPTERS.values(): target = root / spec["filename"] exists = target.exists() in_sync = False if exists and repo_agent_content is not None: expected = _render_adapter( spec, repo_agent_md=".muse/agent.md", ws_agent_md=ws_agent_md, repo_agent_content=repo_agent_content, ws_agent_content=ws_agent_content, ) in_sync = target.read_text(encoding="utf-8") == expected adapter_statuses.append( StatusAdapterEntry( name=spec["name"], filename=spec["filename"], exists=exists, in_sync=in_sync, ) ) any_adapter_exists = any(a["exists"] for a in adapter_statuses) all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"]) ready = agent_md_exists and any_adapter_exists and all_existing_in_sync if json_out: out = _InspectJson( **make_envelope(elapsed), context=kind, workspace_root=str(ws) if ws else None, repo_name=root.name, agent_md_exists=agent_md_exists, merged_content=merged_content, adapters=adapter_statuses, ready=ready, ) print(json.dumps(out)) else: _context_labels = { "standalone": "standalone repo", "workspace_root": "workspace root", "workspace_member": "workspace member", } print(f"Context: {_context_labels.get(kind, kind)}") if ws: print(f"Workspace: {ws}") print(f"Repo: {root.name}") print(f"agent.md: {'✅ exists' if agent_md_exists else '❌ missing'}") ready_label = "✅ ready" if ready else "⚠️ not ready" print(f"Status: {ready_label}") print() for entry in adapter_statuses: if entry["exists"]: sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync" else: sync_label = "❌ missing" print(f" {entry['filename']:<42} {sync_label}") if merged_content: print() print("─" * 60) print(merged_content, end="") # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def run_set(args: argparse.Namespace) -> None: """Persist adapter preferences for ``muse agent-config sync``. Without ``--global``, writes to ``/.muse/config.toml`` (repo scope). With ``--global``, writes to ``~/.muse/config.toml`` (user scope — applies to every repo on this machine, survives branch switches and merges, exactly like ``~/.gitconfig``). Priority when sync runs: repo config > user config > error. Quickstart ---------- :: # Set once globally — never have to think about it again: muse agent-config set --global --adapters claude # Set per-repo (overrides global for this repo only): muse agent-config set --adapters claude,codex JSON fields ----------- adapters List of adapter names now configured. path Config file that was written. Exit codes ---------- 0 Settings saved. 1 Unknown adapter name. """ elapsed = start_timer() json_out: bool = args.json_out adapters_raw: str = args.adapters global_flag: bool = getattr(args, "global_", False) requested = [a.strip() for a in adapters_raw.split(",") if a.strip()] unknown = [a for a in requested if a not in _ADAPTERS] if unknown: msg = ( f"❌ Unknown adapter(s): {', '.join(unknown)}. " f"Available: {', '.join(_ADAPTERS)}" ) if json_out: print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR})) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) if global_flag: user_dir = _user_muse_dir() user_dir.mkdir(parents=True, exist_ok=True) config_path = user_dir / "config.toml" else: root = _find_operation_root() config_path = _config_toml_path(root) # Read existing config, update [agent-config] section, write back. existing_text = config_path.read_text(encoding="utf-8") if config_path.is_file() else "" # Build the new [agent-config] block. adapters_toml = ", ".join(f'"{a}"' for a in requested) new_block = f'[agent-config]\nadapters = [{adapters_toml}]\n' if "[agent-config]" in existing_text: # Replace the existing [agent-config] section up to the next line-initial # '[' (next section header) or end of string. Must NOT use [^\[]* because # that stops at '[' inside values like adapters = ["claude"]. existing_text = re.sub( r"^\[agent-config\].*?(?=^\[|\Z)", new_block, existing_text, count=1, flags=re.DOTALL | re.MULTILINE, ) else: existing_text = existing_text.rstrip("\n") + ("\n\n" if existing_text else "") + new_block write_text_atomic(config_path, existing_text) if json_out: out = _SetJson(**make_envelope(elapsed), adapters=requested, path=str(config_path)) print(json.dumps(out)) else: scope = "global (~/.muse)" if global_flag else "repo (.muse)" print(f"✅ Saved to {config_path} [{scope}]") print(f" adapters = {requested}") print(" Run `muse agent-config sync --force` to regenerate adapter files.") def register( subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]", ) -> None: """Register the ``muse agent-config`` subcommand tree and all its flags.""" parser = subparsers.add_parser( "agent-config", help="Manage agent configuration files (CLAUDE.md, AGENTS.md, etc.).", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND") # ── init ────────────────────────────────────────────────────────── init_p = subs.add_parser( "init", help="Create .muse/agent.md with sane defaults.", description=( "Create ``.muse/agent.md`` with appropriate default rules for this\n" "repo or workspace. Detects standalone, workspace_root, and\n" "workspace_member contexts automatically.\n\n" "Use ``muse agent-config sync`` afterwards to generate IDE adapter files." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) init_p.add_argument( "--force", "-f", action="store_true", help="Overwrite .muse/agent.md if it already exists.", ) init_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) init_p.set_defaults(func=run_init) # ── sync ────────────────────────────────────────────────────────── sync_p = subs.add_parser( "sync", help="Generate IDE adapter files from .muse/agent.md.", description=( "Generate IDE/agent adapter files (CLAUDE.md, AGENTS.md, .cursorrules,\n" ".github/copilot-instructions.md, .windsurfrules) from ``.muse/agent.md``.\n\n" "Claude's CLAUDE.md uses ``@path`` include syntax and is always minimal.\n" "All other adapters embed the full content so each tool sees it directly.\n\n" "Inside a workspace, workspace-level rules are automatically prepended." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) sync_p.add_argument( "--adapters", default=None, metavar="NAME,...", help=( "Comma-separated list of adapters to generate (one-shot override). " "If omitted, reads from repo or user config set via " "`muse agent-config set --adapters`. " f"Available: {', '.join(_ADAPTERS)}" ), ) sync_p.add_argument( "--dry-run", "-n", action="store_true", dest="dry_run", help="Print what would be written without creating any files.", ) sync_p.add_argument( "--force", "-f", action="store_true", help="Write all adapters even if already in sync (default: skip in-sync files).", ) sync_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) sync_p.set_defaults(func=run_sync) # ── read ────────────────────────────────────────────────────────── read_p = subs.add_parser( "read", help="Read the agent.md content.", description=( "Read the ``.muse/agent.md`` content for this repo or workspace.\n\n" "Use ``--scope merged`` inside a workspace member to read both\n" "workspace and repo rules concatenated." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) read_p.add_argument( "--scope", default="repo", choices=["repo", "workspace", "merged"], help="Which config to read: repo (default), workspace, or merged.", ) read_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) read_p.set_defaults(func=run_read) # ── status ──────────────────────────────────────────────────────── status_p = subs.add_parser( "status", help="Show which adapter files exist and whether they are in sync.", description=( "Report the sync state of all IDE adapter files.\n\n" "An adapter is *in sync* when its content matches what\n" "``muse agent-config sync`` would generate from the current ``agent.md``." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) status_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) status_p.set_defaults(func=run_status) # ── inspect ─────────────────────────────────────────────────────── inspect_p = subs.add_parser( "inspect", help="Single-call agent bootstrap: context, merged rules, adapter status.", description=( "Return everything an agent needs when entering a repository: context\n" "classification, merged agent rules, and adapter sync state — all in\n" "one call.\n\n" "JSON output includes:\n" " context — standalone / workspace_root / workspace_member\n" " workspace_root — path to workspace, or null\n" " repo_name — name of this repository\n" " agent_md_exists — whether .muse/agent.md is present\n" " merged_content — workspace + repo rules concatenated\n" " adapters — list of adapter sync entries\n" " ready — true when agent.md exists and adapters are in sync\n\n" "Use ``--json`` for machine-readable output (recommended for agents)." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) inspect_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) inspect_p.set_defaults(func=run_inspect) # ── set ─────────────────────────────────────────────────────────── set_p = subs.add_parser( "set", help="Persist adapter preferences (repo or global).", description=( "Persist which adapters ``muse agent-config sync`` will generate.\n\n" "Without --global: writes to /.muse/config.toml.\n" "With --global: writes to ~/.muse/config.toml — applies to every\n" " repo on this machine, survives branch switches and\n" " merges (like ~/.gitconfig). Do this once.\n\n" f"Available adapters: {', '.join(_ADAPTERS)}\n\n" "Examples::\n\n" " # Set once globally — never touch it again:\n" " muse agent-config set --global --adapters claude\n\n" " # Override for one repo only:\n" " muse agent-config set --adapters claude,codex\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) set_p.add_argument( "--adapters", required=True, metavar="NAME,...", help=( f"Comma-separated list of adapters to generate on sync. " f"Available: {', '.join(_ADAPTERS)}" ), ) set_p.add_argument( "--global", action="store_true", dest="global_", help=( "Write to ~/.muse/config.toml (user-level) instead of the repo config. " "Applies to every repo — survives branch switches and merges." ), ) set_p.add_argument( "--json", "-j", action="store_true", dest="json_out", help="Emit machine-readable JSON on stdout.", ) set_p.set_defaults(func=run_set) parser.set_defaults(func=_show_help(parser)) def _show_help( parser: argparse.ArgumentParser, ) -> Callable[[argparse.Namespace], None]: """Return a callable that prints help and exits.""" def _help(args: argparse.Namespace) -> None: # noqa: ARG001 parser.print_help() raise SystemExit(0) return _help def run(args: argparse.Namespace) -> None: """Dispatch to the correct subcommand handler.""" func = getattr(args, "func", None) if func is None: # No subcommand given — print help raise SystemExit(0) func(args)