gabriel / muse public
agent_config.py python
1,401 lines 50.5 KB
Raw
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 9 days ago
1 """``muse agent-config`` — manage per-repo and workspace agent configuration.
2
3 Generates and syncs the canonical ``.museagent.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>/.museagent.md`` — repo-specific rules.
12 - ``<workspace>/.museagent.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) — ``@.museagent.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 .museagent.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 .museagent.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, muse_dir as _muse_dir, 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 ``.museagent.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 ``.museagent.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 def _migrate_legacy_agent_md(root: pathlib.Path) -> bool:
542 """One-time forward migration: move a pre-#78 ``.muse/agent.md`` to the
543 new tracked location, ``.museagent.md``.
544
545 ``.muse/`` is unconditionally excluded from every snapshot, so any repo
546 that ran ``agent-config init`` before this fix has real content sitting
547 in a location that can never be tracked. This moves it (not copies —
548 the old location must not linger) so upgrading is transparent: the next
549 ``init``/``sync`` picks up the existing content instead of erroring or
550 silently regenerating a fresh default.
551
552 Returns:
553 True if a migration happened, False if there was nothing to migrate
554 (either the new path already exists, or there was no legacy file).
555 """
556 new_path = _agent_md_path(root)
557 if new_path.exists():
558 return False
559 legacy_path = _muse_dir(root) / "agent.md"
560 if not legacy_path.exists():
561 return False
562 content = legacy_path.read_text(encoding="utf-8")
563 write_text_atomic(new_path, content)
564 legacy_path.unlink()
565 print(f"↪ Migrated legacy {legacy_path} → {new_path}", file=sys.stderr)
566 return True
567
568 # ---------------------------------------------------------------------------
569 # Subcommand: init
570 # ---------------------------------------------------------------------------
571
572 def run_init(args: argparse.Namespace) -> None:
573 """Create ``.museagent.md`` with sane defaults.
574
575 Detects whether the current directory is a standalone repo, a workspace
576 root, or a workspace member and generates the appropriate template.
577
578 Agent quickstart
579 ----------------
580 ::
581
582 muse agent-config init --json
583 muse agent-config init --force --json # overwrite existing
584
585 Exit codes
586 ----------
587 0 Created (or already exists when ``--force`` not given).
588 1 File exists without ``--force``.
589 """
590 elapsed = start_timer()
591 json_out: bool = args.json_out
592 force: bool = args.force
593 root = _find_operation_root()
594 kind, ws = _detect_context(root)
595
596 migrated = _migrate_legacy_agent_md(root)
597 agent_md_path = _agent_md_path(root)
598
599 if agent_md_path.exists() and not migrated and not force:
600 msg = (
601 f"❌ {agent_md_path} already exists.\n"
602 " Use --force to overwrite it."
603 )
604 if json_out:
605 print(
606 json.dumps(
607 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
608 )
609 )
610 else:
611 print(msg, file=sys.stderr)
612 raise SystemExit(ExitCode.USER_ERROR)
613
614 if migrated:
615 created = False
616 else:
617 if kind == "workspace_root":
618 manifest = _load_workspace_manifest(root)
619 if manifest and manifest["members"]:
620 members_table = _build_members_table(manifest)
621 else:
622 members_table = "_No members registered yet._"
623 content = _WORKSPACE_ROOT_TEMPLATE.format(members_table=members_table)
624 elif kind == "workspace_member":
625 content = _WORKSPACE_MEMBER_TEMPLATE.format(repo_name=root.name)
626 else:
627 content = _STANDALONE_TEMPLATE.format(repo_name=root.name)
628
629 write_text_atomic(agent_md_path, content)
630 created = True
631
632 if json_out:
633 out = _InitJson(**make_envelope(elapsed), path=str(agent_md_path), scope=kind, created=created)
634 print(json.dumps(out))
635 else:
636 verb = "Migrated" if migrated else "Created"
637 print(f"✅ {verb} {agent_md_path} (scope: {kind})")
638
639 # ---------------------------------------------------------------------------
640 # Subcommand: sync
641 # ---------------------------------------------------------------------------
642
643 def run_sync(args: argparse.Namespace) -> None:
644 """Generate IDE adapter files from ``.museagent.md``.
645
646 Reads the canonical ``.museagent.md`` (and the workspace-level one if
647 inside a workspace) and writes adapter files for each configured IDE/agent
648 tool.
649
650 Adapter selection — three sources in priority order:
651
652 1. ``--adapters`` CLI flag (one-shot override)
653 2. ``[agent-config] adapters`` in ``<repo>/.muse/config.toml``
654 3. ``[agent-config] adapters`` in ``~/.muse/config.toml`` (user-global)
655
656 If none of the above are configured, sync exits with an error.
657 Use ``muse agent-config set --adapters claude`` (or ``--global``) to configure.
658
659 Agent quickstart
660 ----------------
661 ::
662
663 muse agent-config set --global --adapters claude # once, for all repos
664 muse agent-config sync --json
665 muse agent-config sync --adapters claude,codex --json # one-shot override
666 muse agent-config sync --dry-run --json # preview without writing
667
668 Exit codes
669 ----------
670 0 Adapters generated or already in sync.
671 1 No agent.md found, or no adapter configured.
672 """
673 elapsed = start_timer()
674 json_out: bool = args.json_out
675 force: bool = args.force
676 dry_run: bool = args.dry_run
677 adapters_filter: list[str] | None = (
678 [a.strip() for a in args.adapters.split(",")]
679 if args.adapters
680 else None
681 )
682
683 root = _find_operation_root()
684 kind, ws = _detect_context(root)
685
686 _migrate_legacy_agent_md(root)
687 if kind == "workspace_member" and ws is not None:
688 _migrate_legacy_agent_md(ws)
689
690 agent_md_path = _agent_md_path(root)
691 if not agent_md_path.exists():
692 msg = (
693 f"❌ No agent.md found at {agent_md_path}.\n"
694 " Run `muse agent-config init` first."
695 )
696 if json_out:
697 print(
698 json.dumps(
699 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
700 )
701 )
702 else:
703 print(msg, file=sys.stderr)
704 raise SystemExit(ExitCode.USER_ERROR)
705
706 repo_agent_content = agent_md_path.read_text(encoding="utf-8")
707
708 ws_agent_md: str | None = None
709 ws_agent_content: str | None = None
710 if kind == "workspace_member" and ws is not None:
711 rel = _compute_rel_path(root, ws)
712 ws_agent_md = f"{rel}/.museagent.md"
713 ws_path = _agent_md_path(ws)
714 if ws_path.exists():
715 ws_agent_content = ws_path.read_text(encoding="utf-8")
716
717 repo_agent_md = ".museagent.md"
718
719 # Resolve which adapters to generate. Priority (highest → lowest):
720 # 1. --adapters flag (CLI override)
721 # 2. [agent-config] adapters in .muse/config.toml (persistent preference)
722 # There is no "all adapters" default — if neither is set, sync exits with an
723 # error directing the user to run `muse agent-config set --adapters <names>`.
724 effective_filter: list[str] | None = adapters_filter
725 if effective_filter is None:
726 effective_filter = _load_configured_adapters(root)
727
728 if effective_filter is None:
729 msg = (
730 "❌ No adapter configured.\n"
731 " Run `muse agent-config set --adapters <names>` to configure which\n"
732 f" adapters to generate (available: {', '.join(_ADAPTERS)}).\n"
733 " Example: muse agent-config set --adapters claude"
734 )
735 if json_out:
736 print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}))
737 else:
738 print(msg, file=sys.stderr)
739 raise SystemExit(ExitCode.USER_ERROR)
740
741 selected_adapters = {
742 k: v for k, v in _ADAPTERS.items()
743 if k in effective_filter
744 }
745
746 results: list[SyncAdapterResult] = []
747 for spec in selected_adapters.values():
748 target = root / spec["filename"]
749 rendered = _render_adapter(
750 spec,
751 repo_agent_md=repo_agent_md,
752 ws_agent_md=ws_agent_md,
753 repo_agent_content=repo_agent_content,
754 ws_agent_content=ws_agent_content,
755 )
756
757 # Smart skip: if the file already contains the exact content we would
758 # write and --force is not set, leave it untouched. This makes sync
759 # idempotent and agent-friendly — no --force required after every edit.
760 already_in_sync = (
761 not force
762 and not dry_run
763 and target.exists()
764 and target.read_text(encoding="utf-8") == rendered
765 )
766
767 written = False
768 skipped = False
769 if already_in_sync:
770 skipped = True
771 elif not dry_run:
772 target.parent.mkdir(parents=True, exist_ok=True)
773 write_text_atomic(target, rendered)
774 written = True
775
776 results.append(
777 SyncAdapterResult(
778 name=spec["name"],
779 path=str(target),
780 written=written,
781 skipped=skipped,
782 )
783 )
784
785 if json_out:
786 out = _SyncJson(**make_envelope(elapsed), adapters=results)
787 print(json.dumps(out))
788 else:
789 for entry in results:
790 if dry_run:
791 print(f"[dry-run] would write {entry['path']}")
792 elif entry["skipped"]:
793 print(f"✓ already in sync {entry['path']}")
794 else:
795 print(f"✅ wrote {entry['path']}")
796
797 # ---------------------------------------------------------------------------
798 # Subcommand: read
799 # ---------------------------------------------------------------------------
800
801 def run_read(args: argparse.Namespace) -> None:
802 """Read the agent.md content for this repo or workspace.
803
804 ``--scope repo`` — repo-level agent.md only (default).
805 ``--scope workspace`` — workspace-level agent.md only.
806 ``--scope merged`` — workspace + repo content concatenated.
807
808 Agent quickstart
809 ----------------
810 ::
811
812 muse agent-config read --json
813 muse agent-config read --scope merged --json
814
815 JSON fields
816 -----------
817 scope Scope used (``"repo"`` | ``"workspace"`` | ``"merged"``).
818 content Full text of agent.md (or null if file does not exist).
819 path Absolute path to the agent.md file read.
820
821 Exit codes
822 ----------
823 0 Success.
824 1 No agent.md found for the requested scope.
825 """
826 elapsed = start_timer()
827 json_out: bool = args.json_out
828 scope: str = getattr(args, "scope", "repo") or "repo"
829
830 root = _find_operation_root()
831 kind, ws = _detect_context(root)
832
833 agent_md_path = _agent_md_path(root)
834
835 if scope == "workspace":
836 if kind == "workspace_root":
837 target_path = agent_md_path
838 elif kind == "workspace_member" and ws is not None:
839 target_path = _agent_md_path(ws)
840 else:
841 target_path = agent_md_path
842 else:
843 target_path = agent_md_path
844
845 if not target_path.exists():
846 msg = f"❌ No agent.md found at {target_path}."
847 if json_out:
848 print(
849 json.dumps(
850 {"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}
851 )
852 )
853 else:
854 print(msg, file=sys.stderr)
855 raise SystemExit(ExitCode.USER_ERROR)
856
857 if scope == "merged" and kind == "workspace_member" and ws is not None:
858 ws_path = _agent_md_path(ws)
859 parts: list[str] = []
860 if ws_path.exists():
861 parts.append(ws_path.read_text(encoding="utf-8").rstrip())
862 parts.append(target_path.read_text(encoding="utf-8").rstrip())
863 content = "\n\n".join(parts) + "\n"
864 display_path = str(target_path)
865 display_scope = "merged"
866 else:
867 content = target_path.read_text(encoding="utf-8")
868 display_path = str(target_path)
869 display_scope = scope
870
871 if json_out:
872 out = _ReadJson(**make_envelope(elapsed), content=content, path=display_path, scope=display_scope)
873 print(json.dumps(out))
874 else:
875 print(content, end="")
876
877 # ---------------------------------------------------------------------------
878 # Subcommand: status
879 # ---------------------------------------------------------------------------
880
881 def run_status(args: argparse.Namespace) -> None:
882 """Report which adapter files exist and whether they are in sync.
883
884 An adapter is *in sync* when its on-disk content matches what
885 ``muse agent-config sync`` would generate from the current ``agent.md``.
886
887 Agent quickstart
888 ----------------
889 ::
890
891 muse agent-config status --json
892 muse agent-config status --json | jq '.adapters[] | select(.in_sync == false)'
893
894 JSON fields
895 -----------
896 agent_md_exists Whether ``.museagent.md`` exists.
897 adapters List of adapter status entries.
898
899 Each adapter entry: ``name``, ``path``, ``exists``, ``in_sync``.
900
901 Exit codes
902 ----------
903 0 Always.
904 """
905 elapsed = start_timer()
906 json_out: bool = args.json_out
907
908 root = _find_operation_root()
909 kind, ws = _detect_context(root)
910
911 agent_md_path = _agent_md_path(root)
912 agent_md_exists = agent_md_path.exists()
913
914 repo_agent_content: str | None = None
915 ws_agent_md: str | None = None
916 ws_agent_content: str | None = None
917
918 if agent_md_exists:
919 repo_agent_content = agent_md_path.read_text(encoding="utf-8")
920
921 if kind == "workspace_member" and ws is not None:
922 rel = _compute_rel_path(root, ws)
923 ws_agent_md = f"{rel}/.museagent.md"
924 ws_path = _agent_md_path(ws)
925 if ws_path.exists():
926 ws_agent_content = ws_path.read_text(encoding="utf-8")
927
928 adapter_statuses: list[StatusAdapterEntry] = []
929 for spec in _ADAPTERS.values():
930 target = root / spec["filename"]
931 exists = target.exists()
932 in_sync = False
933 if exists and repo_agent_content is not None:
934 expected = _render_adapter(
935 spec,
936 repo_agent_md=".museagent.md",
937 ws_agent_md=ws_agent_md,
938 repo_agent_content=repo_agent_content,
939 ws_agent_content=ws_agent_content,
940 )
941 actual = target.read_text(encoding="utf-8")
942 in_sync = actual == expected
943 adapter_statuses.append(
944 StatusAdapterEntry(
945 name=spec["name"],
946 filename=spec["filename"],
947 exists=exists,
948 in_sync=in_sync,
949 )
950 )
951
952 any_adapter_exists = any(a["exists"] for a in adapter_statuses)
953 all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"])
954 ready = agent_md_exists and any_adapter_exists and all_existing_in_sync
955 in_sync_count = sum(1 for a in adapter_statuses if a["in_sync"])
956 missing_count = sum(1 for a in adapter_statuses if not a["exists"])
957 out_of_sync_count = sum(1 for a in adapter_statuses if a["exists"] and not a["in_sync"])
958
959 if json_out:
960 out = _StatusJson(
961 **make_envelope(elapsed),
962 agent_md=str(agent_md_path),
963 agent_md_exists=agent_md_exists,
964 ready=ready,
965 in_sync_count=in_sync_count,
966 missing_count=missing_count,
967 out_of_sync_count=out_of_sync_count,
968 adapters=adapter_statuses,
969 )
970 print(json.dumps(out))
971 else:
972 agent_label = "✅" if agent_md_exists else "❌"
973 print(f"{agent_label} agent.md: {agent_md_path}")
974 ready_label = "✅ ready" if ready else "⚠️ not ready"
975 print(f" {ready_label} ({in_sync_count} in sync, {out_of_sync_count} out of sync, {missing_count} missing)")
976 print()
977 for entry in adapter_statuses:
978 if entry["exists"]:
979 sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync"
980 else:
981 sync_label = "❌ missing"
982 print(f" {entry['filename']:<42} {sync_label}")
983
984 # ---------------------------------------------------------------------------
985 # Subcommand: inspect
986 # ---------------------------------------------------------------------------
987
988 def run_inspect(args: argparse.Namespace) -> None:
989 """Single-call agent bootstrap: full context, merged rules, adapter status.
990
991 Designed for agents entering a new repository. Returns everything needed
992 to understand the repo context, active rules, and adapter sync state in a
993 single call — replacing separate ``read``, ``status``, and
994 context-detection calls.
995
996 Agent quickstart
997 ----------------
998 ::
999
1000 muse agent-config inspect --json
1001 muse agent-config inspect --json | jq '{context, ready, agent_md_exists}'
1002
1003 JSON fields
1004 -----------
1005 context ``"standalone"`` | ``"workspace_root"`` | ``"workspace_member"``.
1006 workspace_root Absolute path to workspace root, or ``null``.
1007 repo_name Name of the repository directory.
1008 agent_md_exists Whether ``.museagent.md`` is present.
1009 merged_content Workspace rules + repo rules concatenated; ``null`` if no agent.md.
1010 adapters List of adapter sync entries (same schema as ``status --json``).
1011 ready ``true`` when agent.md exists, at least one adapter is present,
1012 and all present adapters are in sync.
1013
1014 Exit codes
1015 ----------
1016 0 Always.
1017 """
1018 elapsed = start_timer()
1019 json_out: bool = args.json_out
1020
1021 root = _find_operation_root()
1022 kind, ws = _detect_context(root)
1023
1024 agent_md_path = _agent_md_path(root)
1025 agent_md_exists = agent_md_path.exists()
1026
1027 repo_agent_content: str | None = None
1028 ws_agent_md: str | None = None
1029 ws_agent_content: str | None = None
1030
1031 if agent_md_exists:
1032 repo_agent_content = agent_md_path.read_text(encoding="utf-8")
1033
1034 if kind == "workspace_member" and ws is not None:
1035 rel = _compute_rel_path(root, ws)
1036 ws_agent_md = f"{rel}/.museagent.md"
1037 ws_path = _agent_md_path(ws)
1038 if ws_path.exists():
1039 ws_agent_content = ws_path.read_text(encoding="utf-8")
1040
1041 # Build merged content (workspace first, repo second).
1042 merged_content: str | None = None
1043 if repo_agent_content is not None:
1044 parts: list[str] = []
1045 if ws_agent_content is not None:
1046 parts.append(ws_agent_content.rstrip())
1047 parts.append(repo_agent_content.rstrip())
1048 merged_content = "\n\n".join(parts) + "\n"
1049
1050 # Adapter sync statuses.
1051 adapter_statuses: list[StatusAdapterEntry] = []
1052 for spec in _ADAPTERS.values():
1053 target = root / spec["filename"]
1054 exists = target.exists()
1055 in_sync = False
1056 if exists and repo_agent_content is not None:
1057 expected = _render_adapter(
1058 spec,
1059 repo_agent_md=".museagent.md",
1060 ws_agent_md=ws_agent_md,
1061 repo_agent_content=repo_agent_content,
1062 ws_agent_content=ws_agent_content,
1063 )
1064 in_sync = target.read_text(encoding="utf-8") == expected
1065 adapter_statuses.append(
1066 StatusAdapterEntry(
1067 name=spec["name"],
1068 filename=spec["filename"],
1069 exists=exists,
1070 in_sync=in_sync,
1071 )
1072 )
1073
1074 any_adapter_exists = any(a["exists"] for a in adapter_statuses)
1075 all_existing_in_sync = all(a["in_sync"] for a in adapter_statuses if a["exists"])
1076 ready = agent_md_exists and any_adapter_exists and all_existing_in_sync
1077
1078 if json_out:
1079 out = _InspectJson(
1080 **make_envelope(elapsed),
1081 context=kind,
1082 workspace_root=str(ws) if ws else None,
1083 repo_name=root.name,
1084 agent_md_exists=agent_md_exists,
1085 merged_content=merged_content,
1086 adapters=adapter_statuses,
1087 ready=ready,
1088 )
1089 print(json.dumps(out))
1090 else:
1091 _context_labels = {
1092 "standalone": "standalone repo",
1093 "workspace_root": "workspace root",
1094 "workspace_member": "workspace member",
1095 }
1096 print(f"Context: {_context_labels.get(kind, kind)}")
1097 if ws:
1098 print(f"Workspace: {ws}")
1099 print(f"Repo: {root.name}")
1100 print(f"agent.md: {'✅ exists' if agent_md_exists else '❌ missing'}")
1101 ready_label = "✅ ready" if ready else "⚠️ not ready"
1102 print(f"Status: {ready_label}")
1103 print()
1104 for entry in adapter_statuses:
1105 if entry["exists"]:
1106 sync_label = "✅ in sync" if entry["in_sync"] else "⚠️ out of sync"
1107 else:
1108 sync_label = "❌ missing"
1109 print(f" {entry['filename']:<42} {sync_label}")
1110 if merged_content:
1111 print()
1112 print("─" * 60)
1113 print(merged_content, end="")
1114
1115 # ---------------------------------------------------------------------------
1116 # Registration
1117 # ---------------------------------------------------------------------------
1118
1119 def run_set(args: argparse.Namespace) -> None:
1120 """Persist adapter preferences for ``muse agent-config sync``.
1121
1122 Without ``--global``, writes to ``<repo>/.muse/config.toml`` (repo scope).
1123 With ``--global``, writes to ``~/.muse/config.toml`` (user scope — applies
1124 to every repo on this machine, survives branch switches and merges, exactly
1125 like ``~/.gitconfig``).
1126
1127 Priority when sync runs: repo config > user config > error.
1128
1129 Quickstart
1130 ----------
1131 ::
1132
1133 # Set once globally — never have to think about it again:
1134 muse agent-config set --global --adapters claude
1135
1136 # Set per-repo (overrides global for this repo only):
1137 muse agent-config set --adapters claude,codex
1138
1139 JSON fields
1140 -----------
1141 adapters List of adapter names now configured.
1142 path Config file that was written.
1143
1144 Exit codes
1145 ----------
1146 0 Settings saved.
1147 1 Unknown adapter name.
1148 """
1149 elapsed = start_timer()
1150 json_out: bool = args.json_out
1151 adapters_raw: str = args.adapters
1152 global_flag: bool = getattr(args, "global_", False)
1153 requested = [a.strip() for a in adapters_raw.split(",") if a.strip()]
1154
1155 unknown = [a for a in requested if a not in _ADAPTERS]
1156 if unknown:
1157 msg = (
1158 f"❌ Unknown adapter(s): {', '.join(unknown)}. "
1159 f"Available: {', '.join(_ADAPTERS)}"
1160 )
1161 if json_out:
1162 print(json.dumps({"error": msg.replace("❌ ", ""), "exit_code": ExitCode.USER_ERROR}))
1163 else:
1164 print(msg, file=sys.stderr)
1165 raise SystemExit(ExitCode.USER_ERROR)
1166
1167 if global_flag:
1168 user_dir = _user_muse_dir()
1169 user_dir.mkdir(parents=True, exist_ok=True)
1170 config_path = user_dir / "config.toml"
1171 else:
1172 root = _find_operation_root()
1173 config_path = _config_toml_path(root)
1174
1175 # Read existing config, update [agent-config] section, write back.
1176 existing_text = config_path.read_text(encoding="utf-8") if config_path.is_file() else ""
1177
1178 # Build the new [agent-config] block.
1179 adapters_toml = ", ".join(f'"{a}"' for a in requested)
1180 new_block = f'[agent-config]\nadapters = [{adapters_toml}]\n'
1181
1182 if "[agent-config]" in existing_text:
1183 # Replace the existing [agent-config] section up to the next line-initial
1184 # '[' (next section header) or end of string. Must NOT use [^\[]* because
1185 # that stops at '[' inside values like adapters = ["claude"].
1186 existing_text = re.sub(
1187 r"^\[agent-config\].*?(?=^\[|\Z)",
1188 new_block,
1189 existing_text,
1190 count=1,
1191 flags=re.DOTALL | re.MULTILINE,
1192 )
1193 else:
1194 existing_text = existing_text.rstrip("\n") + ("\n\n" if existing_text else "") + new_block
1195
1196 write_text_atomic(config_path, existing_text)
1197
1198 if json_out:
1199 out = _SetJson(**make_envelope(elapsed), adapters=requested, path=str(config_path))
1200 print(json.dumps(out))
1201 else:
1202 scope = "global (~/.muse)" if global_flag else "repo (.muse)"
1203 print(f"✅ Saved to {config_path} [{scope}]")
1204 print(f" adapters = {requested}")
1205 print(" Run `muse agent-config sync --force` to regenerate adapter files.")
1206
1207 def register(
1208 subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]",
1209 ) -> None:
1210 """Register the ``muse agent-config`` subcommand tree and all its flags."""
1211 parser = subparsers.add_parser(
1212 "agent-config",
1213 help="Manage agent configuration files (CLAUDE.md, AGENTS.md, etc.).",
1214 description=__doc__,
1215 formatter_class=argparse.RawDescriptionHelpFormatter,
1216 )
1217
1218 subs = parser.add_subparsers(dest="subcommand", metavar="SUBCOMMAND")
1219
1220 # ── init ──────────────────────────────────────────────────────────
1221 init_p = subs.add_parser(
1222 "init",
1223 help="Create .museagent.md with sane defaults.",
1224 description=(
1225 "Create ``.museagent.md`` with appropriate default rules for this\n"
1226 "repo or workspace. Detects standalone, workspace_root, and\n"
1227 "workspace_member contexts automatically.\n\n"
1228 "Use ``muse agent-config sync`` afterwards to generate IDE adapter files."
1229 ),
1230 formatter_class=argparse.RawDescriptionHelpFormatter,
1231 )
1232 init_p.add_argument(
1233 "--force", "-f", action="store_true",
1234 help="Overwrite .museagent.md if it already exists.",
1235 )
1236 init_p.add_argument(
1237 "--json", "-j", action="store_true", dest="json_out",
1238 help="Emit machine-readable JSON on stdout.",
1239 )
1240 init_p.set_defaults(func=run_init)
1241
1242 # ── sync ──────────────────────────────────────────────────────────
1243 sync_p = subs.add_parser(
1244 "sync",
1245 help="Generate IDE adapter files from .museagent.md.",
1246 description=(
1247 "Generate IDE/agent adapter files (CLAUDE.md, AGENTS.md, .cursorrules,\n"
1248 ".github/copilot-instructions.md, .windsurfrules) from ``.museagent.md``.\n\n"
1249 "Claude's CLAUDE.md uses ``@path`` include syntax and is always minimal.\n"
1250 "All other adapters embed the full content so each tool sees it directly.\n\n"
1251 "Inside a workspace, workspace-level rules are automatically prepended."
1252 ),
1253 formatter_class=argparse.RawDescriptionHelpFormatter,
1254 )
1255 sync_p.add_argument(
1256 "--adapters", default=None, metavar="NAME,...",
1257 help=(
1258 "Comma-separated list of adapters to generate (one-shot override). "
1259 "If omitted, reads from repo or user config set via "
1260 "`muse agent-config set --adapters`. "
1261 f"Available: {', '.join(_ADAPTERS)}"
1262 ),
1263 )
1264 sync_p.add_argument(
1265 "--dry-run", "-n", action="store_true", dest="dry_run",
1266 help="Print what would be written without creating any files.",
1267 )
1268 sync_p.add_argument(
1269 "--force", "-f", action="store_true",
1270 help="Write all adapters even if already in sync (default: skip in-sync files).",
1271 )
1272 sync_p.add_argument(
1273 "--json", "-j", action="store_true", dest="json_out",
1274 help="Emit machine-readable JSON on stdout.",
1275 )
1276 sync_p.set_defaults(func=run_sync)
1277
1278 # ── read ──────────────────────────────────────────────────────────
1279 read_p = subs.add_parser(
1280 "read",
1281 help="Read the agent.md content.",
1282 description=(
1283 "Read the ``.museagent.md`` content for this repo or workspace.\n\n"
1284 "Use ``--scope merged`` inside a workspace member to read both\n"
1285 "workspace and repo rules concatenated."
1286 ),
1287 formatter_class=argparse.RawDescriptionHelpFormatter,
1288 )
1289 read_p.add_argument(
1290 "--scope", default="repo",
1291 choices=["repo", "workspace", "merged"],
1292 help="Which config to read: repo (default), workspace, or merged.",
1293 )
1294 read_p.add_argument(
1295 "--json", "-j", action="store_true", dest="json_out",
1296 help="Emit machine-readable JSON on stdout.",
1297 )
1298 read_p.set_defaults(func=run_read)
1299
1300 # ── status ────────────────────────────────────────────────────────
1301 status_p = subs.add_parser(
1302 "status",
1303 help="Show which adapter files exist and whether they are in sync.",
1304 description=(
1305 "Report the sync state of all IDE adapter files.\n\n"
1306 "An adapter is *in sync* when its content matches what\n"
1307 "``muse agent-config sync`` would generate from the current ``agent.md``."
1308 ),
1309 formatter_class=argparse.RawDescriptionHelpFormatter,
1310 )
1311 status_p.add_argument(
1312 "--json", "-j", action="store_true", dest="json_out",
1313 help="Emit machine-readable JSON on stdout.",
1314 )
1315 status_p.set_defaults(func=run_status)
1316
1317 # ── inspect ───────────────────────────────────────────────────────
1318 inspect_p = subs.add_parser(
1319 "inspect",
1320 help="Single-call agent bootstrap: context, merged rules, adapter status.",
1321 description=(
1322 "Return everything an agent needs when entering a repository: context\n"
1323 "classification, merged agent rules, and adapter sync state — all in\n"
1324 "one call.\n\n"
1325 "JSON output includes:\n"
1326 " context — standalone / workspace_root / workspace_member\n"
1327 " workspace_root — path to workspace, or null\n"
1328 " repo_name — name of this repository\n"
1329 " agent_md_exists — whether .museagent.md is present\n"
1330 " merged_content — workspace + repo rules concatenated\n"
1331 " adapters — list of adapter sync entries\n"
1332 " ready — true when agent.md exists and adapters are in sync\n\n"
1333 "Use ``--json`` for machine-readable output (recommended for agents)."
1334 ),
1335 formatter_class=argparse.RawDescriptionHelpFormatter,
1336 )
1337 inspect_p.add_argument(
1338 "--json", "-j", action="store_true", dest="json_out",
1339 help="Emit machine-readable JSON on stdout.",
1340 )
1341 inspect_p.set_defaults(func=run_inspect)
1342
1343 # ── set ───────────────────────────────────────────────────────────
1344 set_p = subs.add_parser(
1345 "set",
1346 help="Persist adapter preferences (repo or global).",
1347 description=(
1348 "Persist which adapters ``muse agent-config sync`` will generate.\n\n"
1349 "Without --global: writes to <repo>/.muse/config.toml.\n"
1350 "With --global: writes to ~/.muse/config.toml — applies to every\n"
1351 " repo on this machine, survives branch switches and\n"
1352 " merges (like ~/.gitconfig). Do this once.\n\n"
1353 f"Available adapters: {', '.join(_ADAPTERS)}\n\n"
1354 "Examples::\n\n"
1355 " # Set once globally — never touch it again:\n"
1356 " muse agent-config set --global --adapters claude\n\n"
1357 " # Override for one repo only:\n"
1358 " muse agent-config set --adapters claude,codex\n"
1359 ),
1360 formatter_class=argparse.RawDescriptionHelpFormatter,
1361 )
1362 set_p.add_argument(
1363 "--adapters", required=True, metavar="NAME,...",
1364 help=(
1365 f"Comma-separated list of adapters to generate on sync. "
1366 f"Available: {', '.join(_ADAPTERS)}"
1367 ),
1368 )
1369 set_p.add_argument(
1370 "--global", action="store_true", dest="global_",
1371 help=(
1372 "Write to ~/.muse/config.toml (user-level) instead of the repo config. "
1373 "Applies to every repo — survives branch switches and merges."
1374 ),
1375 )
1376 set_p.add_argument(
1377 "--json", "-j", action="store_true", dest="json_out",
1378 help="Emit machine-readable JSON on stdout.",
1379 )
1380 set_p.set_defaults(func=run_set)
1381
1382 parser.set_defaults(func=_show_help(parser))
1383
1384 def _show_help(
1385 parser: argparse.ArgumentParser,
1386 ) -> Callable[[argparse.Namespace], None]:
1387 """Return a callable that prints help and exits."""
1388
1389 def _help(args: argparse.Namespace) -> None: # noqa: ARG001
1390 parser.print_help()
1391 raise SystemExit(0)
1392
1393 return _help
1394
1395 def run(args: argparse.Namespace) -> None:
1396 """Dispatch to the correct subcommand handler."""
1397 func = getattr(args, "func", None)
1398 if func is None:
1399 # No subcommand given — print help
1400 raise SystemExit(0)
1401 func(args)
File History 4 commits
sha256:e8214e0062ef8ef0af999937df2731655b6082781fc22bc563f58db2f42b1de9 Merge 'bump/muse-v0.2.1' into 'dev' — proposal: chore: bump… Human 9 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 66 days ago
sha256:a317886dc0496c4af7b285b3e41c86c4c34ea2e79afc63b8829aadb1ada7903f chore: bump version to 0.2.0rc15 to match musehub#113 fix release Sonnet 4.6 patch 66 days ago
sha256:f3b726b50f0aee3622bba751e0a67aa7ae4cf75a798477dbce581940b6a9cf70 feat: migrate invariants cache to .muse/cache/invariants.ms… Sonnet 4.6 patch 134 days ago