Skip to content

Extending Protostar

Protostar's architecture strictly isolates state definition from execution. This guarantees that you can add entirely new languages, tools, or domain workflows without altering the core orchestrator or the system executor.

  • Bootstrap Modules


    Define the foundational environment footprint (languages, core tooling). Evaluated during protostar init.

    Learn more

  • Preset Modules


    Lighter wrappers that inject domain-specific dependencies and directories onto a bootstrap foundation.

    Learn more


Building a Custom Bootstrap Module

Bootstrap modules define the structural environment footprint. To create a new module, subclass BootstrapModule from protostar.modules.base.

You must define its CLI flags, a human-readable name, and the build method. You can also optionally define pre_flight checks, collision_markers, and required_languages to enforce strict footprint constraints.

Dynamic CLI Registration

The CLI parser dynamically reads the cli_flags and cli_help attributes at runtime. Once you append your module to the TOOLING_MODULES tuple in protostar/modules/__init__.py, it will automatically appear in the protostar init --help output.

Here is a complete example of a module that scaffolds a justfile (a modern Makefile alternative):

from pathlib import Path
from protostar.modules import BootstrapModule
from protostar.manifest import EnvironmentManifest

class JustModule(BootstrapModule):
    """Configures a justfile for project task execution."""

    cli_flags = ("--just",)
    cli_help = "Scaffold a standard justfile for project tasks"
    config_key = "just"

    @property
    def name(self) -> str:
        return "Just"

    @property
    def collision_markers(self) -> list[Path]:
        return [Path("justfile")]

    def pre_flight(self) -> None:
        import shutil
        if not shutil.which("just"):
            raise RuntimeError("Missing dependency: 'just' is not installed.")

    def build(self, manifest: EnvironmentManifest) -> None:
        content = """default:
\t@just --list

lint:
\tuv run ruff check .
\tuv run ruff format --check .

test:
\tuv run pytest
"""
        manifest.add_file_injection("justfile", content)

Core Interface: BootstrapModule

protostar.modules.base.BootstrapModule

Bases: ABC

Appends module-specific requirements to the environment manifest.

Source code in src/protostar/modules/base.py
class BootstrapModule(abc.ABC):
    """Appends module-specific requirements to the environment manifest."""

    cli_flags: ClassVar[tuple[str, ...]] = ()
    """The CLI flags to trigger this module (e.g., ('-p', '--python'))."""

    cli_help: ClassVar[str] = ""
    """The help description for the CLI flag."""

    config_key: ClassVar[str] = ""
    """The global configuration key used to evaluate if this module is active."""

    @property
    @abc.abstractmethod
    def name(self) -> str:
        """Returns the human-readable identifier for the module."""
        pass

    @property
    def aliases(self) -> list[str]:
        """Returns a list of configuration aliases that map to this module.

        Used for dynamic resolution from the global configuration file.
        """
        return []

    @property
    def collision_markers(self) -> list[Path]:
        """Returns a list of critical filesystem paths to evaluate for collisions during pre-flight.

        Returns:
            A list of Path objects representing critical configuration files or directories
            managed by this module. Defaults to an empty list.
        """
        return []

    def pre_flight(self) -> None:  # noqa: B027
        """Verifies system prerequisites before manifest building begins.

        Raises:
            RuntimeError: If a critical dependency (e.g., 'uv', 'cargo') is missing.
        """
        pass

    @abc.abstractmethod
    def build(self, manifest: "EnvironmentManifest") -> None:
        """Appends module-specific requirements to the environment manifest.

        Args:
            manifest (EnvironmentManifest): The centralized state object.
        """
        pass

cli_flags class-attribute

cli_flags = ()

The CLI flags to trigger this module (e.g., ('-p', '--python')).

cli_help class-attribute

cli_help = ''

The help description for the CLI flag.

config_key class-attribute

config_key = ''

The global configuration key used to evaluate if this module is active.

name abstractmethod property

name

Returns the human-readable identifier for the module.

aliases property

aliases

Returns a list of configuration aliases that map to this module.

Used for dynamic resolution from the global configuration file.

collision_markers property

collision_markers

Returns a list of critical filesystem paths to evaluate for collisions during pre-flight.

Returns:

Type Description
list[Path]

A list of Path objects representing critical configuration files or directories

list[Path]

managed by this module. Defaults to an empty list.

pre_flight

pre_flight()

Verifies system prerequisites before manifest building begins.

Raises:

Type Description
RuntimeError

If a critical dependency (e.g., 'uv', 'cargo') is missing.

Source code in src/protostar/modules/base.py
def pre_flight(self) -> None:  # noqa: B027
    """Verifies system prerequisites before manifest building begins.

    Raises:
        RuntimeError: If a critical dependency (e.g., 'uv', 'cargo') is missing.
    """
    pass

build abstractmethod

build(manifest)

Appends module-specific requirements to the environment manifest.

Parameters:

Name Type Description Default
manifest EnvironmentManifest

The centralized state object.

required
Source code in src/protostar/modules/base.py
@abc.abstractmethod
def build(self, manifest: "EnvironmentManifest") -> None:
    """Appends module-specific requirements to the environment manifest.

    Args:
        manifest (EnvironmentManifest): The centralized state object.
    """
    pass
Deep Dive: Pre-flight vs Build
  • pre_flight(): Executes before any state changes occur. If shutil.which("just") fails here, the orchestrator immediately halts, guaranteeing the environment remains untouched.
  • build(): Only queues state changes. Notice how we use manifest.add_file_injection() instead of Path("justfile").write_text().

Building a Custom Domain Preset

Presets sit on top of the base language footprint. They inherit from PresetModule in protostar.presets.base and strictly define arrays of dependencies and directory structures.

from protostar.presets import PresetModule

class DataEngineeringPreset(PresetModule):
    """Injects ETL and data pipeline dependencies."""

    cli_flags = ("--data-eng",)
    cli_help = "Inject data engineering dependencies"

    @property
    def name(self) -> str:
        return "Data Engineering"

    @property
    def default_dependencies(self) -> list[str]:
        return ["polars", "pyarrow", "duckdb", "dbt-core"]

    @property
    def default_directories(self) -> list[str]:
        return ["pipelines", "data/raw", "data/processed", "tests/data"]

    @property
    def default_ignores(self) -> list[str]:
        return ["*.parquet", "*.duckdb", "dbt_packages/"]

Domain-Specific Dependencies: PresetModule

protostar.presets.base.PresetModule

Bases: ABC

Appends module-specific requirements to the environment manifest.

Source code in src/protostar/presets/base.py
class PresetModule(abc.ABC):
    """Appends module-specific requirements to the environment manifest."""

    cli_flags: ClassVar[tuple[str, ...]] = ()
    """The CLI flags to trigger this preset (e.g., ('-a', '--astro'))."""

    cli_help: ClassVar[str] = ""
    """The help description for the CLI flag."""

    @property
    @abc.abstractmethod
    def name(self) -> str:
        """Returns the human-readable identifier for the preset."""
        pass

    @property
    def config_key(self) -> str:
        """Returns the dictionary key used in config.toml for overrides."""
        return self.__class__.__name__.replace("Preset", "").lower()

    def _apply_overrides(self, manifest: "EnvironmentManifest") -> bool:
        """Applies user-defined overrides from the global configuration if present.

        Returns:
            True if overrides were applied (and defaults should be skipped), False otherwise.
        """
        # Late import to prevent circular dependency at module initialization
        from protostar.config import ProtostarConfig

        config = ProtostarConfig.load()
        overrides = config.presets.get(self.config_key)

        if not isinstance(overrides, dict):
            return False

        logger.debug(f"Applying custom configuration overrides for {self.name} preset.")

        for dep in overrides.get("dependencies", []):
            manifest.add_dependency(dep)

        for dev_dep in overrides.get("dev_dependencies", []):
            manifest.add_dev_dependency(dev_dep)

        for directory in overrides.get("directories", []):
            manifest.add_directory(directory)

        return True

    @property
    def default_dependencies(self) -> list[str]:
        """Returns a list of default packages to inject for this preset."""
        return []

    @property
    def default_directories(self) -> list[str]:
        """Returns a list of default directories to scaffold for this preset."""
        return []

    @property
    def default_ignores(self) -> list[str]:
        """Returns a list of default VCS ignore patterns for this preset."""
        return []

    def build(self, manifest: "EnvironmentManifest") -> None:
        """Appends preset-specific dependencies and directories to the manifest.

        Automatically applies configuration overrides if present. Otherwise, injects
        the default packages, directories, and ignores defined by the preset subclass.

        Args:
            manifest (EnvironmentManifest): The centralized state object.
        """
        logger.debug(f"Building {self.name} preset layer.")

        if self._apply_overrides(manifest):
            return

        for dep in self.default_dependencies:
            manifest.add_dependency(dep)

        for directory in self.default_directories:
            manifest.add_directory(directory)

        for artifact in self.default_ignores:
            manifest.add_vcs_ignore(artifact)

cli_flags class-attribute

cli_flags = ()

The CLI flags to trigger this preset (e.g., ('-a', '--astro')).

cli_help class-attribute

cli_help = ''

The help description for the CLI flag.

name abstractmethod property

name

Returns the human-readable identifier for the preset.

config_key property

config_key

Returns the dictionary key used in config.toml for overrides.

default_dependencies property

default_dependencies

Returns a list of default packages to inject for this preset.

default_directories property

default_directories

Returns a list of default directories to scaffold for this preset.

default_ignores property

default_ignores

Returns a list of default VCS ignore patterns for this preset.

build

build(manifest)

Appends preset-specific dependencies and directories to the manifest.

Automatically applies configuration overrides if present. Otherwise, injects the default packages, directories, and ignores defined by the preset subclass.

Parameters:

Name Type Description Default
manifest EnvironmentManifest

The centralized state object.

required
Source code in src/protostar/presets/base.py
def build(self, manifest: "EnvironmentManifest") -> None:
    """Appends preset-specific dependencies and directories to the manifest.

    Automatically applies configuration overrides if present. Otherwise, injects
    the default packages, directories, and ignores defined by the preset subclass.

    Args:
        manifest (EnvironmentManifest): The centralized state object.
    """
    logger.debug(f"Building {self.name} preset layer.")

    if self._apply_overrides(manifest):
        return

    for dep in self.default_dependencies:
        manifest.add_dependency(dep)

    for directory in self.default_directories:
        manifest.add_directory(directory)

    for artifact in self.default_ignores:
        manifest.add_vcs_ignore(artifact)

Configuration Overrides

Register your preset in protostar/presets/__init__.py. Protostar automatically handles merging any user-defined overrides for these defaults found in their global config.toml.


The Manifest API

No Direct Disk I/O

Never call subprocess.run or write to disk inside a module's build() method. Modules must strictly communicate via the EnvironmentManifest to ensure the Orchestrator maintains atomicity.

The manifest exposes the following methods to queue state changes:

Method Signature Execution Behavior
add_dependency(package: str) Queues a standard package for resolution.
add_dev_dependency(package: str) Queues a development or tooling package.
add_file_injection(path: str, content: str) Queues a complete file write. Fails if the file exists unless explicitly marked for overwrite.
add_file_append(path: str, content: str) Queues a string payload for late-binding concatenation or TOML AST deep-merging.
add_system_task(command: list[str], timeout: int | None = 30, description: str | None = None) Queues a subprocess command to execute after the disk scaffolding phase is complete. Allows an optional execution timeout and UI description.
add_post_install_task(command: list[str], timeout: int | None = 30, description: str | None = None) Queues a subprocess command to execute after all dependencies have been installed. Allows an optional execution timeout and UI description.
add_vcs_ignore(path: str) Appends a tracking exclusion entry to the version control ignore manifest (e.g., .gitignore).