# cui-cui

A terminal user interface for visualizing and debugging CI pipelines
(GitLab CI, GitHub Actions).

Point it at a `.gitlab-ci.yml` or `.github/workflows/` directory and get an
interactive DAG view of your pipeline, with fully resolved job configurations
and generated shell scripts for local execution.

## Features

- **Pipeline DAG visualization** -- stages as columns, jobs as boxes, dependency
  arrows between them (both explicit `needs:` and implicit stage ordering)
- **Extends/anchor resolution** -- fully resolves `extends` chains, YAML anchors,
  `!reference` tags, and `default:` blocks so you see the final merged config
- **Variable expansion** -- expands `$VAR` and `${VAR:-default}` across global
  and job-level variables, with CI predefined variables from real git context
- **Local script generation** -- builds a shell script from
  `before_script` + `script` + `after_script` with exported variables,
  ready to copy and run locally
- **Container launch** -- press `c` to drop into an interactive container
  matching the job's image, with variables set and the script available to run
- **Four detail tabs** -- Config (resolved YAML), Script (generated bash),
  Variables (all expanded key-value pairs), Debug (extends chain provenance,
  key-level origin tracking, variable origins)
- **Child pipeline tabs** -- press `Enter` on a `trigger:include:local` job
  to open the child pipeline in a new tab; `[`/`]` to switch tabs,
  `Backspace` to close
- **Remote includes** -- fetches `include:project`, `include:remote`, and
  `include:template` via the GitLab API
- **`include:local`** support -- merges locally included files
- **Mouse support** -- click to select jobs/tabs, scroll to navigate,
  drag to resize the detail panel
- **Light and dark themes** -- auto-detected from terminal background;
  override with `--light` or `--dark`
- **Live reload** -- press `F5` to re-parse the file without restarting

## Installation

Download a pre-built Linux binary from the
[releases page](https://gitlab.freedesktop.org/bentiss/cui-cui/-/releases).

Or install from [crates.io](https://crates.io/crates/cui-cui):

```bash
cargo install cui-cui
```

Or build from source:

```bash
cargo build --release
./target/release/cui-cui
```

## Usage

```bash
# Auto-detect CI system from current directory
# (looks for .gitlab-ci.yml, then .github/workflows/)
cui-cui

# Or specify a path
cui-cui path/to/.gitlab-ci.yml
cui-cui path/to/.github/workflows/ci.yml

# Specify a GitLab instance (auto-detected from git remote otherwise)
cui-cui --gitlab-url https://gitlab.example.com .gitlab-ci.yml

# Override theme (auto-detected by default)
cui-cui --light
cui-cui --dark

# Forward host environment variables into containers
cui-cui --pass-env GITHUB_TOKEN --pass-env DOCKER_PASSWORD

# Disable overlay mount (use volume copy instead)
cui-cui --no-overlay

# Use CI config from a separate repo (project dir is the positional arg)
cui-cui --ci-config ~/Src/kernel-ci/ ~/Src/linux

# Headless modes (no TUI, for scripting/automation)
cui-cui --list-jobs                    # List all jobs grouped by stage
cui-cui --dump-script build            # Print the shell script for a job
cui-cui --run build                    # Run a job non-interactively (like r/R in TUI)
cui-cui --run build --privileged       # Same, with privileged mode
cui-cui --container build              # Drop into an interactive container (like c/C in TUI)

# Override or set CI variables
cui-cui -e BUILD_OPTS="-Dfoo=true"     # Set a variable for all jobs
cui-cui -e BUILD_OPTS=""               # Set a variable to empty

# Dispatch mode: run the full pipeline locally
cui-cui --dispatch --no-tags .                   # Run all untagged jobs
cui-cui --dispatch --tags kvm,x86_64 .           # Run jobs matching these tags
cui-cui --dispatch --no-tags --tags kvm .         # Run untagged + kvm-tagged jobs
cui-cui --dispatch --no-tags --skip container-prep .  # Skip a job (treat as passed)
cui-cui --dispatch --no-tags --skip stage=build .    # Skip an entire stage
cui-cui --dispatch --no-tags --skip job=lint .        # Skip only matching job names
cui-cui --dispatch --git-image docker.io/alpine/git:latest .  # Use a custom git image for checkout

# Override container registry hostname (for self-hosted GitLab instances)
cui-cui --ci-registry registry.gitlab.gnome.org .gitlab-ci.yml

# Allow unresolved extends (for remote includes with missing templates)
cui-cui --allow-unresolved path/to/.gitlab-ci.yml

# Generate shell completions
cui-cui --completions bash > /etc/bash_completion.d/cui-cui
cui-cui --completions zsh > ~/.zfunc/_cui-cui
cui-cui --completions fish > ~/.config/fish/completions/cui-cui.fish
```

Job names in `--dump-script`, `--run`, `--container`, and `--skip` support fuzzy matching:
exact, case-insensitive, then substring. Hyphens and underscores are treated as
equivalent (`test_suite` matches `test-suite`). `--skip` also matches stage names
(all jobs in the stage are skipped). Use `stage=` or `job=` prefixes to
restrict matching. Ambiguous matches show available options.

## Key Bindings

| Key | Action |
|-----|--------|
| `h`/`l` or Left/Right | Navigate between stages |
| `j`/`k` or Down/Up | Navigate between jobs within a stage |
| `Tab` / `Shift+Tab` | Cycle detail tabs (Config / Script / Variables / Debug) |
| `Enter` | Open child pipeline (trigger jobs) or toggle focus |
| `Backspace` | Close current child pipeline tab |
| `[` / `]` | Switch between pipeline tabs |
| `PgUp` / `PgDn` | Scroll detail panel |
| `H`/`L`/`K`/`J` | Scroll the DAG view |
| `c` | Launch interactive container for selected job |
| `C` | Launch privileged container (for jobs with kvm tags) |
| `r` | Run selected job non-interactively (shows pass/fail result) |
| `R` | Run selected job non-interactively with privileged mode |
| `v` | Edit job variables in `$EDITOR` |
| `F5` | Reload the file |
| `q` / `Esc` | Quit |

### Dispatch mode (`--dispatch`)

When running in a terminal, dispatch shows a full-screen interactive UI with
a pipeline status panel and a scrollable log pane.

| Key | Action |
|-----|--------|
| `j`/`k` or Down/Up | Scroll log output |
| `g` / `G` | Jump to top / re-enable auto-scroll |
| `PgUp` / `PgDn` | Scroll by page |
| Mouse wheel | Scroll log output |
| `/` | Search logs (Enter to commit, `n`/`N` to navigate hits, `q`/Esc to clear) |
| `f` | Filter logs grep-style (only matching lines shown, `q`/Esc to clear) |
| `q` | Clear active filter/search, or quit when nothing is active |
| `Ctrl-C` | Quit (signals running containers to stop) |

The TUI stays open after dispatch finishes so you can browse logs.
Piped output (`--dispatch | cat`) uses a plain inline display.

## Supported GitLab CI Features

- `stages` ordering
- `extends` (single and multi-parent, with deep merge)
- YAML anchors, aliases, and merge keys (`<<`)
- `!reference` tags
- `include:local`, `include:project`, `include:remote`, `include:template`,
  `include:component`
- `include:inputs` and `spec:inputs` (with `$[[ inputs.* ]]` interpolation)
- `include:rules` (static `when: never` skipping)
- `needs` (DAG dependencies, optional needs, `$[[ matrix.* ]]` in
  `needs:parallel:matrix`)
- `parallel:matrix` (job expansion)
- `variables` (global and per-job, with expansion)
- `inherit:default`, `inherit:variables`
- `before_script`, `script`, `after_script`
- `run:` steps (GitLab Functions -- local `script:`, local `func:`, OCI `func:`)
- `image`, `services`, `tags`
- `rules`, `when`, `allow_failure`
- `artifacts` (paths, exclude, expire_in, when, `reports:dotenv`), `cache`
- Hidden jobs (`.name` used as templates)
- `default:` block

## Supported GitHub Actions Features

- Workflow `on:` triggers
- Job `needs:` (DAG dependencies)
- `strategy.matrix` (job expansion with `${{ matrix.* }}` expressions)
- `env:` variables (workflow-level and job-level, with `${{ env.* }}` expansion)
- `${{ github.* }}` context expressions (auto-detected from local git repo)
- `runs-on:` labels
- `container:` image
- Per-step metadata (`uses`, `run`, `with`, `env`, `if`)
- `uses:` action expansion -- fetches action repos, caches them, and expands
  node/composite actions into executable script lines
- `${{ }}` expression evaluation (comparisons, boolean operators, ternary
  patterns like `runner.os == 'Windows' && 'win-val' || 'linux-val'`)
- Step output propagation (`${{ steps.X.outputs.Y }}` resolved as bash
  variables via `GITHUB_OUTPUT` sourcing between steps)
- Step-level `if:` conditions (with static evaluation where possible)
- `timeout-minutes`
- Job-level `if:` conditions
- GitHub predefined variables (`GITHUB_*`, `RUNNER_*`, `CI`)

## Configuration

cui-cui supports layered config files in TOML format. Settings are merged in
order (later wins):

1. **User config**: `$XDG_CONFIG_HOME/cui-cui/config.toml` (defaults to
   `~/.config/cui-cui/config.toml`)
2. **Repo config**: `.cui-cui.toml` in the target directory
3. **CLI arguments** (highest priority)

Example `.cui-cui.toml`:

```toml
# Theme: "light", "dark", or "auto" (default)
theme = "dark"

# GitLab instance URL
gitlab-url = "https://gitlab.freedesktop.org"

# Disable overlay mount for containers
no-overlay = true

# Forward host environment variables into containers
pass-env = ["GITHUB_TOKEN", "DOCKER_PASSWORD"]

# CI config from a separate repo
ci-config = "~/Src/kernel-ci/"

# Jobs/stages to skip in --dispatch mode (treated as passed for dependencies)
dispatch-skip = ["container-prep", "stage=deploy"]

# Container image for git operations (worktree checkout, apply) in dispatch mode
git-image = "docker.io/alpine/git:latest"

# Override container registry hostname (for self-hosted GitLab instances)
ci-registry = "registry.gitlab.gnome.org"

# Allow unresolved extends (missing templates skipped with warning)
allow-unresolved = true
```

All fields are optional. Missing files are silently ignored.

## Remote Runners

cui-cui can dispatch jobs to remote hosts via SSH. Declare runners in
`.cui-cui.toml` with tags, and when a job's tags match a runner, the job is
executed on that host instead of in a local container.

### Runner declaration

```toml
[[runner]]
name = "linux-builder"         # human-readable name (required)
host = "build-server.local"    # SSH host (omit for local runners)
tags = ["kvm", "x86_64"]       # tags this runner handles
no-tags = true                 # also pick up untagged jobs (default: false)
concurrency = 4                # max parallel jobs on this runner (default: 1)
user = "ci"                    # SSH user (default: current user)
port = 2222                    # SSH port (default: 22)
working-dir = "/opt/ci/trees"  # remote worktree base (default: ~/.cui-cui/worktrees)
binary = "/usr/local/bin/cui-cui"  # path to cui-cui on remote (default: "cui-cui" in PATH)
```

### Tag matching

A runner matches a job when the runner's tags are a **superset** of the job's
tags (case-insensitive). Untagged jobs only match runners with `no-tags = true`.
When multiple runners match, jobs are distributed proportionally by concurrency
to balance load across runners.

When runners are configured, their tags are automatically added to the dispatch
tag filter so tagged jobs are not skipped.

### Execution modes

**Binary mode** (no `exec-prefix`): Requires the cui-cui binary installed on the
remote. Code is pushed via `git push`, then `cui-cui --run <job>` is invoked
over SSH. The remote reuses the full container/volume strategy (identical to
local dispatch).

**Exec-prefix mode** (with `[runner.custom]`): No binary needed on the remote.
A wrapper shell script is generated locally, shipped to the remote, and
executed. The custom command replaces the container runtime:

```toml
[[runner]]
name = "vng-tester"
tags = ["kvm"]

[runner.custom]
command = "vng --run --"       # becomes: vng --run -- ./.cui-cui-job.sh
```

### Lifecycle hooks

`prepare` and `cleanup` run before and after the job. Cleanup is guaranteed
via shell `trap EXIT` (runs on success, failure, and signals). All job
variables are exported before any phase, so prepare/cleanup can reference
them (`$CI_JOB_IMAGE`, `$CI_JOB_NAME`, custom variables, etc.).

```toml
[[runner]]
name = "mac-tester"
host = "mac-mini.local"
tags = ["macos"]

[runner.custom]
command = "limactl shell $CI_JOB_NAME --"
prepare = "limactl start $CI_JOB_NAME"
cleanup = "limactl stop $CI_JOB_NAME"
```

Use `@file` to reference a local script instead of inline commands. The path
is relative to `.cui-cui.toml` (or absolute):

```toml
[runner.custom]
command = "limactl shell $INSTANCE --"
prepare = "@runners/lima-prepare.sh"
cleanup = "@runners/lima-cleanup.sh"
```

(Here `$INSTANCE` is exported by the prepare script; see
[`examples/lima-runner/`](examples/lima-runner/) for reference scripts.)

### Container tuning

For binary-mode runners, the `[runner.container]` section configures the
container runtime:

```toml
[[runner]]
name = "linux-builder"
host = "build-server.local"
tags = ["kvm", "x86_64"]

[runner.container]
kernel = "$LIMA_KEXEC_ARTIFACT"  # --kernel flag (expanded from job variables)
memory = "8G"                    # -m flag
cpus = 4                         # --cpus flag
```

### Artifacts

Artifacts transfer automatically between runners. The local host acts as hub:

1. After a remote job succeeds, its artifacts are downloaded to the local cache
2. Before a remote job runs, needed artifacts from previous jobs are uploaded

Binary mode uses the remote's artifact cache; exec-prefix mode transfers files
directly to/from the remote worktree.

### SSH requirements

- SSH agent must be running (or key-based auth without passphrase)
- `BatchMode=yes` is used -- no interactive password prompts
- The remote user needs write access to `~/.cui-cui/worktrees/` (or the
  configured `remote-dir`)

### Local runners

Omit `host` for a local runner. Local runners require a custom command
(without it, just use regular container dispatch):

```toml
[[runner]]
name = "vng-local"
tags = ["kvm"]

[runner.custom]
command = "vng --run --"
```

## Kernel Testing with Lima

A common use case for remote runners is Linux kernel CI: build the kernel on a
remote Linux server, then test the compiled kernel by booting it in a
[Lima](https://lima-vm.io/) VM (e.g. on macOS or another host with
hardware-specific capabilities).

### Example `.gitlab-ci.yml`

```yaml
stages:
  - build
  - test

build-kernel:
  stage: build
  tags: [kvm, x86_64]
  image: fedora:latest
  script:
    - make -j$(nproc) vmlinux modules
  artifacts:
    paths:
      - vmlinux
      - modules/

kernel-test:
  stage: test
  tags: [macos, arm64]
  image: registry.example.com/fedora-bootc:41
  needs: [build-kernel]
  variables:
    LIMA_KEXEC_ARTIFACT: "vmlinux@build-kernel"
  script:
    - make -C tools/testing/selftests run_tests
```

### Example `.cui-cui.toml`

```toml
ci-config = "~/Src/kernel-ci/"

# Build the kernel on a remote Linux server (binary mode -- cui-cui required)
[[runner]]
name = "linux-builder"
host = "build-server.local"
tags = ["kvm", "x86_64"]
no-tags = true
concurrency = 2

# Test the kernel in a Lima VM on macOS (custom runtime -- no binary needed).
# The prepare script converts the job's bootc image to qcow2 (cached),
# boots a Lima VM, and optionally kexecs into a compiled kernel.
[[runner]]
name = "mac-tester"
host = "mac-mini.local"
tags = ["macos", "arm64"]

[runner.custom]
command = "limactl shell $INSTANCE --"
prepare = "@runners/lima-prepare.sh"
cleanup = "@runners/lima-cleanup.sh"

# Alternative: test locally in a virtme-ng VM (no SSH, no binary needed)
[[runner]]
name = "vng-tester"
tags = ["kvm"]

[runner.custom]
command = "vng --run --"
```

### Workflow

1. `cui-cui --dispatch .` resolves the pipeline and runs jobs in order
2. `build-kernel` matches `linux-builder` -- code is pushed via SSH, the
   remote cui-cui builds the kernel in a container, and artifacts (`vmlinux`,
   `modules/`) are downloaded to the local cache
3. `kernel-test` matches `mac-tester` -- artifacts are uploaded, the prepare
   script converts the bootc image (`$CI_JOB_IMAGE`) to a qcow2 disk via
   [bootc-image-builder](https://github.com/osbuild/bootc-image-builder),
   boots a Lima VM, kexecs into the compiled kernel (`$LIMA_KEXEC_ARTIFACT`),
   and runs the test script inside the VM via `limactl shell`.
   Cleanup deletes the VM (guaranteed via `trap EXIT`)

See [`examples/lima-runner/`](examples/lima-runner/) for reference prepare and
cleanup scripts.

## License

This program is free software; you can redistribute it and/or modify it under
the terms of the GNU General Public License version 2 as published by the Free
Software Foundation. See [COPYING](COPYING) for the full license text.
