"""muse init — initialise a new Muse repository. Creates the ``.muse/`` directory tree in the current working directory. Layout:: .muse/ repo.json — repo_id, schema_version, domain, created_at HEAD — symbolic ref → refs/heads/main refs/heads/main — empty (no commits yet) config.toml — [user], [hub], [remotes], [domain] stubs objects/ — content-addressed blobs (SHA-256 sharded) commits/ — commit records (msgpack, one file per commit) snapshots/ — snapshot manifests (msgpack, one file per snapshot) tags/ — semantic tags (msgpack, one file per tag) .museattributes — TOML merge strategy overrides (working-tree only) .museignore — TOML ignore rules (working-tree only) The repository root IS the working tree. There is no ``state/`` subdirectory. Bare repositories (``--bare``) have no working tree; they store only ``.muse/`` and do not receive ``.museattributes`` or ``.museignore``. Agent use --------- Pass ``--json`` to receive a machine-readable result instead of prose:: muse init --json | jq .repo_id JSON schema (exit 0):: { "repo_id": "...", // UUID — stable across reinit with --force "branch": "main", // initial branch name "domain": "code", // active domain plugin "path": "/abs", // absolute path to the .muse directory "reinitialised": false, // true when --force was used on an existing repo "bare": false, "schema_version": 1 // integer; bumps only on breaking layout changes } """ from __future__ import annotations import argparse import datetime import json import logging import os import pathlib import shutil import sys import uuid from muse.core.errors import ExitCode from muse.core.store import write_head_branch, write_text_atomic from muse.core.validation import ( assert_not_symlink, sanitize_display, validate_branch_name, validate_domain_name, ) type _RepoMeta = dict[str, str | int | bool] type _StrMap = dict[str, str] logger = logging.getLogger(__name__) # Bumped only when the on-disk .muse/ layout changes in a breaking way. # Intentionally separate from the package version (pyproject.toml) so that # patch releases do not falsely signal a schema migration. _REPO_SCHEMA_VERSION: int = 1 # Subdirectories created unconditionally at init time. Must be a superset of # muse.core.repo._CRITICAL_MUSE_DIRS so that _verify_muse_dir_integrity() is # satisfied on the very first require_repo() call after init. # NOTE: "refs" is listed explicitly even though "refs/heads" (with parents=True) # would create it implicitly — explicit listing ensures it is covered by the # post-init integrity check loop. _INIT_SUBDIRS: tuple[str, ...] = ( "refs", "refs/heads", "objects", "commits", "snapshots", "tags", ) _DEFAULT_CONFIG = """\ [user] name = "" email = "" type = "human" # "human" | "agent" [hub] # url = "https://musehub.ai" # Run `muse hub connect ` to attach this repo to MuseHub. # Run `muse auth register` to authenticate. # Credentials are stored in ~/.muse/identity.toml — never here. [remotes] [domain] # Domain-specific configuration. Keys depend on the active domain plugin. """ _BARE_CONFIG = """\ [core] bare = true [user] name = "" email = "" type = "human" # "human" | "agent" [hub] # url = "https://musehub.ai" [remotes] [domain] """ _MUSEIGNORE_HEADER = """\ # .museignore — snapshot exclusion rules for this repository. """ _MUSEIGNORE_GLOBAL = """\ [global] patterns = [ ".DS_Store", "Thumbs.db", "*.tmp", "*.swp", "*.swo", ] """ _MUSEIGNORE_DOMAIN_BLOCKS: _StrMap = { "midi": """\ [domain.midi] patterns = [ "*.bak", "*.autosave", "/renders/", "/exports/", "/previews/", ] """, "code": """\ [domain.code] patterns = [ "__pycache__/", "*.pyc", "*.pyo", "node_modules/", "dist/", "build/", ".venv/", "venv/", ".tox/", "*.egg-info/", ] """, "knowtation": """\ [domain.knowtation] patterns = [ # Knowtation index and vector-store data — never snapshot the index. "data/", # Local config (contains secrets and machine-specific paths). "config/local.yaml", # OS and editor artefacts inside the vault. ".obsidian/", ".logseq/", # Node.js tooling (CLI / indexer). "node_modules/", "*.log", ] """, } # Domain-specific `.museattributes` rule blocks, appended by # _museattributes_template() when a domain has opinionated defaults. _MUSEATTRIBUTES_DOMAIN_RULES: _StrMap = { "knowtation": """\ # ─── Knowtation defaults ────────────────────────────────────────────────────── # Rule 1 — Frontmatter sets (tags, entities, attachments) are independent. [[rules]] path = "**/*.md" dimension = "frontmatter" strategy = "union" comment = "Tag/entity/attachment sets are independent — accumulate additions from both sides." priority = 20 # Rule 2 — Inbox notes are locally-authoritative. [[rules]] path = "inbox/**" dimension = "*" strategy = "ours" comment = "Inbox captures are local-authoritative; remote edits are discarded." priority = 10 # Rule 3 — Archived notes are immutable. [[rules]] path = "archive/**" dimension = "*" strategy = "ours" comment = "Archived notes are immutable; local version is always preferred." priority = 10 """, } def _museignore_template(domain: str) -> str: """Return a TOML ``.museignore`` template pre-filled for *domain*. The ``[global]`` section covers cross-domain OS artifacts. The ``[domain.]`` section lists patterns specific to the chosen domain. Patterns from other domains are never loaded at snapshot time. """ domain_block = _MUSEIGNORE_DOMAIN_BLOCKS.get(domain, f"""\ [domain.{domain}] # patterns = [] """) return _MUSEIGNORE_HEADER + _MUSEIGNORE_GLOBAL + "\n" + domain_block def _museattributes_template(domain: str) -> str: """Return a TOML `.museattributes` template pre-filled with *domain*. When *domain* has an entry in :data:`_MUSEATTRIBUTES_DOMAIN_RULES`, the pre-populated rules are written as active (uncommented) blocks so the user gets working defaults immediately. Unknown domains fall back to a minimal commented-out example. """ domain_rules = _MUSEATTRIBUTES_DOMAIN_RULES.get(domain) if domain_rules: rules_section = "\n" + domain_rules else: rules_section = """\ # [[rules]] # path = "*" # dimension = "*" # strategy = "auto" """ return f"""\ # .museattributes — merge strategy overrides for this repository. # Documentation: docs/reference/muse-attributes.md [meta] domain = "{domain}" {rules_section}""" def _copy_template(template_path: pathlib.Path, dest_root: pathlib.Path) -> None: """Copy *template_path* contents into *dest_root*, with two safety guards. Guards: 1. Any item whose name is ``.muse`` is skipped — prevents a malicious template from overwriting the freshly created VCS state directory. 2. Any item that is a symlink is skipped — prevents a template with symlinks pointing outside the tree from reading or overwriting sensitive files (e.g. ``/etc/passwd``). Args: template_path: Verified-existing directory to copy from. dest_root: Repository working-tree root (``cwd``). """ for item in template_path.iterdir(): if item.name == ".muse": logger.warning( "⚠️ init: skipping .muse/ in template — " "templates must not contain VCS state directories." ) continue if item.is_symlink(): logger.warning( "⚠️ init: skipping symlink %s in template — " "symlinks are not copied to prevent path-traversal.", item.name, ) continue dest = dest_root / item.name if item.is_dir(): shutil.copytree(item, dest, dirs_exist_ok=True) else: shutil.copy2(item, dest) def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None: """Register the init subcommand.""" parser = subparsers.add_parser( "init", help="Initialise a new Muse repository.", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--bare", action="store_true", help="Initialise as a bare repository (no working tree).", ) parser.add_argument( "--template", default=None, metavar="PATH", help="Copy PATH contents into the working tree after initialising.", ) parser.add_argument( "--default-branch", default="main", metavar="BRANCH", dest="default_branch", help="Name of the initial branch (default: main).", ) parser.add_argument( "--force", "-f", action="store_true", help="Re-initialise even if already a Muse repository. Preserves repo_id.", ) parser.add_argument( "--domain", "-d", default="code", help="Domain plugin to activate (e.g. code, midi). Default: code.", ) parser.add_argument( "--json", action="store_true", dest="as_json", help="Emit a machine-readable JSON result and exit.", ) parser.set_defaults(func=run) def run(args: argparse.Namespace) -> None: """Initialise a new Muse repository in the current directory.""" bare: bool = args.bare template: str | None = args.template default_branch: str = args.default_branch force: bool = args.force domain: str = args.domain as_json: bool = args.as_json try: validate_branch_name(default_branch) except ValueError as exc: if as_json: print(json.dumps({"error": f"Invalid --default-branch: {exc}"})) else: print(f"❌ Invalid --default-branch: {sanitize_display(str(exc))}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) try: validate_domain_name(domain) except ValueError as exc: if as_json: print(json.dumps({"error": f"Invalid --domain: {exc}"})) else: print(f"❌ Invalid --domain: {sanitize_display(str(exc))}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) cwd = pathlib.Path.cwd() muse_dir = cwd / ".muse" template_path: pathlib.Path | None = None if template is not None: raw_template = pathlib.Path(template) # Check for symlink BEFORE resolving so a symlinked path is caught # before .resolve() follows it to the real target. A symlinked # template directory could be swapped between validation and use. if raw_template.is_symlink(): msg = "Template path must not be a symbolic link." if as_json: print(json.dumps({"error": msg})) else: print(f"❌ {msg}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) template_path = raw_template.resolve() if not template_path.is_dir(): msg = f"Template path is not a directory: {template_path}" if as_json: print(json.dumps({"error": msg})) else: print(f"❌ {sanitize_display(msg)}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) already_exists = muse_dir.is_dir() if already_exists and not force: if as_json: print(json.dumps({"error": "Already a Muse repository. Use --force to reinitialise."})) else: print( f"Already a Muse repository at {sanitize_display(str(cwd))}.\n" "Use --force to reinitialise.", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) existing_repo_id: str | None = None if force and already_exists: repo_json = muse_dir / "repo.json" if repo_json.exists(): try: raw_id = json.loads(repo_json.read_text(encoding="utf-8")).get("repo_id") if isinstance(raw_id, str): existing_repo_id = raw_id except (json.JSONDecodeError, OSError): pass try: # Create all required subdirectories up front. _INIT_SUBDIRS must be # a superset of _CRITICAL_MUSE_DIRS so _verify_muse_dir_integrity() # never sees an expected directory that is missing. for subdir in _INIT_SUBDIRS: (muse_dir / subdir).mkdir(parents=True, exist_ok=True) repo_id = existing_repo_id or str(uuid.uuid4()) created_at = datetime.datetime.now(datetime.timezone.utc).isoformat() repo_meta: _RepoMeta = { "repo_id": repo_id, "schema_version": _REPO_SCHEMA_VERSION, "created_at": created_at, "domain": domain, } if bare: repo_meta["bare"] = True # Use write_text_atomic for repo.json: a SIGKILL between write and # rename would otherwise leave a zero-byte file, breaking every # subsequent command that reads repo_id. write_text_atomic( muse_dir / "repo.json", json.dumps(repo_meta, indent=2) + "\n", ) write_head_branch(muse_dir.parent, default_branch) # Write an empty branch ref only if it does not yet exist (fresh) or # --force was given. write_text_atomic protects against torn writes # on the ref itself (also used by write_head_branch). ref_file = muse_dir / "refs" / "heads" / default_branch if not ref_file.exists() or force: write_text_atomic(ref_file, "") config_path = muse_dir / "config.toml" if not config_path.exists(): write_text_atomic( config_path, _BARE_CONFIG if bare else _DEFAULT_CONFIG, ) if not bare: attrs_path = cwd / ".museattributes" if not attrs_path.exists(): write_text_atomic(attrs_path, _museattributes_template(domain)) ignore_path = cwd / ".museignore" if not ignore_path.exists(): write_text_atomic(ignore_path, _museignore_template(domain)) if not bare and template_path is not None: _copy_template(template_path, cwd) # Post-init integrity check: verify every critical directory is a real # directory (not a symlink) before returning success. This catches # environmental races and confirms the layout is self-consistent. for subdir in _INIT_SUBDIRS: candidate = muse_dir / subdir try: assert_not_symlink(candidate, label=f".muse/{subdir}") except ValueError as exc: msg = f"Repository structure compromised during init: {exc}" if as_json: print(json.dumps({"error": msg})) else: print(f"❌ {msg}", file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) except PermissionError: msg = f"Permission denied: cannot write to {cwd}." if as_json: print(json.dumps({"error": msg})) else: print(f"❌ {sanitize_display(msg)}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) except OSError as exc: msg = f"Failed to initialise repository: {exc}" if as_json: print(json.dumps({"error": msg})) else: print(f"❌ {sanitize_display(msg)}", file=sys.stderr) raise SystemExit(ExitCode.INTERNAL_ERROR) reinitialised = bool(force and already_exists) if as_json: print(json.dumps({ "repo_id": repo_id, "branch": default_branch, "domain": domain, "path": str(muse_dir), "reinitialised": reinitialised, "bare": bare, "schema_version": _REPO_SCHEMA_VERSION, })) else: action = "Reinitialised" if reinitialised else "Initialised" kind = "bare " if bare else "" print(f"✅ {action} {kind}Muse repository in {sanitize_display(str(muse_dir))}")