agent-worktree-manager 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """Portable, project-owned worktree environments."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from agent_worktree_manager.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,49 @@
1
+ """Export the bundled, agent-independent worktree skill."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+
11
+ from .errors import AWMError
12
+
13
+ SKILL_FILES = ("SKILL.md", "agents/openai.yaml")
14
+
15
+
16
+ def bundled_files() -> dict[str, bytes]:
17
+ root = files("agent_worktree_manager").joinpath("skills", "awm")
18
+ return {name: root.joinpath(*name.split("/")).read_bytes() for name in SKILL_FILES}
19
+
20
+
21
+ def install_skill(destination: Path) -> Path:
22
+ destination = Path(os.path.abspath(destination.expanduser()))
23
+ content = bundled_files()
24
+ if destination.is_symlink():
25
+ raise AWMError(f"Skill destination must not be a symlink: {destination}")
26
+ if destination.exists():
27
+ if destination.is_dir() and all(
28
+ (destination / name).is_file()
29
+ and not (destination / name).is_symlink()
30
+ and (destination / name).read_bytes() == value
31
+ for name, value in content.items()
32
+ ):
33
+ return destination
34
+ raise AWMError(
35
+ f"Skill destination already exists with different contents: {destination}; "
36
+ "review or move the existing skill before installing"
37
+ )
38
+ destination.parent.mkdir(parents=True, exist_ok=True)
39
+ temporary = Path(tempfile.mkdtemp(prefix=".awm-skill-", dir=destination.parent))
40
+ try:
41
+ for name, value in content.items():
42
+ path = temporary / name
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_bytes(value)
45
+ temporary.rename(destination)
46
+ finally:
47
+ if temporary.exists():
48
+ shutil.rmtree(temporary)
49
+ return destination
@@ -0,0 +1,386 @@
1
+ """The installed awm entry point. Mutations belong to shared services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import signal
8
+ import sys
9
+ import threading
10
+ from pathlib import Path
11
+
12
+ from . import __version__, environments, lifecycle, migration, registry
13
+ from .config import BACKENDS, initialize, load_project
14
+ from .errors import AWMError
15
+ from .git import check_repo, is_dirty
16
+
17
+
18
+ def log(message: str) -> None:
19
+ print(f"[awm] {message}")
20
+
21
+
22
+ def err(message: str) -> None:
23
+ print(f"[awm] ERROR: {message}", file=sys.stderr)
24
+
25
+
26
+ def selected(opts):
27
+ return registry.select(root=getattr(opts, "root", None), project=getattr(opts, "project", None))
28
+
29
+
30
+ def confirmation(message: str, yes: bool) -> bool:
31
+ if yes:
32
+ return True
33
+ if not sys.stdin.isatty():
34
+ raise AWMError("Non-interactive deletion/import requires --yes; use --dry-run to preview")
35
+ try:
36
+ return input(message + " [y/N] ").strip().lower() in ("y", "yes")
37
+ except (EOFError, KeyboardInterrupt):
38
+ return False
39
+
40
+
41
+ def print_plan(project, record: dict) -> None:
42
+ log(f"{project.name}/{record['name']} ({record['status']})")
43
+ for worktree in record["worktrees"]:
44
+ print(
45
+ f" worktree {worktree['alias']}[{worktree['branch'] or 'detached'}] {worktree['path']}"
46
+ )
47
+ for env in record["environments"]:
48
+ print(f" env {env['kind']} {env['path']}")
49
+ for editable in record["editables"]:
50
+ print(
51
+ f" editable {editable['name']} -> {editable['path']} ({'shared source' if editable['shared'] else 'isolated source'})"
52
+ )
53
+ if record.get("error"):
54
+ print(f" error: {record['error']}")
55
+
56
+
57
+ def project_overview() -> list[dict]:
58
+ output = []
59
+ for entry in registry.entries():
60
+ try:
61
+ project = load_project(Path(entry["root"]))
62
+ if project.id != entry["id"]:
63
+ raise AWMError("Project identity changed; re-register this location")
64
+ output.append(lifecycle.project_summary(project))
65
+ except (AWMError, OSError) as exc:
66
+ output.append({**entry, "unavailable": str(exc)})
67
+ return output
68
+
69
+
70
+ def cmd_projects(opts) -> int:
71
+ if opts.action == "add":
72
+ project = load_project(Path(opts.path))
73
+ if not opts.dry_run:
74
+ registry.register(project)
75
+ log(f"{'Would register' if opts.dry_run else 'Registered'} {project.name}: {project.id}")
76
+ elif opts.action == "remove":
77
+ entry = registry.find(opts.selector)
78
+ if not opts.dry_run:
79
+ registry.unregister(opts.selector)
80
+ log(
81
+ f"{'Would unregister' if opts.dry_run else 'Unregistered'} {entry['name']}; project resources are preserved"
82
+ )
83
+ else:
84
+ items = project_overview()
85
+ if opts.json:
86
+ print(json.dumps({"schema_version": 1, "projects": items}, indent=2))
87
+ for item in [] if opts.json else items:
88
+ print(
89
+ f"{item['id']} {item['name']} {item.get('states', item.get('unavailable'))} {item['root']}"
90
+ )
91
+ return 0
92
+
93
+
94
+ def cmd_init(opts) -> int:
95
+ if getattr(opts, "project", None):
96
+ raise AWMError("init takes --root, not --project")
97
+ project = initialize(
98
+ Path(getattr(opts, "root", None) or "."),
99
+ Path(opts.base),
100
+ opts.backend,
101
+ opts.name,
102
+ opts.repo,
103
+ dry_run=opts.dry_run,
104
+ )
105
+ if project:
106
+ registry.register(project)
107
+ log(f"Initialized {project.name}: {project.id}")
108
+ else:
109
+ log("Initialization preflight passed; no files written")
110
+ return 0
111
+
112
+
113
+ def cmd_create(opts) -> int:
114
+ project = selected(opts)
115
+ record = lifecycle.create(project, opts.name, opts.repo, opts.ref, dry_run=True)
116
+ print_plan(project, record)
117
+ if opts.dry_run:
118
+ log("Dry run; no resources created")
119
+ else:
120
+ log("Creating worktrees and reproducing the installed base…")
121
+ print_plan(project, lifecycle.create(project, opts.name, opts.repo, opts.ref))
122
+ return 0
123
+
124
+
125
+ def cmd_import(opts) -> int:
126
+ project = selected(opts)
127
+ kwargs = dict(
128
+ legacy=opts.legacy,
129
+ venv_home=opts.venv_home,
130
+ base_env=opts.base_env,
131
+ conda_base_env=opts.conda_base_env,
132
+ )
133
+ record = migration.import_sandbox(
134
+ project, opts.name, opts.worktree, opts.env, dry_run=True, **kwargs
135
+ )
136
+ print_plan(project, record)
137
+ if opts.dry_run:
138
+ log("Dry run; ownership records unchanged")
139
+ elif confirmation("Adopt these resources for management by awm?", opts.yes):
140
+ migration.import_sandbox(project, opts.name, opts.worktree, opts.env, **kwargs)
141
+ log("Import complete")
142
+ else:
143
+ return 1
144
+ return 0
145
+
146
+
147
+ def cmd_delete(opts) -> int:
148
+ project = selected(opts)
149
+ kwargs = dict(
150
+ force=opts.force,
151
+ keep_env=opts.keep_env,
152
+ keep_worktrees=opts.keep_worktrees,
153
+ delete_branch=opts.delete_branch,
154
+ )
155
+ records = lifecycle.delete(project, opts.names, dry_run=True, **kwargs)
156
+ for record in records:
157
+ print_plan(project, record)
158
+ log(
159
+ f"Delete worktrees: {not opts.keep_worktrees}; delete environments: {not opts.keep_env}; delete branches: {opts.delete_branch}; force: {opts.force}"
160
+ )
161
+ if opts.dry_run:
162
+ log("Dry run; nothing deleted")
163
+ elif confirmation(f"Delete selected resources in {project.name}?", opts.yes):
164
+ lifecycle.delete(project, opts.names, **kwargs)
165
+ log("Deletion complete")
166
+ else:
167
+ return 1
168
+ return 0
169
+
170
+
171
+ def list_payload(project) -> dict:
172
+ records = []
173
+ for record in lifecycle.read_state(project)["sandboxes"].values():
174
+ summary = {
175
+ k: record[k]
176
+ for k in ("name", "status", "error", "worktrees", "environments", "editables")
177
+ }
178
+ summary["worktrees"] = []
179
+ for resource in record["worktrees"]:
180
+ path = Path(resource["path"])
181
+ try:
182
+ dirty = is_dirty(path) if path.exists() else None
183
+ except AWMError:
184
+ dirty = None
185
+ summary["worktrees"].append({**resource, "missing": not path.exists(), "dirty": dirty})
186
+ summary["environments"] = [
187
+ {**e, "missing": not Path(e["path"]).exists()} for e in record["environments"]
188
+ ]
189
+ records.append(summary)
190
+ return {"id": project.id, "name": project.name, "root": str(project.root), "sandboxes": records}
191
+
192
+
193
+ def cmd_list(opts) -> int:
194
+ projects = []
195
+ if opts.all:
196
+ if getattr(opts, "root", None) or getattr(opts, "project", None):
197
+ raise AWMError("--all cannot be combined with a project selector")
198
+ for entry in registry.entries():
199
+ try:
200
+ projects.append(list_payload(registry.select(project=entry["id"])))
201
+ except (AWMError, OSError) as exc:
202
+ projects.append({**entry, "unavailable": str(exc)})
203
+ else:
204
+ projects = [list_payload(selected(opts))]
205
+ if opts.json:
206
+ print(json.dumps({"schema_version": 1, "projects": projects}, indent=2))
207
+ else:
208
+ for project in projects:
209
+ print(f"{project['name']} ({project['id']}) {project['root']}")
210
+ if "unavailable" in project:
211
+ print(f" unavailable: {project['unavailable']}")
212
+ for record in project.get("sandboxes", []):
213
+ kinds = ", ".join(e["kind"] for e in record["environments"]) or "-"
214
+ worktrees = (
215
+ ", ".join(
216
+ f"{w['alias']}[{w['branch'] or 'detached'}]{'*' if w['dirty'] else ''}{' (missing)' if w['missing'] else ''}"
217
+ for w in record["worktrees"]
218
+ )
219
+ or "-"
220
+ )
221
+ print(f" {record['name']} {record['status']} {kinds} {worktrees}")
222
+ if record["error"]:
223
+ print(f" {record['error']}")
224
+ return 0
225
+
226
+
227
+ def cmd_doctor(opts) -> int:
228
+ project = selected(opts)
229
+ for repo in project.repos.values():
230
+ check_repo(repo.path)
231
+ base = environments.validate_base(project.base, project.backend)
232
+ lifecycle.read_state(project)
233
+ for package in base["packages"]:
234
+ path = environments.editable_path(package)
235
+ if path and not path.exists():
236
+ raise AWMError(f"Missing editable source: {path}")
237
+ log(
238
+ f"{project.name}: Git checkouts and {project.backend} base are valid; Python {base['python']}, {len(base['packages'])} packages"
239
+ )
240
+ for name, recorded, declared, source in environments.stale_editables(base):
241
+ log(
242
+ f"Stale editable metadata: {name} records {recorded}, {source} declares {declared}; "
243
+ f"reinstall it into the base to reproduce that version in new sandboxes"
244
+ )
245
+ return 0
246
+
247
+
248
+ def cmd_run(opts) -> int:
249
+ command = opts.args[1:] if opts.args[:1] == ["--"] else opts.args
250
+ return lifecycle.run_command(selected(opts), opts.name, command, opts.repo, opts.env)
251
+
252
+
253
+ def cmd_skill(opts) -> int:
254
+ from .agent_skill import bundled_files, install_skill
255
+
256
+ if opts.install:
257
+ log(f"Skill installed at {install_skill(Path(opts.install))}")
258
+ else:
259
+ print(bundled_files()["SKILL.md"].decode("utf-8"), end="")
260
+ return 0
261
+
262
+
263
+ def cmd_shell_init(opts) -> int:
264
+ from .shell import shell_init
265
+
266
+ print(shell_init(opts.shell), end="")
267
+ return 0
268
+
269
+
270
+ def cmd_ui(opts) -> int:
271
+ if not (sys.stdin.isatty() and sys.stdout.isatty()):
272
+ raise AWMError("Interactive mode requires a TTY; use 'awm projects list' or 'awm list'")
273
+ from .tui import run_ui
274
+
275
+ return run_ui(opts)
276
+
277
+
278
+ def build_parser() -> argparse.ArgumentParser:
279
+ selectors = argparse.ArgumentParser(add_help=False)
280
+ group = selectors.add_mutually_exclusive_group()
281
+ group.add_argument("--root", default=argparse.SUPPRESS, help="project root containing awm.toml")
282
+ group.add_argument("--project", default=argparse.SUPPRESS, help="registered project name or ID")
283
+ parser = argparse.ArgumentParser(
284
+ prog="awm",
285
+ description="Manage project worktrees and isolated Python environments",
286
+ parents=[selectors],
287
+ )
288
+ parser.add_argument("--version", action="version", version=f"awm {__version__}")
289
+ parser.set_defaults(func=cmd_ui, envs=False)
290
+ commands = parser.add_subparsers(dest="command")
291
+ shell = commands.add_parser("shell-init", help="print current-shell integration for eval")
292
+ shell.add_argument("shell", choices=("bash", "zsh"))
293
+ shell.set_defaults(func=cmd_shell_init)
294
+ skill = commands.add_parser("skill", help="print or install the bundled coding-agent skill")
295
+ skill.add_argument("--install", metavar="PATH", help="copy the skill into this skill directory")
296
+ skill.set_defaults(func=cmd_skill)
297
+ init = commands.add_parser("init", parents=[selectors], help="configure and register a project")
298
+ init.add_argument("--base", required=True, help="existing base environment directory")
299
+ init.add_argument("--backend", choices=BACKENDS, default="venv")
300
+ init.add_argument("--name")
301
+ init.add_argument("--repo", action="append", default=[], metavar="ALIAS=PATH")
302
+ init.add_argument("--dry-run", action="store_true")
303
+ init.set_defaults(func=cmd_init)
304
+ create = commands.add_parser(
305
+ "create", parents=[selectors], help="create a sandbox from the installed base"
306
+ )
307
+ create.add_argument("name")
308
+ create.add_argument("--repo", action="append", default=[])
309
+ create.add_argument("--ref", default="HEAD")
310
+ create.add_argument("-n", "--dry-run", action="store_true")
311
+ create.set_defaults(func=cmd_create)
312
+ imp = commands.add_parser(
313
+ "import", parents=[selectors], help="explicitly adopt existing worktrees/environments"
314
+ )
315
+ imp.add_argument("name")
316
+ imp.add_argument("--worktree", action="append", default=[], metavar="ALIAS=PATH")
317
+ imp.add_argument("--env", action="append", default=[], metavar="BACKEND=PATH")
318
+ imp.add_argument("--legacy", action="store_true")
319
+ for flag in ("venv-home", "base-env", "conda-base-env"):
320
+ imp.add_argument(f"--{flag}")
321
+ imp.add_argument("-n", "--dry-run", action="store_true")
322
+ imp.add_argument("-y", "--yes", action="store_true")
323
+ imp.set_defaults(func=cmd_import)
324
+ delete = commands.add_parser(
325
+ "delete", parents=[selectors], help="delete owned sandbox resources"
326
+ )
327
+ delete.add_argument("names", nargs="+")
328
+ for short, long in (("-n", "--dry-run"), ("-y", "--yes"), ("-f", "--force")):
329
+ delete.add_argument(short, long, action="store_true")
330
+ for flag in ("keep-env", "keep-worktrees", "delete-branch"):
331
+ delete.add_argument(f"--{flag}", action="store_true")
332
+ delete.set_defaults(func=cmd_delete)
333
+ ls = commands.add_parser("list", parents=[selectors], help="list owned sandboxes")
334
+ ls.add_argument("--json", action="store_true")
335
+ ls.add_argument("--all", action="store_true")
336
+ ls.set_defaults(func=cmd_list)
337
+ doctor = commands.add_parser(
338
+ "doctor", parents=[selectors], help="check project prerequisites without changing them"
339
+ )
340
+ doctor.set_defaults(func=cmd_doctor)
341
+ run = commands.add_parser(
342
+ "run", parents=[selectors], help="run a command in a sandbox; options precede NAME"
343
+ )
344
+ run.add_argument("--repo")
345
+ run.add_argument("--env")
346
+ run.add_argument("name")
347
+ run.add_argument("args", nargs=argparse.REMAINDER)
348
+ run.set_defaults(func=cmd_run)
349
+ for command in ("ui", "envs"):
350
+ ui = commands.add_parser(command, parents=[selectors])
351
+ ui.add_argument("--envs", action="store_true", default=command == "envs")
352
+ ui.set_defaults(func=cmd_ui)
353
+ projects = commands.add_parser("projects", help="register or browse projects")
354
+ actions = projects.add_subparsers(dest="action", required=True)
355
+ for action in ("add", "remove", "list"):
356
+ p = actions.add_parser(action)
357
+ if action != "list":
358
+ p.add_argument("path" if action == "add" else "selector")
359
+ p.add_argument("--dry-run", action="store_true")
360
+ else:
361
+ p.add_argument("--json", action="store_true")
362
+ p.set_defaults(func=cmd_projects)
363
+ return parser
364
+
365
+
366
+ def main(argv: list[str] | None = None) -> int:
367
+ previous = {}
368
+
369
+ def interrupted(signum, frame):
370
+ raise KeyboardInterrupt
371
+
372
+ if threading.current_thread() is threading.main_thread():
373
+ for signum in (signal.SIGTERM, signal.SIGHUP):
374
+ previous[signum] = signal.signal(signum, interrupted)
375
+ try:
376
+ opts = build_parser().parse_args(argv)
377
+ return opts.func(opts)
378
+ except (AWMError, OSError, ValueError) as exc:
379
+ err(str(exc))
380
+ return 1
381
+ except KeyboardInterrupt:
382
+ err("Interrupted")
383
+ return 130
384
+ finally:
385
+ for signum, handler in previous.items():
386
+ signal.signal(signum, handler)
@@ -0,0 +1,196 @@
1
+ """Portable workspace configuration and per-machine settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import tomllib
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from packaging.requirements import Requirement
14
+ from packaging.utils import canonicalize_name
15
+
16
+ from .errors import AWMError
17
+ from .git import check_repo
18
+ from .storage import atomic_text, lock
19
+
20
+ CONFIG_NAME = "awm.toml"
21
+ BACKENDS = ("venv", "uv", "conda")
22
+
23
+
24
+ def slug(value: str) -> str:
25
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,79}", value):
26
+ raise AWMError(
27
+ "Names must be 1–80 letters, digits, dots, underscores or dashes, starting with a letter or digit"
28
+ )
29
+ return value
30
+
31
+
32
+ def read_toml(path: Path) -> dict:
33
+ try:
34
+ value = tomllib.loads(path.read_text(encoding="utf-8"))
35
+ except (OSError, ValueError) as exc:
36
+ raise AWMError(f"Cannot read {path}: {exc}") from exc
37
+ if value.get("schema_version") != 1:
38
+ raise AWMError(f"Unsupported configuration version: {path}")
39
+ return value
40
+
41
+
42
+ def editable_target(target: str) -> tuple[str, tuple[str, ...]]:
43
+ subdir, separator, suffix = target.partition("[")
44
+ if not subdir or Path(subdir).is_absolute() or ".." in Path(subdir).parts:
45
+ raise ValueError(f"editable target must stay inside its repository: {target}")
46
+ if not separator:
47
+ return subdir, ()
48
+ requirement = Requirement("editable-target[" + suffix)
49
+ if not target.endswith("]") or requirement.marker or requirement.specifier or requirement.url:
50
+ raise ValueError(f"invalid editable target extras: {target}")
51
+ return subdir, tuple(sorted(canonicalize_name(extra) for extra in requirement.extras))
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Repository:
56
+ alias: str
57
+ path: Path
58
+ targets: tuple[str, ...] = ()
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Project:
63
+ root: Path
64
+ id: str
65
+ name: str
66
+ backend: str
67
+ base: Path
68
+ repos: dict[str, Repository]
69
+
70
+ @property
71
+ def local(self) -> Path:
72
+ path = self.root / ".awm"
73
+ if path.is_symlink():
74
+ raise AWMError(f"Project state directory must not be a symlink: {path}")
75
+ return path
76
+
77
+
78
+ def load_project(root: Path) -> Project:
79
+ root = root.expanduser().resolve()
80
+ if root.name == CONFIG_NAME and root.is_file():
81
+ root = root.parent
82
+ shared = read_toml(root / CONFIG_NAME)
83
+ if (root / ".awm").is_symlink():
84
+ raise AWMError("Project state directory must not be a symlink")
85
+ local = read_toml(root / ".awm" / "local.toml")
86
+ return parse_project(root, shared, local)
87
+
88
+
89
+ def parse_project(root: Path, shared: dict, local: dict) -> Project:
90
+ try:
91
+ project_id = str(uuid.UUID(local["project_id"]))
92
+ name = shared["name"]
93
+ environment = local["environment"]
94
+ backend, base = environment["backend"], environment["base"]
95
+ if (
96
+ not isinstance(name, str)
97
+ or not name.strip()
98
+ or backend not in BACKENDS
99
+ or not isinstance(base, str)
100
+ ):
101
+ raise ValueError("invalid project name or environment")
102
+ repos = {}
103
+ overrides = local.get("repositories", {})
104
+ if not isinstance(overrides, dict) or overrides.keys() - shared["repositories"].keys():
105
+ raise ValueError("local repository overrides must use configured aliases")
106
+ for alias, spec in shared["repositories"].items():
107
+ slug(alias)
108
+ spec = {**spec, **overrides.get(alias, {})}
109
+ targets = spec.get("targets", [])
110
+ if not isinstance(targets, list) or not all(isinstance(t, str) and t for t in targets):
111
+ raise ValueError(f"invalid editable targets for {alias}")
112
+ for target in targets:
113
+ editable_target(target)
114
+ repos[alias] = Repository(
115
+ alias, (root / Path(spec["path"]).expanduser()).resolve(), tuple(targets)
116
+ )
117
+ if not repos or len({r.path for r in repos.values()}) != len(repos):
118
+ raise ValueError("configure at least one repository, with distinct checkout paths")
119
+ return Project(
120
+ root, project_id, name, backend, (root / Path(base).expanduser()).resolve(), repos
121
+ )
122
+ except (KeyError, TypeError, ValueError, AttributeError) as exc:
123
+ raise AWMError(f"Invalid project configuration at {root}: {exc}") from exc
124
+
125
+
126
+ def discover_root(cwd: Path | None = None) -> Path:
127
+ path = (cwd or Path.cwd()).resolve()
128
+ for candidate in (path, *path.parents):
129
+ if (candidate / CONFIG_NAME).is_file():
130
+ return candidate
131
+ raise AWMError(
132
+ "No project selected; use --project, --root, or run inside an initialized project"
133
+ )
134
+
135
+
136
+ def initialize(
137
+ root: Path,
138
+ base: Path,
139
+ backend: str,
140
+ name: str | None,
141
+ repositories: list[str],
142
+ *,
143
+ dry_run: bool = False,
144
+ ) -> Project | None:
145
+ root, base = root.expanduser().resolve(), base.expanduser().resolve()
146
+ if not root.is_dir():
147
+ raise AWMError(f"Project directory does not exist: {root}")
148
+ if not (base / "bin" / "python").is_file():
149
+ raise AWMError(f"Base has no Python interpreter: {base}")
150
+ if backend == "conda" and not (base / "conda-meta").is_dir():
151
+ raise AWMError(f"Not a conda environment: {base}")
152
+ if backend != "conda" and not (base / "pyvenv.cfg").is_file():
153
+ raise AWMError(f"Select a dedicated venv base, or use --backend conda: {base}")
154
+ if (root / ".awm").is_symlink():
155
+ raise AWMError("Project state directory must not be a symlink")
156
+ shared_path = root / CONFIG_NAME
157
+ if shared_path.exists():
158
+ if repositories or name:
159
+ raise AWMError(
160
+ "Configuration already exists; edit awm.toml to change its name/repositories"
161
+ )
162
+ shared = read_toml(shared_path)
163
+ text = None
164
+ else:
165
+ entries = repositories or ["repo=."]
166
+ text = f"schema_version = 1\nname = {json.dumps(name or root.name)}\n"
167
+ seen = set()
168
+ for entry in entries:
169
+ alias, sep, value = entry.partition("=")
170
+ if not sep or not value or slug(alias) in seen:
171
+ raise AWMError("Repositories use unique ALIAS=PATH entries")
172
+ seen.add(alias)
173
+ path = (root / Path(value).expanduser()).resolve()
174
+ check_repo(path)
175
+ text += f"\n[repositories.{json.dumps(alias)}]\npath = {json.dumps(os.path.relpath(path, root))}\ntargets = []\n"
176
+ if (root / ".awm" / "local.toml").exists():
177
+ raise AWMError("Project is already initialized; edit .awm/local.toml to change its base")
178
+ local_text = (
179
+ f'schema_version = 1\nproject_id = "{uuid.uuid4()}"\n\n[environment]\n'
180
+ f"backend = {json.dumps(backend)}\nbase = {json.dumps(str(base))}\n"
181
+ )
182
+ candidate = parse_project(
183
+ root, tomllib.loads(text) if text is not None else shared, tomllib.loads(local_text)
184
+ )
185
+ for repo in candidate.repos.values():
186
+ check_repo(repo.path)
187
+ if dry_run:
188
+ return None
189
+ with lock(root / ".awm" / "operation.lock"):
190
+ if (root / ".awm" / "local.toml").exists():
191
+ raise AWMError("Project was initialized by another process")
192
+ atomic_text(root / ".awm" / ".gitignore", "*\n")
193
+ if text is not None:
194
+ atomic_text(shared_path, text)
195
+ atomic_text(root / ".awm" / "local.toml", local_text)
196
+ return load_project(root)