Skip to content

projects

projects

Project discovery and loading.

logger = logging.getLogger(__name__) module-attribute

DEFAULT_GIT_AUTHORSHIP = 'agent-human' module-attribute

Default Git authorship mode for task containers.

VALID_GIT_AUTHORSHIP_MODES = ('agent-human', 'human-agent', 'agent', 'human') module-attribute

Supported values for git.authorship in config files.

BrokenProject(name, config_path, error) dataclass

A project directory whose project.yml failed to load.

Carries just enough context for the TUI to render a row and show the validation error in the details pane, without forcing callers to re-run the failing load_project to rediscover the message.

name instance-attribute

config_path instance-attribute

error instance-attribute

normalize_git_authorship(value)

Validate and normalize a git.authorship config value.

None or an empty string fall back to DEFAULT_GIT_AUTHORSHIP. Raises SystemExit for invalid values so project loading can fail with a clear configuration error.

Source code in src/terok/lib/core/projects.py
def normalize_git_authorship(value: object) -> str:
    """Validate and normalize a ``git.authorship`` config value.

    ``None`` or an empty string fall back to [`DEFAULT_GIT_AUTHORSHIP`][terok.lib.core.projects.DEFAULT_GIT_AUTHORSHIP].
    Raises [`SystemExit`][SystemExit] for invalid values so project loading can fail
    with a clear configuration error.
    """
    if value is None:
        return DEFAULT_GIT_AUTHORSHIP

    if not isinstance(value, str):
        valid = ", ".join(VALID_GIT_AUTHORSHIP_MODES)
        raise SystemExit(f"Invalid git.authorship value: expected a string.\nValid values: {valid}")

    normalized = value.strip().lower()
    if not normalized:
        return DEFAULT_GIT_AUTHORSHIP

    if normalized in VALID_GIT_AUTHORSHIP_MODES:
        return normalized

    valid = ", ".join(VALID_GIT_AUTHORSHIP_MODES)
    raise SystemExit(f"Invalid git.authorship value {value!r}.\nValid values: {valid}")

derive_project(source_id, new_id)

Create a new project config that shares infrastructure with an existing one.

The derived project points at the same git-gate mirror and the same SSH keypair as the source — only project.name and the agent: section differ. This is the "sibling project" use case: rerun the same repo through a different image or agent without re-provisioning keys or re-cloning the mirror. The source's instructions.md, if present, is copied over so the derived project starts with the same user-provided guidance.

Returns the new project's root directory.

Raises SystemExit if the source project is not found or the target already exists.

Source code in src/terok/lib/core/projects.py
def derive_project(source_id: str, new_id: str) -> Path:
    """Create a new project config that *shares infrastructure* with an existing one.

    The derived project points at the same git-gate mirror and the same SSH
    keypair as the source — only ``project.name`` and the ``agent:`` section
    differ.  This is the "sibling project" use case: rerun the same repo
    through a different image or agent without re-provisioning keys or
    re-cloning the mirror.  The source's ``instructions.md``, if present, is
    copied over so the derived project starts with the same user-provided
    guidance.

    Returns the new project's root directory.

    Raises SystemExit if the source project is not found or the target already exists.
    """
    validate_project_name(new_id)
    source = load_project(source_id)
    projects_root = user_projects_dir().resolve()
    target_root = (projects_root / new_id).resolve()

    # Guard against directory traversal (belt-and-suspenders with the regex above)
    if not target_root.is_relative_to(projects_root):
        raise SystemExit(f"Invalid project name '{new_id}': path escapes projects directory")

    if target_root.exists():
        raise SystemExit(f"Project '{new_id}' already exists at {target_root}")

    source_cfg = _yaml_load((source.root / _PROJECT_YML).read_text(encoding="utf-8")) or {}

    _rewrite_project_identity(source_cfg, new_id)
    source_cfg.pop("agent", None)
    _pin_shared_infra(source_cfg, source)

    target_root.mkdir(parents=True, exist_ok=True)
    (target_root / _PROJECT_YML).write_text(
        _yaml_dump(source_cfg),
        encoding="utf-8",
    )

    instructions_src = source.root / _INSTRUCTIONS_MD
    if instructions_src.is_file():
        shutil.copy2(instructions_src, target_root / _INSTRUCTIONS_MD)

    return target_root

normalize_project_name(project_name)

Rewrite project.yml so project.name matches its directory.

This is the explicit quick fix for project-name mismatches: directory names are the local project identity, and the config file is normalised to declare the same name. Legacy project.id is removed and any old display-only project.name is preserved as project.description when safe.

Source code in src/terok/lib/core/projects.py
def normalize_project_name(project_name: str) -> Path:
    """Rewrite ``project.yml`` so ``project.name`` matches its directory.

    This is the explicit quick fix for project-name mismatches: directory
    names are the local project identity, and the config file is normalised
    to declare the same name.  Legacy ``project.id`` is removed and any old
    display-only ``project.name`` is preserved as ``project.description``
    when safe.
    """
    validate_project_name(project_name)
    cfg_path = _find_project_root(project_name) / _PROJECT_YML
    try:
        cfg = _yaml_load(cfg_path.read_text(encoding="utf-8")) or {}
    except (OSError, UnicodeDecodeError, YAMLError) as exc:
        raise SystemExit(f"Failed to read {cfg_path}: {exc}") from exc
    if not isinstance(cfg, dict):
        raise SystemExit(f"Invalid {_PROJECT_YML} ({cfg_path}): expected a mapping")
    _rewrite_project_identity(cfg, project_name)
    cfg_path.write_text(_yaml_dump(cfg), encoding="utf-8")
    return cfg_path

require_project_exists(project_name)

Raise SystemExit unless project_name names a known project.

Cheap stat-based check — no YAML parse, no pydantic validation. Use this in CLI entry points that want to fail before any user-visible side effect (interactive prompt, status print, image build offer). The downstream load_project call still catches malformed YAML.

Source code in src/terok/lib/core/projects.py
def require_project_exists(project_name: str) -> None:
    """Raise [`SystemExit`][SystemExit] unless *project_name* names a known project.

    Cheap stat-based check — no YAML parse, no pydantic validation.  Use
    this in CLI entry points that want to fail before any user-visible
    side effect (interactive prompt, status print, image build offer).
    The downstream [`load_project`][terok.lib.core.projects.load_project]
    call still catches malformed YAML.
    """
    _find_project_root(project_name)

discover_projects()

Load every project on disk, splitting successes from config-level failures.

The broken list lets the TUI render damaged projects alongside healthy ones (issue #565) — silently hiding them turns "project vanished" into a mystery. _parse_project_yaml wraps every config error (bad YAML, schema drift, filesystem issues) in SystemExit with a human-readable message; anything else propagates as a genuine bug.

Source code in src/terok/lib/core/projects.py
def discover_projects() -> tuple[list[ProjectConfig], list[BrokenProject]]:
    """Load every project on disk, splitting successes from config-level failures.

    The broken list lets the TUI render damaged projects alongside healthy
    ones (issue #565) — silently hiding them turns "project vanished" into
    a mystery.  ``_parse_project_yaml`` wraps every config error (bad YAML,
    schema drift, filesystem issues) in ``SystemExit`` with a human-readable
    message; anything else propagates as a genuine bug.
    """
    paths_by_id = _discover_project_paths()
    valid: list[ProjectConfig] = []
    broken: list[BrokenProject] = []
    for pid in sorted(paths_by_id):
        try:
            valid.append(load_project(pid))
        except SystemExit as exc:
            msg = _sanitize_for_tty(str(exc))
            broken.append(BrokenProject(name=pid, config_path=paths_by_id[pid], error=msg))
    return valid, broken

list_projects()

Discover all projects (user + system), warning on broken configs.

Thin wrapper over discover_projects that preserves the existing stderr + logger diagnostics for CLI callers. The TUI uses discover_projects directly to render broken entries in-place.

User projects override system ones with the same id.

Source code in src/terok/lib/core/projects.py
def list_projects() -> list[ProjectConfig]:
    """Discover all projects (user + system), warning on broken configs.

    Thin wrapper over [`discover_projects`][terok.lib.core.projects.discover_projects] that preserves the existing
    stderr + logger diagnostics for CLI callers.  The TUI uses
    [`discover_projects`][terok.lib.core.projects.discover_projects] directly to render broken entries in-place.

    User projects override system ones with the same id.
    """
    valid, broken = discover_projects()
    for bp in broken:
        # Log records are one-line structured entries; a message carrying
        # embedded newlines would split across records and could be read
        # as injected log lines.  stderr print keeps newlines so pydantic's
        # multi-line validation output is readable on the console.
        logger.warning("Skipping broken project '%s': %s", bp.name, bp.error.replace("\n", "\\n"))
        print(f"warning: skipping broken project '{bp.name}': {bp.error}", file=sys.stderr)
    return valid

load_project(project_name)

Load and return a fully resolved ProjectConfig from project_name.

Source code in src/terok/lib/core/projects.py
def load_project(project_name: str) -> ProjectConfig:
    """Load and return a fully resolved [`ProjectConfig`][terok.cli.commands.sickbay.ProjectConfig] from *project_name*."""
    root = _find_project_root(project_name)
    cfg_path = root / _PROJECT_YML
    if not cfg_path.is_file():
        raise SystemExit(f"Missing {_PROJECT_YML} in {root}")

    raw = _parse_project_yaml(cfg_path)

    # Git identity resolved via ConfigStack: git-global → terok-global → project.yml
    git_dict = raw.git.model_dump(exclude_none=True)
    identity_stack = ConfigStack()
    identity_stack.push(ConfigScope("git-global", None, _git_global_identity()))
    identity_stack.push(ConfigScope("terok-global", None, _validated_global_git_section()))
    identity_stack.push(ConfigScope("project", cfg_path, git_dict))
    identity = identity_stack.resolve()

    try:
        return _build_project_config(raw, identity, root, project_name)
    except ValidationError as exc:
        # Identity values come from merged sources (git config, global config,
        # project.yml).  Include provenance in the error so the user knows
        # where to look.
        sources = ", ".join(s.level for s in identity_stack.scopes if s.data)
        raise SystemExit(
            _format_validation_error(exc, cfg_path) + f"\n  (git identity merged from: {sources})"
        )

set_project_image_agents(project_name, selection)

Write selection into the project's project.yml under image.agents.

Caller validates selection up-front; on success returns the project.yml path written.

Source code in src/terok/lib/core/projects.py
def set_project_image_agents(project_name: str, selection: str) -> Path:
    """Write *selection* into the project's ``project.yml`` under ``image.agents``.

    Caller validates *selection* up-front; on success returns the
    project.yml path written.
    """
    from terok.lib.integrations.sandbox import yaml_update_section

    cfg_path = _find_project_root(project_name) / _PROJECT_YML
    yaml_update_section(cfg_path, "image", {"agents": selection})
    return cfg_path