gabriel / muse public
init.py python
695 lines 23.1 KB
Raw
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor ⚠ breaking 50 days ago
1 """muse init — initialize a new Muse repository.
2
3 Creates the ``.muse/`` directory tree in the current working directory.
4
5 Layout::
6
7 .muse/
8 repo.json — repo_id, schema_version, domain, created_at
9 HEAD — symbolic ref → refs/heads/main
10 refs/heads/main — empty (no commits yet)
11 config.toml — [user], [hub], [remotes], [domain] stubs
12 objects/ — content-addressed objects: blobs, commits, snapshots (SHA-256 sharded)
13 tags/ — semantic tags (JSON, one file per tag)
14 .museattributes — TOML merge strategy overrides (working-tree only)
15 .museignore — TOML ignore rules (working-tree only)
16
17 The repository root IS the working tree. There is no ``state/`` subdirectory.
18 Bare repositories (``--bare``) have no working tree; they store only ``.muse/``
19 and do not receive ``.museattributes`` or ``.museignore``.
20
21 Agent use
22 ---------
23 Pass ``--json`` to receive a machine-readable result instead of prose::
24
25 muse init --json | jq .repo_id
26
27 JSON schema (exit 0) — all keys always present::
28
29 {
30 "status": "ok", // always "ok" on success
31 "error": "", // always empty string on success
32 "warnings": [], // notices (e.g. template symlinks skipped)
33 "repo_id": "...", // sha256:<hex> genesis fingerprint — stable across reinit with --force
34 "branch": "main", // initial branch name
35 "domain": "code", // active domain plugin
36 "path": "/abs", // absolute path to the .muse directory
37 "reinitialized": false, // true when --force was used on an existing repo
38 "bare": false,
39 "schema_version": 1, // integer; bumps only on breaking layout changes
40 "created_at": "...", // ISO 8601 UTC timestamp written to repo.json
41 "duration_ms": 0.0, // wall-clock time for the init operation
42 "exit_code": 0
43 }
44
45 JSON schema (exit non-zero) — error payload::
46
47 {
48 "status": "error",
49 "error": "<human-readable message>",
50 "warnings": [],
51 "exit_code": 1
52 }
53 """
54
55 import argparse
56 import json
57 import logging
58 import os
59 import pathlib
60 import shutil
61 import sys
62 from muse import __version__ as _MUSE_VERSION
63 from muse.core.types import content_hash, load_json_file, now_utc_iso
64 from muse.core.paths import muse_dir as _muse_dir, ref_path as _ref_path, init_repo_dirs as _init_repo_dirs, user_identity_toml_path
65 from muse.core.envelope import EnvelopeJson, make_envelope
66 from muse.core.errors import ExitCode
67 from muse.core.io import write_text_atomic
68 from muse.core.refs import write_head_branch
69 from muse.core.timing import start_timer
70 from muse.core.validation import (
71 assert_not_symlink,
72 sanitize_display,
73 validate_branch_name,
74 validate_domain_name,
75 validate_repo_id,
76 )
77
78 type _RepoMeta = dict[str, str | int | bool]
79 type _StrMap = dict[str, str]
80
81 # ---------------------------------------------------------------------------
82 # Default hub environments
83 # ---------------------------------------------------------------------------
84
85 HUB_LOCAL = "https://localhost:1337"
86 HUB_STAGING = "https://staging.musehub.ai"
87 HUB_PRODUCTION = "https://musehub.ai"
88
89 _DEFAULT_REMOTES = {
90 "local": HUB_LOCAL,
91 "staging": HUB_STAGING,
92 "production": HUB_PRODUCTION,
93 }
94
95 def resolve_default_handle() -> str | None:
96 """Return the authenticated handle from ``~/.muse/identity.toml``, or None.
97
98 Tries the localhost hub first, then staging, then production. Returns the
99 first non-empty handle found, or ``None`` when no identity is configured.
100 """
101 try:
102 import tomllib as _tomllib
103 except ImportError:
104 return None
105 identity_path = user_identity_toml_path()
106 if not identity_path.is_file():
107 return None
108 try:
109 with identity_path.open("rb") as fh:
110 data = _tomllib.load(fh)
111 except Exception:
112 return None
113 for hub_key in ("localhost:1337", "staging.musehub.ai", "musehub.ai"):
114 entry = data.get(hub_key, {})
115 handle = entry.get("handle", "")
116 if handle:
117 return handle
118 return None
119
120 class _InitJson(EnvelopeJson):
121 status: str # "ok"
122 error: str # "" on success
123 repo_id: str
124 branch: str
125 domain: str
126 path: str
127 reinitialized: bool
128 bare: bool
129 schema_version: int
130 created_at: str # ISO 8601 UTC
131 remotes: dict[str, str] # remote_name → url (empty when no handle)
132
133 class _InitErrorJson(EnvelopeJson):
134 status: str # "error"
135 error: str
136
137 logger = logging.getLogger(__name__)
138
139 # Bumped only when the on-disk .muse/ layout changes in a breaking way.
140 # Intentionally separate from the package version (pyproject.toml) so that
141 # patch releases do not falsely signal a schema migration.
142 _REPO_SCHEMA_VERSION: int = 1
143
144 # Subdirectories created unconditionally at init time. Must be a superset of
145 # muse.core.repo._CRITICAL_MUSE_DIRS so that _verify_muse_dir_integrity() is
146 # satisfied on the very first require_repo() call after init.
147 # NOTE: "refs" is listed explicitly even though "refs/heads" (with parents=True)
148 # would create it implicitly — explicit listing ensures it is covered by the
149 # post-init integrity check loop.
150 _INIT_SUBDIRS: tuple[str, ...] = (
151 "refs",
152 "refs/heads",
153 "objects",
154 "tags",
155 "cache",
156 )
157
158 _DEFAULT_CONFIG = """\
159 [user]
160 name = ""
161 email = ""
162 type = "human" # "human" | "agent"
163
164 [hub]
165 # url = "https://musehub.ai"
166 # Run `muse hub connect <url>` to attach this repo to MuseHub.
167 # Run `muse auth register` to authenticate.
168 # Credentials are stored in ~/.muse/identity.toml — never here.
169
170 [remotes]
171
172 [domain]
173 # Domain-specific configuration. Keys depend on the active domain plugin.
174 """
175
176 def _build_default_config(handle: str | None, slug: str) -> str:
177 """Return the default config.toml content for a new repo.
178
179 When *handle* is known, pre-wires ``local``, ``staging``, and
180 ``production`` remotes and sets ``hub.url`` to localhost. Without a
181 handle the section stubs are written but left empty so the user fills
182 them in after running ``muse auth register``.
183 """
184 hub_line = f'url = "{HUB_LOCAL}"'
185 if handle:
186 remotes_block = "\n".join(
187 f'[remotes.{name}]\nurl = "{base}/{handle}/{slug}"\n'
188 for name, base in _DEFAULT_REMOTES.items()
189 )
190 else:
191 remotes_block = (
192 "[remotes]\n"
193 "# Remotes will be wired automatically after `muse auth register`.\n"
194 )
195
196 return (
197 "[user]\n"
198 'name = ""\n'
199 'email = ""\n'
200 'type = "human" # "human" | "agent"\n'
201 "\n"
202 "[hub]\n"
203 f"{hub_line}\n"
204 "# Credentials are stored in ~/.muse/identity.toml — never here.\n"
205 "\n"
206 f"{remotes_block}\n"
207 "[domain]\n"
208 "# Domain-specific configuration. Keys depend on the active domain plugin.\n"
209 )
210
211 _BARE_CONFIG = """\
212 [core]
213 bare = true
214
215 [user]
216 name = ""
217 email = ""
218 type = "human" # "human" | "agent"
219
220 [hub]
221 # url = "https://musehub.ai"
222
223 [remotes]
224
225 [domain]
226 """
227
228 _MUSEIGNORE_HEADER = """\
229 # .museignore — snapshot exclusion rules for this repository.
230
231 """
232
233 _MUSEIGNORE_GLOBAL = """\
234 [global]
235 patterns = [
236 ".DS_Store",
237 "Thumbs.db",
238 "*.tmp",
239 "*.swp",
240 "*.swo",
241
242 # IDE / editor state — applies to every domain
243 ".vscode/",
244 ".idea/",
245 "*.iml",
246 ]
247 """
248
249 _MUSEIGNORE_DOMAIN_BLOCKS: _StrMap = {
250 "midi": """\
251 [domain.midi]
252 patterns = [
253 "*.bak",
254 "*.autosave",
255 "/renders/",
256 "/exports/",
257 "/previews/",
258 ]
259 """,
260 "code": """\
261 [domain.code]
262 # Secrets (.env, *.key, *.pem, *.p12) are blocked by the Muse engine
263 # automatically — no need to list them here.
264 patterns = [
265 # ── Universal build / output directories ─────────────────────────
266 "dist/",
267 "build/",
268 "out/",
269 "tmp/",
270
271 # ── Dependency trees ─────────────────────────────────────────────
272 "node_modules/", # JavaScript / Node
273 "vendor/", # Go, PHP (Composer)
274
275 # ── Python ───────────────────────────────────────────────────────
276 "__pycache__/",
277 "*.pyc",
278 "*.pyo",
279 ".venv/",
280 "venv/",
281 ".tox/",
282 "*.egg-info/",
283
284 # ── Rust ─────────────────────────────────────────────────────────
285 # "target/",
286
287 # ── Java / Kotlin / Scala ────────────────────────────────────────
288 # "*.class",
289 # "*.jar",
290
291 # ── Ruby ─────────────────────────────────────────────────────────
292 # ".mpack/",
293
294 ]
295 """,
296 }
297
298 _MUSEIGNORE_FORCE_TRACK_BLOCK = """\
299 # [force_track]
300 # Exact repo-relative paths to track even if they match a secrets pattern.
301 # Use for dev infrastructure (e.g. self-signed TLS certs) that must survive
302 # clean pulls. No glob expansion — each entry must be an exact file path.
303 # paths = [
304 # "deploy/local-tls/localhost.key",
305 # "deploy/local-tls/localhost.crt",
306 # ]
307 """
308
309 def _museignore_template(domain: str) -> str:
310 """Return a TOML ``.museignore`` template pre-filled for *domain*.
311
312 The ``[global]`` section covers cross-domain OS artifacts. The
313 ``[domain.<name>]`` section lists patterns specific to the chosen domain.
314 Patterns from other domains are never loaded at snapshot time.
315 The ``[force_track]`` block is documented but commented out — uncomment
316 and fill in paths to whitelist specific files from the secrets blocklist.
317 """
318 domain_block = _MUSEIGNORE_DOMAIN_BLOCKS.get(domain, f"""\
319 [domain.{domain}]
320 # patterns = []
321 """)
322 return f"{_MUSEIGNORE_HEADER}{_MUSEIGNORE_GLOBAL}\n{domain_block}\n{_MUSEIGNORE_FORCE_TRACK_BLOCK}"
323
324 def _museattributes_template(domain: str) -> str:
325 """Return a TOML `.museattributes` template pre-filled with *domain*."""
326 return f"""\
327 # .museattributes — merge strategy overrides for this repository.
328
329 [meta]
330 domain = "{domain}"
331
332 # [[rules]]
333 # path = "*"
334 # dimension = "*"
335 # strategy = "auto"
336 """
337
338 def _copy_template(
339 template_path: pathlib.Path,
340 dest_root: pathlib.Path,
341 warnings: list[str],
342 *,
343 json_out: bool = False,
344 ) -> None:
345 """Copy *template_path* contents into *dest_root*, with two safety guards.
346
347 Guards:
348 1. Any item whose name is ``.muse`` is skipped — prevents a malicious
349 template from overwriting the freshly created VCS state directory.
350 2. Any item that is a symlink is skipped — prevents a template with
351 symlinks pointing outside the tree from reading or overwriting
352 sensitive files (e.g. ``/etc/passwd``).
353
354 Skipped items are appended to *warnings* so agents can audit what was
355 omitted without parsing log output. When *json_out* is True the human-
356 readable ``logger.warning`` is suppressed — the warnings list is the
357 sole channel, keeping stderr clean for machine consumers.
358
359 Args:
360 template_path: Verified-existing directory to copy from.
361 dest_root: Repository working-tree root (``cwd``).
362 warnings: Mutable list; skipped-item notices are appended here.
363 json_out: When True, suppress human-readable stderr log lines.
364 """
365 for item in template_path.iterdir():
366 if item.name == ".muse":
367 msg = (
368 "init: skipping .muse/ in template — "
369 "templates must not contain VCS state directories."
370 )
371 if not json_out:
372 logger.warning("⚠️ %s", msg)
373 warnings.append(msg)
374 continue
375 if item.is_symlink():
376 msg = (
377 f"init: skipping symlink {item.name!r} in template — "
378 "symlinks are not copied to prevent path-traversal."
379 )
380 if not json_out:
381 logger.warning("⚠️ %s", msg)
382 warnings.append(msg)
383 continue
384 dest = dest_root / item.name
385 if item.is_dir():
386 shutil.copytree(item, dest, dirs_exist_ok=True)
387 else:
388 item.copy(dest)
389
390 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
391 """Register the init subcommand."""
392 parser = subparsers.add_parser(
393 "init",
394 help="Initialize a new Muse repository.",
395 description=__doc__,
396 formatter_class=argparse.RawDescriptionHelpFormatter,
397 )
398 parser.add_argument(
399 "--bare",
400 action="store_true",
401 help="Initialize as a bare repository (no working tree).",
402 )
403 parser.add_argument(
404 "--template",
405 default=None,
406 metavar="PATH",
407 help="Copy PATH contents into the working tree after initializing.",
408 )
409 parser.add_argument(
410 "--default-branch",
411 default="main",
412 metavar="BRANCH",
413 dest="default_branch",
414 help="Name of the initial branch (default: main).",
415 )
416 parser.add_argument(
417 "--force", "-f",
418 action="store_true",
419 help="Re-initialize even if already a Muse repository. Preserves repo_id.",
420 )
421 parser.add_argument(
422 "--domain", "-d",
423 default="code",
424 help="Domain plugin to activate (e.g. code, midi). Default: code.",
425 )
426 parser.add_argument(
427 "directory",
428 nargs="?",
429 default=None,
430 metavar="DIRECTORY",
431 help=(
432 "Directory to initialize as a Muse repository. "
433 "Created (including any missing parents) if it does not yet exist. "
434 "Defaults to the current working directory."
435 ),
436 )
437 parser.add_argument(
438 "--json", "-j",
439 action="store_true",
440 dest="json_out",
441 help="Emit a machine-readable JSON result and exit.",
442 )
443 parser.set_defaults(func=run)
444
445 def _emit_error(json_out: bool, msg: str, code: ExitCode, warnings: list[str], elapsed: float) -> None:
446 """Print an error and raise SystemExit. Never returns."""
447 if json_out:
448 print(json.dumps(_InitErrorJson(
449 **make_envelope(elapsed, exit_code=int(code), warnings=list(warnings)),
450 status="error",
451 error=msg,
452 )))
453 else:
454 print(f"❌ {sanitize_display(msg)}", file=sys.stderr)
455 raise SystemExit(code)
456
457 def run(args: argparse.Namespace) -> None:
458 """Initialize a new Muse repository in the current directory (or DIRECTORY).
459
460 Creates the ``.muse/`` directory structure and writes an initial
461 ``HEAD``, ``config.toml``, and empty ``objects/`` tree. Safe to re-run
462 with ``--force`` on an existing repo (reinitializes without data loss).
463
464 Agent quickstart
465 ----------------
466 ::
467
468 muse init --json
469 muse init --domain code --json
470 muse init /tmp/new-repo --json
471 muse init --bare --json
472
473 JSON fields
474 -----------
475 status ``"ok"`` on success.
476 repo_id Content-addressed ID of the new repository.
477 branch Default branch name created.
478 domain Domain plugin name configured.
479 path Absolute path to the ``.muse/`` directory.
480 reinitialized ``true`` if an existing repo was reinitialized.
481 bare ``true`` if initialized as a bare repository.
482 schema_version On-disk layout version.
483 created_at ISO 8601 UTC timestamp of creation.
484
485 Exit codes
486 ----------
487 0 Repository initialized (or reinitialized).
488 1 Invalid arguments or domain name.
489 3 I/O error creating the repository.
490 """
491 elapsed = start_timer()
492 bare: bool = args.bare
493 template: str | None = args.template
494 default_branch: str = args.default_branch
495 force: bool = args.force
496 domain: str = args.domain
497 json_out: bool = args.json_out
498 directory: str | None = args.directory
499
500 warnings: list[str] = []
501
502 try:
503 validate_branch_name(default_branch)
504 except ValueError as exc:
505 _emit_error(json_out, f"Invalid --default-branch: {exc}", ExitCode.USER_ERROR, warnings, elapsed)
506
507 try:
508 validate_domain_name(domain)
509 except ValueError as exc:
510 _emit_error(json_out, f"Invalid --domain: {exc}", ExitCode.USER_ERROR, warnings, elapsed)
511
512 # Resolve the target directory (defaults to CWD when no argument is given).
513 if directory is not None:
514 raw_dir = pathlib.Path(directory)
515 target = (pathlib.Path.cwd() / raw_dir).resolve() if not raw_dir.is_absolute() else raw_dir.resolve()
516 try:
517 target.mkdir(parents=True, exist_ok=True)
518 except OSError as exc:
519 _emit_error(
520 json_out,
521 f"Cannot create directory '{sanitize_display(str(target))}': {exc}",
522 ExitCode.INTERNAL_ERROR,
523 warnings,
524 elapsed,
525 )
526 os.chdir(target)
527
528 cwd = pathlib.Path.cwd()
529 muse_dir = _muse_dir(cwd)
530 written_remotes: dict[str, str] = {}
531
532 template_path: pathlib.Path | None = None
533 if template is not None:
534 raw_template = pathlib.Path(template)
535 # Check for symlink BEFORE resolving so a symlinked path is caught
536 # before .resolve() follows it to the real target. A symlinked
537 # template directory could be swapped between validation and use.
538 if raw_template.is_symlink():
539 _emit_error(
540 json_out,
541 "Template path must not be a symbolic link.",
542 ExitCode.USER_ERROR,
543 warnings,
544 elapsed,
545 )
546 template_path = raw_template.resolve()
547 if not template_path.is_dir():
548 _emit_error(
549 json_out,
550 f"Template path is not a directory: {template_path}",
551 ExitCode.USER_ERROR,
552 warnings,
553 elapsed,
554 )
555
556 already_exists = muse_dir.is_dir()
557 if already_exists and not force:
558 _emit_error(
559 json_out,
560 "Already a Muse repository. Use --force to reinitialize.",
561 ExitCode.USER_ERROR,
562 warnings,
563 elapsed,
564 )
565
566 existing_repo_id: str | None = None
567 if force and already_exists:
568 repo_json = muse_dir / "repo.json"
569 if repo_json.exists():
570 _repo_data = load_json_file(repo_json)
571 if _repo_data is not None:
572 raw_id = _repo_data.get("repo_id")
573 try:
574 if isinstance(raw_id, str):
575 existing_repo_id = validate_repo_id(raw_id)
576 except (ValueError, TypeError):
577 pass # non-sha256: repo_id (e.g. legacy plain string) — generate fresh
578
579 try:
580 # Create the canonical layout first, then any init-specific extras.
581 _init_repo_dirs(cwd)
582 for subdir in _INIT_SUBDIRS:
583 (muse_dir / subdir).mkdir(parents=True, exist_ok=True)
584
585 created_at = now_utc_iso()
586 if existing_repo_id:
587 repo_id = existing_repo_id
588 else:
589 repo_id = content_hash({"created_at": created_at, "domain": domain, "path": str(cwd)})
590 repo_meta: _RepoMeta = {
591 "repo_id": repo_id,
592 "schema_version": _REPO_SCHEMA_VERSION,
593 "created_at": created_at,
594 "domain": domain,
595 "bare": bare,
596 "muse_version": _MUSE_VERSION,
597 }
598
599 # Use write_text_atomic for repo.json: a SIGKILL between write and
600 # rename would otherwise leave a zero-byte file, breaking every
601 # subsequent command that reads repo_id.
602 write_text_atomic(
603 muse_dir / "repo.json",
604 f"{json.dumps(repo_meta)}\n",
605 )
606
607 write_head_branch(muse_dir.parent, default_branch)
608
609 # Write an empty branch ref only when the file does not yet exist.
610 # Never overwrite an existing ref — even under --force — to avoid
611 # orphaning commits on the branch.
612 ref_file = _ref_path(cwd, default_branch)
613 if not ref_file.exists():
614 write_text_atomic(ref_file, "")
615
616 config_path = muse_dir / "config.toml"
617 written_remotes: dict[str, str] = {}
618 if not config_path.exists():
619 if bare:
620 write_text_atomic(config_path, _BARE_CONFIG)
621 else:
622 handle = resolve_default_handle()
623 slug = cwd.name
624 written_remotes = (
625 {name: f"{base}/{handle}/{slug}" for name, base in _DEFAULT_REMOTES.items()}
626 if handle else {}
627 )
628 write_text_atomic(config_path, _build_default_config(handle, slug))
629
630 if not bare:
631 attrs_path = cwd / ".museattributes"
632 if not attrs_path.exists():
633 write_text_atomic(attrs_path, _museattributes_template(domain))
634
635 ignore_path = cwd / ".museignore"
636 if not ignore_path.exists():
637 write_text_atomic(ignore_path, _museignore_template(domain))
638
639 if not bare and template_path is not None:
640 _copy_template(template_path, cwd, warnings, json_out=json_out)
641
642 # Post-init integrity check: verify every critical directory is a real
643 # directory (not a symlink) before returning success. This catches
644 # environmental races and confirms the layout is self-consistent.
645 for subdir in _INIT_SUBDIRS:
646 candidate = muse_dir / subdir
647 try:
648 assert_not_symlink(candidate, label=f".muse/{subdir}")
649 except ValueError as exc:
650 _emit_error(
651 json_out,
652 f"Repository structure compromised during init: {exc}",
653 ExitCode.INTERNAL_ERROR,
654 warnings,
655 elapsed,
656 )
657
658 except PermissionError:
659 _emit_error(
660 json_out,
661 f"Permission denied: cannot write to {cwd}.",
662 ExitCode.USER_ERROR,
663 warnings,
664 elapsed,
665 )
666 except OSError as exc:
667 _emit_error(
668 json_out,
669 f"Failed to initialize repository: {exc}",
670 ExitCode.INTERNAL_ERROR,
671 warnings,
672 elapsed,
673 )
674
675 reinitialized = bool(force and already_exists)
676
677 if json_out:
678 print(json.dumps(_InitJson(
679 **make_envelope(elapsed, warnings=warnings),
680 status="ok",
681 error="",
682 repo_id=repo_id,
683 branch=default_branch,
684 domain=domain,
685 path=str(muse_dir),
686 reinitialized=reinitialized,
687 bare=bare,
688 schema_version=_REPO_SCHEMA_VERSION,
689 created_at=created_at,
690 remotes=written_remotes,
691 )))
692 else:
693 action = "Reinitialized" if reinitialized else "Initialized"
694 kind = "bare " if bare else ""
695 print(f"✅ {action} {kind}Muse repository in {sanitize_display(str(muse_dir))}")
File History 1 commit
sha256:3767afb72520f9b56053bb98fd83d323f738ee4cad16e306e8cf6862608380e4 feat: first-class directory tracking across status, diff, r… Sonnet 4.6 minor 50 days ago