diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 108d8c0c7..9a1552e61 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -55,6 +55,28 @@ +## 🗂️ Environment Manifest Checklist + +- [ ] Environment directory is under `environments/community/` (or appropriate category subfolder) +- [ ] `README.md` is present with a `#` heading (used as the display name) and a short description paragraph below it +- [ ] At least one `.py` file exists in the environment directory (required for discovery) +- [ ] Ran `python scripts/build_env_manifest.py` from the repo root to rebuild the manifest +- [ ] Committed the updated `web/public/environments.json` and the new `web/public/env-data/.json` file(s) +- [ ] Committed the copied environment files under `web/public/env-files//` + +--- + +## 🌐 Web Checklist + +- [ ] `cd web && npm run build` completes with no errors +- [ ] `cd web && npm run lint` passes (ESLint clean) +- [ ] `cd web && npx tsc --noEmit` reports no type errors +- [ ] UI tested in both light and dark mode +- [ ] UI tested at mobile (< 768px) and desktop (≥ 1280px) widths +- [ ] No hardcoded `localhost` URLs or dev-only values left in code +- [ ] If adding/modifying environments: ran `python scripts/build_env_manifest.py` to rebuild `web/public/environments.json` +- [ ] New environment variables documented (added to `web/.env.example` or equivalent) + --- ## ✅ Developer & Reviewer Checklist diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 000000000..02e449535 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,112 @@ +name: Deploy Web Hub + +on: + push: + branches: + - main + - endpoint + paths: + - ".github/workflows/deploy-web.yml" + - "scripts/build_env_manifest.py" + - "web/**" + - "environments/**/README.md" + - "environments/**/*.py" + - "environments/**/*.md" + - "environments/**/*.json" + - "environments/**/*.toml" + - "environments/**/*.yaml" + - "environments/**/*.yml" + - "environments/**/*.txt" + - "environments/**/*.ts" + - "environments/**/*.tsx" + - "environments/**/*.js" + - "environments/**/*.css" + - "environments/**/*.html" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + outputs: + base_path: ${{ steps.base-path.outputs.base_path }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Compute base path + id: base-path + shell: bash + run: | + owner="${GITHUB_REPOSITORY%%/*}" + repo="${GITHUB_REPOSITORY#*/}" + if [ "$repo" = "${owner}.github.io" ]; then + echo "base_path=" >> "$GITHUB_OUTPUT" + else + echo "base_path=/$repo" >> "$GITHUB_OUTPUT" + fi + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install web dependencies + working-directory: web + run: npm ci + + - name: Generate static environment data + run: python scripts/build_env_manifest.py + + - name: Hide local API routes for Pages export + working-directory: web + shell: bash + run: | + if [ -d app/api ]; then + mkdir -p ../.pages-build-cache + mv app/api ../.pages-build-cache/app-api + fi + + - name: Build static site + working-directory: web + env: + STATIC_EXPORT: "true" + PAGES_BASE_PATH: ${{ steps.base-path.outputs.base_path }} + NEXT_PUBLIC_BASE_PATH: ${{ steps.base-path.outputs.base_path }} + NEXT_PUBLIC_GITHUB_REPO: ${{ github.repository }} + NEXT_PUBLIC_GITHUB_REF: ${{ github.sha }} + run: npm run build + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: web/out + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index e12cea2b7..13020d014 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -175,6 +175,10 @@ cython_debug/ # data *.json *.jsonl +!web/public/environments.json +!web/package.json +!web/package-lock.json +!web/tsconfig.json # wandb wandb diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10fee35ff..043dafb86 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,3 +36,4 @@ repos: hooks: - id: codespell args: ["--skip", "*.csv,*.html", "-L", "te,ans,sems,lsat,anc,strokin,lod,nam,ques,unparseable,rouge,oll,managin,expressio,re-declare"] + exclude: 'package-lock\.json' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5420514e..05c99baed 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,42 @@ pre-commit run --all-files ``` This command will automatically fix formatting issues found by `black` and `isort`. However, you may need to manually address any linting errors reported by `flake8`. +## Environments Hub (Web + CLI) + +The project includes an **Environments Hub**: a Next.js web app for browsing environments and a CLI for installing them locally. + +* **Web app** (`web/`): Lists environments from a manifest, shows detail pages, and exposes APIs for listing files and downloading environments (per-file or as a zip). Run from repo root with the manifest and `environments/` folder available: +```bash +cd web && npm install && npm run dev +``` +Set `ENVIRONMENTS_MANIFEST_PATH` and `ENVIRONMENTS_PATH` if the defaults (`web/public/environments.json` and `../environments`) do not apply. +* **Manifest**: The web app reads `web/public/environments.json`. Rebuild it after adding or changing environments under `environments/`: +```bash +python scripts/build_env_manifest.py +``` +(Optional: pass an output path, e.g. `python scripts/build_env_manifest.py web/public/environments.json`.) +* **CLI** (`atropos`): Install, list, or delete cached environments. It talks to the web app API (list files, then download each file with progress). Example: +```bash +pip install -e . +atropos install answer_format_environment --base-url http://localhost:3000 +atropos list +atropos delete answer_format_environment --yes +``` +Default base URL is `http://localhost:3000`; override with `ATROPOS_BASE_URL` or `--base-url`. + +When contributing a new environment under `environments/community/`, ensure it has a `README.md` and at least one `.py` file so the manifest builder will include it. See [web/README.md](web/README.md) for more detail. + +After adding or changing an environment, **rebuild the static manifest** and commit the output: +```bash +python scripts/build_env_manifest.py +``` +This script writes three things that must be committed with your PR: +- `web/public/environments.json` — the full index of all environments +- `web/public/env-data/.json` — per-environment detail (name, description, file list) +- `web/public/env-files//` — static copies of the environment's files for download + +The script derives each environment's display name and description from the first `#` heading and the first non-heading paragraph in its `README.md`, so make sure your README follows that format. + ## License for Contributions Any contributions you make will be under the MIT License. In short, when you submit code changes, your submissions are understood to be under the same [MIT License](LICENSE) that covers the project. Feel free to contact the maintainers if that's a concern. @@ -127,6 +163,14 @@ Any contributions you make will be under the MIT License. In short, when you sub Since Atropos is focused on reinforcement learning environments, we encourage contributions of new training environments. However, please adhere to the following guidelines: * **Directory Structure**: Please create your new environment within the `environments/community/` subdirectory. This helps us organize incoming contributions and allows for a streamlined initial review process before full testing and integration. +* **Required files**: Your environment directory must contain: + * `README.md` — with a `# Title` heading (used as the display name in the hub) and a short description paragraph immediately below it + * At least one `.py` file — required for the manifest builder to recognise the directory as an environment +* **Rebuild the manifest**: After adding your environment, run the manifest builder from the repo root and commit its output alongside your code: + ```bash + python scripts/build_env_manifest.py + ``` + The files to commit are `web/public/environments.json`, `web/public/env-data/.json`, and `web/public/env-files//`. Without this step your environment will not appear in the Environments Hub. * **Import Style**: We prefer that you treat your environment's directory as the package root for imports. For example, if your environment resides in `environments/community/my_new_env/` and you need to import `SomeClass` (assuming it's in a `some_file_in_my_env.py` file at the root of your `my_new_env` directory or accessible via your Python path setup), you should be able to use a direct import like: ```python from some_file_in_my_env import SomeClass diff --git a/atroposlib/cli/env_hub.py b/atroposlib/cli/env_hub.py new file mode 100644 index 000000000..dd007c4dc --- /dev/null +++ b/atroposlib/cli/env_hub.py @@ -0,0 +1,244 @@ +""" +Atropos CLI: install, list, delete cached environments. +""" + +import os +import shutil +import sys +from pathlib import Path +from typing import Optional +from urllib.parse import quote as urlquote + +import requests +import typer +from rich.console import Console +from rich.progress import ( + BarColumn, + DownloadColumn, + Progress, + TaskProgressColumn, + TextColumn, + TimeRemainingColumn, +) + +console = Console() + +DEFAULT_BASE_URL = "http://localhost:3000" + +app = typer.Typer( + name="atropos", + help="Install, list, and delete Atropos environments (no auth). Uses the web app API.", +) + + +def get_cache_dir() -> Path: + if sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))) + return base / "atropos" / "envs" + base = Path(os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))) + return base / "atropos" / "envs" + + +def _env_id_to_dir_name(env_id: str) -> str: + return env_id.replace("/", "-") + + +def _dir_name_to_display_id(dir_name: str) -> str: + return dir_name.replace("-", "/", 1) if "-" in dir_name else dir_name + + +def _normalize_file_paths(files: list) -> list[str]: + """Accept API response as list of strings or list of {path, size}.""" + out = [] + for x in files: + if isinstance(x, dict) and "path" in x: + out.append(str(x["path"])) + elif isinstance(x, str) and x: + out.append(x) + return out + + +def _slugify_env_id(env_id: str) -> str: + """Convert env_id to static-file slug (/ -> --).""" + return env_id.replace("/", "--") + + +def _try_api_file_list(base_url: str, env_id: str) -> list[str] | None: + """Fetch file list from the API endpoint. Returns None on any failure.""" + url = f"{base_url.rstrip('/')}/api/environments/{urlquote(env_id, safe='')}/files" + try: + r = requests.get(url, timeout=30) + if not r.ok: + return None + raw = r.json() + return _normalize_file_paths(raw if isinstance(raw, list) else []) + except Exception: + return None + + +def _try_static_file_list(base_url: str, env_id: str) -> list[str] | None: + """Fetch file list from the static env-data JSON (GitHub Pages fallback). Returns None on any failure.""" + slug = _slugify_env_id(env_id) + url = f"{base_url.rstrip('/')}/env-data/{slug}.json" + try: + r = requests.get(url, timeout=30) + if not r.ok: + return None + data = r.json() + files = data.get("files", []) + return _normalize_file_paths(files) + except Exception: + return None + + +def download_with_progress( + base_url: str, + env_id: str, + rel_path: str, + dest_path: Path, + static: bool = False, +) -> None: + safe_path = urlquote(rel_path.replace("\\", "/"), safe="/") + if static: + slug = _slugify_env_id(env_id) + url = f"{base_url.rstrip('/')}/env-files/{slug}/{safe_path}" + else: + safe_id = urlquote(env_id, safe="") + url = f"{base_url.rstrip('/')}/api/environments/{safe_id}/files/{safe_path}" + resp = requests.get( + url, stream=True, timeout=60, headers={"Accept-Encoding": "identity"} + ) + resp.raise_for_status() + total = int(resp.headers.get("Content-Length", 0)) or None + dest_path.parent.mkdir(parents=True, exist_ok=True) + with Progress( + TextColumn("[bold blue]{task.description}"), + BarColumn(), + TaskProgressColumn(), + DownloadColumn(), + TimeRemainingColumn(), + console=console, + ) as progress: + task = progress.add_task(rel_path, total=total) + with open(dest_path, "wb") as f: + for chunk in resp.iter_content(chunk_size=64 * 1024): + if chunk: + f.write(chunk) + progress.update(task, advance=len(chunk)) + + +@app.command() +def install( + env_id: str = typer.Argument(..., help="Environment id (e.g. community/word_hunt)"), + base_url: str = typer.Option( + os.environ.get("ATROPOS_BASE_URL", DEFAULT_BASE_URL), + "--base-url", + help="Web app base URL (e.g. http://localhost:3000)", + ), + cache_dir: Optional[Path] = typer.Option( + None, + "--cache-dir", + help="Override cache dir", + ), +) -> None: + """Download and install an environment.""" + cache = cache_dir if cache_dir is not None else get_cache_dir() + + files = _try_api_file_list(base_url, env_id) + use_static = False + if files is None: + files = _try_static_file_list(base_url, env_id) + use_static = True + + if files is None: + console.print(f"[red]Failed to list files from {base_url}[/red]") + raise typer.Exit(1) + if not files: + console.print("[red]No files returned[/red]") + raise typer.Exit(1) + + cache.mkdir(parents=True, exist_ok=True) + dir_name = _env_id_to_dir_name(env_id) + dest_dir = cache / dir_name + if dest_dir.exists(): + shutil.rmtree(dest_dir) + dest_dir.mkdir(parents=True) + + for rel_path in files: + if ".." in rel_path or rel_path.startswith("/"): + continue + dest_path = dest_dir / rel_path + try: + download_with_progress( + base_url, env_id, rel_path, dest_path, static=use_static + ) + except requests.RequestException as e: + console.print(f"[red]Failed to download {rel_path}: {e}[/red]") + raise typer.Exit(1) + + console.print(f"[green]Installed to {dest_dir}[/green]") + + +@app.command("list") +def list_cached( + cache_dir: Optional[Path] = typer.Option( + None, + "--cache-dir", + help="Override cache dir", + ), +) -> None: + """List cached environments.""" + cache = cache_dir if cache_dir is not None else get_cache_dir() + if not cache.is_dir(): + console.print("No cached environments.") + return + entries = sorted(cache.iterdir()) + dirs = [e for e in entries if e.is_dir()] + if not dirs: + console.print("No cached environments.") + return + from rich.table import Table + + table = Table(show_header=True, header_style="bold") + table.add_column("ID", style="cyan") + table.add_column("Path") + for d in dirs: + table.add_row(_dir_name_to_display_id(d.name), str(d)) + console.print(table) + + +@app.command() +def delete( + env_id: str = typer.Argument(..., help="Environment id to remove from cache"), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"), + cache_dir: Optional[Path] = typer.Option( + None, + "--cache-dir", + help="Override cache dir", + ), +) -> None: + """Remove an environment from cache.""" + cache = cache_dir if cache_dir is not None else get_cache_dir() + dir_name = env_id.replace("/", "-") + dest_dir = cache / dir_name + if not dest_dir.is_dir(): + console.print(f"[red]Not found in cache: {env_id}[/red]") + raise typer.Exit(1) + if not yes: + try: + confirm = input(f"Delete {dest_dir}? [y/N]: ").strip().lower() + except EOFError: + confirm = "n" + if confirm != "y": + return + shutil.rmtree(dest_dir) + console.print(f"[green]Deleted {env_id}[/green]") + + +def main() -> int: + app() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 6f23666c3..fa7b6aa7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "jsonlines", "pydantic-cli", "hf_transfer", + "requests", + "typer", ] [project.scripts] @@ -37,6 +39,7 @@ atropos-sft-gen = "atroposlib.cli.sft:main" atropos-dpo-gen = "atroposlib.cli.dpo:main" atropos-grpo = "example_trainer.grpo:main" atropos-grpo-run = "example_trainer.run:main" +atropos = "atroposlib.cli.env_hub:main" [project.optional-dependencies] all = [ diff --git a/scripts/build_env_manifest.py b/scripts/build_env_manifest.py new file mode 100644 index 000000000..fad915915 --- /dev/null +++ b/scripts/build_env_manifest.py @@ -0,0 +1,258 @@ +""" +Build static environment metadata for the web hub. + +Outputs: +- web/public/environments.json +- web/public/env-data/.json +""" + +import json +import os +import re +import shutil +import sys +from pathlib import Path + +TEXT_PREVIEW_EXTENSIONS = { + ".css", + ".html", + ".js", + ".json", + ".md", + ".py", + ".sh", + ".toml", + ".ts", + ".tsx", + ".txt", + ".xml", + ".yaml", + ".yml", +} +MAX_PREVIEW_BYTES = 256 * 1024 + + +def has_python_files(dir_path: Path) -> bool: + """True if directory contains at least one .py file (recursively).""" + for _ in dir_path.rglob("*.py"): + return True + return False + + +def is_excluded_dir(name: str) -> bool: + """Exclude pure data/config dirs from being treated as environments.""" + excluded = {"configs", "ifeval_instructions", "__pycache__", ".git"} + return name in excluded or name.startswith(".") + + +def get_relative_id(env_dir: Path, environments_root: Path) -> str: + """Path relative to environments/ as id (e.g. community/word_hunt).""" + return str(env_dir.relative_to(environments_root)).replace("\\", "/") + + +def slugify_env_id(env_id: str) -> str: + """Filesystem-safe slug used for static route params.""" + return env_id.replace("/", "--") + + +def parse_readme(readme_path: Path) -> tuple[str, str]: + """Extract first # title and first paragraph from README. Returns (name, description).""" + name = "" + description = "" + if not readme_path.is_file(): + return name, description + try: + text = readme_path.read_text(encoding="utf-8", errors="replace") + lines = text.strip().splitlines() + for line in lines: + line = line.strip() + if not line: + continue + if line.startswith("#"): + if not name: + name = re.sub(r"^#+\s*", "", line).strip() + continue + if not description and name: + description = line + break + if not name: + name = line[:80] if len(line) > 80 else line + break + except Exception: + pass + return name or "", description or "" + + +def derive_tags(env_id: str) -> list[str]: + """Derive tags from path (e.g. community/word_hunt -> community).""" + parts = env_id.split("/") + tags = [] + if "community" in parts: + tags.append("community") + if "eval_environments" in parts: + tags.append("eval") + if "game_environments" in parts: + tags.append("games") + if "letter_counting" in env_id or "math" in env_id or "gsm8k" in env_id: + tags.append("math") + if "code" in env_id or "swe" in env_id or "coding" in env_id: + tags.append("coding") + return list(dict.fromkeys(tags)) + + +def find_environment_dirs(environments_root: Path) -> list[Path]: + """Find environment root directories: have .py and README, prefer shallowest.""" + candidates: list[Path] = [] + for root, dirs, _ in os.walk(environments_root): + root_path = Path(root) + dirs[:] = [d for d in dirs if not is_excluded_dir(d)] + for d in dirs: + sub = root_path / d + if has_python_files(sub): + candidates.append(sub) + + def has_readme(path: Path) -> bool: + return (path / "README.md").is_file() + + with_readme = [candidate for candidate in candidates if has_readme(candidate)] + minimal_readme = [ + candidate + for candidate in with_readme + if not any( + candidate != other and other.is_relative_to(candidate) and has_readme(other) + for other in with_readme + ) + ] + + def is_category_root(path: Path) -> bool: + rel = path.relative_to(environments_root) + parts = rel.parts + return len(parts) == 1 and parts[0] in ( + "community", + "eval_environments", + "game_environments", + ) + + minimal_readme = [ + candidate for candidate in minimal_readme if not is_category_root(candidate) + ] + return sorted(minimal_readme, key=lambda path: str(path)) + + +def list_env_files(env_dir: Path) -> tuple[list[dict], int, str]: + """Collect relative file paths and metadata for an environment directory.""" + files = [] + total_size = 0 + readme_path = "" + + for file_path in sorted(env_dir.rglob("*")): + if not file_path.is_file(): + continue + if any(part.startswith(".") for part in file_path.relative_to(env_dir).parts): + continue + + relative_path = str(file_path.relative_to(env_dir)).replace("\\", "/") + stat = file_path.stat() + total_size += stat.st_size + + if not readme_path and relative_path.lower().endswith("readme.md"): + readme_path = relative_path + + files.append( + { + "path": relative_path, + "size": stat.st_size, + "previewable": file_path.suffix.lower() in TEXT_PREVIEW_EXTENSIONS + and stat.st_size <= MAX_PREVIEW_BYTES, + } + ) + + files.sort( + key=lambda item: ( + 0 if item["path"].lower().endswith("readme.md") else 1, + item["path"].lower(), + ) + ) + return files, total_size, readme_path + + +def build_manifest(environments_path: Path, output_path: Path) -> None: + """Scan environments_path and write static manifest JSON files.""" + env_dirs = find_environment_dirs(environments_path) + entries = [] + details_root = output_path.parent / "env-data" + + if details_root.exists(): + shutil.rmtree(details_root) + details_root.mkdir(parents=True, exist_ok=True) + + for env_dir in env_dirs: + env_id = get_relative_id(env_dir, environments_path) + slug = slugify_env_id(env_id) + readme_file = env_dir / "README.md" + name, description = parse_readme(readme_file) + if not name: + name = env_dir.name.replace("_", " ").title() + tags = derive_tags(env_id) + files, total_size, readme_path = list_env_files(env_dir) + + env_entry = { + "id": env_id, + "slug": slug, + "name": name, + "description": (description or "")[:500], + "tags": tags, + "fileCount": len(files), + "totalSize": total_size, + "readmePath": readme_path or None, + } + entries.append(env_entry) + + detail_payload = { + "environment": env_entry, + "files": files, + "readmePath": readme_path or None, + "totalSize": total_size, + } + (details_root / f"{slug}.json").write_text( + json.dumps(detail_payload, indent=2), + encoding="utf-8", + ) + + # Copy env files to static path so they can be downloaded without API + env_files_dir = output_path.parent / "env-files" / slug + if env_files_dir.exists(): + shutil.rmtree(env_files_dir) + env_files_dir.mkdir(parents=True, exist_ok=True) + for file_info in files: + src = env_dir / file_info["path"] + dst = env_files_dir / file_info["path"] + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(entries, indent=2), encoding="utf-8") + print( + f"Wrote {len(entries)} environments to {output_path} and static details to {details_root}", + file=sys.stderr, + ) + + +def main() -> None: + repo_root = Path(__file__).resolve().parents[1] + env_path = os.environ.get("ENVIRONMENTS_PATH") + if env_path: + environments_root = Path(env_path) + else: + environments_root = repo_root / "environments" + output_path = repo_root / "web" / "public" / "environments.json" + if len(sys.argv) > 1: + output_path = Path(sys.argv[1]) + if not environments_root.is_dir(): + print(f"Environments path not found: {environments_root}", file=sys.stderr) + sys.exit(1) + build_manifest(environments_root, output_path) + + +if __name__ == "__main__": + main() diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..36c9fdcaa --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,4 @@ +node_modules +.next +out +tsconfig.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..5d9c6a29a --- /dev/null +++ b/web/README.md @@ -0,0 +1,47 @@ +# Environments Hub (Web) + +Next.js app for the Atropos Environments Hub. The UI now supports two modes: + +- Local development mode with the existing Next route handlers under `app/api`. +- Static mirror mode for GitHub Pages, powered by generated JSON metadata plus GitHub raw file fetches. + +## Setup + +```bash +npm install +``` + +Generate the frontend data from repo root: + +```bash +python scripts/build_env_manifest.py +``` + +That command writes: + +- `web/public/environments.json` +- `web/public/env-data/*.json` + +## Run + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Static Export + +The GitHub Pages workflow: + +1. Runs `python scripts/build_env_manifest.py` +2. Temporarily moves `web/app/api` out of the app tree +3. Builds the site with `STATIC_EXPORT=true` +4. Deploys the resulting `web/out` directory to Pages + +Static pages fetch: + +- Generated metadata from `public/` +- File contents from `raw.githubusercontent.com` + +This keeps the hosted UI lightweight while preserving environment inspection. diff --git a/web/app/api/environments/[id]/download/route.ts b/web/app/api/environments/[id]/download/route.ts new file mode 100644 index 000000000..fbc6e429b --- /dev/null +++ b/web/app/api/environments/[id]/download/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import { PassThrough } from "stream"; +import archiver from "archiver"; +import { getEnvById, getEnvDirPath, listEnvFiles } from "@/lib/env-api"; + +function nodeStreamToWeb(readable: NodeJS.ReadableStream): ReadableStream { + return new ReadableStream({ + start(controller) { + readable.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk))); + readable.on("end", () => controller.close()); + readable.on("error", (err) => controller.error(err)); + }, + }); +} + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const env = getEnvById(id); + if (!env) + return NextResponse.json({ error: "Environment not found" }, { status: 404 }); + const dirPath = getEnvDirPath(id); + if (!dirPath) + return NextResponse.json({ error: "Environment directory not found" }, { status: 404 }); + const files = listEnvFiles(id); + const archive = archiver("zip", { zlib: { level: 5 } }); + const pass = new PassThrough(); + archive.pipe(pass); + for (const rel of files) { + const full = path.join(dirPath, rel); + if (fs.statSync(full).isFile()) archive.file(full, { name: rel }); + } + void archive.finalize(); + const filename = id.replace(/\//g, "-") + ".zip"; + return new NextResponse(nodeStreamToWeb(pass), { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); +} diff --git a/web/app/api/environments/[id]/files/[...path]/route.ts b/web/app/api/environments/[id]/files/[...path]/route.ts new file mode 100644 index 000000000..fa84eed05 --- /dev/null +++ b/web/app/api/environments/[id]/files/[...path]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import fs from "fs"; +import { getEnvById, getEnvFilePath } from "@/lib/env-api"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string; path: string[] }> } +) { + const { id, path: pathSegments } = await params; + if (!getEnvById(id)) + return NextResponse.json({ error: "Environment not found" }, { status: 404 }); + const filePath = pathSegments.join("/"); + const fullPath = getEnvFilePath(id, filePath); + if (!fullPath) + return NextResponse.json({ error: "File not found" }, { status: 404 }); + const stat = fs.statSync(fullPath); + const stream = fs.createReadStream(fullPath); + const res = new NextResponse(stream as unknown as ReadableStream); + res.headers.set("Content-Length", String(stat.size)); + const ext = fullPath.split(".").pop()?.toLowerCase(); + const types: Record = { + py: "text/x-python", + md: "text/markdown", + json: "application/json", + yaml: "text/yaml", + yml: "text/yaml", + toml: "text/plain", + txt: "text/plain", + }; + if (ext && types[ext]) res.headers.set("Content-Type", types[ext]); + return res; +} diff --git a/web/app/api/environments/[id]/files/route.ts b/web/app/api/environments/[id]/files/route.ts new file mode 100644 index 000000000..3e878b84e --- /dev/null +++ b/web/app/api/environments/[id]/files/route.ts @@ -0,0 +1,18 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEnvById, listEnvFiles, listEnvFilesWithSizes } from "@/lib/env-api"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + if (!getEnvById(id)) + return NextResponse.json({ error: "Environment not found" }, { status: 404 }); + const withSizes = request.nextUrl.searchParams.get("sizes") === "1"; + if (withSizes) { + const files = listEnvFilesWithSizes(id); + return NextResponse.json(files); + } + const files = listEnvFiles(id); + return NextResponse.json(files); +} diff --git a/web/app/api/environments/[id]/route.ts b/web/app/api/environments/[id]/route.ts new file mode 100644 index 000000000..691e68740 --- /dev/null +++ b/web/app/api/environments/[id]/route.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEnvById } from "@/lib/env-api"; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const env = getEnvById(id); + if (!env) return NextResponse.json({ error: "Not found" }, { status: 404 }); + return NextResponse.json(env); +} diff --git a/web/app/api/environments/route.ts b/web/app/api/environments/route.ts new file mode 100644 index 000000000..ee4e405d6 --- /dev/null +++ b/web/app/api/environments/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from "next/server"; +import { loadManifest } from "@/lib/env-api"; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const q = searchParams.get("q")?.toLowerCase(); + const category = searchParams.get("category"); + let manifest = loadManifest(); + if (q) { + manifest = manifest.filter( + (e) => + e.name.toLowerCase().includes(q) || + (e.description || "").toLowerCase().includes(q) || + (e.tags || []).some((t) => t.toLowerCase().includes(q)) + ); + } + if (category) { + manifest = manifest.filter((e) => (e.tags || []).includes(category)); + } + return NextResponse.json(manifest); +} diff --git a/web/app/environments/[id]/page.tsx b/web/app/environments/[id]/page.tsx new file mode 100644 index 000000000..e036020a2 --- /dev/null +++ b/web/app/environments/[id]/page.tsx @@ -0,0 +1,18 @@ +import { EnvironmentDetailClient } from "@/components/EnvironmentDetailClient"; +import { readStaticManifest } from "@/lib/env-static"; + +export const dynamicParams = false; + +export function generateStaticParams() { + return readStaticManifest().map((environment) => ({ + id: environment.slug, + })); +} + +export default function EnvironmentDetailPage({ + params, +}: { + params: { id: string }; +}) { + return ; +} diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 000000000..23883d8b3 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,264 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 0%; + --foreground: 0 0% 97%; + --card: 0 0% 5%; + --card-foreground: 0 0% 97%; + --primary: 0 0% 100%; + --primary-foreground: 0 0% 0%; + --muted: 0 0% 9%; + --muted-foreground: 0 0% 52%; + --border: 0 0% 100% / 0.22; + --line: 0 0% 100% / 0.08; + --panel: 0 0% 4%; + --panel-alt: 0 0% 6%; + --panel-elevated: 0 0% 10%; + --accent-secondary: 0 0% 88%; + --warning: 0 0% 75%; + --grid: 0 0% 100% / 0.07; + --glow: 0 0% 100% / 0.04; + --shadow: 0 0% 0% / 0.8; + } + + * { + border-color: hsl(var(--border)); + } + + html { + color-scheme: dark; + } + + body { + @apply bg-background text-foreground antialiased; + min-height: 100vh; + background-image: + linear-gradient(to right, hsl(var(--grid)) 1px, transparent 1px), + linear-gradient(to bottom, hsl(var(--grid)) 1px, transparent 1px); + background-position: center, center; + background-size: 44px 44px, 44px 44px; + background-attachment: fixed; + font-feature-settings: "ss01" on, "cv01" on; + } + + body::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + z-index: -1; + opacity: 0.16; + background-image: + linear-gradient(transparent 0, rgba(255, 255, 255, 0.08) 1px, transparent 2px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02), transparent 32%, rgba(255, 255, 255, 0.03) 68%, rgba(255, 255, 255, 0.01)); + background-size: 100% 4px, 100% 100%; + mix-blend-mode: screen; + } + + ::selection { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + } +} + +@layer components { + .ui-shell { + @apply relative min-h-screen overflow-hidden; + } + + .ui-shell::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 14% 18%, rgba(255, 255, 255, 0.03), transparent 20%), + radial-gradient(circle at 85% 12%, rgba(255, 255, 255, 0.02), transparent 15%); + mix-blend-mode: screen; + } + + .screen-frame { + @apply relative border border-white/20 bg-[hsl(var(--panel))] shadow-[0_18px_60px_rgba(0,0,0,0.7)]; + } + + .screen-frame::before { + content: ""; + position: absolute; + inset: 1px; + border: 1px solid rgba(255, 255, 255, 0.05); + pointer-events: none; + } + + .screen-frame-alt { + @apply screen-frame bg-[hsl(var(--panel-alt))]; + } + + .screen-frame-elevated { + @apply screen-frame bg-[hsl(var(--panel-elevated))]; + } + + .hero-grid { + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.08) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.08) 1px, transparent 1px); + background-size: 64px 64px; + } + + .scanlines { + background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.06), transparent 3px); + background-size: 100% 6px; + } + + .barcode-rule { + height: 14px; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.9) 0, + rgba(255, 255, 255, 0.9) 2px, + transparent 2px, + transparent 5px, + rgba(255, 255, 255, 0.6) 5px, + rgba(255, 255, 255, 0.6) 6px, + transparent 6px, + transparent 10px + ); + } + + .panel-tag { + @apply inline-flex items-center border border-white/15 bg-black/35 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.32em] text-primary; + } + + .warning-tag { + @apply inline-flex items-center border border-warning/40 bg-warning/10 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.32em] text-warning; + } + + .section-kicker { + @apply text-[10px] font-semibold uppercase tracking-[0.35em] text-primary; + } + + .data-label { + @apply text-[10px] font-semibold uppercase tracking-[0.32em] text-muted-foreground; + } + + .data-value { + @apply text-sm font-medium uppercase tracking-[0.12em] text-foreground; + } + + .headline-display { + @apply text-4xl font-black uppercase tracking-[0.08em] text-foreground sm:text-5xl lg:text-6xl; + } + + .research-copy { + @apply max-w-2xl text-sm leading-6 text-muted-foreground sm:text-base; + } + + .terminal-code { + @apply bg-black/55 font-mono text-[13px] text-foreground; + } + + .poster-title { + @apply text-4xl font-black uppercase leading-[0.88] tracking-[0.02em] text-foreground sm:text-6xl lg:text-7xl; + } + + .poster-caption { + @apply text-[11px] font-semibold uppercase tracking-[0.28em] text-muted-foreground; + } + + .label-panel { + @apply screen-frame hero-grid relative overflow-hidden; + } + + .label-panel::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(180deg, transparent 0, rgba(255, 255, 255, 0.02) 100%); + } + + .caution-rule { + height: 10px; + background-image: repeating-linear-gradient( + 90deg, + rgba(255, 255, 255, 0.9) 0, + rgba(255, 255, 255, 0.9) 10px, + rgba(0, 0, 0, 0) 10px, + rgba(0, 0, 0, 0) 14px, + rgba(255, 255, 255, 0.5) 14px, + rgba(255, 255, 255, 0.5) 22px, + rgba(0, 0, 0, 0) 22px, + rgba(0, 0, 0, 0) 28px + ); + } + + .spec-list { + @apply border-y border-white/10; + } + + .spec-list > * + * { + border-top: 1px solid rgba(255, 255, 255, 0.08); + } + + .spec-row { + @apply flex items-center justify-between gap-4 py-3 text-xs uppercase tracking-[0.22em]; + } + + .spec-row span:first-child { + @apply text-muted-foreground; + } + + .spec-row span:last-child { + @apply text-foreground; + } + + .orbital-mark { + position: relative; + width: 8rem; + height: 8rem; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 9999px; + overflow: hidden; + } + + .orbital-mark::before { + content: ""; + position: absolute; + inset: 12%; + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 9999px; + } + + .orbital-mark::after { + content: ""; + position: absolute; + left: 50%; + top: -18%; + width: 1px; + height: 136%; + background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.55), transparent); + box-shadow: -34px 0 0 rgba(255, 255, 255, 0.18), 34px 0 0 rgba(255, 255, 255, 0.18); + transform: translateX(-50%); + } + + .orbital-grid { + position: absolute; + inset: 0; + background-image: + linear-gradient(to right, rgba(255, 255, 255, 0.14) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.14) 1px, transparent 1px); + background-size: 16px 16px; + -webkit-mask-image: radial-gradient(circle at center, black 36%, transparent 78%); + mask-image: radial-gradient(circle at center, black 36%, transparent 78%); + } + + .document-title { + @apply text-2xl font-black uppercase tracking-[0.14em] text-foreground sm:text-3xl; + } + + .fine-print { + @apply text-[11px] uppercase tracking-[0.22em] text-muted-foreground; + } +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 000000000..44a66cfad --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Atropos Environments Hub", + description: + "A Nous Research-aligned archive for discovering, evaluating, and operationalizing Atropos environments.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 000000000..bde15c27e --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,468 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + ArrowUpRight, + ChevronRight, + Database, + Folder, + Globe, + Grid2X2, + List, + Orbit, + Search, + Sparkles, + Star, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { getContributingUrl, getEnvironmentRoute, getManifestUrl, getRepositoryUrl } from "@/lib/site"; +import { cn } from "@/lib/utils"; +import type { Environment } from "@/types/env"; + +const CATEGORIES = [ + { label: "Coding", value: "coding" }, + { label: "Games", value: "games" }, + { label: "Math", value: "math" }, + { label: "Eval", value: "eval" }, + { label: "Community", value: "community" }, +]; + +function formatBytes(bytes?: number) { + if (!bytes) { + return "--"; + } + if (bytes < 1024) { + return `${bytes} B`; + } + if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function EnvironmentCard({ + env, + view, + index, +}: { + env: Environment; + view: "grid" | "list"; + index: number; +}) { + return ( + + + +
+
+ {env.name} + +
+
+ Unit {String(index + 1).padStart(2, "0")} + + {env.id} + +
+ + <>{children}, + }} + > + {env.description || "No descriptive abstract has been logged for this environment."} + + +
+ +
+
+
+
Files
+
{env.fileCount ?? "--"}
+
+
+
Mass
+
+ {formatBytes(env.totalSize)} +
+
+
+
+
+ + {view === "list" && ( + +
+ {(env.tags.length ? env.tags : ["general"]).slice(0, 5).map((tag) => ( + + {tag} + + ))} +
+
+
+ Registry + Atropos +
+
+ README + {env.readmePath ? "Indexed" : "Missing"} +
+
+ Surface + Dossier +
+
+
+ )} +
+ + ); +} + +function EmptyPanel({ message }: { message: string }) { + return ( +
+

{message}

+
+ ); +} + +function SectionHeader({ + label, + title, + count, +}: { + label: string; + title: string; + count: number; +}) { + return ( +
+
+

{label}

+

{title}

+
+
{count} entries surfaced
+
+ ); +} + +function SignalTile({ + icon: Icon, + label, + value, +}: { + icon: typeof Database; + label: string; + value: string; +}) { + return ( +
+
+ {label} + +
+
+ {value} +
+
+ ); +} + +export default function ExplorePage() { + const [envs, setEnvs] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [category, setCategory] = useState(null); + const [view, setView] = useState<"grid" | "list">("grid"); + const [activeTab, setActiveTab] = useState("explore"); + + useEffect(() => { + fetch(getManifestUrl()) + .then((response) => response.json()) + .then((data) => { + setEnvs(Array.isArray(data) ? (data as Environment[]) : []); + }) + .catch(() => setEnvs([])) + .finally(() => setLoading(false)); + }, []); + + const filtered = useMemo(() => { + return envs.filter((env) => { + const query = search.trim().toLowerCase(); + const matchesSearch = + !query || + env.name.toLowerCase().includes(query) || + env.id.toLowerCase().includes(query) || + env.description.toLowerCase().includes(query) || + env.tags.some((tag) => tag.toLowerCase().includes(query)); + const matchesCategory = !category || env.tags.includes(category); + return matchesSearch && matchesCategory; + }); + }, [category, envs, search]); + + const featured = filtered.slice(0, 6); + const archiveMass = filtered.reduce((sum, env) => sum + (env.totalSize ?? 0), 0); + + return ( +
+
+
+
+
+
+ Atropos Archive + Handle With Care + Static Mirror Ready +
+ +
+
+

Nous Research // environment registry

+

+ Human +
+ Training +
+ Worlds +

+

+ A poster-styled inspection surface for the Atropos environment archive. Browse + candidate worlds, inspect tracked files, and move from discovery to repo-level + retrieval without standing up backend infrastructure. +

+
+ +
+
+
+
+
Visual Motif
+
+ Research Label +
+
+
+
+
+
+
+
+ +
+
Acquisition Path
+
+                      {`git clone --filter=blob:none --sparse\ncd atropos\nbrowse dossier -> fetch source`}
+                    
+
+
+
+ +
+ + + +
+
+
+ + +
+ + +
+
+ + + Explore + + + Signals + + + Archive + + + +
+
+ + setSearch(event.target.value)} + /> +
+ +
+ + +
+
+
+ +
+ {CATEGORIES.map((cat) => ( + + ))} +
+
+ + + {loading ? ( + + ) : filtered.length === 0 ? ( + + ) : ( +
+
+ +
+ {featured.map((env, index) => ( + + ))} +
+
+ + {filtered.length > featured.length && ( +
+ +
+ {filtered.slice(featured.length).map((env, index) => ( + + ))} +
+
+ )} +
+ )} +
+ + + + + + + {loading ? ( + + ) : filtered.length > 0 ? ( +
+ {filtered.map((env, index) => ( + + ))} +
+ ) : ( + + )} +
+
+
+
+ ); +} diff --git a/web/components/EnvironmentDetailClient.tsx b/web/components/EnvironmentDetailClient.tsx new file mode 100644 index 000000000..fe1250996 --- /dev/null +++ b/web/components/EnvironmentDetailClient.tsx @@ -0,0 +1,470 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + ArrowLeft, + ChevronRight, + ExternalLink, + FileText, + Folder, + Orbit, + ScanSearch, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +import { InstallModal } from "@/components/InstallModal"; +import { FileViewer } from "@/components/FileViewer"; +import { + getEnvironmentDataUrl, + getGithubRawUrl, + getGithubTreeUrl, + getRepositoryUrl, +} from "@/lib/site"; +import { cn } from "@/lib/utils"; +import type { EnvironmentDetail } from "@/types/env"; + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function DetailPanel({ + label, + value, +}: { + label: string; + value: string; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function PlaceholderPanel({ + label, + message, +}: { + label: string; + message: string; +}) { + return ( +
+
{label}
+

{message}

+
+ ); +} + +async function fetchText(url: string) { + const response = await fetch(url); + if (!response.ok) { + throw new Error("Request failed"); + } + return response.text(); +} + +export function EnvironmentDetailClient({ slug }: { slug: string }) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(true); + const [installOpen, setInstallOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + const [fileContent, setFileContent] = useState(null); + const [fileLoading, setFileLoading] = useState(false); + const [readmeContent, setReadmeContent] = useState(null); + const [readmeLoading, setReadmeLoading] = useState(false); + + useEffect(() => { + fetch(getEnvironmentDataUrl(slug)) + .then((response) => { + if (!response.ok) { + throw new Error("Not found"); + } + return response.json(); + }) + .then((data) => setDetail(data as EnvironmentDetail)) + .catch(() => setDetail(null)) + .finally(() => setLoading(false)); + }, [slug]); + + const env = detail?.environment ?? null; + const files = detail?.files ?? []; + const readmePath = detail?.readmePath ?? null; + + useEffect(() => { + if (files.length === 0 || selectedFile) return; + const preferred = files.find((file) => /readme\.md$/i.test(file.path)) ?? files[0]; + setSelectedFile(preferred.path); + }, [files, selectedFile]); + + const selectedFileMeta = useMemo( + () => files.find((file) => file.path === selectedFile) ?? null, + [files, selectedFile] + ); + + useEffect(() => { + if (!env || !selectedFileMeta) { + setFileContent(null); + return; + } + if (!selectedFileMeta.previewable) { + setFileContent(null); + return; + } + + setFileLoading(true); + fetchText(getGithubRawUrl(env.id, selectedFileMeta.path)) + .then(setFileContent) + .catch(() => setFileContent(null)) + .finally(() => setFileLoading(false)); + }, [env, selectedFileMeta]); + + useEffect(() => { + if (!env || !readmePath) { + setReadmeContent(null); + return; + } + setReadmeLoading(true); + fetchText(getGithubRawUrl(env.id, readmePath)) + .then(setReadmeContent) + .catch(() => setReadmeContent(null)) + .finally(() => setReadmeLoading(false)); + }, [env, readmePath]); + + if (loading) { + return ( +
+
+

+ Loading environment dossier +

+
+
+ ); + } + + if (!env || !detail) { + return ( +
+
+

+ Environment not found +

+
+ + + +
+ ); + } + + const githubTreeUrl = getGithubTreeUrl(env.id); + const readmeUrl = readmePath ? getGithubRawUrl(env.id, readmePath) : null; + + return ( +
+
+
+
+
+
+ Environment Dossier + Static Inspection + {env.id} +
+ +
+ + + +
+
+

Atropos // environment archive

+

{env.name}

+
+

+ {env.description || "No environment description is currently available."} +

+
+
+ +
+ + + +
+
+ +
+
+
+
+
Reference Mark
+
+ Source Orbit +
+
+
+
+
+
+
+
+
+
Action Cluster
+
+ + +
+
+
+
+ +
+ +
+
+
+
+
+
Package Archive
+
+ Files +
+
+ +
+ +
    + {files.length === 0 && ( +
  • + No files indexed for this environment. +
  • + )} + {files.map((file) => ( +
  • + +
  • + ))} +
+
+ +
+
+
+
Inspection Surface
+
+ {selectedFile ? "File Viewer" : "Awaiting File Selection"} +
+
+ +
+ + {selectedFile ? ( +
+
+
Selected Path
+
{selectedFile}
+
+
+ {fileLoading ? ( +
Loading file contents…
+ ) : selectedFileMeta && !selectedFileMeta.previewable ? ( +
+

This file is not previewable in the static mirror. Open the source tree to inspect it directly.

+ + + +
+ ) : fileContent !== null ? ( + + ) : ( +
+ Could not load this file preview from GitHub raw content. +
+ )} +
+
+ ) : ( +
+ Select a file from the archive panel to inspect its contents. +
+ )} +
+
+ +
+
+
Operational Summary
+
+
+
+

+ Overview +

+
    +
  • + Environment ID: {env.id} +
  • +
  • + Description:{" "} + {children}, + }} + > + {env.description || "—"} + +
  • +
  • + Research Tags:{" "} + {(env.tags || []).join(", ") || "—"} +
  • +
  • + Tracked Size: {formatSize(detail.totalSize)} +
  • +
+
+ +
+

+ Usage Direction +

+

+ This hosted mirror emphasizes file inspection and acquisition. Use the source + folder link or the sparse-checkout flow in the install panel when you need the + actual environment package on disk. +

+
+
+ + +
+
+ + {readmePath ? ( +
+
+
Documentation Surface
+
+ README +
+
+ {readmePath} +
+
+
+ {readmeLoading ? ( +
Loading README…
+ ) : readmeContent !== null ? ( + + ) : ( +
+

Could not load the README preview from GitHub raw content.

+ {readmeUrl && ( + + + + )} +
+ )} +
+
+ ) : ( + + )} +
+
+
+ + +
+ ); +} diff --git a/web/components/FileViewer.tsx b/web/components/FileViewer.tsx new file mode 100644 index 000000000..6737dcd8b --- /dev/null +++ b/web/components/FileViewer.tsx @@ -0,0 +1,111 @@ +"use client"; + +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; +import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; +import { cn } from "@/lib/utils"; + +const CODE_EXT_TO_LANG: Record = { + py: "python", + js: "javascript", + ts: "typescript", + tsx: "tsx", + jsx: "jsx", + json: "json", + yaml: "yaml", + yml: "yaml", + toml: "toml", + md: "markdown", + sh: "bash", + bash: "bash", + css: "css", + html: "html", +}; + +function getLanguage(filename: string): string { + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + return CODE_EXT_TO_LANG[ext] ?? "text"; +} + +function isMarkdown(path: string): boolean { + const lower = path.toLowerCase(); + return lower.endsWith(".md") || lower.endsWith(".markdown"); +} + +export function FileViewer({ + path, + content, + className, +}: { + path: string; + content: string; + className?: string; +}) { + if (isMarkdown(path)) { + return ( +
+ + {children} + + ); + } + return ( + + {String(children).replace(/\n$/, "")} + + ); + }, + }} + > + {content} + +
+ ); + } + + const language = getLanguage(path); + return ( + + {content} + + ); +} diff --git a/web/components/InstallModal.tsx b/web/components/InstallModal.tsx new file mode 100644 index 000000000..b832469d6 --- /dev/null +++ b/web/components/InstallModal.tsx @@ -0,0 +1,175 @@ +"use client"; + +import { useState } from "react"; +import { Check, Copy, ExternalLink, FolderGit2, TerminalSquare, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogClose, +} from "@/components/ui/dialog"; +import { GITHUB_REF, GITHUB_REPO, getSiteUrl } from "@/lib/site"; +import type { Environment } from "@/types/env"; + +const STEPS = [ + { + title: "Install Atropos", + note: "Install the CLI directly from the repository.", + code: "pip install git+https://github.com/NousResearch/atropos.git", + }, + { + title: "Install Environment", + note: "Install a specific environment from your base URL.", + code: (id: string) => + `atropos install ${id} --base-url ${getSiteUrl()}`, + }, + { + title: "List Installed Environments", + note: "Verify available environments.", + code: "atropos list", + }, +]; + +function CodeBlock({ + code, + onCopy, +}: { + code: string; + onCopy?: () => void; +}) { + const [copied, setCopied] = useState(false); + const handleCopy = () => { + navigator.clipboard.writeText(code); + setCopied(true); + onCopy?.(); + setTimeout(() => setCopied(false), 2000); + }; + return ( +
+
Command
+
{code}
+ +
+ ); +} + +interface InstallModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + env: Environment; + sourceUrl: string; + readmeUrl?: string | null; +} + +export function InstallModal({ open, onOpenChange, env, sourceUrl, readmeUrl }: InstallModalProps) { + return ( + + + + + +
+ +
+ Acquisition Procedure + {env.id} +
+ Acquire & Use Environment + + Use the hosted dossier to inspect files, then retrieve the environment directly from + the repository with a sparse checkout when you need the package on disk. + +
+
+ +
+
+
+
Environment
+
+ {env.name} +
+

+ {env.description || "No environment description is currently available."} +

+
+ +
+
Actions
+
+ + +
+
+
+ +
+
+
+
Execution Steps
+
+ Operator Checklist +
+
+ +
+ +
+ {STEPS.map((step, i) => ( +
+
+
Step {String(i + 1).padStart(2, "0")}
+

+ {step.title} +

+ {step.note && ( +

{step.note}

+ )} +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/web/components/ui/badge.tsx b/web/components/ui/badge.tsx new file mode 100644 index 000000000..17d06ccab --- /dev/null +++ b/web/components/ui/badge.tsx @@ -0,0 +1,29 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const badgeVariants = cva( + "inline-flex items-center border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.2em] transition-colors", + { + variants: { + variant: { + default: "border-primary/45 bg-primary/10 text-primary", + secondary: "border-white/10 bg-white/5 text-muted-foreground", + outline: "border-white/15 text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/web/components/ui/button.tsx b/web/components/ui/button.tsx new file mode 100644 index 000000000..2517608ff --- /dev/null +++ b/web/components/ui/button.tsx @@ -0,0 +1,49 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap border border-transparent px-4 text-[11px] font-semibold uppercase tracking-[0.24em] transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "border-primary/70 bg-primary/95 text-primary-foreground shadow-[0_0_0_1px_rgba(255,220,183,0.18)] hover:-translate-y-px hover:brightness-105", + secondary: + "border-white/10 bg-[hsl(var(--panel-elevated))] text-foreground hover:-translate-y-px hover:border-primary/35 hover:bg-white/6", + outline: + "border-white/15 bg-transparent text-foreground hover:-translate-y-px hover:border-primary/80 hover:bg-primary/10", + ghost: + "border-transparent bg-transparent text-muted-foreground hover:border-white/10 hover:bg-white/5 hover:text-foreground", + link: "h-auto border-none px-0 text-primary underline-offset-4 hover:text-white hover:underline", + }, + size: { + default: "h-11", + sm: "h-9 px-3 text-[10px]", + lg: "h-12 px-8 text-xs", + icon: "h-11 w-11 px-0", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +