diff --git a/README.md b/README.md index acc9cb7ae..fc14df5c1 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,21 @@ Code Puppy is an AI-powered code generation agent, designed to understand progra uvx code-puppy -i ```` +## Lean bootstrap planning + +If you need to decide installation policy before attaching optional capabilities, +use the lightweight bootstrap planner instead of treating `uvx code-puppy` as a +universal installer: + +```bash +uvx --from code-puppy code-puppy-bootstrap plan --profile auto --json +``` + +That bootstrap path stays stdlib-only, inspects the environment, and emits a +profile-driven install/reattach plan so Android/Termux deployments can keep +optional browser/image/fuzzy/search/provider dependencies detached until the +real target environment is known. + ## Installation ### UV (Recommended) @@ -62,6 +77,56 @@ uvx code-puppy -i curl -LsSf https://astral.sh/uv/install.sh | sh uvx code-puppy +# or inspect a lean install plan first +uvx --from code-puppy code-puppy-bootstrap plan --profile auto --json +``` + +#### Native Android / Termux + +On native Android / Termux, use the bootstrap planner first so you can inspect +a lean install plan before attaching optional extras: + +```bash +# Install UV if you don't have it +curl -LsSf https://astral.sh/uv/install.sh | sh + +# Inspect the current environment +uvx --from code-puppy code-puppy-bootstrap detect --json + +# Build the recommended lean install plan for this device +uvx --from code-puppy code-puppy-bootstrap plan --profile auto + +# Install suggested Termux-side system packages for the lean profile +pkg install ripgrep +pkg install proot + +# Attach the lean base runtime +uv tool install --refresh code-puppy + +# Run Code Puppy +code-puppy -i +``` + +Why this flow exists: + +- `android-termux-lean` is the auto-selected profile on Termux +- it keeps the initial Python install minimal on constrained devices +- browser/image/fuzzy/search/provider extras stay detached until you decide to + reattach them + +If you later want a heavier profile, inspect it first and then reattach using +that plan: + +```bash +uvx --from code-puppy code-puppy-bootstrap plan --profile desktop-browser +``` + +You can also feed policy overrides through a manifest: + +```bash +uvx --from code-puppy code-puppy-bootstrap plan \ + --profile android-termux-lean \ + --manifest-json '{"extras_add": ["durable"], "notes": ["Enable only after validating the device."]}' ``` #### Windows diff --git a/code_puppy/bootstrap.py b/code_puppy/bootstrap.py new file mode 100644 index 000000000..38c6cfdac --- /dev/null +++ b/code_puppy/bootstrap.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import argparse +import json +import sys +from typing import Any + +from code_puppy.bootstrap_profiles import ( + available_profiles, + build_install_plan, + detect_environment, +) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Code Puppy bootstrap planner for lean environment-aware installs" + ) + subparsers = parser.add_subparsers(dest="command") + + detect_parser = subparsers.add_parser( + "detect", + help="Inspect the current environment without importing the runtime CLI", + ) + detect_parser.add_argument("--json", action="store_true", help="Print JSON output") + + plan_parser = subparsers.add_parser( + "plan", + help="Build a lean install/reattach plan from a builtin profile and optional manifest", + ) + plan_parser.add_argument( + "--profile", + default="auto", + choices=["auto", *available_profiles()], + help="Builtin install profile to use", + ) + plan_parser.add_argument( + "--manifest-json", + default="", + help="Inline JSON manifest with extras_add/extras_remove/notes overrides", + ) + plan_parser.add_argument( + "--manifest-file", + default="", + help="Path to a JSON manifest file with plan overrides", + ) + plan_parser.add_argument("--json", action="store_true", help="Print JSON output") + + return parser + + +def _human_detect(environment: dict[str, Any]) -> str: + lines = [ + "Code Puppy bootstrap environment", + f"- python: {environment['python_executable']}", + f"- version: {environment['python_version']}", + f"- platform: {environment['platform_system']} {environment['platform_release']} ({environment['platform_machine']})", + f"- termux: {'yes' if environment['is_termux'] else 'no'}", + f"- android: {'yes' if environment['is_android'] else 'no'}", + f"- uv: {'yes' if environment['has_uv'] else 'no'}", + f"- uvx: {'yes' if environment['has_uvx'] else 'no'}", + f"- proot: {'yes' if environment['has_proot'] else 'no'}", + f"- ripgrep: {'yes' if environment['has_ripgrep'] else 'no'}", + ] + return "\n".join(lines) + + +def _human_plan(plan: dict[str, Any]) -> str: + lines = [ + f"Profile: {plan['profile']}", + f"Package spec: {plan['package_spec']}", + f"Install: {plan['install_command']}", + f"Reattach: {plan['reattach_command']}", + f"Run: {plan['run_command']}", + ] + if plan["extras"]: + lines.append(f"Extras: {', '.join(plan['extras'])}") + if plan["degraded_capabilities"]: + lines.append("Degraded until reattach:") + lines.extend(f"- {item}" for item in plan["degraded_capabilities"]) + if plan["system_packages"]: + lines.append("Suggested system packages:") + lines.extend(f"- {item}" for item in plan["system_packages"]) + if plan["notes"]: + lines.append("Notes:") + lines.extend(f"- {item}" for item in plan["notes"]) + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + + command = args.command or "plan" + + try: + if command == "detect": + environment = detect_environment() + if args.json: + print(json.dumps(environment, indent=2, sort_keys=True)) + else: + print(_human_detect(environment)) + return 0 + + plan = build_install_plan( + requested_profile=getattr(args, "profile", "auto"), + manifest_json=getattr(args, "manifest_json", ""), + manifest_file=getattr(args, "manifest_file", ""), + ) + if getattr(args, "json", False): + print(json.dumps(plan, indent=2, sort_keys=True)) + else: + print(_human_plan(plan)) + return 0 + except ValueError as exc: + parser.exit(status=2, message=f"error: {exc}\n") + return 2 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/code_puppy/bootstrap_profiles.py b/code_puppy/bootstrap_profiles.py new file mode 100644 index 000000000..f8c742d41 --- /dev/null +++ b/code_puppy/bootstrap_profiles.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +import json +import os +import platform +import shlex +import shutil +import sys +from copy import deepcopy +from pathlib import Path +from typing import Any + +DEFAULT_PACKAGE_NAME = "code-puppy" + +_BUILTIN_PROFILES: dict[str, dict[str, Any]] = { + "core": { + "description": "Lean base runtime with no optional extras attached.", + "extras": [], + "notes": [ + "Use this when a launcher only needs the core CLI/runtime.", + "Optional capabilities should reattach later based on deployment policy.", + ], + "system_packages": [], + }, + "android-termux-lean": { + "description": "Android/Termux-safe profile that keeps Python deps lean.", + "extras": [], + "notes": [ + "Avoid browser/image/fuzzy/search/provider extras during initial attach.", + "Reattach optional capabilities only after the target environment is known.", + ], + "system_packages": [ + "pkg install ripgrep", + "pkg install proot", + ], + }, + "desktop-browser": { + "description": "Desktop-oriented profile with local browser and UX helpers.", + "extras": ["browser", "fuzzy", "images", "search"], + "notes": [ + "Useful for developer laptops and desktop automation environments.", + ], + "system_packages": [], + }, + "developer-full": { + "description": "Broad developer profile with provider and tooling extras attached.", + "extras": [ + "anthropic", + "azure", + "bedrock", + "browser", + "durable", + "fuzzy", + "images", + "openai", + "search", + ], + "notes": [ + "This is intentionally heavy; use it only when the environment is known-good.", + ], + "system_packages": [], + }, +} + + +def available_profiles() -> list[str]: + return sorted(_BUILTIN_PROFILES) + + +def detect_environment() -> dict[str, Any]: + executable = Path(sys.executable).expanduser().resolve() + release = platform.release() + system_name = platform.system() + termux_prefix = os.environ.get("PREFIX", "") + termux_version = os.environ.get("TERMUX_VERSION", "") + is_termux = ( + bool(termux_version) + or "com.termux" in str(executable) + or (system_name == "Linux" and "com.termux" in termux_prefix) + ) + is_android = is_termux or "android" in release.lower() + + return { + "python_executable": str(executable), + "python_version": platform.python_version(), + "platform_system": system_name, + "platform_release": release, + "platform_machine": platform.machine(), + "is_android": is_android, + "is_termux": is_termux, + "is_windows": system_name == "Windows", + "is_macos": system_name == "Darwin", + "is_linux": system_name == "Linux", + "has_uv": shutil.which("uv") is not None, + "has_uvx": shutil.which("uvx") is not None, + "has_proot": shutil.which("proot") is not None, + "has_ripgrep": shutil.which("rg") is not None, + "has_git": shutil.which("git") is not None, + } + + +def auto_profile_name(environment: dict[str, Any]) -> str: + if environment.get("is_termux") or environment.get("is_android"): + return "android-termux-lean" + if environment.get("is_windows") or environment.get("is_macos"): + return "desktop-browser" + if environment.get("is_linux"): + return "desktop-browser" + return "core" + + +def resolve_profile_name( + requested_profile: str | None, environment: dict[str, Any] +) -> str: + raw = ( + requested_profile or os.environ.get("CODE_PUPPY_INSTALL_PROFILE", "auto") + ).strip() + if not raw or raw == "auto": + return auto_profile_name(environment) + if raw not in _BUILTIN_PROFILES: + choices = ", ".join(available_profiles()) + raise ValueError(f"unknown install profile '{raw}'. Choices: auto, {choices}") + return raw + + +def _parse_manifest_json(raw: str) -> dict[str, Any]: + data = json.loads(raw) + if not isinstance(data, dict): + raise ValueError("bootstrap manifest must be a JSON object") + return data + + +def load_manifest( + *, + manifest_json: str = "", + manifest_file: str = "", +) -> dict[str, Any]: + raw_json = ( + manifest_json.strip() + or os.environ.get("CODE_PUPPY_BOOTSTRAP_MANIFEST_JSON", "").strip() + ) + raw_file = ( + manifest_file.strip() + or os.environ.get("CODE_PUPPY_BOOTSTRAP_MANIFEST_FILE", "").strip() + ) + + if raw_json and raw_file: + raise ValueError("choose manifest_json or manifest_file, not both") + if raw_json: + return _parse_manifest_json(raw_json) + if raw_file: + return _parse_manifest_json( + Path(raw_file).expanduser().read_text(encoding="utf-8") + ) + return {} + + +def _normalize_list(value: Any, *, field_name: str) -> list[str]: + if value in (None, ""): + return [] + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ValueError( + f"bootstrap manifest field '{field_name}' must be a list of strings" + ) + return [item.strip() for item in value if item.strip()] + + +def merge_profile( + profile_name: str, + manifest: dict[str, Any] | None = None, +) -> tuple[dict[str, Any], list[str]]: + profile = deepcopy(_BUILTIN_PROFILES[profile_name]) + reasons = [f"base profile: {profile_name}"] + manifest = manifest or {} + if not manifest: + return profile, reasons + + profile_override = manifest.get("profile", "") + if profile_override and profile_override != profile_name: + raise ValueError( + f"manifest profile '{profile_override}' does not match resolved profile '{profile_name}'" + ) + + extras = set(profile["extras"]) + extras.update(_normalize_list(manifest.get("extras_add"), field_name="extras_add")) + extras.difference_update( + _normalize_list(manifest.get("extras_remove"), field_name="extras_remove") + ) + profile["extras"] = sorted(extras) + + for key in ("notes", "system_packages"): + profile[key] = profile[key] + _normalize_list(manifest.get(key), field_name=key) + + description = manifest.get("description", "") + if description: + if not isinstance(description, str): + raise ValueError("bootstrap manifest field 'description' must be a string") + profile["description"] = description.strip() + + package_name = manifest.get("package_name", "") + if package_name: + if not isinstance(package_name, str): + raise ValueError("bootstrap manifest field 'package_name' must be a string") + profile["package_name"] = package_name.strip() + + if manifest: + reasons.append("manifest overrides applied") + return profile, reasons + + +def package_spec(package_name: str, extras: list[str]) -> str: + if not extras: + return package_name + return f"{package_name}[{','.join(sorted(extras))}]" + + +def install_command(spec: str, environment: dict[str, Any]) -> str: + if environment.get("has_uv"): + return f"uv tool install --refresh {shlex.quote(spec)}" + return f"python -m pip install {shlex.quote(spec)}" + + +def reattach_command(spec: str, environment: dict[str, Any]) -> str: + if environment.get("has_uv"): + return f"uv tool install --refresh {shlex.quote(spec)}" + return f"python -m pip install --upgrade {shlex.quote(spec)}" + + +def build_install_plan( + *, + requested_profile: str | None = None, + manifest_json: str = "", + manifest_file: str = "", +) -> dict[str, Any]: + environment = detect_environment() + resolved_profile = resolve_profile_name(requested_profile, environment) + manifest = load_manifest(manifest_json=manifest_json, manifest_file=manifest_file) + profile, reasons = merge_profile(resolved_profile, manifest) + selected_package = profile.get("package_name", DEFAULT_PACKAGE_NAME) + selected_extras = profile["extras"] + spec = package_spec(selected_package, selected_extras) + + degraded = [] + if resolved_profile == "android-termux-lean": + degraded = [ + "browser automation extras detached", + "provider SDK extras detached", + "image/fuzzy/search extras detached", + ] + + missing_system_packages = [] + if resolved_profile == "android-termux-lean" and not environment.get("has_ripgrep"): + missing_system_packages.append("ripgrep") + if resolved_profile == "android-termux-lean" and not environment.get("has_proot"): + missing_system_packages.append("proot") + + return { + "profile": resolved_profile, + "package_name": selected_package, + "package_spec": spec, + "description": profile["description"], + "extras": selected_extras, + "notes": profile["notes"], + "system_packages": profile["system_packages"], + "missing_system_packages": missing_system_packages, + "environment": environment, + "reasons": reasons, + "install_command": install_command(spec, environment), + "reattach_command": reattach_command(spec, environment), + "run_command": "code-puppy -i", + "degraded_capabilities": degraded, + "manifest_applied": bool(manifest), + } diff --git a/pyproject.toml b/pyproject.toml index fb69e0450..c6ccf13df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ HomePage = "https://github.com/mpfaffenberger/code_puppy" [project.scripts] code-puppy = "code_puppy.main:main_entry" pup = "code_puppy.main:main_entry" +code-puppy-bootstrap = "code_puppy.bootstrap:main" [tool.logfire] ignore_no_config = true diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 000000000..e2c871110 --- /dev/null +++ b/tests/test_bootstrap.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from code_puppy import bootstrap, bootstrap_profiles + + +def test_bootstrap_import_does_not_pull_cli_runner(): + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; import code_puppy.bootstrap; print('code_puppy.cli_runner' in sys.modules)", + ], + capture_output=True, + text=True, + check=True, + ) + assert result.stdout.strip() == "False" + + +def test_detect_environment_termux(monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.119") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + environment = bootstrap_profiles.detect_environment() + assert environment["is_termux"] is True + assert environment["is_android"] is True + + +def test_auto_profile_prefers_android_termux(monkeypatch): + monkeypatch.setenv("TERMUX_VERSION", "0.119") + monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") + plan = bootstrap_profiles.build_install_plan(requested_profile="auto") + assert plan["profile"] == "android-termux-lean" + assert plan["extras"] == [] + assert "browser automation extras detached" in plan["degraded_capabilities"] + + +def test_build_install_plan_applies_manifest_overrides(tmp_path: Path): + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "profile": "android-termux-lean", + "extras_add": ["images", "search"], + "extras_remove": ["search"], + "notes": ["cloud picked a camera-friendly lane"], + } + ) + ) + plan = bootstrap_profiles.build_install_plan( + requested_profile="android-termux-lean", + manifest_file=str(manifest_path), + ) + assert plan["extras"] == ["images"] + assert plan["manifest_applied"] is True + assert "cloud picked a camera-friendly lane" in plan["notes"] + assert plan["package_spec"] == "code-puppy[images]" + + +def test_build_install_plan_rejects_profile_mismatch(tmp_path: Path): + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text(json.dumps({"profile": "desktop-browser"})) + with pytest.raises(ValueError, match="does not match"): + bootstrap_profiles.build_install_plan( + requested_profile="android-termux-lean", + manifest_file=str(manifest_path), + ) + + +def test_cli_detect_json(capsys): + exit_code = bootstrap.main(["detect", "--json"]) + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert "python_executable" in payload + assert "is_termux" in payload + + +def test_cli_plan_defaults_to_auto_json(capsys): + exit_code = bootstrap.main(["plan", "--json"]) + assert exit_code == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["profile"] in bootstrap_profiles.available_profiles() + assert payload["package_spec"].startswith("code-puppy") + + +def test_cli_plan_rejects_bad_manifest_json(capsys): + with pytest.raises(SystemExit) as exc_info: + bootstrap.main(["plan", "--manifest-json", "[]"]) + assert exc_info.value.code == 2 + assert "bootstrap manifest must be a JSON object" in capsys.readouterr().err