Multi-repository docker-compose workspace orchestrator: one YAML, one `cupli up`, every container running.
Project description
Multi-repository docker-compose workspace orchestrator. One YAML, one `cupli up`, every container running.
๐ท๐บ README ะฝะฐ ััััะบะพะผ
cupli is for projects where each component (backend, frontend,
worker, shared SDK, infra) lives in its own git repository but the
whole stack needs to come up on one machine with one command. It builds
on docker compose without replacing it.
- Spec-first. One
space.cupli.yamldeclares everything: repos, bases, mounts, services, shortcuts. - Inline or external compose. Define services in YAML directly, or
point at an existing
docker-compose.yml. Mix freely. - Multi-repo git.
cupli git status / pull / fetch / checkoutoperate across every cloned component in parallel, with per-repo selectors and per-repo branch maps. - Variable scope. Space โ bases (C3) โ app, with
${VAR}and${VAR:-default}interpolation everywhere. - Branch pinning + drift.
branch: mainon a component is honoured bycupli init(git clone -b) and surfaced incupli git statuswhen the working tree drifts off. - Mount toggling.
cupli mounts attach <name>bind-mounts a shared SDK into N containers without YAML edits. - Shell completion for every name (apps, services, mounts, tags, shortcuts, error codes).
๐ค Using an AI agent to edit
space.cupli.yaml? Point it atAGENTS.mdโ a self-contained guide to the schema, service binding,commands:, top-level blocks, and error codes.
Table of contents
- Install
- Quick-start
- Concepts
- The
space.cupli.yamlreference - CLI reference
- Recipes
- IDE setup
- Limitations
- Troubleshooting + error codes
Install
uv tool install cupli # recommended
# or
pipx install cupli
# or
pip install --user cupli
Verify:
cupli -V # cupli 0.1.0 (script-friendly)
cupli --version # full info: python, platform, deps
Cupli requires Python โฅ 3.10. Docker / docker compose must be on PATH.
Shell completion
One-shot, picks your shell automatically from $SHELL:
cupli completion install
Or pin the shell:
cupli completion install --shell bash # bash | zsh | fish | pwsh
cupli completion show --shell zsh > ~/.zsh/completions/_cupli
Quick-start
mkdir my-workspace && cd my-workspace
cupli init --name my-workspace # scaffolds space.cupli.yaml + .env + .locals/
$EDITOR space.cupli.yaml # describe your apps
cupli up # build + start everything
cupli ps # see what's running
cupli logs my-api -f
cupli down # tear down
The smallest possible workspace:
# space.cupli.yaml
schema_version: 1
name: hello
apps:
cache:
service: # inline compose-spec
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports: ["6379:6379"]
cupli up
cupli exec -c cache -- redis-cli ping # PONG
See docs/examples/minimal/ for the same workspace with comments.
Concepts
Space
A space is the unit cupli operates on โ one space.cupli.yaml,
one project, one docker-compose project. Spaces have a name:, which
also doubles as the docker-compose project name and the default network
name.
App
An app is what cupli starts and stops. Each app binds to one or more docker-compose services. The binding is declared one of four ways:
- Implicit โ service name equals app name.
service: "name"โ bind to an existing compose service by name.service: {image: ..., command: ..., ...}โ inline single-service spec, no separate compose file.services: { name1: {...}, name2: {...} }โ compound app with multiple compose services (think: api + celery workers + beat).
In forms 3 and 4 the dict accepts any docker-compose service
attribute (image, build, command, environment, depends_on,
healthcheck, volumes, restart, โฆ). Cupli reserves vars and
ports for its own injection logic; everything else is passed through
to docker-compose verbatim via a generated docker-compose.inline.yml.
Base
A base is a reusable template. Apps cite bases via
bases: [name1, name2] and inherit vars:, envs:, composes:,
repo: from them in C3 linearisation order. Bases keep boilerplate
DRY across apps that share runtime.
Mount
A mount is a host-to-container bind that toggles on/off without
editing YAML. Useful for hot-swapping a vendored SDK to a local
checkout. cupli mounts attach/detach <name> flips the state.
Host resolution for IDEs (host_bridge & exports)
IDEs index the host filesystem, while cupli runs everything inside containers. Two opt-in features bridge that gap (both are off by default and exist purely for editor resolution โ never for running host tooling):
host_bridgeon a mount keeps an inverse host symlink (<host-equivalent of exec_path> โ mount.path) so a library mounted under the app workdir is visible on the host at the same relative path the container uses. Lifecycle-managed (up/mounts attach/detach) or explicit viacupli mounts bridge/unbridge.exportscopies a container-built directory (typically a named volume likenode_modules) onto the host.strategy: sync(default) mirrors the volume onrefresh_onevents;strategy: bind-seededturns the path into a live host bind. Manage withcupli exports sync/clean.
They compose: pnpm's relative symlinks inside an exported node_modules
(@scope/<lib> โ ../../packages/<lib>) resolve on the host only when
packages/<lib> is bridged. There's an asymmetry between stacks โ a JS remote
(Docker) interpreter does not resolve dependencies, so node_modules must
exist on the host; a Python remote interpreter resolves fine, so prefer it over
exporting .venv (a .venv export is skipped unless rewrite_paths: true).
See the exports.<name> reference for fields.
Service
A service in cupli is exactly what docker-compose calls a service โ a container declaration. Apps own services; one app may own many.
Workspace registry
Spaces register themselves in ~/.config/cupli/spaces.json so you can
operate by name from anywhere:
cupli workspace add -n shop -f ~/work/shop/space.cupli.yaml
cupli -s shop up
cupli workspace select shop # sticky: subsequent cupli calls target shop
cupli workspace unselect # back to cwd-detect
The space.cupli.yaml reference
The full reference is space.cupli.yaml at the
repo root and copied to
docs/examples/full-reference/. Below is the
schema with one-line descriptions.
Top level
| Key | Type | Default | What it does |
|---|---|---|---|
schema_version |
int | โ | Version pin. Only 1 is supported. |
name |
string | โ | Project identifier. Used as docker-compose project name and default network name. Matches ^[A-Za-z][A-Za-z0-9_-]*$. |
cupli_min / cupli_max |
string | "*" |
โ | Tool-version guards. |
extends |
string | โ | Path to a parent space (one level only in v1). |
envs |
list[string] | [] |
.env files loaded into space scope, before vars. |
vars |
map[str, str] | {} |
Space-scope variables; visible everywhere; written to override.env for docker-compose substitution. |
bases |
map[str, base] | {} |
Reusable templates. |
apps |
map[str, app] | {} |
Run units. |
mounts |
map[str, mount] | {} |
Toggleable bind-mounts. |
exports |
map[str, export] | {} |
Materialise container-built dirs (node_modules) onto the host for IDE resolution. |
hooks |
map[str, hook-override] | {} |
Per-target tweaks for cupli hooks install. |
commands |
map[str, command-shortcut] | {} |
cupli sc <name> / cupli <name> (with top_level: true). |
networks |
map[str, dict] | {} |
Top-level docker-compose networks: block. Values are compose-spec verbatim (driver, name, ipam, etc.). Cupli's default network is merged in automatically. |
volumes |
map[str, dict] | {} |
Top-level docker-compose volumes: block. Named volumes (compose-spec verbatim) so inline services can reference them without a separate compose file. A null body (minio_data:) is a default-driver volume. |
secrets |
map[str, dict] | {} |
Top-level docker-compose secrets: block. Secret definitions (compose-spec verbatim) referenced by service-level secrets:. |
configs |
map[str, dict] | {} |
Top-level docker-compose configs: block. Config definitions (compose-spec verbatim) referenced by service-level configs:. |
bases.<name>
| Key | Type | Default | What it does |
|---|---|---|---|
path |
string | ${BASES_PATH}/<name> |
On-disk location. |
repo |
string | โ | Git URL (omit for an in-place base). |
branch |
string | โ | Branch to clone (git clone -b <branch>). |
post_clone |
string | โ | Shell command run on host after a successful clone. |
init_vars |
map | {} |
Env exported to clone + post_clone. |
vars |
map | {} |
Variables contributed to inheriting apps. |
envs |
list[string] | [] |
Env files loaded into the base scope. |
composes |
list[string] | [] |
Compose-fragments prepended to inheriting apps' COMPOSE_FILE chain. |
apps.<name>
| Key | Type | Default | What it does |
|---|---|---|---|
path |
string | ${APPS_PATH}/<name> |
On-disk location. |
repo |
string | โ | Git URL. |
branch |
string | โ | Branch to clone. cupli git status flags drift. |
post_clone |
string | โ | Shell command run on host after clone. |
init_vars |
map | {} |
Env exported to clone + post_clone. |
bases |
list[string] | [] |
Bases to inherit (C3 multi-inherit). |
deps |
list[str] | map[str, โฆ] | {} |
Cross-app depends_on. List form [a, b] or map with per-dep settings (mode tags for --mode filtering, compose condition / restart / required). See Dependency conditions. |
tags |
list[string] | [] |
For cupli up --tag <tag>. |
mode |
enum | up |
up (long-running), oneshot (run-once), disabled. |
composes |
list[string] | [] |
External compose files. |
service |
string | dict | โ | Single-service binding. Dict form is inline compose-spec. |
services |
map[str, dict] | list[str] | โ | Multi-service map (each value is a compose-spec with optional cupli-only vars and ports) or a bare list of service names (equivalent to a map with empty overrides). Mutually exclusive with service. |
vars |
map | {} |
Variables; injected as environment on every managed service. |
envs |
list[string] | [] |
Env files loaded into app scope. |
ports |
list[string] | [] |
Compose-style port mappings; injected into the app's primary service (or every service in services:). |
forward_ssh |
bool | false |
Mount $SSH_AUTH_SOCK into the container. |
Service binding forms โ all four are valid
# 1) implicit (service name = app name)
apps:
api: {}
# 2) string (rename binding)
apps:
redis:
service: cache-redis # bind to compose service `cache-redis`
composes: [./compose.yml]
# 3) inline single-service (any compose attribute is fair game)
apps:
cache:
service:
image: memcached:1.6
command: ["memcached", "-m", "64"]
healthcheck: {test: ["CMD", "echo", "stats", "|", "nc", "localhost", "11211"]}
vars: {LOG_LEVEL: info}
ports: ["11211:11211"]
# 4) services map (one app, N compose services)
apps:
backend:
vars: {DATABASE_URL: ...} # shared with every service below
services:
backend:
image: ${IMAGE}
command: [uvicorn, app.main:app]
celery-worker:
image: ${IMAGE}
command: [celery, -A, app.tasks, worker]
vars: {CELERY_LOG_LEVEL: info} # per-service override (merged)
ports: [] # explicit empty: opt out of app-level ports
# 4b) services as a bare list โ same as `{name: {}}` for each
apps:
fleet:
composes: [${APP_PATH}/docker-compose.yml]
services:
- api
- worker
- beat
${VAR}inside inline compose-spec (service.build.context: ${APP_PATH}) is substituted by docker-compose, not cupli โ use${<APP_NAME>_APP_PATH}(per-component path-var, which cupli writes intooverride.env) when you need the path of a specific app. Bare${APP_PATH}only resolves where cupli does the substitution itself (e.g.composes:).
mounts.<name>
| Key | Type | Default | What it does |
|---|---|---|---|
path |
string | ${MOUNTS_PATH}/<name> |
Host source dir. |
repo |
string | โ | Git URL. |
branch |
string | โ | Branch to clone. |
post_clone |
string | โ | After-clone host command. |
hosted_in |
list[string] | required | App names whose every service gets the bind. |
exec_path |
string | required | Absolute POSIX path inside container. |
mode |
enum | rw |
rw | ro. |
mac_volume |
enum | โ | macOS volume consistency hint. |
host_bridge |
bool | map | false |
Maintain an inverse host symlink so host tooling (IDEs) sees the mount at the container-relative path. true auto-derives the link from the hosting app's workdir bind; a map ({link, relative}) overrides it. See host_bridge & exports. |
envs |
list[string] | [] |
Env files. |
vars |
map | {} |
Variables. |
exports.<name>
Materialise a directory built inside a container (typically a named volume
such as node_modules) onto the host so IDEs that only resolve from the local
filesystem can index it. For IDE indexing, not for running host tooling โ
exported native binaries may target the image's libc, not the host's.
| Key | Type | Default | What it does |
|---|---|---|---|
from |
string | required | App (single) whose service owns the source directory. |
exec_path |
string | required | Absolute POSIX source path inside the container. |
path |
string | required | Host destination path (${VAR} resolved in scope). |
strategy |
enum | sync |
sync (keep the named volume, copy to host on refresh_on) or bind-seeded (turn exec_path into a host bind seeded from the image โ always live). |
refresh_on |
list[enum] | string | [build] |
Lifecycle events that re-materialise the export: up, build, restart. |
gitignore |
bool | true |
Add path to the root .gitignore (under a # cupli exports section). |
mac_volume |
enum | โ | macOS volume consistency hint. |
rewrite_paths |
bool | false |
Experimental: sync a .venv-like export anyway and rewrite absolute container paths (/app/...) in .pth / .egg-link files to host equivalents. Without it, a .venv-like export is skipped (E034). |
commands.<name>
| Key | Type | Default | What it does |
|---|---|---|---|
container |
string | list[string] | required | App name(s) whose primary service runs the command. A list runs it in each. |
run |
string | list[string] | required | Shell command line. A block scalar or list of lines is joined with newlines and run via sh -c. {{name}} placeholders are filled from args. |
workdir |
string | โ | Working directory inside the container. |
help |
string | โ | Short help shown in cupli --help. |
top_level |
bool | false |
When true, also exposes as cupli <name> (alongside cupli sc <name>). |
group |
string | โ | Label; groups the command under a panel in cupli --help and the cupli sc listing. |
execute |
enum | sequential |
For a multi-container command: sequential (fail-fast), continue (run all, non-zero if any failed), or parallel. |
args |
list[arg] | [] |
Declared, typed parameters surfaced in cupli <cmd> --help and substituted into run via {{name}}. A bare list of names is shorthand for required positional string args. |
strict |
bool | false |
When false, CLI tokens not matching a declared arg (flags + positionals) are forwarded to the end of the command; when true, unknown tokens are rejected. |
commands.<name>.args[]
| Key | Type | Default | What it does |
|---|---|---|---|
name |
string | required | Identifier; the {{name}} placeholder and CLI arg/option name. |
help |
string | โ | Description shown in cupli <cmd> --help. |
type |
enum | str |
str, int, or bool. A bool is always an option (flag). |
option |
bool | false |
When true, a --name option; otherwise a positional argument. |
short |
string | โ | Single-letter alias for an option (l โ -l). |
required |
bool | false |
Whether the value must be supplied. Mutually exclusive with default. |
default |
string | โ | Value substituted when the parameter is omitted. |
commands:
db-migrate:
group: Database # `cupli --help` shows it under a "Database" panel
container: api
run: python manage.py migrate {{app}} {{fake}}
args:
- name: app # required positional: `cupli db-migrate users`
required: true
help: Django app label.
- name: fake # bool -> a `--fake` flag
type: bool
top_level: true
pip-freeze:
container: [api, worker] # run in several services
execute: parallel # sequential (default) | continue | parallel
run: pip freeze
For a multi-line script, use a block scalar (newline-separated commands; add
&& or set -e for fail-fast within the script):
setup:
container: api
run: |
python manage.py migrate
python manage.py loaddata initial
Auto-vars (always interpolatable)
- Space scope โ
SPACE_NAME,SPACE_PATH,APPS_DIR,APPS_PATH,BASES_DIR,BASES_PATH,MOUNTS_DIR,MOUNTS_PATH,LOCALS_DIR,LOCALS_PATH,NETWORK,COMPOSE_PROJECT_NAME. - Per-component โ
<NAME>_APP_PATHfor every app,<NAME>_BASE_PATHfor every base,<NAME>_MOUNT_PATHfor every mount,<NAME>_EXPORT_PATHfor every export. Name is upper-cased with-mapped to_. Visible in YAML AND inoverride.env. A mount with an explicithost_bridge.linkalso exposes<NAME>_BRIDGE_PATH. - App / base โ
APP_NAME,APP_PATH,APP_LOCAL_PATH(apps only). - Mount โ
MOUNT_NAME,MOUNT_PATH,MOUNT_HOST,MOUNT_EXEC_PATH. - Export โ
EXPORT_NAME,EXPORT_PATH,EXPORT_EXEC_PATH.
Default paths: APPS_PATH = $SPACE_PATH/src/apps, similarly for
bases and mounts. Override per-component with an explicit path:.
Interpolation rules
${VAR},${VAR:-literal-default}, and bare$VARare all recognised.$$escapes a literal$(docker-compose convention).- Nested
${...}inside a default is not supported. The default is literal. - Cycles raise
E014. - Unknown vars resolve to
""with a yellow warning. Pass--strict-varsto make them hard errors (E016). - Shadowing a reserved auto-var name raises
E015unless--allow-shadowis passed.
CLI reference
cupli --help lists everything. Highlights:
Lifecycle
| Command | What |
|---|---|
cupli up [services] [--tag t] [--mode m] [--build] [--pull p] |
docker compose up. Service args can be app names OR individual compose-service names from a compound app's services: map. |
cupli stop [services] [--tag t] |
docker compose stop. |
cupli restart [services] [--tag t] [--hard] |
restart, or down+up with --hard. |
cupli down [-v] [--images] |
down --remove-orphans, optional volumes + images. |
cupli ps [--tag t] |
running services table. |
cupli logs [service] [-f] |
per-service or all. |
cupli build [services] [--tag t] |
build images. |
cupli pull [services] [--tag t] |
pull images. |
cupli compose -- <args> |
pass-through to docker compose. |
cupli config |
merged compose configuration. |
cupli watch [services] |
docker compose watch โ for develop.watch declared on a service. |
--mode default|hook|full filters cross-app deps: by their declared
mode-list. Use it to express dev-vs-prod-style dependency sets:
api: {deps: {redis: [default, full]}} pulls redis on both modes;
audit: {deps: {redis: [full]}} skips it under --mode default.
Dependency conditions
deps: accepts compose-style start conditions per dependency. Default is
service_started (or service_completed_successfully for a mode: oneshot
dep). String value = condition shorthand; null = defaults; list = mode tags
(back-compat with --mode filtering); mapping = full spec.
apps:
api:
deps:
postgres: service_healthy # wait for healthcheck
redis: ~ # default: service_started
init-data:
condition: service_completed_successfully
restart: true # restart api when init-data restarts
required: false # api still starts if init-data can't
These map straight onto compose depends_on.<svc>.{condition,restart,required}.
Exec / run
| Command | What |
|---|---|
cupli exec -c <service> -- <cmd> |
run inside a running container. |
cupli run -c <service> -- <cmd> |
one-shot container (run --rm). |
cupli shell -c <service> |
open /bin/bash (override with --shell). |
cupli wrap -c <app> -- <cmd> |
run on the host with the app's env exported. |
cupli env [-c <app>] [--export] |
print resolved env. |
Shortcuts
| Command | What |
|---|---|
cupli sc |
list declared commands:. |
cupli sc <name> [args] |
run shortcut. |
cupli <name> |
same shortcut when top_level: true. |
Workspace
| Command | What |
|---|---|
cupli init [-n name] [--path .] [--force] [--no-sync] [--no-ide] |
scaffold + register. Creates space.cupli.yaml, .env, .locals/; src/apps/, src/bases/, src/mounts/ are created lazily by cupli space sync when a declared component first needs them. |
cupli workspace add -n <name> -f <file> |
register an existing space. |
cupli workspace list |
every registered space with * on the active one. |
cupli workspace select <name> |
sticky active selection. |
cupli workspace unselect |
clear it (cwd-detect resumes). |
cupli workspace current |
what would be targeted right now. |
cupli workspace remove <name> |
drop from registry (filesystem untouched). |
cupli space sync [--apps/--bases/--mounts] [--pull] |
clone declared repos + optional pull. |
cupli space doctor [--strict] |
validate paths + repos. |
Git (across every cloned component)
| Command | What |
|---|---|
cupli git status [targets] |
status table. Flags drifted when working tree branch โ pinned. |
cupli git pull [targets] [--rebase] |
parallel pull. |
cupli git fetch [targets] |
parallel fetch. |
cupli git checkout <branch> [-t target] [-m name=branch] |
branch switch with per-repo overrides. |
Mounts
| Command | What |
|---|---|
cupli mounts list |
every declared mount and its state (incl. host_bridge). |
cupli mounts attach <name> |
bind-mount into hosted_in apps. |
cupli mounts detach <name> |
remove the bind. |
cupli mounts bridge [names] |
create/repair host_bridge symlinks. |
cupli mounts unbridge [names] |
remove cupli-created host_bridge symlinks. |
Exports
| Command | What |
|---|---|
cupli exports list |
every declared export and its status (missing/stale/seeded/synced). |
cupli exports sync [names] |
materialise / refresh host copies. |
cupli exports clean [names] |
remove sync host copies (bind-seeded data kept). |
Hooks
| Command | What |
|---|---|
cupli hooks install <hooks-dir> [--scope all/apps/bases/mounts] [--target name] |
install per-target git-hook shims. |
cupli hooks remove [--scope] [--target] |
remove shims. |
Hook scripts under <hooks-dir>/<hook-name>/*.sh are dispatched into the
target's container. A first-line directive overrides the defaults:
#!/usr/bin/env bash
# cupli: container=api workdir=/app shell=sh
echo "running inside the container"
shell=sh switches the in-container interpreter from bash (default) to
POSIX sh โ useful for alpine-based images that have no bash.
IDE
| Command | What |
|---|---|
cupli ide setup [--target auto/vscode/pycharm/all] [--force] |
write JSON-schema mappings for the workspace. auto walks up looking for .vscode/ / .idea/ (stops at the git-repo boundary) and writes only for the editor(s) found. |
Diagnostics
| Command | What |
|---|---|
cupli graph |
tree of bases / apps / mounts / commands. |
cupli dashboard [-i interval] |
live status table. |
cupli stats [--follow] |
docker stats scoped to the workspace. |
cupli explain <code> |
error code reference. |
Recipes
Single inline service, no compose file
schema_version: 1
name: hello
apps:
cache:
service:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
ports: ["6379:6379"]
Compound app (celery)
apps:
backend:
vars: {DATABASE_URL: ..., REDIS_URL: ...}
services:
backend:
image: ${IMAGE}
command: [uvicorn, app.main:app]
ports: ["8000:8000"]
celery-worker:
image: ${IMAGE}
command: [celery, -A, app.tasks, worker]
depends_on: [backend]
celery-beat:
image: ${IMAGE}
command: [celery, -A, app.tasks, beat]
depends_on: [backend]
Full file: docs/examples/celery/.
Multi-repo workspace
repo:+branch:on every app/mount that has its own checkout.cupli initclones them undersrc/apps/<name>.cupli git statusaggregates state across all of them.
Full file: docs/examples/multi-repo-shop/.
Renaming a compose service
apps:
redis:
service: cache-redis # compose-fragment calls it cache-redis
composes: [./compose.yml]
Hot-swap a vendored SDK
mounts:
shared-sdk:
repo: git@github.com:example/shared-sdk.git
hosted_in: [shop-web]
exec_path: /opt/shared-sdk
cupli mounts attach shared-sdk # mount in
cupli mounts detach shared-sdk # mount out
Per-repo branch on checkout
cupli git checkout main # all repos โ main
cupli git checkout main -t shop-api -t shop-web # only these two
cupli git checkout -m shop-api=feature/x -m shop-web=main
Tag-based filtering
apps:
postgres: {tags: [infra, db]}
redis: {tags: [infra, cache]}
shop-api: {tags: [backend]}
cupli up --tag infra # only postgres + redis
Targeting one service of a compound app
cupli up backend # all services owned by `backend`
cupli up celery-worker # only that compose-service of the compound app
cupli up celery-worker celery-beat # several specific services
Custom networks
networks:
shared-net:
name: my-org-shared
driver: bridge
monitoring:
driver: bridge
apps:
api:
service:
image: ...
networks: [default, shared-net] # `default` is cupli's auto network
metrics:
service:
image: ...
networks: [monitoring]
Named volumes, secrets, configs
Top-level volumes: / secrets: / configs: are merged verbatim into
docker-compose.pre.yml, so an inline service can reference them without a
separate compose file. No synthetic default is injected (unlike networks),
and an empty block is omitted from the output.
volumes:
minio_data: # null body == default-driver named volume
secrets:
ci_token:
environment: CI_JOB_TOKEN
apps:
minio:
service:
image: minio/minio
command: server /data
volumes: [minio_data:/data] # references the named volume above
IDE setup
cupli init and cupli ide setup write JSON-schema mappings for the
editor(s) it detects around the workspace (auto walks parent dirs up
to the git-repo boundary, looking for .vscode/ or .idea/). Every
generated space.cupli.yaml also carries a
# yaml-language-server: $schema=... directive on line 1, so modern
editors pick the schema up even without the config files.
VS Code
Install the YAML extension. That's it โ the schema directive is honoured. Optional pinning if you prefer:
// .vscode/settings.json
{
"yaml.schemas": {
"./space.schema.json": "space.cupli.yaml"
}
}
PyCharm / IntelliJ
The bundled YAML plugin understands # yaml-language-server: $schema=
directives in IntelliJ 2023.2+. If yours doesn't:
Settings โ Languages & Frameworks โ Schemas and DTDs โ JSON Schema Mappings โ +
- Name:
cupli space - Schema file: pick
space.schema.jsonfrom the repo root - File path pattern:
space.cupli.yaml(or*.cupli.yaml)
neovim with LSP
yaml-language-server understands the inline directive. Make sure it's
running on *.cupli.yaml.
Generating the schema
The schema lives at space.schema.json and is
generated from the Pydantic models:
make schema # or: uv run python scripts/generate_schema.py
Re-run after changing src/cupli/domain/models.py.
Why JSON Schema for a YAML file? JSON Schema is the editor-side contract โ both
yaml-language-server(VS Code, neovim) and IntelliJ's bundled YAML support understand it natively and apply it to YAML files. No conversion needed.
Custom file icon for space.cupli.yaml
JSON-Schema mappings don't change file icons. If you want the cupli logo on the file in your project tree:
VS Code (with Material Icon Theme)
โ add to .vscode/settings.json:
{
"material-icon-theme.files.associations": {
"space.cupli.yaml": "docs/resources/logo.svg",
"*.cupli.yaml": "docs/resources/logo.svg"
}
}
PyCharm / IntelliJ โ a custom file icon needs a real Kotlin plugin
(FileIconProvider extension point); JSON-Schema mappings alone can't
do it.
Limitations
- One project at a time. A
space.cupli.yamlmaps to exactly one docker-compose project. To compose two cupli workspaces, share infra via external networks. - No native Kubernetes. Compose-only.
- No remote build farm. Builds run locally via
docker compose. - No secrets management. Use
.env.local(gitignored) and the usual${VAR}substitution. Cupli doesn't ship a vault integration. - Schema v1. Forward-incompatible breaking changes are gated on
schema_version.cupli upgrade-configis the migration path placeholder.
Troubleshooting + error codes
cupli explain <code> prints the full description. The cheat-sheet:
| Code | Meaning |
|---|---|
E001 |
Space file not found. |
E002 |
Validation failed (pydantic). Per-field messages include file:line:col. |
E003 |
Empty / comment-only space file. |
E004 |
YAML syntax error. |
E014 |
Variable interpolation cycle. |
E015 |
User variable shadows a reserved auto-var. |
E016 |
Unknown ${VAR} reference under --strict-vars. |
E017 |
git clone failed. |
E020 |
Unknown name (app / mount / target / space). |
E028 |
Unknown cupli error code (catch-all). |
E029 |
Space file already exists. |
E030 |
Per-component env-var name collision (e.g. shop-api and shop_api both โ SHOP_API_APP_PATH). |
For cupli space doctor and cupli config errors, the output now
includes a per-field summary with source locations.
Changelog
v0.8.1
Bug-fix release. Makes cupli exec / run / shell / sc usable from
non-interactive contexts (git hooks, CI, pipes).
Fixes
exec/run/shell/scno longer fail outside a terminal.docker compose exec/runallocate a pseudo-TTY by default, so these commands aborted withcannot attach stdin to a TTY-enabled container because stdin is not a terminalwhenever stdin was not a terminal โ e.g. a workspace command invoked throughcupli scduring a git pre-commit hook, in CI, or behind a pipe. cupli now adds-Tautomatically when stdin is not a terminal, while an interactive terminal keeps its TTY (colors, interactivity). The in-container hook runner already passed-T; the shortcut/exec path now matches it.
v0.8.0
Feature release. Adds mounts.<x>.exclude so a container's package-manager
output stays in the container instead of leaking onto the host bind.
Features
mounts.<x>.excludeโ sub-path masking. Anrwlibrary bind is two-way, so a package manager running in the container (pnpm/npm/uv) writes artifacts laid out for the container โ e.g.node_modulessymlinks into/app/node_modules/.pnpm/...โ that leak back onto the host bind, where they are broken and pollute the working copy.excludemasks listed sub-paths of the bind with their own volume so those artifacts live only inside the container. Each entry injects a volume at<exec_path>/<sub>(it nests deeper than the bind, so docker resolves it by path length); on the host the sub-path stays an empty mount point, pre-created as the user rather than root. Bare strings (exclude: [node_modules]) are shorthand for an anonymous overlay; the expanded form takesstrategy(anonymousdefault โ re-seeds from the image on recreate โ ornamedโ a persistent volume declared in the generated pre-override) and, fornamed,refresh_on: [up|build|restart]to drop the volume after that event so the nextupre-seeds it from the fresh image (best-effort โ skipped while the volume is in use). Sub-paths must be concrete relative paths (globs and..are rejected), and derived named-volume keys are checked for collisions.cupli mounts listgains anexcludescolumn (shown only when declared). Pairs withhost_bridge/exports: the library is bridged for the IDE, its ownnode_modulesis masked, and the app-levelnode_modulesis exported โ so resolution works without a phantomnode_modulesunder the library.
v0.7.0
Hygiene release. Fixture and example services renamed to project-neutral names; no functional changes vs v0.6.3.
Chores
- Renamed fixture/example services across README (EN + RU), full-reference
example, tests, and one docstring to project-neutral identifiers
(
cache-redis,api-backend).
v0.6.3
Patch release. Two fixes from a fresh-clone deploy of the exports /
host_bridge features.
Fixes
-
bind-seededexports reinstalled dependencies at runtime instead of copying from the image. On an empty host the bind started empty, so the container ran a slow, network-boundpnpm/uv install(hanging onETIMEDOUTin a restricted network). Two causes: the source image/volume were resolved against every service in the merged compose (so an unrelated app's image could win โ e.g. a Pythonapi-backendimage used to seed a JSnode_modulesexport), and on a freshcupli up --buildthe image was not built yet when the seed ran. Source resolution is now scoped to the export'sfromapp's compose service(s), andcupli upbuilds first (as a separate step) so the seed copies real content from the image โ an offlinedocker cp-style copy ofexec_path, never a package install.cupli up --buildtherefore no longer forwards--buildto theupstep. -
A fresh
cupli upraced docker's named-volume initialisation when several services of an app shared one volume (failed to mkdir โฆ/_data/โฆ: file exists), so the first deploy failed and needed a manual retry. Beforeup, cupli now pre-initialises each named volume shared by two or more services once, serially (building any missing image first). This is a no-op once the volume exists and is gated so spaces without compound apps / exports pay nothing.
v0.6.2
Patch release. Seven fixes from the second real-world run of the exports /
host_bridge features.
Fixes
-
host_bridge: trueauto-derivation returned "no hosting bind found".derive_host_linkmatched the mount's own injected bind (whose target equalsexec_path), yielding an empty relative part. It now requires a strict ancestor, so the app's/appworkdir bind wins โhost_bridge: trueworks without an explicitlink:in the common case. Derivation is scoped to the hosting app's compose service(s); a bind from an unrelated service (e.g. another app that also binds its own directory to/app) no longer leaks in. -
cupli mounts unbridgereported "not cupli-owned" for symlinks cupli created. A conflicting mount raised mid-loop, sostate/bridges.jsonwas never written and symlinks created earlier in the batch were orphaned. Conflicts are now reported as per-mount results rather than a raised error, ownership is persisted even when a later mount conflicts, and the conflict no longer aborts the batch orcupli up.cupli mounts bridgestill exits non-zero when any mount conflicts. -
host_bridgebroke on mounts bound directly under the app's/appbind (e.g.docs,mkdocs.yml). The host link path is the docker sub-mount point, which the daemon creates as an empty directory or a 0-byte root-owned file. cupli already reclaimed an empty directory; it now also reclaims a 0-byte file stub and replaces it with the bridge symlink. Only a non-empty directory, a non-empty file, or a foreign symlink is a conflict (E032). -
.venvexports synced despiteE034, producing broken editable installs. A.venv-like export is now skipped unlessrewrite_paths: trueis set โ editable.pthfiles carry absolute container paths (/app/...) that do not exist on the host. Withrewrite_paths: true(experimental) the sync rewrites the container workdir prefix (/app/...โ the app's host path) in.pth/.egg-linkfiles. -
cupli exports cleanreportedmissingfor a copy it had just removed. It now reportsremoved, and prunes the export's entry from the root.gitignore(dropping the# cupli exportssection once it is empty). -
cupli graphnow lists anexportssection (name / from /exec_pathโ host path / strategy), alongsidebases/apps/mounts/commands.
v0.6.1
Patch release. Fixes from the first real-world run of the v0.6.0 exports /
host_bridge features.
Fixes
-
exports syncproduced an emptyroot:rootdirectory and failed chown (E033). Thesynchelper copied as--user <uid>:<gid>, which can neither read root-owned named-volume content nor chown the result; the follow-up host-side chown then hitEPERM, leaving an empty directory whileexports listreportedmissing. The copy and the chown now run as root inside the helper container (cp -a <src>/. /dst/ && chown -R <uid>:<gid> /dst), so the source is readable regardless of its in-image ownership and the result is owned by the host user โ no host-side chown, noE033.cp -apreserves the relative symlinks insidenode_modules; afind -mindepth 1 -deletepass makes repeated syncs idempotent. The reported status now reconciles with the on-disk fact (missingwhen nothing materialised, never a falsesynced).bind-seededseeding uses the same root copy+chown path, so it no longer leaves root-owned files either. -
cupli mounts bridgeraised a falseE032on an empty directory. An emptypackages/<lib>mount point (routinely left by docker or a prior run) was treated as a foreign object. An empty directory on the link path is now reclaimed (rmdir+ symlink); only a non-empty directory, a file, or a foreign symlink is a conflict (E032). -
Top-level
${<NAME>_APP_PATH}ignored anapp.pathoverride. In top-levelexports/mounts,${<NAME>_APP_PATH}resolved to the default${APPS_PATH}/<name>instead of the app's overriddenpath:, soexports.pathcould point at the wrong directory. The loader now overwrites the pre-pass default path-vars with each component's actual resolved path before resolving top-levelmounts/exports. -
host_bridge: trueauto-derivation missed the workdir bind from an externalcomposes:file when the compose service name differed from the app name (no hosting bind found). Auto-derivation now falls back to scanning every merged-compose service's binds for the one whose target is an ancestor ofexec_path.
v0.6.0
Feature release: host-side materialisation for IDEs โ host_bridge mount
symlinks and the exports: block. Both are opt-in and backward-compatible;
existing space files are unaffected. Configs using the new keys should pin
cupli_min: 0.6.0.
Features
-
host_bridgeon a mount. A mount whoseexec_pathlives under the hosting app's workdir bind (e.g.${APP_PATH}:/app) can keep an inverse host symlink โ<host-equivalent of exec_path> โ mount.pathโ so host tooling (IDEs, workspace-package resolvers) sees the library at the same relative path the container uses. This makes the relative symlinks pnpm writes insidenode_modules(@scope/<lib> โ ../../packages/<lib>) resolve on the host.mounts: web-ui-kit: hosted_in: [web] path: ${LIBS_PATH}/ui-kit exec_path: /app/packages/ui-kit host_bridge: true # auto-derive the host link from the workdir bind # host_bridge: # or override: # link: ${WEB_APP_PATH}/packages/ui-kit # relative: true # relative symlink (default; portable)
The link is created/repaired on
cupli upandcupli mounts attach, and removed oncupli mounts detach; manage it explicitly withcupli mounts bridge [namesโฆ]/cupli mounts unbridge [namesโฆ].cupli mounts listgains abridgecolumn (none/pending/ok/broken/conflict). cupli only touches symlinks it created (tracked instate/bridges.json); a foreign non-symlink on the link path is left alone and surfaces asE032. Auto-derivation reads the workdir bind fromdocker compose config; an explicitlink:works offline and is exposed as${<MOUNT>_BRIDGE_PATH}. Symlink creation degrades gracefully on platforms that cannot create symlinks. -
exports:โ materialise container-built directories onto the host. A new top-level block copies a directory built inside a container (typically a named volume such asnode_modules) out to the host so IDEs that only resolve from the local filesystem can index it. For IDE indexing, not for running host tooling โ the exported tree may carry native binaries built for the image's libc, not the host's.exports: web-node-modules: from: web # the app (single) that owns it exec_path: /app/node_modules # container source path: ${WEB_APP_PATH}/node_modules # host destination strategy: sync # sync (default) | bind-seeded refresh_on: [build] # up | build | restart (default: [build]) gitignore: true # add path to root .gitignore (default true)
sync(default) keeps the named volume for container I/O and copies it to the host one-way on eachrefresh_onevent (symlinks preserved, so pnpm's.pnpm/@scope/<lib>structure survives).bind-seededturnsexec_pathinto a host bind (injected into the generated post-override) seeded from the image, so the container writes straight to the host (always live).
Seeded/refreshed automatically after
up/build/restartperrefresh_on; manage manually withcupli exports list/sync/clean. Auto-vars${EXPORT_PATH}/${EXPORT_EXEC_PATH}/${<NAME>_EXPORT_PATH}are available in scope. A foreign non-empty host directory cupli did not create raisesE032. Exporting a.venv-like path with editable installs warnsE034(a Python remote interpreter resolves fine โ prefer it; opt in with experimentalrewrite_paths: true). -
New error codes
E032(host path occupied),E033(ownership mismatch),E034(editable paths point into the container).
Chores
AGENTS.mddocuments the per-service-verb closure semantics introduced in v0.5.3, plus the newhost_bridge/exportsschema, CLI verbs, auto-vars, and error codes. README (EN + RU) and the full-reference example are updated;space.schema.jsonis regenerated.
v0.5.3
Patch release.
Fixes
- Per-service lifecycle verbs no longer drag every dependency along.
cupli restart apirestartedapiand every container the app depends on (postgres,redis,minio, โฆ) โ bouncing the whole stack. Same shape forcupli stop,cupli down,cupli ps,cupli build, andcupli pull. Root cause: these verbs forwarded the closure-expandedplan.servicesto docker compose, butfilter_service.closurealways walksapps[*].depstransitively. Closure expansion is intended forcupli up(deps must start first), not for operations on already-running containers. Per-service verbs now resolve user-named seeds via the newcompose_service.target_serviceshelper which skips the deps walk;docker compose <verb> <svc>already handles per-verb semantics correctly. Workspace-wide forms (cupli restartwithout arguments) and tag-filtered forms (cupli restart --tag api) keep their existing behaviour.cupli upretains closure expansion. The v0.5.2 audit caught the silent-drop side ofcupli downbut missed this broader closure-expansion issue across the verb family โ both bug classes are addressed now.
v0.5.2
Patch release.
Fixes
cupli down <service>no longer silently tears the whole stack down.down_commanddiscarded the service-name half of the passthrough split, socupli down kanchiran a fulldocker compose down --remove-orphansand removed every container in the workspace.downnow declares an optionalservicespositional: with services named, only those containers go down (docker compose down [SERVICESโฆ]โ compose v2 supports the per-service form); without arguments the workspace-wide teardown behaviour is unchanged. The other lifecycle verbs (up,stop,restart,ps,logs,build,pull) were audited at the same time and already forward service names correctly.
v0.5.1
Patch release.
Fixes
cupli --listworks under typer โฅ0.25 / click โฅ8.4. Newer typer restructured the class hierarchy soTyperGroupis no longer a subclass ofclick.Group. Theisinstance(cmd, click.Group)guard in_list_commandsand the builtin-collision check silently returned an empty group, hiding every command fromcupli --list. The guard now duck-typesobj.commandsso cupli is compatible with both old and new typer/click lines.cupli --listand top-level shortcut promotion work under a freshpipx installagain.
v0.5.0
Feature release: compose-style start conditions for apps[*].deps.
Features
-
Compose start conditions on
deps. A dependency inapps[*].depscan now declare its docker-compose start condition (service_started/service_healthy/service_completed_successfully) plus therestartandrequiredflags. Previously cupli always renderedservice_started(with a special case toservice_completed_successfullyformode: oneshotdeps), which forced workspaces to shipwait-for-pgscripts to actually wait for a healthy database. Now:apps: api-backend: deps: postgres: service_healthy # condition shorthand redis: ~ # default (service_started) migrate: # full DepSpec form condition: service_completed_successfully modes: [default] restart: true required: true
Accepted
depsvalue forms (back-compat preserved):- list of names โ defaults (unchanged);
~(null) โ defaults;- string โ condition shorthand;
- list of mode tags โ
modesfor--modefiltering (unchanged); - mapping โ full
DepSpec(modes/condition/restart/required).
Mode-tag and condition name spaces are disjoint, so a bare string is unambiguous. Cupli forwards
restart/requiredto compose only when they differ from compose defaults.
v0.4.2
Patch release.
Fixes
- File-placeholder for sub-binds is read-only on the host. Cupli pre-creates
a 0-byte file on the host for every sub-bind whose source is a single file
(e.g.
mkdocs.ymlmounted at/app/mkdocs.yml). The placeholder is just a mount point โ docker overlays the bind source on top inside the container, and the host file stays empty by design. To stop IDEs and humans from editing that empty file by mistake, the placeholder is now created withchmod 0o444. Docker mount ignores host perms, so the running container is unaffected. Directory placeholders keep their default perms (some are parents to other sub-bind placeholders and need write perm during prep).
v0.4.1
Patch release.
Fixes
- Pre-create host placeholders for sub-mounts under bind targets. Docker
daemon (running as root) creates a missing mount point on demand, so under a
bind
host:/appplus sub-mounts targeting/app/<sub>(named volumes, cupli mounts, additional binds) the daemon ended up creating those host placeholders as root โ leaving root-owned junk on the host. Cupli now resolves the merged compose config beforeup/build/run/watchand pre-creates each placeholder as the current user; the daemon finds an existing mount point and skips the root creation. Idempotent and silent on every failure mode โ prep never blocks compose.
v0.4.0
Feature release: workspace-command ergonomics and variable interpolation.
Features
- Undeclared command args pass through (+
strict). When acommands.<name>declaresargs, CLI tokens that don't match a declared arg (flags and positionals) are forwarded verbatim to the end of the command โ e.g.cupli sc deploy prod --force-recreate. A newstrict: trueattribute restores reject-unknown. cupli sc <name>are real, typed subcommands.scis now a group whose subcommands resolve live from the active space (-f/-s/ cwd, cold cache, or freshly edited YAML); declaredargsparse, appear incupli sc <name> --help, and tab-complete. Completion is per-space (no cross-space leakage).- Builtin compose verbs forward unknown flags.
up/down/build/pull/stop/restart/ps/logspass unknown flags through to docker compose (e.g.cupli up --force-recreate); service names are not mistaken for flags. Use the--opt=valueform for value-taking flags. - Bare
$VARinterpolation.$VARis recognised alongside${VAR}and${VAR:-default};$$escapes a literal$(docker-compose convention). - env-file values are interpolated against the cupli scope. A value such as
DATABASE_URL=postgres://db:${POSTGRES_PORT}/appinside an env file now resolves${POSTGRES_PORT}fromvars/ earlier env layers, so port-dependent wiring and credential reuse can live in env files, not only invars:. - Richer schema completion.
service:/services:compose-service fields in the JSON schema were expanded to the compose-spec set (~88 properties:cpus,mem_limit,network_mode,sysctls,gpus,extends,develop, โฆ) for accurate editor completion and hover docs.
Fixes
- A declared
envs:file is read with interpolation disabled, so python-dotenv no longer collapses cupli-scope${VAR}references to empty strings. - A missing declared
envs:file warns under--strict-vars(silent by default to preserve optional.env.local).
v0.3.1
Hotfix release.
Fixes
clickis now a declared dependency.v0.3.0importsclickdirectly (for the typed command-shortcut parameters), but relied on it being present transitively viatyper. In environments wheretyperdoes not pullclickin (e.g. apipx install),cuplicrashed on startup withModuleNotFoundError: No module named 'click'.clickis now listed in the project dependencies so it is always installed.
Chores
- Bumped GitHub Actions off the deprecated Node 20 runtime.
actions/checkoutโ v6,actions/setup-pythonโ v6,actions/upload-artifactโ v7,actions/download-artifactโ v8,astral-sh/setup-uvโ v7,dawidd6/action-download-artifactโ v21.
v0.3.0
Feature release extending workspace command shortcuts (commands:).
Features
- Command grouping. A
group:label groups a command under a panel incupli --helpand in thecupli sclisting. - Multi-line
run.runaccepts a single line, a YAML block scalar, or a list of lines (joined with newlines) and runs viash -c. The legacy$@passthrough is appended only for a single-line snippet with no declared args. - Typed arguments. A
commands.<name>.argslist declares typed parameters (str/int/bool, positional or--option, withhelp,shortalias,required/default). For top-level promoted commands they become real typer parameters shown incupli <name> --help; forcupli scthey are parsed with click. Values are substituted intorunvia{{name}}placeholders and shell-quoted. A bare list of names is shorthand for required positional string arguments. - Multi-container commands.
container:accepts one app or a list, and a newexecute:field selectssequential(default, fail-fast),continue(run all, non-zero if any failed), orparallel(concurrent, output captured per container).
Docs
AGENTS.mdactualized with the new command capabilities and the top-levelvolumes:/secrets:/configs:blocks; READMEs now point agents at it.
v0.2.0
Feature release adding three top-level docker-compose blocks to the space schema.
Features
- Top-level
volumes:,secrets:,configs:blocks. A space can now declare named volumes, secret definitions and config definitions at the top level alongsidenetworks:. They are merged verbatim intodocker-compose.pre.yml, so inline services (apps.<x>.service/services) can reference them without a separate compose file โ e.g. aminio_data:/datanamed volume or aCI_JOB_TOKENbuild secret. No syntheticdefaultis injected and an empty block is omitted. A null body (minio_data:) is treated as a default-driver entry.
v0.1.2
Hotfix release that fixes broken GitHub URLs scaffolded into user workspaces
and surfaced by the IDE-setup command. The earlier v0.1.1 tag was the
intended hotfix but never reached PyPI, so the same payload ships under
v0.1.2 alongside this URL fix.
Fixes
- Scaffolded
space.cupli.yamland IDE-setup link tomaster. The default branch ofextralait-web/cupliismaster, but theyaml-language-server$schema=URL, the README reference comment in the scaffolded space, andSCHEMA_URL_DEFAULTall pointed athttps://raw.githubusercontent.com/extralait-web/cupli/main/โฆ, which 404s. All three references now use/master/.
Tests
- Coverage raised above the smokeshow gate (80%) with new unit and CLI
suites for
utils/json,utils/subprocess,utils/git,domain/runtime,core/cache,cli/diagnostics,cli/lifecycle,cli/gitandcli/_completion.
v0.1.1
Hotfix release on top of v0.1.0 to make CI green on a fresh runner and on the full ubuntu / macos / windows ร py3.10โ3.13 matrix.
Fixes
create_filenow creates parent directories. The per-user spaces registry at${XDG_CONFIG_HOME:-~/.config}/cupli/spaces.jsonwas being touched without ensuringcupli/existed first, which madecupli ... graph,examples-validate, and ~70 unit/CLI tests fail on any machine that had never had~/.config/cupli/.- Test suite no longer relies on
FORCE_COLOR=0. A top-levelconftest.pynow popsFORCE_COLORand setsNO_COLOR=1before any cupli module is imported, so rich-formatted output does not inject ANSI escape sequences that break substring assertions on captured stdout. - Windows compatibility in path assertions. Loader tests that match
computed
*_PATHvars with a literal forward-slash suffix now normalise throughPath(...).as_posix()so they pass underWindowsPath. install_hookschmod check skipped on Windows. Windows file systems do not model POSIX executable bits, so thest_mode & 0o111assertion is@pytest.mark.skipifonwin32._pid_aliveusesOpenProcesson Windows. CPython routesos.kill(pid, 0)toGenerateConsoleCtrlEvent(CTRL_C_EVENT, pid)on Windows, which actively sendsCtrl+Cto a process group instead of probing liveness โ that interrupted the test session as soon as the lock module checked an unknown PID. The Windows path now opens the process withPROCESS_QUERY_LIMITED_INFORMATIONand inspects the exit code viaGetExitCodeProcess.- Registry prefix detection accepts both separators. The longest-
prefix matcher hard-coded
/as the directory delimiter, sodetect_current_spacecould not locate a registered space from a Windowscwdunder it. Both/andos.separe now treated as valid separators.
v0.1.0
Initial release.
Highlights
-
Schema (
schema_version: 1) โ top-levelapps,bases,mounts,hooks,commands,networks; per-appmode/service/services/forward_ssh; per-mounthosted_in/exec_path/mac_volume. -
Two ways to declare compound apps.
services:accepts either a map of service-name โ override or a bare list of names:services: # map form: per-service overrides api: {} worker: vars: {LOG_LEVEL: debug} services: # list form: just the names - api - worker - beat
-
Top-level
networks:carries any docker-composenetworks.<name>.*spec verbatim and is merged intodocker-compose.pre.ymlalongside the auto-attacheddefaultworkspace network. -
Pydantic v2 models with cross-references (
bases,deps,hosted_in,commands.container) validated by a singlemodel_validator. Barevars:(YAML null) is coerced to{}. -
Line-aware parser (ruamel.yaml round-trip) feeds a
LineMarkslookup table that backs friendly validation errors. -
Numbered error catalog (
E001โE031) pluscupli explain <code>.E031fires when a planned service is not declared in any compose source โ surfaces the missing app + service beforedocker composewould error out. -
CLI surface โ
init,workspace add/list/select/unselect/remove,space sync/doctor,up/stop/restart/down/ps/logs/build/pull/compose/config/ watch,exec/run/shell/wrap/sc,mounts list/attach/detach,hooks install/remove,git status/pull/fetch/checkout,ide setup,dashboard,env,explain,upgrade-config,completion,--list/--version.cupli upaccepts--tag <t>(repeatable),--mode default|hook|fulland bare service names โ including individual services of compound apps (cupli up api-1targets just that service). -
Compose overrides are emitted into the per-space state dir using docker-compose naming convention:
docker-compose.pre.yml(defaults โ network, container_name; merged BEFORE user composes),docker-compose.post.yml(forced โ env injection, ports, mount volumes, cross-filedepends_on; merged AFTER) anddocker-compose.inline.ymlfor services declared inline underapps.<x>.service/apps.<x>.services. Deps onmode: oneshotapps emitcondition: service_completed_successfullyso dependants block on the one-off command. -
Hooks-in-docker (elc-style) โ
cupli hooks install <dir>installs idempotent bash shims tagged with# cupli-hook v1. Per-script first-line directives override defaults:# cupli: container=node workdir=/app shell=sh.shell=lets alpine-only images use busyboxsh; default staysbash. Pre-commit-framework conflicts surface asE024unless--forceis passed. -
Workspace commands declared under
commands:are surfaced throughcupli sc <name>(with optionaltop_level: trueto expose as barecupli <name>).run:is a shell command line โ cupli wraps it insh -cso&&,|,${VAR}work inside the container. -
C3 linearisation for
apps[*].basesโ deterministic even under diamond inheritance when nested bases land later. -
Parallel
space syncviaconcurrent.futures.ThreadPoolExecutor.${VAR}references inrepo:/branch:/post_clone:are substituted before invoking git, so self-hosted file:// URLs and parameterised branches work. -
Scaffold (
cupli init) writes a minimal layout:space.cupli.yaml,.env,.locals/.src/apps/,src/bases/,src/mounts/are created lazily bycupli space syncwhen a declared component first needs them. -
State directory layout under
.locals/<space>/state/:docker-compose.pre.yml,docker-compose.post.yml,docker-compose.inline.yml,override.env,vars.json,cache.json,hooks-manifest.json,active-mounts.json,lock. -
IDE integration โ
cupli ide setupwalks up from the workspace looking for.vscode//.idea/(stopping at the git-repo boundary) and writes JSON-schema mappings only for the editor(s) found. On a brand-new workspace where nothing is detected, writes both as a safe default.cupli initcalls the same flow. -
Layered architecture enforced by an
.importlinterconfig:cli โ services โ core โ domain โ utils.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file cupli-0.8.1.tar.gz.
File metadata
- Download URL: cupli-0.8.1.tar.gz
- Upload date:
- Size: 211.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
66ac736ec77661f27fcfd74eafa8c4584cbdeb49bbf7bae1dd3c13913f48a7f9
|
|
| MD5 |
0d9a6d68b3a0323e9c74c2607fa95584
|
|
| BLAKE2b-256 |
202fe1dff0d119857fcabfc6d9be5083cbc1f8f75376db6a4f7e585ce1619eaf
|
Provenance
The following attestation bundles were made for cupli-0.8.1.tar.gz:
Publisher:
ci.yml on extralait-web/cupli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cupli-0.8.1.tar.gz -
Subject digest:
66ac736ec77661f27fcfd74eafa8c4584cbdeb49bbf7bae1dd3c13913f48a7f9 - Sigstore transparency entry: 2021476663
- Sigstore integration time:
-
Permalink:
extralait-web/cupli@8534a37fa9efd23e5e39560bffc7eceb1e1c9f27 -
Branch / Tag:
refs/tags/v0.8.1 - Owner: https://github.com/extralait-web
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8534a37fa9efd23e5e39560bffc7eceb1e1c9f27 -
Trigger Event:
push
-
Statement type:
File details
Details for the file cupli-0.8.1-py3-none-any.whl.
File metadata
- Download URL: cupli-0.8.1-py3-none-any.whl
- Upload date:
- Size: 160.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
36ede4fc265dfc789c38831f15cb99e9bd5f37e06599c4e7ae736dda50ad9f8d
|
|
| MD5 |
e9140841369f7ccd505a042733ab3dad
|
|
| BLAKE2b-256 |
b2d3d5d572e2ace57eb4b41584704eb1daa9f5f9e68b5155669c2731e9c40e43
|
Provenance
The following attestation bundles were made for cupli-0.8.1-py3-none-any.whl:
Publisher:
ci.yml on extralait-web/cupli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
cupli-0.8.1-py3-none-any.whl -
Subject digest:
36ede4fc265dfc789c38831f15cb99e9bd5f37e06599c4e7ae736dda50ad9f8d - Sigstore transparency entry: 2021476720
- Sigstore integration time:
-
Permalink:
extralait-web/cupli@8534a37fa9efd23e5e39560bffc7eceb1e1c9f27 -
Branch / Tag:
refs/tags/v0.8.1 - Owner: https://github.com/extralait-web
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8534a37fa9efd23e5e39560bffc7eceb1e1c9f27 -
Trigger Event:
push
-
Statement type: