treebox 0.1.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.
treebox/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """treebox: isolated, ready-to-run git worktrees for AI coding agents."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("treebox")
7
+ except PackageNotFoundError: # not installed (e.g. running from a bare source tree)
8
+ __version__ = "0+unknown"
@@ -0,0 +1,103 @@
1
+ FROM python:3.14.6-slim
2
+
3
+ ARG TZ=UTC
4
+ ARG USER_UID=1000
5
+ ARG USER_GID=1000
6
+ ARG USERNAME=agent
7
+ ARG NODE_MAJOR=22
8
+ ARG CODEX_CLI_VERSION=latest
9
+ ARG CLAUDE_CODE_VERSION=latest
10
+ ARG GIT_DELTA_VERSION=0.18.2
11
+
12
+ ENV TZ="$TZ"
13
+ ENV DEBIAN_FRONTEND=noninteractive
14
+
15
+ RUN apt-get update && apt-get install -y --no-install-recommends \
16
+ ca-certificates curl wget gnupg2 less git procps sudo fzf zsh man-db \
17
+ unzip zip tar gzip bzip2 xz-utils nano vim jq yq rsync ripgrep fd-find \
18
+ iptables ipset iproute2 dnsutils aggregate openssh-client socat \
19
+ gcc g++ make cmake pkg-config python3-dev python3-venv bubblewrap \
20
+ && curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \
21
+ && mkdir -p -m 755 /etc/apt/keyrings \
22
+ && curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
23
+ | dd of=/etc/apt/keyrings/githubcli-archive-keyring.gpg \
24
+ && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
25
+ && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
26
+ > /etc/apt/sources.list.d/github-cli.list \
27
+ && apt-get update && apt-get install -y --no-install-recommends nodejs gh \
28
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
29
+
30
+ RUN ARCH=$(dpkg --print-architecture) && \
31
+ wget -q "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
32
+ dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
33
+ rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
34
+
35
+ RUN ARCH=$(dpkg --print-architecture) && \
36
+ case "$ARCH" in \
37
+ amd64) AWS_ARCH=x86_64 ;; \
38
+ arm64) AWS_ARCH=aarch64 ;; \
39
+ *) echo "unsupported arch $ARCH" && exit 1 ;; \
40
+ esac && \
41
+ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${AWS_ARCH}.zip" -o /tmp/awscliv2.zip && \
42
+ unzip -q /tmp/awscliv2.zip -d /tmp && \
43
+ /tmp/aws/install && \
44
+ rm -rf /tmp/awscliv2.zip /tmp/aws
45
+
46
+ RUN if ! getent group "${USER_GID}" >/dev/null; then groupadd --gid "${USER_GID}" "${USERNAME}"; fi && \
47
+ useradd -m -u "${USER_UID}" -g "${USER_GID}" -s /bin/zsh "${USERNAME}" && \
48
+ echo "${USERNAME} ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/${USERNAME}-firewall && \
49
+ chmod 0440 /etc/sudoers.d/${USERNAME}-firewall
50
+
51
+ RUN mkdir -p /workspace /home/${USERNAME}/.codex /home/${USERNAME}/.claude /home/${USERNAME}/.aws /commandhistory && \
52
+ chown -R ${USERNAME}:${USER_GID} /workspace /home/${USERNAME} /commandhistory
53
+
54
+ RUN echo "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
55
+ >> /home/${USERNAME}/.bashrc && \
56
+ echo "export HISTFILE=/commandhistory/.zsh_history" \
57
+ >> /home/${USERNAME}/.zshrc && \
58
+ touch /commandhistory/.bash_history /commandhistory/.zsh_history && \
59
+ chown -R ${USERNAME}:${USER_GID} /commandhistory /home/${USERNAME}/.bashrc /home/${USERNAME}/.zshrc
60
+
61
+ USER ${USERNAME}
62
+
63
+ RUN curl -LsSf https://astral.sh/uv/install.sh | sh
64
+ ENV PATH="/home/${USERNAME}/.local/bin:${PATH}"
65
+
66
+ USER root
67
+ RUN npm install -g "@openai/codex@${CODEX_CLI_VERSION}" && codex --version
68
+
69
+ USER ${USERNAME}
70
+ RUN curl -fsSL https://claude.ai/install.sh | bash -s ${CLAUDE_CODE_VERSION} && claude --version
71
+
72
+ USER root
73
+
74
+ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
75
+ RUN npm install -g @playwright/cli playwright && \
76
+ mkdir -p /ms-playwright && \
77
+ npx playwright install-deps chromium && \
78
+ playwright-cli install-browser && \
79
+ chown -R ${USERNAME}:${USER_GID} /ms-playwright && \
80
+ chmod -R u+rwX,go+rX /ms-playwright && \
81
+ CHROMIUM_BIN="$(find /ms-playwright -type f -name chrome -path '*chromium-*' | head -1)" && \
82
+ if [ -z "$CHROMIUM_BIN" ]; then echo "treebox: no Chromium binary found under /ms-playwright" >&2; ls -R /ms-playwright >&2; exit 1; fi && \
83
+ mkdir -p /opt/google/chrome && \
84
+ ln -sfn "$CHROMIUM_BIN" /opt/google/chrome/chrome && \
85
+ apt-get clean && rm -rf /var/lib/apt/lists/*
86
+
87
+ # Setup hooks are baked into the image, never run from the mounted worktree, so a
88
+ # boxed agent cannot edit what runs at container setup.
89
+ COPY init-firewall.sh /usr/local/bin/init-firewall.sh
90
+ COPY allowed-domains.sh /usr/local/share/allowed-domains.sh
91
+ COPY post-create.sh /usr/local/bin/post-create.sh
92
+ RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/post-create.sh
93
+
94
+ USER ${USERNAME}
95
+
96
+ ENV SHELL=/bin/zsh
97
+ ENV EDITOR=nano
98
+ ENV VISUAL=nano
99
+ ENV CODEX_HOME=/home/${USERNAME}/.codex
100
+ ENV CLAUDE_CONFIG_DIR=/home/${USERNAME}/.claude
101
+ ENV NODE_OPTIONS="--max-old-space-size=4096"
102
+
103
+ WORKDIR /workspace
@@ -0,0 +1,25 @@
1
+ # shellcheck shell=bash
2
+ # Domains allowed through the container firewall (sourced by init-firewall.sh).
3
+ # shellcheck disable=SC2034 # consumed by init-firewall.sh after sourcing
4
+ ALLOWED_DOMAINS=(
5
+ api.anthropic.com
6
+ sentry.io
7
+ statsig.anthropic.com
8
+ statsig.com
9
+
10
+ api.openai.com
11
+ chatgpt.com
12
+ auth.openai.com
13
+ cdn.openai.com
14
+
15
+ pypi.org
16
+ files.pythonhosted.org
17
+ pypi.python.org
18
+ astral.sh
19
+
20
+ registry.npmjs.org
21
+ nodejs.org
22
+ deb.nodesource.com
23
+
24
+ cdn.playwright.dev
25
+ )
@@ -0,0 +1,27 @@
1
+ {
2
+ "build": {
3
+ "dockerfile": "Dockerfile",
4
+ "args": {
5
+ "TZ": "UTC",
6
+ "CODEX_CLI_VERSION": "latest",
7
+ "CLAUDE_CODE_VERSION": "latest"
8
+ }
9
+ },
10
+ "user": "agent",
11
+ "mounts": [
12
+ "type=volume,source=treebox-shellhistory-${workspaceName},target=/commandhistory"
13
+ ],
14
+ "env": {
15
+ "CODEX_HOME": "/home/agent/.codex",
16
+ "CLAUDE_CONFIG_DIR": "/home/agent/.claude",
17
+ "HTTP_PROXY": "",
18
+ "HTTPS_PROXY": "",
19
+ "ALL_PROXY": "",
20
+ "NO_PROXY": "",
21
+ "http_proxy": "",
22
+ "https_proxy": "",
23
+ "all_proxy": "",
24
+ "no_proxy": ""
25
+ },
26
+ "postCreate": "bash /usr/local/bin/post-create.sh"
27
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
3
+ "env": { "TREEBOX_FIREWALL": "1" }
4
+ }
@@ -0,0 +1,142 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ # post-create.sh refuses to touch the (untrusted) workspace until this exists;
6
+ # it is only ever created after a fully successful lockdown (see the end).
7
+ READY_FLAG=/run/treebox-firewall-ready
8
+
9
+ # The runner execs this BOTH at setup (so default-deny egress exists before any
10
+ # workspace-derived code runs in post-create.sh) and on every container restart
11
+ # (rules don't survive a restart). When this boot already locked down
12
+ # successfully, skip the rebuild rather than tearing live rules down
13
+ # mid-session. Requires the flag AND the live policy: policy DROP alone also
14
+ # matches the fail_closed end state, and the flag alone could be stale if /run
15
+ # survived a restart. Placed before the trap so a clean skip cannot trip it.
16
+ if [[ -f "$READY_FLAG" ]] && iptables -S OUTPUT 2>/dev/null | grep -q '^-P OUTPUT DROP'; then
17
+ echo "Firewall already active; skipping reconfiguration."
18
+ exit 0
19
+ fi
20
+ # A rebuild is starting: whatever a previous boot left behind, the firewall is
21
+ # not ready until this run completes.
22
+ rm -f "$READY_FLAG"
23
+
24
+ # Fail closed: if setup aborts for any reason, drop all traffic rather than
25
+ # leaving the container's inherited default-ACCEPT policies in place.
26
+ fail_closed() {
27
+ local status=$?
28
+ if [[ "$status" -ne 0 ]]; then
29
+ iptables -P INPUT DROP
30
+ iptables -P FORWARD DROP
31
+ iptables -P OUTPUT DROP
32
+ iptables -F
33
+ if ip6tables -L INPUT >/dev/null 2>&1; then
34
+ ip6tables -P INPUT DROP
35
+ ip6tables -P FORWARD DROP
36
+ ip6tables -P OUTPUT DROP
37
+ ip6tables -F
38
+ fi
39
+ echo "ERROR: firewall setup failed; all traffic dropped" >&2
40
+ fi
41
+ }
42
+ trap fail_closed EXIT
43
+
44
+ DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
45
+ DNS_SERVERS=$(awk '/^nameserver/ {print $2}' /etc/resolv.conf | grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || true)
46
+
47
+ # Fetch everything that needs open egress before locking down.
48
+ echo "Fetching GitHub IP ranges..."
49
+ gh_ranges=$(curl -s https://api.github.com/meta)
50
+ [[ -n "$gh_ranges" ]] || { echo "ERROR: Failed to fetch GitHub IP ranges"; exit 1; }
51
+ echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null
52
+
53
+ DOMAINS_FILE=/usr/local/share/allowed-domains.sh
54
+ [[ -f "$DOMAINS_FILE" ]] || { echo "ERROR: $DOMAINS_FILE missing"; exit 1; }
55
+ # shellcheck source=src/treebox/assets/container/allowed-domains.sh
56
+ source "$DOMAINS_FILE"
57
+ [[ ${#ALLOWED_DOMAINS[@]} -gt 0 ]] || { echo "ERROR: ALLOWED_DOMAINS is empty"; exit 1; }
58
+
59
+ # Default-deny before any rules are rebuilt: flushing alone would leave the
60
+ # inherited ACCEPT policies in place, so an abort mid-setup would fail open.
61
+ iptables -P INPUT DROP
62
+ iptables -P FORWARD DROP
63
+ iptables -P OUTPUT DROP
64
+ iptables -F
65
+ iptables -X
66
+ iptables -t nat -F
67
+ iptables -t nat -X
68
+ iptables -t mangle -F
69
+ iptables -t mangle -X
70
+ ipset destroy allowed-domains 2>/dev/null || true
71
+
72
+ # The allowlist is IPv4-only, so IPv6 would bypass it entirely: drop all
73
+ # IPv6 traffic except loopback.
74
+ if ip6tables -L INPUT >/dev/null 2>&1; then
75
+ ip6tables -P INPUT DROP
76
+ ip6tables -P FORWARD DROP
77
+ ip6tables -P OUTPUT DROP
78
+ ip6tables -F
79
+ ip6tables -X
80
+ ip6tables -A INPUT -i lo -j ACCEPT
81
+ ip6tables -A OUTPUT -o lo -j ACCEPT
82
+ else
83
+ echo "WARN: ip6tables unavailable; assuming no IPv6 stack"
84
+ fi
85
+
86
+ if [[ -n "$DOCKER_DNS_RULES" ]]; then
87
+ iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
88
+ iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
89
+ echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
90
+ fi
91
+
92
+ iptables -A INPUT -i lo -j ACCEPT
93
+ iptables -A OUTPUT -o lo -j ACCEPT
94
+ iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
95
+ iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
96
+
97
+ # DNS only to the configured resolvers; docker's embedded resolver
98
+ # (127.0.0.11) is covered by the restored NAT rules plus the loopback allow.
99
+ while read -r resolver; do
100
+ [[ -n "$resolver" ]] || continue
101
+ iptables -A OUTPUT -p udp --dport 53 -d "$resolver" -j ACCEPT
102
+ iptables -A OUTPUT -p tcp --dport 53 -d "$resolver" -j ACCEPT
103
+ done <<< "$DNS_SERVERS"
104
+
105
+ ipset create allowed-domains hash:net
106
+
107
+ while read -r cidr; do
108
+ [[ "$cidr" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}/[0-9]{1,2}$ ]] || { echo "ERROR: Invalid GitHub CIDR: $cidr"; exit 1; }
109
+ ipset add -exist allowed-domains "$cidr"
110
+ done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
111
+
112
+ for domain in "${ALLOWED_DOMAINS[@]}"; do
113
+ echo "Resolving $domain..."
114
+ ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
115
+ if [[ -z "$ips" ]]; then
116
+ echo "WARN: Failed to resolve $domain; skipping"
117
+ continue
118
+ fi
119
+ while read -r ip; do
120
+ [[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || { echo "ERROR: Invalid DNS IP for $domain: $ip"; exit 1; }
121
+ ipset add -exist allowed-domains "$ip"
122
+ done < <(echo "$ips")
123
+ done
124
+
125
+ HOST_IP=$(ip route | awk '/default/ {print $3; exit}')
126
+ [[ -n "$HOST_IP" ]] || { echo "ERROR: Failed to detect host IP"; exit 1; }
127
+ iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
128
+
129
+ iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
130
+ iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
131
+
132
+ if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
133
+ echo "ERROR: Firewall verification failed - reached https://example.com"
134
+ exit 1
135
+ fi
136
+ curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1 || {
137
+ echo "ERROR: Firewall verification failed - cannot reach api.github.com"
138
+ exit 1
139
+ }
140
+
141
+ touch "$READY_FLAG"
142
+ echo "Firewall configuration complete"
@@ -0,0 +1,48 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # Baked into the image at /usr/local/bin, so the workspace can't be derived from
5
+ # this script's location. The runner execs it with the workspace as the working
6
+ # dir; $1 overrides that when given.
7
+ WS="${1:-${PWD}}"
8
+
9
+ # Egress lockdown gate: when the operator enabled the firewall, refuse to run
10
+ # anything derived from the untrusted workspace (uv sync executes the repo's
11
+ # build backend) until init-firewall.sh has established default-deny egress.
12
+ # The runner execs the firewall first; this fails closed if that ordering is
13
+ # ever lost.
14
+ if [[ "${TREEBOX_FIREWALL:-}" == "1" && ! -f /run/treebox-firewall-ready ]]; then
15
+ echo "ERROR: firewall enabled but not initialized; refusing to run workspace setup." >&2
16
+ echo " init-firewall.sh must complete before post-create.sh." >&2
17
+ exit 1
18
+ fi
19
+
20
+ if [[ -f "$WS/pyproject.toml" ]] && command -v uv >/dev/null 2>&1; then
21
+ echo "Running uv sync..."
22
+ (cd "$WS" && uv sync)
23
+ elif [[ ! -d "$WS/.venv" ]] && { [[ -f "$WS/requirements.txt" ]] || [[ -f "$WS/setup.py" ]]; }; then
24
+ echo "Creating Python virtual environment..."
25
+ (cd "$WS" && uv venv .venv)
26
+ if [[ -f "$WS/requirements.txt" ]]; then
27
+ echo "Installing requirements.txt..."
28
+ (cd "$WS" && uv pip install -r requirements.txt)
29
+ fi
30
+ if [[ -f "$WS/setup.py" ]]; then
31
+ echo "Installing project in editable mode..."
32
+ (cd "$WS" && uv pip install -e .)
33
+ fi
34
+ fi
35
+
36
+ if [[ -d "$HOME/.ssh" ]]; then
37
+ chmod 700 "$HOME/.ssh" 2>/dev/null || true
38
+ chmod 600 "$HOME/.ssh"/* 2>/dev/null || true
39
+ chmod 644 "$HOME/.ssh"/*.pub 2>/dev/null || true
40
+ chmod 644 "$HOME/.ssh/known_hosts" 2>/dev/null || true
41
+ fi
42
+
43
+ if command -v playwright-cli >/dev/null 2>&1; then
44
+ echo "Installing playwright skills..."
45
+ (cd "$WS" && playwright-cli install --skills)
46
+ fi
47
+
48
+ echo "post-create.sh done."
@@ -0,0 +1,27 @@
1
+ #!/bin/sh
2
+ # treebox pre-push guard: placeholder branches are un-pushable by design.
3
+ #
4
+ # Installed per-worktree by `treebox create` (a private core.hooksPath — never
5
+ # the shared repo hooks). The treebox/ prefix marks an auto-generated name; a
6
+ # PR must never carry one. Renaming clears the guard — use a conventional
7
+ # branch name (feature/<name>, fix/<name>, chore/<name>, …):
8
+ # git branch -m <type>/<short-name>
9
+ # `git push --no-verify` remains the deliberate human escape hatch.
10
+ status=0
11
+ while read -r local_ref _local_oid _remote_ref _remote_oid; do
12
+ case "$local_ref" in
13
+ refs/heads/treebox/*)
14
+ branch="${local_ref#refs/heads/}"
15
+ {
16
+ printf '%s\n' "✗ treebox: refusing to push placeholder branch '$branch'."
17
+ printf '%s\n' " ↳ name this work first, then push again: git branch -m <type>/<short-name>"
18
+ printf '%s\n' " ↳ we use conventional-commits style branch names — pick the type that fits"
19
+ printf '%s\n' " the change: feature/user-auth, fix/login-race, chore/bump-deps,"
20
+ printf '%s\n' " docs/api-guide, refactor/db-layer, test/flaky-suite, …"
21
+ printf '%s\n' " ↳ the treebox/ prefix marks auto-generated names; PRs should never carry them."
22
+ } >&2
23
+ status=1
24
+ ;;
25
+ esac
26
+ done
27
+ exit $status
treebox/assets.py ADDED
@@ -0,0 +1,69 @@
1
+ """Resolve the sandbox container template directory.
2
+
3
+ The sandbox template is operator-owned and never read from the target repo: a
4
+ repo you don't trust must not define the container it runs in (mounts, runArgs,
5
+ and env can all reach the host). The agent runs *inside* the box; the config
6
+ that defines the box lives where a boxed agent cannot see or edit it.
7
+
8
+ Templates are selectable by name so devs can keep several sandbox shapes
9
+ (``python``, ``node``, a locked-down vs. a permissive one) side by side and pick
10
+ one per run with ``--template``. Resolution order for ``name``:
11
+
12
+ 1. ``$TREEBOX_TEMPLATE_DIR`` (explicit dir; wins for any name)
13
+ 2. ``$XDG_CONFIG_HOME/treebox/templates/<name>``
14
+ 3. the bundled package data (``assets/container``) — only for the default.
15
+
16
+ A named template that resolves to none of these is an error, not a silent
17
+ fallback to the default: asking for ``--template hardened`` and quietly getting
18
+ the stock box would defeat the point.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from importlib import resources
25
+ from pathlib import Path
26
+
27
+ DEFAULT_TEMPLATE = "default"
28
+
29
+ # The container definition the docker runner renders and runs. Its schema is
30
+ # treebox-owned: build.{dockerfile,args}, user, mounts, env, runArgs, postCreate.
31
+ CONFIG_FILE = "container.json"
32
+
33
+ TEMPLATE_FILES = (
34
+ CONFIG_FILE,
35
+ "Dockerfile",
36
+ "post-create.sh",
37
+ "init-firewall.sh",
38
+ "allowed-domains.sh",
39
+ )
40
+ FIREWALL_FILE = "firewall.json"
41
+
42
+
43
+ def _user_templates_root() -> Path:
44
+ base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
45
+ return Path(base).expanduser() / "treebox" / "templates"
46
+
47
+
48
+ def template_dir(name: str = DEFAULT_TEMPLATE) -> Path:
49
+ """Resolve the operator-owned template directory for ``name``.
50
+
51
+ Never reads from the target repo — see the module docstring for why.
52
+ """
53
+ explicit = os.environ.get("TREEBOX_TEMPLATE_DIR")
54
+ if explicit:
55
+ return Path(explicit).expanduser()
56
+
57
+ user = _user_templates_root() / name
58
+ if user.is_dir():
59
+ return user
60
+
61
+ if name == DEFAULT_TEMPLATE:
62
+ with resources.as_file(resources.files("treebox") / "assets" / "container") as p:
63
+ return Path(p)
64
+
65
+ raise RuntimeError(
66
+ f"No template named '{name}'. Create one at {user} "
67
+ f"(or point $TREEBOX_TEMPLATE_DIR at a template dir). "
68
+ f"'{DEFAULT_TEMPLATE}' is the only built-in template."
69
+ )