"""``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 sync [--adapters NAME,...] [--dry-run] [--force] [--json] Generate IDE adapter files from .muse/agent.md. muse agent-config show [--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. Exit codes:: 0 — success 1 — user error (file exists without --force, missing agent.md, etc.) """ from __future__ import annotations 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.errors import ExitCode from muse.core.store import write_text_atomic 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 class StatusAdapterEntry(TypedDict): """One entry in the ``muse agent-config status --json`` output.""" name: str filename: str exists: bool in_sync: bool 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` | --- ## Status Always verify a clean state before switching branches: ```bash muse status --json # must show "clean": true before muse checkout ``` """ _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` | """ _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 (root / ".muse" / "workspace.toml").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 = ws_root / ".muse" / "workspace.toml" 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 _load_configured_adapters(root: pathlib.Path) -> list[str] | None: """Read ``[agent-config] adapters`` from ``.muse/config.toml``. Returns the list of adapter names if configured, or ``None`` if the key is absent (meaning "all adapters"). Example ``.muse/config.toml``:: [agent-config] adapters = ["claude", "codex"] """ config_path = root / ".muse" / "config.toml" if not config_path.is_file(): return None try: import tomllib raw = tomllib.loads(config_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 config.toml: %s", exc) return None 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 (cwd / ".muse" / "workspace.toml").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. """ fmt: str = getattr(args, "fmt", "text") force: bool = args.force root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = root / ".muse" / "agent.md" if agent_md_path.exists() and not force: msg = ( f"❌ {agent_md_path} already exists.\n" " Use --force to overwrite it." ) if fmt == "json": 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 fmt == "json": print( json.dumps( { "path": str(agent_md_path), "scope": kind, "created": created, } ) ) 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 IDE/agent tool. """ fmt: str = getattr(args, "fmt", "text") 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 = root / ".muse" / "agent.md" if not agent_md_path.exists(): msg = ( f"❌ No agent.md found at {agent_md_path}.\n" " Run `muse agent-config init` first." ) if fmt == "json": 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 = ws / ".muse" / "agent.md" 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) # 3. All adapters (default) effective_filter: list[str] | None = adapters_filter if effective_filter is None: effective_filter = _load_configured_adapters(root) selected_adapters = { k: v for k, v in _ADAPTERS.items() if effective_filter is None or k in effective_filter } # Check for existing files before writing anything (fail-fast) if not force and not dry_run: conflicts = [] for spec in selected_adapters.values(): target = root / spec["filename"] if target.exists(): conflicts.append(str(target)) if conflicts: msg = ( "❌ Adapter files already exist. Use --force to overwrite:\n" + "\n".join(f" {p}" for p in conflicts) ) if fmt == "json": print( json.dumps( { "error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR, } ) ) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) 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, ) written = False if 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, ) ) if fmt == "json": print(json.dumps({"adapters": results})) else: for entry in results: action = "would write" if dry_run else "wrote" print(f"{'[dry-run] ' if dry_run else ''}✅ {action} {entry['path']}") # --------------------------------------------------------------------------- # Subcommand: show # --------------------------------------------------------------------------- def run_show(args: argparse.Namespace) -> None: """Print 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 """ fmt: str = getattr(args, "fmt", "text") scope: str = getattr(args, "scope", "repo") or "repo" root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = root / ".muse" / "agent.md" if scope == "workspace": if kind == "workspace_root": target_path = agent_md_path elif kind == "workspace_member" and ws is not None: target_path = ws / ".muse" / "agent.md" 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 fmt == "json": 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 = ws / ".muse" / "agent.md" 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 fmt == "json": print( json.dumps( { "content": content, "path": display_path, "scope": display_scope, } ) ) 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``. """ fmt: str = getattr(args, "fmt", "text") root = _find_operation_root() kind, ws = _detect_context(root) agent_md_path = root / ".muse" / "agent.md" 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 = ws / ".muse" / "agent.md" 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, ) ) if fmt == "json": print( json.dumps( { "agent_md": str(agent_md_path), "adapters": adapter_statuses, } ) ) else: agent_label = "✅" if agent_md_exists else "❌" print(f"{agent_label} agent.md: {agent_md_path}") 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}") # --------------------------------------------------------------------------- # Registration # --------------------------------------------------------------------------- def run_set(args: argparse.Namespace) -> None: """Write persistent agent-config settings to ``.muse/config.toml``. Currently supports setting the default adapter list so that ``muse agent-config sync`` only generates the tools you actually use, without requiring ``--adapters`` on every invocation. """ fmt: str = getattr(args, "fmt", "text") adapters_raw: str = args.adapters 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 fmt == "json": print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR})) else: print(msg, file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) root = _find_operation_root() config_path = root / ".muse" / "config.toml" # 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 fmt == "json": print(json.dumps({"adapters": requested, "path": str(config_path)})) else: print(f"✅ Saved to {config_path}") 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, ) parser.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) parser.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) 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", action="store_true", help="Overwrite .muse/agent.md if it already exists.", ) init_p.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) init_p.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) 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 sync (default: all).", ) sync_p.add_argument( "--dry-run", action="store_true", dest="dry_run", help="Print what would be written without creating any files.", ) sync_p.add_argument( "--force", action="store_true", help="Overwrite existing adapter files.", ) sync_p.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) sync_p.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) sync_p.set_defaults(func=run_sync) # ── show ────────────────────────────────────────────────────────── show_p = subs.add_parser( "show", help="Print the agent.md content.", description=( "Print the ``.muse/agent.md`` content for this repo or workspace.\n\n" "Use ``--scope merged`` inside a workspace member to see both\n" "workspace and repo rules concatenated." ), formatter_class=argparse.RawDescriptionHelpFormatter, ) show_p.add_argument( "--scope", default="repo", choices=["repo", "workspace", "merged"], help="Which config to show: repo (default), workspace, or merged.", ) show_p.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) show_p.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) show_p.set_defaults(func=run_show) # ── 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( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) status_p.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) status_p.set_defaults(func=run_status) # ── set ─────────────────────────────────────────────────────────── set_p = subs.add_parser( "set", help="Persist agent-config preferences in .muse/config.toml.", description=( "Write persistent agent-config settings to ``.muse/config.toml``.\n\n" "Settings written here become the default for every subsequent\n" "``muse agent-config sync`` invocation in this repo without\n" "needing to pass flags each time.\n\n" "Available adapters: " + ", ".join(_ADAPTERS) + "\n\n" "Examples::\n\n" " muse agent-config set --adapters claude,codex\n" " muse agent-config set --adapters claude\n" ), formatter_class=argparse.RawDescriptionHelpFormatter, ) set_p.add_argument( "--adapters", required=True, metavar="NAME,...", help=( "Comma-separated list of adapters to generate on sync. " "Available: " + ", ".join(_ADAPTERS) ), ) set_p.add_argument( "--format", "-f", default="text", dest="fmt", help="Output format: text or json.", ) set_p.add_argument( "--json", "-j", action="store_const", const="json", dest="fmt", help="Shorthand for --format json.", ) 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)