diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7351947..aa00a0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,26 @@
All notable changes to this project are documented here. Pythonlings follows
Semantic Versioning.
+## [0.4.0] - 2026-06-20
+
+### Added
+
+- Zero-config first run: `pythonlings` with no workspace creates one at
+ `~/.pythonlings` and opens the first exercise — no `init`, no `--path`, no
+ `cd` required.
+- `PYTHONLINGS_HOME` environment variable to point the default workspace
+ somewhere else.
+
+### Changed
+
+- `pythonlings init` with no `--path` now targets `~/.pythonlings` instead of
+ the current directory, so it can no longer fail by landing in a folder full
+ of unrelated files.
+- `init` is now idempotent on an existing workspace (a friendly note instead of
+ an error), and its non-empty-directory error explains how to proceed.
+- Every command resolves its workspace the same way: an explicit `--root`, then
+ the current directory if it is a workspace, then `~/.pythonlings`.
+
## [0.3.1] - 2026-06-20
### Fixed
diff --git a/Readme.md b/Readme.md
index b750e61..083129a 100644
--- a/Readme.md
+++ b/Readme.md
@@ -20,14 +20,13 @@ Python documentation snippets so learners can work without leaving the terminal.
and [uv](https://docs.astral.sh/uv/):
```bash
-uvx pythonlings init --path ./learn-python
-cd learn-python && uvx pythonlings
+uvx pythonlings
```
How it works: **edit** the broken exercise in the built-in editor → checks
rerun as you type and advance you to the next one. That's the whole loop.
-Status: `v0.3.1`, alpha — published on PyPI as `pythonlings`.
+Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`.

@@ -49,8 +48,9 @@ Status: `v0.3.1`, alpha — published on PyPI as `pythonlings`.
## What Happens When You Run It
-1. `pythonlings init` creates a self-contained learner workspace with exercises,
- hidden checks, local docs, and original snapshots for reset.
+1. `pythonlings` with no workspace creates a self-contained one at
+ `~/.pythonlings` (override with `PYTHONLINGS_HOME` or `pythonlings init
+ --path
`) and opens the first exercise.
2. `pythonlings` opens the first pending exercise in the terminal UI.
3. You edit the broken code, remove `# I AM NOT DONE`, and checks rerun as you
work.
diff --git a/docs/superpowers/plans/2026-06-20-zero-config-first-run.md b/docs/superpowers/plans/2026-06-20-zero-config-first-run.md
new file mode 100644
index 0000000..e3f94ac
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-20-zero-config-first-run.md
@@ -0,0 +1,644 @@
+# Zero-config First Run Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make bare `pythonlings` set up a workspace automatically (hidden `~/.pythonlings`, overridable) and launch the first exercise, so a new user just types `pythonlings`.
+
+**Architecture:** A new `core/workspace.py` owns the default location and a precedence resolver (explicit `--root` → cwd-workspace → home-workspace → create home). `cli.py` switches `--root`/`--path` to lazy defaults and routes every command's root through the resolver; only TUI-launch commands auto-create. `init` gets friendlier, actionable messages. No curriculum, state-format, or TUI changes.
+
+**Tech Stack:** Python 3.9+, argparse, pathlib, dataclasses, pytest (+ pytest-asyncio auto mode). Textual stays out of unit tests via mocking `run_tui`.
+
+## Global Constraints
+
+- `requires-python = ">=3.9"`; start every new module with `from __future__ import annotations`.
+- Default workspace root: `PYTHONLINGS_HOME` env var if set, else `~/.pythonlings`.
+- The unrelated `pylings` PyPI name is never used; command/package are `pythonlings`.
+- Conventional commit prefixes (`feat:`, `fix:`, `docs:`, `test:`, `chore:`).
+- CLI tests must mock `pythonlings.app.run_tui` — never launch Textual.
+- Target release: **0.4.0** (minor).
+- Keep core free of UI imports (`core/` has no Textual/screens imports).
+
+---
+
+### Task 1: Default location + workspace detection (`core/workspace.py`)
+
+**Files:**
+- Create: `pythonlings/core/workspace.py`
+- Test: `tests/unit/test_workspace.py`
+
+**Interfaces:**
+- Produces: `default_workspace_root() -> Path` (reads `PYTHONLINGS_HOME`, else `~/.pythonlings`, always expanded+resolved); `is_workspace(path: Path) -> bool` (True iff `path/"info.toml"` is a file).
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/unit/test_workspace.py
+from __future__ import annotations
+
+from pathlib import Path
+
+from pythonlings.core.workspace import default_workspace_root, is_workspace
+
+
+def test_default_root_uses_env_when_set(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "custom"))
+ assert default_workspace_root() == (tmp_path / "custom").resolve()
+
+
+def test_default_root_falls_back_to_hidden_home_dir(monkeypatch):
+ monkeypatch.delenv("PYTHONLINGS_HOME", raising=False)
+ assert default_workspace_root() == (Path.home() / ".pythonlings").resolve()
+
+
+def test_is_workspace_true_only_with_info_toml(tmp_path):
+ assert is_workspace(tmp_path) is False
+ (tmp_path / "info.toml").write_text("", encoding="utf-8")
+ assert is_workspace(tmp_path) is True
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/unit/test_workspace.py -q`
+Expected: FAIL with `ModuleNotFoundError: No module named 'pythonlings.core.workspace'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# pythonlings/core/workspace.py
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+
+def default_workspace_root() -> Path:
+ """Where a no-argument `pythonlings` keeps its workspace.
+
+ `PYTHONLINGS_HOME` overrides; otherwise a hidden `~/.pythonlings`.
+ """
+ env = os.environ.get("PYTHONLINGS_HOME")
+ if env:
+ return Path(env).expanduser().resolve()
+ return (Path.home() / ".pythonlings").resolve()
+
+
+def is_workspace(path: Path) -> bool:
+ """True if `path` is a pythonlings workspace (has an `info.toml`)."""
+ return (path / "info.toml").is_file()
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `python -m pytest tests/unit/test_workspace.py -q`
+Expected: PASS (3 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pythonlings/core/workspace.py tests/unit/test_workspace.py
+git commit -m "feat: add default_workspace_root and is_workspace helpers"
+```
+
+---
+
+### Task 2: Workspace resolver (`resolve_workspace_root`)
+
+**Files:**
+- Modify: `pythonlings/core/workspace.py`
+- Test: `tests/unit/test_workspace.py`
+
+**Interfaces:**
+- Consumes: `default_workspace_root`, `is_workspace` (Task 1); `init_workspace` from `pythonlings.core.curriculum` (signature `init_workspace(path: Path, *, force: bool = False) -> Path`).
+- Produces: `ResolvedWorkspace` (frozen dataclass with `root: Path`, `created: bool`); `resolve_workspace_root(cwd: Path, explicit_root: Path | None = None, *, create_if_missing: bool = False) -> ResolvedWorkspace`.
+
+Precedence: explicit_root → cwd-is-workspace → home-is-workspace → (create home if `create_if_missing` else return home uncreated).
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# append to tests/unit/test_workspace.py
+from pythonlings.core.workspace import ResolvedWorkspace, resolve_workspace_root
+
+
+def _make_ws(path: Path) -> Path:
+ path.mkdir(parents=True, exist_ok=True)
+ (path / "info.toml").write_text("", encoding="utf-8")
+ return path
+
+
+def test_resolve_prefers_explicit_root(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home"))
+ explicit = tmp_path / "given"
+ out = resolve_workspace_root(tmp_path / "cwd", explicit, create_if_missing=True)
+ assert out == ResolvedWorkspace(explicit.resolve(), created=False)
+
+
+def test_resolve_uses_cwd_when_cwd_is_workspace(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home"))
+ cwd = _make_ws(tmp_path / "cwd")
+ out = resolve_workspace_root(cwd, None, create_if_missing=True)
+ assert out == ResolvedWorkspace(cwd.resolve(), created=False)
+
+
+def test_resolve_resumes_existing_home_workspace(tmp_path, monkeypatch):
+ home = _make_ws(tmp_path / "home")
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True)
+ assert out == ResolvedWorkspace(home.resolve(), created=False)
+
+
+def test_resolve_creates_home_when_missing(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True)
+ assert out.root == home.resolve()
+ assert out.created is True
+ assert (home / "info.toml").is_file()
+
+
+def test_resolve_does_not_create_when_flag_off(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=False)
+ assert out == ResolvedWorkspace(home.resolve(), created=False)
+ assert not home.exists()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/unit/test_workspace.py -q`
+Expected: FAIL with `ImportError: cannot import name 'resolve_workspace_root'`.
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# add to pythonlings/core/workspace.py
+from dataclasses import dataclass
+
+from pythonlings.core.curriculum import init_workspace
+
+
+@dataclass(frozen=True)
+class ResolvedWorkspace:
+ root: Path
+ created: bool
+
+
+def resolve_workspace_root(
+ cwd: Path,
+ explicit_root: Path | None = None,
+ *,
+ create_if_missing: bool = False,
+) -> ResolvedWorkspace:
+ """Pick the workspace root for a command.
+
+ Order: an explicit `--root`, then the current directory if it is a
+ workspace, then the default home workspace, then (only when
+ `create_if_missing`) a freshly created home workspace.
+ """
+ if explicit_root is not None:
+ return ResolvedWorkspace(explicit_root.expanduser().resolve(), created=False)
+
+ cwd = cwd.resolve()
+ if is_workspace(cwd):
+ return ResolvedWorkspace(cwd, created=False)
+
+ home = default_workspace_root()
+ if is_workspace(home):
+ return ResolvedWorkspace(home, created=False)
+
+ if create_if_missing:
+ return ResolvedWorkspace(init_workspace(home), created=True)
+ return ResolvedWorkspace(home, created=False)
+```
+
+Place the `from dataclasses import dataclass` and `from pythonlings.core.curriculum import init_workspace` imports at the top of the module with the existing imports.
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `python -m pytest tests/unit/test_workspace.py -q`
+Expected: PASS (8 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add pythonlings/core/workspace.py tests/unit/test_workspace.py
+git commit -m "feat: add resolve_workspace_root precedence resolver"
+```
+
+---
+
+### Task 3: Friendlier `init` messages
+
+**Files:**
+- Modify: `pythonlings/core/curriculum.py:81-82` (error text)
+- Modify: `pythonlings/cli.py:84-93` (`_cmd_init`)
+- Test: `tests/integration/test_cli_workspace.py`
+
+**Interfaces:**
+- Consumes: `is_workspace` (Task 1).
+- Behavior: init on an existing workspace → friendly, exit 0; init on a non-empty non-workspace dir → actionable error, exit 1; empty/new → scaffold, exit 0.
+
+- [ ] **Step 1: Update the existing test and add two new ones**
+
+Replace `test_init_command_requires_force_for_non_empty_directory` and add the two below in `tests/integration/test_cli_workspace.py`:
+
+```python
+def test_init_rejects_non_empty_non_workspace_dir(tmp_path: Path, capsys) -> None:
+ target = tmp_path / "stuff"
+ target.mkdir()
+ (target / "notes.txt").write_text("keep", encoding="utf-8")
+
+ code = main(["init", "--path", str(target)])
+
+ assert code == 1
+ err = capsys.readouterr().err
+ assert "isn't empty and isn't a pythonlings workspace" in err
+
+
+def test_init_on_existing_workspace_is_friendly_noop(tmp_path: Path, capsys) -> None:
+ target = tmp_path / "ws"
+ assert main(["init", "--path", str(target)]) == 0
+ capsys.readouterr() # discard first output
+
+ code = main(["init", "--path", str(target)])
+
+ assert code == 0
+ assert "Already set up" in capsys.readouterr().out
+
+
+def test_init_force_overwrites_existing_workspace(tmp_path: Path) -> None:
+ target = tmp_path / "ws"
+ assert main(["init", "--path", str(target)]) == 0
+ code = main(["init", "--path", str(target), "--force"])
+ assert code == 0
+ assert (target / "info.toml").exists()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/integration/test_cli_workspace.py -q`
+Expected: FAIL — old message assertion gone; `Already set up` not printed yet.
+
+- [ ] **Step 3: Update the error message in `curriculum.py`**
+
+Replace `pythonlings/core/curriculum.py:81-82`:
+
+```python
+ if path.exists() and any(path.iterdir()) and not force:
+ raise WorkspaceError(
+ f"{path} isn't empty and isn't a pythonlings workspace. "
+ "Pick another location with --path , or --force to set up here anyway."
+ )
+```
+
+- [ ] **Step 4: Update `_cmd_init` in `cli.py`**
+
+Replace `pythonlings/cli.py:84-93`:
+
+```python
+def _cmd_init(path: Path, force: bool) -> int:
+ from pythonlings.core.curriculum import WorkspaceError, init_workspace
+ from pythonlings.core.workspace import is_workspace
+
+ path = path.expanduser().resolve()
+ if is_workspace(path) and not force:
+ print(f"Already set up at {path} — just run `pythonlings`")
+ return 0
+ try:
+ root = init_workspace(path, force=force)
+ except WorkspaceError as e:
+ sys.stderr.write(f"pythonlings: {e}\n")
+ return 1
+ print(f"Created your workspace at {root}")
+ return 0
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `python -m pytest tests/integration/test_cli_workspace.py -q`
+Expected: PASS (all tests in the file).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add pythonlings/core/curriculum.py pythonlings/cli.py tests/integration/test_cli_workspace.py
+git commit -m "feat: make init messages actionable and idempotent"
+```
+
+---
+
+### Task 4: Wire defaults + resolver into the CLI
+
+**Files:**
+- Modify: `pythonlings/cli.py` (`_build_parser` defaults; add `_display_path`; rewrite the resolution block in `main`)
+- Test: `tests/integration/test_first_run.py` (new), `tests/integration/test_cli_workspace.py`
+
+**Interfaces:**
+- Consumes: `resolve_workspace_root`, `default_workspace_root` (Tasks 1-2).
+- Behavior: bare `pythonlings`/`watch`/`start`/`topics` create the home workspace if none is found, print a one-line notice, and launch the TUI on the resolved root. All other root commands resolve cwd-workspace → home-workspace (no create). `init`/`update` with no `--path` default to the home workspace.
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+# tests/integration/test_first_run.py
+from __future__ import annotations
+
+from pathlib import Path
+
+import pythonlings.app as app_module
+from pythonlings.cli import main
+
+
+def test_bare_run_creates_home_workspace_and_launches(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ monkeypatch.chdir(tmp_path) # cwd is not a workspace
+
+ calls = {}
+
+ def fake_run_tui(root, start_topic, force_picker=False):
+ calls["root"] = root
+ return 0
+
+ monkeypatch.setattr(app_module, "run_tui", fake_run_tui)
+
+ code = main([])
+
+ assert code == 0
+ assert (home / "info.toml").is_file()
+ assert calls["root"] == home.resolve()
+ assert "Created your workspace at" in capsys.readouterr().out
+
+
+def test_bare_run_resumes_existing_home_without_notice(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ main(["init", "--path", str(home)])
+ capsys.readouterr()
+ monkeypatch.chdir(tmp_path)
+
+ monkeypatch.setattr(app_module, "run_tui", lambda root, t, force_picker=False: 0)
+
+ code = main([])
+
+ assert code == 0
+ assert "Created your workspace" not in capsys.readouterr().out
+
+
+def test_init_with_no_path_targets_home(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ monkeypatch.chdir(tmp_path)
+
+ code = main(["init"])
+
+ assert code == 0
+ assert (home / "info.toml").is_file()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `python -m pytest tests/integration/test_first_run.py -q`
+Expected: FAIL — `init` still defaults to cwd; bare run does not create/notice.
+
+- [ ] **Step 3: Update parser defaults in `_build_parser`**
+
+In `pythonlings/cli.py`, change line 24 (`--root` default) and lines 30 / 36 (`--path` defaults). First add the import near the top of the module (after the existing imports):
+
+```python
+from pythonlings.core.workspace import default_workspace_root
+```
+
+Then:
+
+```python
+ parser.add_argument(
+ "--root",
+ type=Path,
+ default=None,
+ help="Workspace root containing info.toml (default: auto-resolve).",
+ )
+```
+
+```python
+ p_init.add_argument("--path", type=Path, default=default_workspace_root())
+```
+
+```python
+ p_update.add_argument("--path", type=Path, default=default_workspace_root())
+```
+
+- [ ] **Step 4: Add the `_display_path` helper**
+
+Add near `_resolve_topic` in `pythonlings/cli.py`:
+
+```python
+def _display_path(path: Path) -> str:
+ """Render `path` with a leading `~/` when inside the home directory."""
+ try:
+ return "~/" + str(path.relative_to(Path.home()))
+ except ValueError:
+ return str(path)
+```
+
+- [ ] **Step 5: Rewrite the resolution + dispatch block in `main`**
+
+Replace `pythonlings/cli.py:261-311` (from the migration comment through the watch branch) with:
+
+```python
+ from pythonlings.core.curriculum import migrate_legacy_state_dir
+ from pythonlings.core.workspace import resolve_workspace_root
+
+ try:
+ root: Path | None = None
+ if args.command in ("init", "update"):
+ migrate_legacy_state_dir(Path(args.path))
+ else:
+ launches_tui = args.command in (None, "watch", "start", "topics")
+ resolved = resolve_workspace_root(
+ Path.cwd(), args.root, create_if_missing=launches_tui
+ )
+ root = resolved.root
+ migrate_legacy_state_dir(root)
+ if resolved.created:
+ print(
+ f"Created your workspace at {_display_path(root)} "
+ "(edit in-app, or open that folder in your editor)"
+ )
+
+ if getattr(args, "debug", False) and root is not None:
+ try:
+ (root / ".pythonlings_debug.log").write_text(
+ f"argv={argv if argv is not None else sys.argv[1:]!r}\n",
+ encoding="utf-8",
+ )
+ except OSError:
+ pass
+
+ if args.command == "init":
+ return _cmd_init(args.path, args.force)
+ if args.command == "update":
+ return _cmd_update(args.path)
+
+ assert root is not None
+ if args.command == "verify":
+ return _cmd_verify(root, args.topic)
+ if args.command == "list":
+ return _cmd_list(root, args.topic)
+ if args.command == "hint":
+ return _cmd_hint(root, args.name)
+ if args.command == "run":
+ return _cmd_run(root, args.name)
+ if args.command == "dry-run":
+ return _cmd_run(root, args.name)
+ if args.command in {"solution", "sol"}:
+ return _cmd_solution(root, args.name)
+ if args.command == "reset":
+ return _cmd_reset(root, args.name, args.yes)
+
+ if args.command in (None, "watch", "start", "topics"):
+ start_topic = getattr(args, "topic", None)
+ if start_topic is not None:
+ from pythonlings.core.manifest import load as load_manifest
+ if _resolve_topic(load_manifest(root), start_topic) is None:
+ return 2
+ from pythonlings.app import run_tui # lazy: Textual is heavy
+ return run_tui(
+ root,
+ start_topic,
+ force_picker=args.command == "topics",
+ )
+
+ # Other subcommands wired in later tasks.
+ sys.stderr.write(f"pythonlings: '{args.command}' not implemented yet\n")
+ return 1
+```
+
+Leave the existing `except Exception as e:` handler that follows it unchanged.
+
+- [ ] **Step 6: Run the new tests, then the full suite**
+
+Run: `python -m pytest tests/integration/test_first_run.py -q`
+Expected: PASS (3 passed).
+
+Run: `python -m pytest -q`
+Expected: PASS (full suite). If a pre-existing test fails because it relied on `--root` defaulting to cwd while cwd is not a workspace, fix it by passing an explicit `--root` (matching prior intent).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add pythonlings/cli.py tests/integration/test_first_run.py
+git commit -m "feat: auto-resolve and auto-create the workspace for bare pythonlings"
+```
+
+---
+
+### Task 5: Docs + version bump to 0.4.0
+
+**Files:**
+- Modify: `Readme.md:19-25` (quick-start), `Readme.md:30` (status), `Readme.md:52-53` (step 1)
+- Modify: `pyproject.toml:7` (version)
+- Modify: `CHANGELOG.md` (new `[0.4.0]` entry)
+
+**Interfaces:** none (docs/metadata only).
+
+- [ ] **Step 1: Update the README quick-start**
+
+Replace `Readme.md:19-25`:
+
+```markdown
+**Try it in 10 seconds** — zero install, needs [Python 3.9+](https://www.python.org/downloads/)
+and [uv](https://docs.astral.sh/uv/):
+
+```bash
+uvx pythonlings
+```
+```
+
+Replace `Readme.md:30`:
+
+```markdown
+Status: `v0.4.0`, alpha — published on PyPI as `pythonlings`.
+```
+
+Replace `Readme.md:52-53` (step 1 of "What Happens When You Run It"):
+
+```markdown
+1. `pythonlings` with no workspace creates a self-contained one at
+ `~/.pythonlings` (override with `PYTHONLINGS_HOME` or `pythonlings init
+ --path `) and opens the first exercise.
+```
+
+- [ ] **Step 2: Bump the version**
+
+Replace `pyproject.toml:7`:
+
+```toml
+version = "0.4.0"
+```
+
+- [ ] **Step 3: Add the CHANGELOG entry**
+
+Insert above the most recent entry in `CHANGELOG.md` (after the intro paragraph that ends on line 4):
+
+```markdown
+## [0.4.0] - 2026-06-20
+
+### Added
+
+- Zero-config first run: `pythonlings` with no workspace creates one at
+ `~/.pythonlings` and opens the first exercise — no `init`, no `--path`, no
+ `cd` required.
+- `PYTHONLINGS_HOME` environment variable to point the default workspace
+ somewhere else.
+
+### Changed
+
+- `pythonlings init` with no `--path` now targets `~/.pythonlings` instead of
+ the current directory, so it can no longer fail by landing in a folder full
+ of unrelated files.
+- `init` is now idempotent on an existing workspace (a friendly note instead of
+ an error), and its non-empty-directory error explains how to proceed.
+- Every command resolves its workspace the same way: an explicit `--root`, then
+ the current directory if it is a workspace, then `~/.pythonlings`.
+
+```
+
+- [ ] **Step 4: Verify the build reports the new version**
+
+Run:
+```bash
+python -m pytest -q
+rm -rf dist && python -m build >/dev/null 2>&1
+python -m pip install --force-reinstall dist/pythonlings-0.4.0-py3-none-any.whl >/dev/null
+pythonlings --version
+```
+Expected: full suite PASS; `pythonlings 0.4.0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add Readme.md pyproject.toml CHANGELOG.md
+git commit -m "docs: document zero-config first run and bump to 0.4.0"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:**
+- Default location `PYTHONLINGS_HOME`/`~/.pythonlings` → Task 1.
+- `resolve_workspace_root` precedence (4 branches) → Task 2.
+- `init` default → home; friendly/actionable messages → Tasks 3-4.
+- One-line create notice + `~/.pythonlings` display form → Task 4 (`_display_path`).
+- cwd-workspace-wins; `--root`/`--path` custom support preserved → Tasks 2, 4.
+- Edge: home exists non-empty non-workspace → `init_workspace` raises the actionable error (Task 3) via the resolver's create branch; surfaces through the existing `except` handler.
+- Unit tests (resolver, default root) + integration (init messaging, first run) with `run_tui` mocked → Tasks 1-4.
+- Version 0.4.0 + CHANGELOG → Task 5.
+
+**Placeholder scan:** none — every code/test step is complete.
+
+**Type consistency:** `default_workspace_root() -> Path`, `is_workspace(Path) -> bool`, `ResolvedWorkspace(root, created)`, and `resolve_workspace_root(cwd, explicit_root=None, *, create_if_missing=False)` are used identically across Tasks 2 and 4. `_display_path(Path) -> str` used only in Task 4.
+
+**Known risk:** changing `--root`'s default from `Path.cwd()` to `None` could surface a pre-existing test that ran a root command without `--root` from a non-workspace cwd. Task 4 Step 6 runs the full suite to catch it; the fix is to pass an explicit `--root`.
diff --git a/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md b/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md
new file mode 100644
index 0000000..c3d40d9
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-20-zero-config-first-run-design.md
@@ -0,0 +1,128 @@
+# Zero-config first run for pythonlings
+
+- **Date:** 2026-06-20
+- **Status:** Approved design
+- **Target release:** 0.4.0 (minor — new behavior)
+
+## Problem
+
+`pythonlings init` and bare `pythonlings` both default their target directory to
+the current working directory (`Path.cwd()`):
+
+- `init` scaffolds into cwd and aborts with ` already exists and is not
+ empty` when cwd contains unrelated files. Observed in the wild: a user ran
+ `pythonlings init` inside a folder full of `.mp4` screen recordings and got a
+ cryptic failure.
+- Bare `pythonlings` launches the TUI against cwd and only prints guidance when
+ cwd lacks `info.toml`.
+
+A brand-new user therefore has to know to create and enter a dedicated directory
+before anything works. We want Rustlings-grade onboarding: install, type
+`pythonlings`, start learning.
+
+## Goals
+
+- A first-time user runs `pythonlings` (no flags, from any directory) and lands
+ in the first exercise.
+- No prompts, no required `init`, no `--path`.
+- Existing workflows keep working: `--root`, `--path`, running inside a
+ workspace, and the test fixtures.
+- `init`'s confusing non-empty error is replaced with actionable guidance.
+
+## Non-goals
+
+- An in-TUI welcome / onboarding screen (possible later; not required here).
+- Changes to the curriculum, the state format, or the TUI editor.
+
+## Design
+
+### Default workspace location
+
+- Default workspace root: the `PYTHONLINGS_HOME` environment variable if set,
+ otherwise `~/.pythonlings` (a hidden folder).
+- Why hidden: exercises are edited in the **built-in TUI editor** (checks rerun
+ as you type), so a beginner never navigates to the files on disk. A hidden
+ folder keeps `$HOME` clean. Power users who prefer their own editor pass an
+ explicit `--path` / `--root` pointing at a visible location.
+- Single source of truth: `default_workspace_root() -> Path` in `core/`. It
+ reads `PYTHONLINGS_HOME` (expanded + resolved) and falls back to
+ `Path.home() / ".pythonlings"`.
+- Note: the in-workspace state dir is also named `.pythonlings/`, so state for
+ the default workspace lands at `~/.pythonlings/.pythonlings/state.json`.
+ Functional; no change to the state-dir convention.
+
+### Workspace resolution (bare `pythonlings`, no `--root`)
+
+A new `resolve_workspace_root(cwd, explicit_root=None)` in `core/` returns the
+chosen root and whether it was just created (e.g. a small dataclass/namedtuple
+`ResolvedWorkspace(root: Path, created: bool)`). Precedence:
+
+1. **`explicit_root` given** (`--root`): use it as-is. Existing behavior — error
+ later if it is not a workspace.
+2. **cwd is a workspace** (`cwd/info.toml` exists): use cwd. Preserves "run
+ inside a workspace", multiple workspaces, and the `--root` test fixtures.
+3. **default home workspace exists** (`default_workspace_root()/info.toml`
+ exists): resume it, silently.
+4. **none of the above**: create a workspace at `default_workspace_root()` via
+ `init_workspace`, and return `created=True`.
+
+`cli.py` calls the resolver, prints the one-line create notice when `created`,
+then launches the TUI. The TUI already resumes at the first unsolved exercise,
+so a fresh workspace lands on `variables1` with no extra code.
+
+### `init` changes
+
+- `--path` default changes from `Path.cwd()` to `default_workspace_root()`.
+- `_cmd_init` / `init_workspace` behavior by target state:
+ - **Already a workspace** (`info.toml` present): print
+ `` Already set up at — just run `pythonlings` `` and exit `0` (not an
+ error).
+ - **Empty or nonexistent**: scaffold as today.
+ - **Non-empty and NOT a workspace**: error with
+ ` isn't empty and isn't a pythonlings workspace. Pick another location with --path , or --force to set up here anyway.`
+ and a non-zero exit. This replaces today's cryptic message.
+ - **`--force`**: scaffold regardless (unchanged).
+
+### Messaging
+
+- Create notice: `Created your workspace at (edit in-app, or open that folder in your editor)`,
+ where `` renders the `~/.pythonlings` form rather than an
+ absolute path.
+- Resume: silent (just launch).
+
+### Edge cases
+
+- Default home root exists, non-empty, no `info.toml`: treated as the `init`
+ non-workspace error (never clobber). Rare.
+- Not a TTY: the resolver still creates the workspace; TUI launch behaves as it
+ does today in non-interactive contexts (out of scope to change here).
+- `PYTHONLINGS_HOME` set to a relative or `~`-prefixed path: resolved via
+ `Path(...).expanduser().resolve()`.
+
+## Affected code
+
+- `core/` (`curriculum.py`, or a focused new `core/workspace.py`):
+ `default_workspace_root()`, `resolve_workspace_root()`, and the friendlier
+ `init_workspace` messaging.
+- `cli.py`: `--path` / `--root` defaults; the bare-command resolver call + create
+ notice; `_cmd_init` messaging.
+
+## Testing
+
+- **Unit** (`tests/unit/`):
+ - `default_workspace_root()`: respects `PYTHONLINGS_HOME`; falls back to
+ `~/.pythonlings` (monkeypatch env + `Path.home`).
+ - `resolve_workspace_root()`: all four precedence branches (explicit root,
+ cwd-workspace, existing home workspace, create-new), asserting the chosen
+ root and the `created` flag, using `tmp_path` + monkeypatched home. No TUI.
+ - `init` messaging: existing-workspace → friendly exit 0; non-empty
+ non-workspace → actionable error; empty → scaffolds.
+- **Integration** (`tests/integration/`): `pythonlings init` (no path) targets
+ the monkeypatched home; bare run resolves correctly with `run_tui` patched.
+- Keep `run_tui` mocked in CLI tests so Textual stays out of unit tests.
+
+## Versioning
+
+Minor feature → **0.4.0**. CHANGELOG entries under `Added` (zero-config first
+run, `PYTHONLINGS_HOME`) and `Changed` (`init` default location + friendlier
+errors).
diff --git a/pyproject.toml b/pyproject.toml
index 924dc03..d19ec16 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "pythonlings"
-version = "0.3.1"
+version = "0.4.0"
description = "Python learnings, Rustlings-style, in a terminal TUI."
readme = "Readme.md"
requires-python = ">=3.9"
diff --git a/pythonlings/cli.py b/pythonlings/cli.py
index 9ffccb1..f12905c 100644
--- a/pythonlings/cli.py
+++ b/pythonlings/cli.py
@@ -6,6 +6,8 @@
from importlib.metadata import PackageNotFoundError, version as _package_version
from pathlib import Path
+from pythonlings.core.workspace import default_workspace_root
+
try:
__version__ = _package_version("pythonlings")
except PackageNotFoundError: # running from a source checkout without an install
@@ -21,19 +23,19 @@ def _build_parser() -> argparse.ArgumentParser:
parser.add_argument(
"--root",
type=Path,
- default=Path.cwd(),
- help="Project root containing info.toml (default: cwd).",
+ default=None,
+ help="Workspace root containing info.toml (default: auto-resolve).",
)
sub = parser.add_subparsers(dest="command")
p_init = sub.add_parser("init", help="Create a pythonlings workspace.")
- p_init.add_argument("--path", type=Path, default=Path.cwd())
+ p_init.add_argument("--path", type=Path, default=default_workspace_root())
p_init.add_argument(
"--force", action="store_true", help="Overwrite managed workspace files."
)
p_update = sub.add_parser("update", help="Update an existing pythonlings workspace.")
- p_update.add_argument("--path", type=Path, default=Path.cwd())
+ p_update.add_argument("--path", type=Path, default=default_workspace_root())
sub.add_parser("watch", help="Launch the TUI in watch mode (default).")
sub.add_parser("topics", help="Launch the TUI on the topic picker.")
@@ -70,6 +72,14 @@ def _build_parser() -> argparse.ArgumentParser:
return parser
+def _display_path(path: Path) -> str:
+ """Render `path` with a leading `~/` when inside the home directory."""
+ try:
+ return "~/" + str(path.relative_to(Path.home()))
+ except ValueError:
+ return str(path)
+
+
def _resolve_topic(manifest, topic: str):
"""Return the topic name if valid, else write an error and return None."""
if topic in manifest.topics():
@@ -83,13 +93,18 @@ def _resolve_topic(manifest, topic: str):
def _cmd_init(path: Path, force: bool) -> int:
from pythonlings.core.curriculum import WorkspaceError, init_workspace
+ from pythonlings.core.workspace import is_workspace
+ path = path.expanduser().resolve()
+ if is_workspace(path) and not force:
+ print(f"Already set up at {path} — just run `pythonlings`")
+ return 0
try:
root = init_workspace(path, force=force)
except WorkspaceError as e:
sys.stderr.write(f"pythonlings: {e}\n")
return 1
- print(f"initialized: {root}")
+ print(f"Created your workspace at {_display_path(root)}")
return 0
@@ -258,19 +273,29 @@ def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
- # Migrate any legacy .pylings/ state dir. init/update target --path; the
- # in-workspace commands target --root. Both are no-ops without a legacy dir.
from pythonlings.core.curriculum import migrate_legacy_state_dir
-
- for attr in ("path", "root"):
- workspace = getattr(args, attr, None)
- if workspace is not None:
- migrate_legacy_state_dir(Path(workspace))
+ from pythonlings.core.workspace import resolve_workspace_root
try:
- if getattr(args, "debug", False):
+ root: Path | None = None
+ if args.command in ("init", "update"):
+ migrate_legacy_state_dir(Path(args.path))
+ else:
+ launches_tui = args.command in (None, "watch", "start", "topics")
+ resolved = resolve_workspace_root(
+ Path.cwd(), args.root, create_if_missing=launches_tui
+ )
+ root = resolved.root
+ migrate_legacy_state_dir(root)
+ if resolved.created:
+ print(
+ f"Created your workspace at {_display_path(root)} "
+ "(edit in-app, or open that folder in your editor)"
+ )
+
+ if getattr(args, "debug", False) and root is not None:
try:
- (args.root / ".pythonlings_debug.log").write_text(
+ (root / ".pythonlings_debug.log").write_text(
f"argv={argv if argv is not None else sys.argv[1:]!r}\n",
encoding="utf-8",
)
@@ -282,30 +307,31 @@ def main(argv: list[str] | None = None) -> int:
if args.command == "update":
return _cmd_update(args.path)
+ assert root is not None
if args.command == "verify":
- return _cmd_verify(args.root, args.topic)
+ return _cmd_verify(root, args.topic)
if args.command == "list":
- return _cmd_list(args.root, args.topic)
+ return _cmd_list(root, args.topic)
if args.command == "hint":
- return _cmd_hint(args.root, args.name)
+ return _cmd_hint(root, args.name)
if args.command == "run":
- return _cmd_run(args.root, args.name)
+ return _cmd_run(root, args.name)
if args.command == "dry-run":
- return _cmd_run(args.root, args.name)
+ return _cmd_run(root, args.name)
if args.command in {"solution", "sol"}:
- return _cmd_solution(args.root, args.name)
+ return _cmd_solution(root, args.name)
if args.command == "reset":
- return _cmd_reset(args.root, args.name, args.yes)
+ return _cmd_reset(root, args.name, args.yes)
if args.command in (None, "watch", "start", "topics"):
start_topic = getattr(args, "topic", None)
if start_topic is not None:
from pythonlings.core.manifest import load as load_manifest
- if _resolve_topic(load_manifest(args.root), start_topic) is None:
+ if _resolve_topic(load_manifest(root), start_topic) is None:
return 2
from pythonlings.app import run_tui # lazy: Textual is heavy
return run_tui(
- args.root,
+ root,
start_topic,
force_picker=args.command == "topics",
)
diff --git a/pythonlings/core/curriculum.py b/pythonlings/core/curriculum.py
index d9a7e58..655dbf5 100644
--- a/pythonlings/core/curriculum.py
+++ b/pythonlings/core/curriculum.py
@@ -79,7 +79,10 @@ def _sync_originals(root: Path, src_root: Path) -> None:
def init_workspace(path: Path, *, force: bool = False) -> Path:
path = path.expanduser().resolve()
if path.exists() and any(path.iterdir()) and not force:
- raise WorkspaceError(f"{path} already exists and is not empty")
+ raise WorkspaceError(
+ f"{path} isn't empty and isn't a pythonlings workspace. "
+ "Pick another location with --path , or --force to set up here anyway."
+ )
path.mkdir(parents=True, exist_ok=True)
src_root = source_root()
diff --git a/pythonlings/core/manifest.py b/pythonlings/core/manifest.py
index ca6b188..fcf41e0 100644
--- a/pythonlings/core/manifest.py
+++ b/pythonlings/core/manifest.py
@@ -50,7 +50,7 @@ def load(root: Path) -> Manifest:
info_path = root / "info.toml"
if not info_path.exists():
raise ManifestError(
- f"no pythonlings workspace here ({info_path} not found). "
+ f"no pythonlings workspace at {root} (info.toml not found). "
"Run 'pythonlings init' to create one."
)
diff --git a/pythonlings/core/workspace.py b/pythonlings/core/workspace.py
new file mode 100644
index 0000000..c55713e
--- /dev/null
+++ b/pythonlings/core/workspace.py
@@ -0,0 +1,57 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+
+from pythonlings.core.curriculum import init_workspace
+
+
+@dataclass(frozen=True)
+class ResolvedWorkspace:
+ root: Path
+ created: bool
+
+
+def default_workspace_root() -> Path:
+ """Where a no-argument `pythonlings` keeps its workspace.
+
+ `PYTHONLINGS_HOME` overrides; otherwise a hidden `~/.pythonlings`.
+ """
+ env = os.environ.get("PYTHONLINGS_HOME")
+ if env:
+ return Path(env).expanduser().resolve()
+ return (Path.home() / ".pythonlings").resolve()
+
+
+def is_workspace(path: Path) -> bool:
+ """True if `path` is a pythonlings workspace (has an `info.toml`)."""
+ return (path / "info.toml").is_file()
+
+
+def resolve_workspace_root(
+ cwd: Path,
+ explicit_root: Path | None = None,
+ *,
+ create_if_missing: bool = False,
+) -> ResolvedWorkspace:
+ """Pick the workspace root for a command.
+
+ Order: an explicit `--root`, then the current directory if it is a
+ workspace, then the default home workspace, then (only when
+ `create_if_missing`) a freshly created home workspace.
+ """
+ if explicit_root is not None:
+ return ResolvedWorkspace(explicit_root.expanduser().resolve(), created=False)
+
+ cwd = cwd.resolve()
+ if is_workspace(cwd):
+ return ResolvedWorkspace(cwd, created=False)
+
+ home = default_workspace_root()
+ if is_workspace(home):
+ return ResolvedWorkspace(home, created=False)
+
+ if create_if_missing:
+ return ResolvedWorkspace(init_workspace(home), created=True)
+ return ResolvedWorkspace(home, created=False)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..dda4e9b
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,19 @@
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True, scope="function")
+def _isolate_pythonlings_home(monkeypatch, tmp_path):
+ """Point PYTHONLINGS_HOME at a fresh tmp dir for every test.
+
+ This prevents any test from accidentally creating or reading the
+ developer's real ~/.pythonlings workspace.
+
+ Tests that need to exercise the *absence* of PYTHONLINGS_HOME
+ (e.g. test_default_root_falls_back_to_hidden_home_dir) can simply
+ call ``monkeypatch.delenv("PYTHONLINGS_HOME", raising=False)`` in
+ their own body — the per-test monkeypatch runs after this fixture
+ but within the same scope, so the test's own delenv/setenv wins.
+ """
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "_pythonlings_home"))
diff --git a/tests/integration/test_cli_workspace.py b/tests/integration/test_cli_workspace.py
index d6ba721..7294d22 100644
--- a/tests/integration/test_cli_workspace.py
+++ b/tests/integration/test_cli_workspace.py
@@ -14,17 +14,35 @@ def test_init_command_creates_workspace(tmp_path: Path) -> None:
assert (target / "checks").is_dir()
-def test_init_command_requires_force_for_non_empty_directory(
- tmp_path: Path, capsys
-) -> None:
- target = tmp_path / "learn-python"
+def test_init_rejects_non_empty_non_workspace_dir(tmp_path: Path, capsys) -> None:
+ target = tmp_path / "stuff"
target.mkdir()
(target / "notes.txt").write_text("keep", encoding="utf-8")
code = main(["init", "--path", str(target)])
assert code == 1
- assert "already exists and is not empty" in capsys.readouterr().err
+ err = capsys.readouterr().err
+ assert "isn't empty and isn't a pythonlings workspace" in err
+
+
+def test_init_on_existing_workspace_is_friendly_noop(tmp_path: Path, capsys) -> None:
+ target = tmp_path / "ws"
+ assert main(["init", "--path", str(target)]) == 0
+ capsys.readouterr() # discard first output
+
+ code = main(["init", "--path", str(target)])
+
+ assert code == 0
+ assert "Already set up" in capsys.readouterr().out
+
+
+def test_init_force_overwrites_existing_workspace(tmp_path: Path) -> None:
+ target = tmp_path / "ws"
+ assert main(["init", "--path", str(target)]) == 0
+ code = main(["init", "--path", str(target), "--force"])
+ assert code == 0
+ assert (target / "info.toml").exists()
def test_update_via_path_migrates_legacy_state_dir(tmp_path: Path) -> None:
diff --git a/tests/integration/test_first_run.py b/tests/integration/test_first_run.py
new file mode 100644
index 0000000..0e40ee2
--- /dev/null
+++ b/tests/integration/test_first_run.py
@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import pythonlings.app as app_module
+from pythonlings.cli import main
+
+
+def test_bare_run_creates_home_workspace_and_launches(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ monkeypatch.chdir(tmp_path) # cwd is not a workspace
+
+ calls = {}
+
+ def fake_run_tui(root, start_topic, force_picker=False):
+ calls["root"] = root
+ return 0
+
+ monkeypatch.setattr(app_module, "run_tui", fake_run_tui)
+
+ code = main([])
+
+ assert code == 0
+ assert (home / "info.toml").is_file()
+ assert calls["root"] == home.resolve()
+ assert "Created your workspace at" in capsys.readouterr().out
+
+
+def test_bare_run_resumes_existing_home_without_notice(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ main(["init", "--path", str(home)])
+ capsys.readouterr()
+ monkeypatch.chdir(tmp_path)
+
+ monkeypatch.setattr(app_module, "run_tui", lambda root, t, force_picker=False: 0)
+
+ code = main([])
+
+ assert code == 0
+ assert "Created your workspace" not in capsys.readouterr().out
+
+
+def test_init_with_no_path_targets_home(tmp_path, monkeypatch, capsys):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ monkeypatch.chdir(tmp_path)
+
+ code = main(["init"])
+
+ assert code == 0
+ assert (home / "info.toml").is_file()
diff --git a/tests/unit/test_curriculum.py b/tests/unit/test_curriculum.py
index 948cb45..f923e88 100644
--- a/tests/unit/test_curriculum.py
+++ b/tests/unit/test_curriculum.py
@@ -38,7 +38,7 @@ def test_init_workspace_refuses_non_empty_directory(tmp_path: Path) -> None:
try:
curriculum.init_workspace(target)
except curriculum.WorkspaceError as exc:
- assert "already exists and is not empty" in str(exc)
+ assert "isn't empty and isn't a pythonlings workspace" in str(exc)
else:
raise AssertionError("expected WorkspaceError")
diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py
new file mode 100644
index 0000000..bb9f8dc
--- /dev/null
+++ b/tests/unit/test_workspace.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from pythonlings.core.workspace import (
+ ResolvedWorkspace,
+ default_workspace_root,
+ is_workspace,
+ resolve_workspace_root,
+)
+
+
+def test_default_root_uses_env_when_set(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "custom"))
+ assert default_workspace_root() == (tmp_path / "custom").resolve()
+
+
+def test_default_root_falls_back_to_hidden_home_dir(monkeypatch):
+ monkeypatch.delenv("PYTHONLINGS_HOME", raising=False)
+ assert default_workspace_root() == (Path.home() / ".pythonlings").resolve()
+
+
+def test_is_workspace_true_only_with_info_toml(tmp_path):
+ assert is_workspace(tmp_path) is False
+ (tmp_path / "info.toml").write_text("", encoding="utf-8")
+ assert is_workspace(tmp_path) is True
+
+
+def _make_ws(path: Path) -> Path:
+ path.mkdir(parents=True, exist_ok=True)
+ (path / "info.toml").write_text("", encoding="utf-8")
+ return path
+
+
+def test_resolve_prefers_explicit_root(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home"))
+ explicit = tmp_path / "given"
+ out = resolve_workspace_root(tmp_path / "cwd", explicit, create_if_missing=True)
+ assert out == ResolvedWorkspace(explicit.resolve(), created=False)
+
+
+def test_resolve_uses_cwd_when_cwd_is_workspace(tmp_path, monkeypatch):
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(tmp_path / "home"))
+ cwd = _make_ws(tmp_path / "cwd")
+ out = resolve_workspace_root(cwd, None, create_if_missing=True)
+ assert out == ResolvedWorkspace(cwd.resolve(), created=False)
+
+
+def test_resolve_resumes_existing_home_workspace(tmp_path, monkeypatch):
+ home = _make_ws(tmp_path / "home")
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True)
+ assert out == ResolvedWorkspace(home.resolve(), created=False)
+
+
+def test_resolve_creates_home_when_missing(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=True)
+ assert out.root == home.resolve()
+ assert out.created is True
+ assert (home / "info.toml").is_file()
+
+
+def test_resolve_does_not_create_when_flag_off(tmp_path, monkeypatch):
+ home = tmp_path / "home"
+ monkeypatch.setenv("PYTHONLINGS_HOME", str(home))
+ out = resolve_workspace_root(tmp_path / "cwd", None, create_if_missing=False)
+ assert out == ResolvedWorkspace(home.resolve(), created=False)
+ assert not home.exists()