gabriel / muse public
agent_config.py python
1,365 lines 49.0 KB
Raw
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