diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 12a6a9d7..a5c30678 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -93,6 +93,7 @@ prompt_position_id, ) from bittensor_cli.src.commands import proxy as proxy_commands +from bittensor_cli.src.commands.extensions import ext_commands from bittensor_cli.src.commands.proxy import ProxyType from bittensor_cli.src.commands.stake import ( auto_staking as auto_stake, @@ -786,6 +787,7 @@ class CLIManager: subnet_mechanisms_app: typer.Typer weights_app: typer.Typer crowd_app: typer.Typer + ext_app: typer.Typer utils_app: typer.Typer view_app: typer.Typer asyncio_runner = asyncio @@ -872,6 +874,7 @@ def __init__(self): self.utils_app = typer.Typer(epilog=_epilog) self.axon_app = typer.Typer(epilog=_epilog) self.proxy_app = typer.Typer(epilog=_epilog) + self.ext_app = typer.Typer(epilog=_epilog) # config alias self.app.add_typer( @@ -986,6 +989,20 @@ def __init__(self): no_args_is_help=True, ) + # ext (extensions) app + self.app.add_typer( + self.ext_app, + name="ext", + short_help="Extension commands, aliases: `extension`, `extensions`", + no_args_is_help=True, + ) + self.app.add_typer( + self.ext_app, name="extension", hidden=True, no_args_is_help=True + ) + self.app.add_typer( + self.ext_app, name="extensions", hidden=True, no_args_is_help=True + ) + # config commands self.config_app.command("set")(self.set_config) self.config_app.command("get")(self.get_config) @@ -1244,6 +1261,29 @@ def __init__(self): rich_help_panel=HELP_PANELS["PROXY"]["MGMT"], )(self.proxy_execute_announced) + # ext commands + self.ext_app.command( + "add", rich_help_panel=HELP_PANELS["EXT"]["MGMT"] + )(self.ext_add) + self.ext_app.command( + "update", rich_help_panel=HELP_PANELS["EXT"]["MGMT"] + )(self.ext_update) + self.ext_app.command( + "remove", rich_help_panel=HELP_PANELS["EXT"]["MGMT"] + )(self.ext_remove) + self.ext_app.command( + "list", rich_help_panel=HELP_PANELS["EXT"]["MGMT"] + )(self.ext_list) + self.ext_app.command( + "create", rich_help_panel=HELP_PANELS["EXT"]["DEV"] + )(self.ext_create) + self.ext_app.command( + "test", rich_help_panel=HELP_PANELS["EXT"]["DEV"] + )(self.ext_test) + self.ext_app.command( + "run", rich_help_panel=HELP_PANELS["EXT"]["DEV"] + )(self.ext_run) + # Sub command aliases # Wallet self.wallet_app.command( @@ -10256,6 +10296,135 @@ def proxy_execute_announced( with ProxyAnnouncements.get_db() as (conn, cursor): ProxyAnnouncements.mark_as_executed(conn, cursor, got_call_from_db) + # --- Extension commands --- + + def ext_add( + self, + repo_url: str = typer.Argument( + help="Git repository URL of the extension to install" + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Install a btcli extension from a git repository. + + Clones the repository into ~/.bittensor/extensions/ and validates its + extension.yaml manifest. + + EXAMPLE + + [green]$[/green] btcli ext add https://github.com/user/my-btcli-extension + """ + self.verbosity_handler(quiet, verbose) + return self._run_command(ext_commands.ext_add(repo_url)) + + def ext_update( + self, + name: Optional[str] = typer.Argument( + None, help="Extension name to update. Updates all if omitted." + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Update installed extension(s) by pulling the latest changes from git. + + EXAMPLE + + [green]$[/green] btcli ext update my-extension + [green]$[/green] btcli ext update + """ + self.verbosity_handler(quiet, verbose) + return self._run_command(ext_commands.ext_update(name)) + + def ext_remove( + self, + name: str = typer.Argument(help="Name of the extension to remove"), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Remove an installed extension. + + EXAMPLE + + [green]$[/green] btcli ext remove my-extension + """ + self.verbosity_handler(quiet, verbose) + ext_commands.ext_remove(name) + + def ext_list( + self, + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + List all installed btcli extensions. + + EXAMPLE + + [green]$[/green] btcli ext list + """ + self.verbosity_handler(quiet, verbose) + ext_commands.ext_list() + + def ext_create( + self, + name: str = typer.Argument(help="Name for the new extension"), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Generate boilerplate for a new btcli extension. + + Creates a directory under ~/.bittensor/extensions/ with a template + extension.yaml, main.py, and tests/ directory. + + EXAMPLE + + [green]$[/green] btcli ext create my-extension + """ + self.verbosity_handler(quiet, verbose) + ext_commands.ext_create(name) + + def ext_test( + self, + name: Optional[str] = typer.Argument( + None, help="Extension name to test. Tests all if omitted." + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Run tests for an installed extension. + + EXAMPLE + + [green]$[/green] btcli ext test my-extension + """ + self.verbosity_handler(quiet, verbose) + return self._run_command(ext_commands.ext_test(name)) + + def ext_run( + self, + name: str = typer.Argument(help="Name of the extension to run"), + args: Optional[list[str]] = typer.Argument( + None, help="Arguments to pass to the extension" + ), + quiet: bool = Options.quiet, + verbose: bool = Options.verbose, + ): + """ + Run an installed extension's entry point. + + EXAMPLE + + [green]$[/green] btcli ext run my-extension -- --flag value + """ + self.verbosity_handler(quiet, verbose) + return self._run_command(ext_commands.ext_run(name, args or [])) + @staticmethod def convert( from_rao: Optional[str] = typer.Option( diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 95a4d3c3..ff35e66a 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -960,6 +960,10 @@ class RootSudoOnly(Enum): "PROXY": { "MGMT": "Proxy Account Management", }, + "EXT": { + "MGMT": "Extension Management", + "DEV": "Extension Development", + }, } diff --git a/bittensor_cli/src/commands/extensions/__init__.py b/bittensor_cli/src/commands/extensions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bittensor_cli/src/commands/extensions/ext_commands.py b/bittensor_cli/src/commands/extensions/ext_commands.py new file mode 100644 index 00000000..a336cfb3 --- /dev/null +++ b/bittensor_cli/src/commands/extensions/ext_commands.py @@ -0,0 +1,265 @@ +import shutil +import subprocess +import sys +from typing import Optional + +_GIT_AVAILABLE: Optional[bool] = None + + +def _check_git() -> bool: + """Check if git is available on the system.""" + global _GIT_AVAILABLE + if _GIT_AVAILABLE is None: + _GIT_AVAILABLE = shutil.which("git") is not None + return _GIT_AVAILABLE + +from rich import box +from rich.table import Table + +from bittensor_cli.src.bittensor.utils import ( + console, + err_console, +) +from bittensor_cli.src.commands.extensions.manifest import ( + ExtensionManifest, + get_extensions_dir, + get_installed_extensions, + get_extension_by_name, +) +from bittensor_cli.src.commands.extensions.templates import ( + EXTENSION_YAML_TEMPLATE, + MAIN_PY_TEMPLATE, + TEST_TEMPLATE, +) + + +async def ext_add(repo_url: str) -> None: + """Clone a git repository into ~/.bittensor/extensions/ and validate it.""" + ext_dir = get_extensions_dir() + + # Derive directory name from repo URL + repo_name = repo_url.rstrip("/").split("/")[-1] + if repo_name.endswith(".git"): + repo_name = repo_name[:-4] + + target = ext_dir / repo_name + if target.exists(): + err_console.print( + f"[red]Error:[/red] Directory '{repo_name}' already exists. " + f"Use [bold]btcli ext update {repo_name}[/bold] to update it." + ) + return + + if not _check_git(): + err_console.print( + "[red]Error:[/red] git is not installed. " + "Please install git and try again." + ) + return + + console.print(f"Cloning [bold]{repo_url}[/bold] ...") + result = subprocess.run( + ["git", "clone", repo_url, str(target)], + capture_output=True, + text=True, + ) + if result.returncode != 0: + err_console.print(f"[red]Error:[/red] git clone failed:\n{result.stderr}") + return + + # Validate extension.yaml exists + try: + manifest = ExtensionManifest.from_yaml(target) + except (FileNotFoundError, ValueError) as e: + err_console.print(f"[red]Error:[/red] {e}") + err_console.print("Removing cloned directory.") + shutil.rmtree(target, ignore_errors=True) + return + + # Install dependencies if specified + if manifest.dependencies: + console.print("Installing dependencies ...") + dep_result = subprocess.run( + [sys.executable, "-m", "pip", "install"] + manifest.dependencies, + capture_output=True, + text=True, + ) + if dep_result.returncode != 0: + err_console.print( + f"[yellow]Warning:[/yellow] Some dependencies failed to install:\n" + f"{dep_result.stderr}" + ) + + console.print( + f"[green]Successfully installed extension " + f"[bold]{manifest.name}[/bold] v{manifest.version}[/green]" + ) + + +async def ext_update(name: Optional[str] = None) -> None: + """Update extension(s) by pulling latest changes from git.""" + if name: + try: + path, manifest = get_extension_by_name(name) + except FileNotFoundError as e: + err_console.print(f"[red]Error:[/red] {e}") + return + extensions = [(path, manifest)] + else: + extensions = get_installed_extensions() + if not extensions: + console.print("No extensions installed.") + return + + if not _check_git(): + err_console.print( + "[red]Error:[/red] git is not installed. " + "Please install git and try again." + ) + return + + for path, manifest in extensions: + console.print(f"Updating [bold]{manifest.name}[/bold] ...") + result = subprocess.run( + ["git", "-C", str(path), "pull"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + err_console.print( + f"[red]Error:[/red] Failed to update {manifest.name}:\n{result.stderr}" + ) + else: + console.print(f"[green]Updated {manifest.name}[/green]") + + +def ext_remove(name: str) -> None: + """Remove an installed extension.""" + try: + path, manifest = get_extension_by_name(name) + except FileNotFoundError as e: + err_console.print(f"[red]Error:[/red] {e}") + return + + shutil.rmtree(path) + console.print(f"[green]Removed extension [bold]{manifest.name}[/bold][/green]") + + +def ext_list() -> None: + """List all installed extensions.""" + extensions = get_installed_extensions() + if not extensions: + console.print("No extensions installed.") + return + + table = Table( + title="Installed Extensions", + box=box.ROUNDED, + show_lines=True, + ) + table.add_column("Name", style="bold cyan") + table.add_column("Version") + table.add_column("Description") + table.add_column("Entry Point") + table.add_column("Path", style="dim") + + for path, manifest in extensions: + table.add_row( + manifest.name, + manifest.version, + manifest.description, + manifest.entry_point, + str(path), + ) + + console.print(table) + + +def ext_create(name: str) -> None: + """Generate boilerplate for a new extension.""" + ext_dir = get_extensions_dir() + target = ext_dir / name + + if target.exists(): + err_console.print( + f"[red]Error:[/red] Directory '{name}' already exists in extensions." + ) + return + + target.mkdir(parents=True) + tests_dir = target / "tests" + tests_dir.mkdir() + + # Write extension.yaml + (target / "extension.yaml").write_text(EXTENSION_YAML_TEMPLATE.format(name=name)) + + # Write main.py + (target / "main.py").write_text(MAIN_PY_TEMPLATE.format(name=name)) + + # Write test file + safe_name = name.replace("-", "_").replace(" ", "_") + (tests_dir / f"test_{safe_name}.py").write_text( + TEST_TEMPLATE.format(safe_name=safe_name) + ) + + console.print( + f"[green]Created extension boilerplate at [bold]{target}[/bold][/green]" + ) + console.print( + f" Edit [bold]{target / 'extension.yaml'}[/bold] to configure your extension." + ) + + +async def ext_test(name: Optional[str] = None) -> None: + """Run tests for extension(s).""" + if name: + try: + path, manifest = get_extension_by_name(name) + except FileNotFoundError as e: + err_console.print(f"[red]Error:[/red] {e}") + return + extensions = [(path, manifest)] + else: + extensions = get_installed_extensions() + if not extensions: + console.print("No extensions installed.") + return + + for path, manifest in extensions: + tests_dir = path / "tests" + if not tests_dir.exists(): + console.print( + f"[yellow]Skipping {manifest.name}: no tests/ directory[/yellow]" + ) + continue + + console.print(f"Running tests for [bold]{manifest.name}[/bold] ...") + result = subprocess.run( + [sys.executable, "-m", "pytest", str(tests_dir), "-v"], + cwd=str(path), + ) + if result.returncode == 0: + console.print(f"[green]{manifest.name}: all tests passed[/green]") + else: + err_console.print(f"[red]{manifest.name}: tests failed[/red]") + + +async def ext_run(name: str, args: Optional[list[str]] = None) -> None: + """Run an extension's entry point.""" + try: + path, manifest = get_extension_by_name(name) + except FileNotFoundError as e: + err_console.print(f"[red]Error:[/red] {e}") + return + + entry_point = path / manifest.entry_point + if not entry_point.exists(): + err_console.print( + f"[red]Error:[/red] Entry point '{manifest.entry_point}' " + f"not found in {path}" + ) + return + + cmd = [sys.executable, str(entry_point)] + (args or []) + result = subprocess.run(cmd, cwd=str(path)) + raise SystemExit(result.returncode) diff --git a/bittensor_cli/src/commands/extensions/manifest.py b/bittensor_cli/src/commands/extensions/manifest.py new file mode 100644 index 00000000..77c5df66 --- /dev/null +++ b/bittensor_cli/src/commands/extensions/manifest.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +from yaml import safe_load, safe_dump + +from bittensor_cli.src import defaults + +EXTENSIONS_DIR = Path(defaults.config.base_path).expanduser() / "extensions" + + +@dataclass +class ExtensionManifest: + name: str + version: str + description: str + entry_point: str + dependencies: list[str] = field(default_factory=list) + author: Optional[str] = None + repository: Optional[str] = None + + @classmethod + def from_yaml(cls, path: Path) -> "ExtensionManifest": + manifest_file = path / "extension.yaml" + if not manifest_file.exists(): + raise FileNotFoundError(f"No extension.yaml found in {path}") + with open(manifest_file, "r") as f: + data = safe_load(f) or {} + + required = ["name", "version", "description", "entry_point"] + missing = [k for k in required if k not in data] + if missing: + raise ValueError( + f"extension.yaml missing required fields: {', '.join(missing)}" + ) + return cls( + name=data["name"], + version=data["version"], + description=data["description"], + entry_point=data["entry_point"], + dependencies=data.get("dependencies", []), + author=data.get("author"), + repository=data.get("repository"), + ) + + def to_yaml(self, path: Path) -> None: + data = { + "name": self.name, + "version": self.version, + "description": self.description, + "entry_point": self.entry_point, + } + if self.dependencies: + data["dependencies"] = self.dependencies + if self.author: + data["author"] = self.author + if self.repository: + data["repository"] = self.repository + with open(path / "extension.yaml", "w+") as f: + safe_dump(data, f, default_flow_style=False, sort_keys=False) + + +def get_extensions_dir() -> Path: + EXTENSIONS_DIR.mkdir(parents=True, exist_ok=True) + return EXTENSIONS_DIR + + +def get_installed_extensions() -> list[tuple[Path, ExtensionManifest]]: + ext_dir = get_extensions_dir() + results = [] + for child in sorted(ext_dir.iterdir()): + if child.is_dir() and (child / "extension.yaml").exists(): + try: + manifest = ExtensionManifest.from_yaml(child) + results.append((child, manifest)) + except (ValueError, FileNotFoundError): + continue + return results + + +def get_extension_by_name(name: str) -> tuple[Path, ExtensionManifest]: + for path, manifest in get_installed_extensions(): + if manifest.name == name or path.name == name: + return path, manifest + raise FileNotFoundError(f"Extension '{name}' not found") diff --git a/bittensor_cli/src/commands/extensions/templates.py b/bittensor_cli/src/commands/extensions/templates.py new file mode 100644 index 00000000..c779fd5a --- /dev/null +++ b/bittensor_cli/src/commands/extensions/templates.py @@ -0,0 +1,36 @@ +EXTENSION_YAML_TEMPLATE = """\ +name: {name} +version: 0.1.0 +description: A btcli extension +entry_point: main.py +dependencies: [] +""" + +MAIN_PY_TEMPLATE = """\ +#!/usr/bin/env python3 +\"\"\"Entry point for the {name} btcli extension.\"\"\" + + +def main(): + print("Hello from {name}!") + + +if __name__ == "__main__": + main() +""" + +TEST_TEMPLATE = """\ +from pathlib import Path +import subprocess +import sys + + +def test_{safe_name}_runs(): + entry = Path(__file__).parent.parent / "main.py" + result = subprocess.run( + [sys.executable, str(entry)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 +""" diff --git a/examples/sample-extension/extension.yaml b/examples/sample-extension/extension.yaml new file mode 100644 index 00000000..aa36d8d1 --- /dev/null +++ b/examples/sample-extension/extension.yaml @@ -0,0 +1,7 @@ +name: sample-extension +version: 0.1.0 +description: A sample btcli extension that prints subnet info +entry_point: main.py +dependencies: [] +author: btcli-contributors +repository: https://github.com/opentensor/btcli diff --git a/examples/sample-extension/main.py b/examples/sample-extension/main.py new file mode 100644 index 00000000..8c98c6dd --- /dev/null +++ b/examples/sample-extension/main.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""A sample btcli extension that demonstrates the extensions framework. + +This extension prints a greeting and basic system info to show how an +extension entry point works. +""" + +import platform +import sys + + +def main(): + print("=== Sample btcli Extension ===") + print(f"Python: {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}") + print(f"Platform: {platform.system()} {platform.machine()}") + print("Extension loaded successfully!") + + +if __name__ == "__main__": + main() diff --git a/examples/sample-extension/tests/test_sample_extension.py b/examples/sample-extension/tests/test_sample_extension.py new file mode 100644 index 00000000..a91af6c0 --- /dev/null +++ b/examples/sample-extension/tests/test_sample_extension.py @@ -0,0 +1,29 @@ +from pathlib import Path +import subprocess +import sys + + +def test_sample_extension_runs(): + """Verify the sample extension entry point runs without errors.""" + entry = Path(__file__).parent.parent / "main.py" + result = subprocess.run( + [sys.executable, str(entry)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Sample btcli Extension" in result.stdout + assert "Extension loaded successfully!" in result.stdout + + +def test_sample_extension_prints_python_version(): + """Verify the extension prints Python version info.""" + entry = Path(__file__).parent.parent / "main.py" + result = subprocess.run( + [sys.executable, str(entry)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + expected = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + assert expected in result.stdout diff --git a/tests/unit_tests/test_extensions.py b/tests/unit_tests/test_extensions.py new file mode 100644 index 00000000..09724258 --- /dev/null +++ b/tests/unit_tests/test_extensions.py @@ -0,0 +1,236 @@ +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +from bittensor_cli.src.commands.extensions.manifest import ( + ExtensionManifest, + get_extensions_dir, + get_installed_extensions, + get_extension_by_name, +) +from bittensor_cli.src.commands.extensions.ext_commands import ( + _check_git, + ext_create, + ext_list, + ext_remove, +) + + +@pytest.fixture +def tmp_extensions_dir(tmp_path): + """Override the extensions directory to a temporary path.""" + ext_dir = tmp_path / "extensions" + ext_dir.mkdir() + with patch( + "bittensor_cli.src.commands.extensions.manifest.EXTENSIONS_DIR", ext_dir + ): + yield ext_dir + + +@pytest.fixture +def sample_extension(tmp_extensions_dir): + """Create a sample extension directory with a valid manifest.""" + ext_path = tmp_extensions_dir / "sample-ext" + ext_path.mkdir() + (ext_path / "extension.yaml").write_text( + "name: sample-ext\n" + "version: 1.0.0\n" + "description: A sample extension\n" + "entry_point: main.py\n" + ) + (ext_path / "main.py").write_text('print("hello")\n') + return ext_path + + +class TestExtensionManifest: + def test_from_yaml_valid(self, sample_extension): + manifest = ExtensionManifest.from_yaml(sample_extension) + assert manifest.name == "sample-ext" + assert manifest.version == "1.0.0" + assert manifest.description == "A sample extension" + assert manifest.entry_point == "main.py" + assert manifest.dependencies == [] + + def test_from_yaml_missing_file(self, tmp_extensions_dir): + empty_dir = tmp_extensions_dir / "empty" + empty_dir.mkdir() + with pytest.raises(FileNotFoundError, match="No extension.yaml"): + ExtensionManifest.from_yaml(empty_dir) + + def test_from_yaml_missing_required_fields(self, tmp_extensions_dir): + ext_path = tmp_extensions_dir / "bad-ext" + ext_path.mkdir() + (ext_path / "extension.yaml").write_text("name: bad-ext\n") + with pytest.raises(ValueError, match="missing required fields"): + ExtensionManifest.from_yaml(ext_path) + + def test_from_yaml_with_optional_fields(self, tmp_extensions_dir): + ext_path = tmp_extensions_dir / "full-ext" + ext_path.mkdir() + (ext_path / "extension.yaml").write_text( + "name: full-ext\n" + "version: 2.0.0\n" + "description: Full extension\n" + "entry_point: run.py\n" + "dependencies:\n" + " - requests\n" + " - numpy\n" + "author: test-author\n" + "repository: https://github.com/test/repo\n" + ) + manifest = ExtensionManifest.from_yaml(ext_path) + assert manifest.dependencies == ["requests", "numpy"] + assert manifest.author == "test-author" + assert manifest.repository == "https://github.com/test/repo" + + def test_to_yaml_roundtrip(self, tmp_extensions_dir): + ext_path = tmp_extensions_dir / "roundtrip" + ext_path.mkdir() + manifest = ExtensionManifest( + name="roundtrip", + version="1.0.0", + description="Roundtrip test", + entry_point="main.py", + ) + manifest.to_yaml(ext_path) + loaded = ExtensionManifest.from_yaml(ext_path) + assert loaded.name == manifest.name + assert loaded.version == manifest.version + assert loaded.description == manifest.description + assert loaded.entry_point == manifest.entry_point + + +class TestGetExtensionsDir: + def test_creates_directory(self, tmp_path): + ext_dir = tmp_path / "new_extensions" + with patch( + "bittensor_cli.src.commands.extensions.manifest.EXTENSIONS_DIR", ext_dir + ): + result = get_extensions_dir() + assert result == ext_dir + assert ext_dir.exists() + + def test_returns_existing_directory(self, tmp_extensions_dir): + result = get_extensions_dir() + assert result == tmp_extensions_dir + assert result.exists() + + +class TestGetInstalledExtensions: + def test_empty_directory(self, tmp_extensions_dir): + result = get_installed_extensions() + assert result == [] + + def test_finds_valid_extensions(self, sample_extension, tmp_extensions_dir): + result = get_installed_extensions() + assert len(result) == 1 + path, manifest = result[0] + assert path == sample_extension + assert manifest.name == "sample-ext" + + def test_skips_invalid_extensions(self, tmp_extensions_dir): + bad_dir = tmp_extensions_dir / "bad" + bad_dir.mkdir() + (bad_dir / "extension.yaml").write_text("name: bad\n") + result = get_installed_extensions() + assert result == [] + + def test_skips_files(self, tmp_extensions_dir): + (tmp_extensions_dir / "not-a-dir.txt").write_text("hello") + result = get_installed_extensions() + assert result == [] + + +class TestGetExtensionByName: + def test_find_by_manifest_name(self, sample_extension, tmp_extensions_dir): + path, manifest = get_extension_by_name("sample-ext") + assert manifest.name == "sample-ext" + + def test_not_found(self, tmp_extensions_dir): + with pytest.raises(FileNotFoundError, match="not found"): + get_extension_by_name("nonexistent") + + +class TestExtCreate: + def test_creates_boilerplate(self, tmp_extensions_dir): + ext_create("my-plugin") + target = tmp_extensions_dir / "my-plugin" + assert target.exists() + assert (target / "extension.yaml").exists() + assert (target / "main.py").exists() + assert (target / "tests" / "test_my_plugin.py").exists() + + manifest = ExtensionManifest.from_yaml(target) + assert manifest.name == "my-plugin" + + def test_refuses_duplicate(self, sample_extension, tmp_extensions_dir): + ext_create("sample-ext") + # Should print error but not crash + + +class TestExtList: + def test_no_extensions(self, tmp_extensions_dir, capsys): + ext_list() + + def test_with_extensions(self, sample_extension, tmp_extensions_dir, capsys): + ext_list() + + +class TestExtRemove: + def test_removes_extension(self, sample_extension, tmp_extensions_dir): + assert sample_extension.exists() + ext_remove("sample-ext") + assert not sample_extension.exists() + + def test_remove_nonexistent(self, tmp_extensions_dir): + ext_remove("nonexistent") + + +class TestCheckGit: + def test_git_available(self): + import bittensor_cli.src.commands.extensions.ext_commands as mod + mod._GIT_AVAILABLE = None # reset cache + assert _check_git() is True + + def test_git_not_available(self): + import bittensor_cli.src.commands.extensions.ext_commands as mod + mod._GIT_AVAILABLE = None # reset cache + with patch("shutil.which", return_value=None): + assert _check_git() is False + mod._GIT_AVAILABLE = None # reset cache for other tests + + +class TestSampleExtension: + """Integration tests using the sample extension in examples/.""" + + SAMPLE_EXT = Path(__file__).parent.parent.parent / "examples" / "sample-extension" + + def test_sample_extension_has_valid_manifest(self): + manifest = ExtensionManifest.from_yaml(self.SAMPLE_EXT) + assert manifest.name == "sample-extension" + assert manifest.version == "0.1.0" + assert manifest.entry_point == "main.py" + + def test_sample_extension_runs(self): + entry = self.SAMPLE_EXT / "main.py" + result = subprocess.run( + [sys.executable, str(entry)], + capture_output=True, + text=True, + ) + assert result.returncode == 0 + assert "Sample btcli Extension" in result.stdout + assert "Extension loaded successfully!" in result.stdout + + def test_sample_extension_tests_pass(self): + tests_dir = self.SAMPLE_EXT / "tests" + result = subprocess.run( + [sys.executable, "-m", "pytest", str(tests_dir), "-v"], + capture_output=True, + text=True, + cwd=str(self.SAMPLE_EXT), + ) + assert result.returncode == 0