Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test clean install run help
.PHONY: build test test-podman clean install run help

# Project variables
BINARY_NAME=late
Expand All @@ -17,6 +17,10 @@ build: ## Build the late binary
test: ## Run tests for the entire project
@echo "Running tests..."
@go test -v -race ./...
@./test/late-podman-test.sh

test-podman: ## Test the Podman launcher without requiring Podman
@./test/late-podman-test.sh

clean: ## Remove build artifacts
@echo "Cleaning..."
Expand All @@ -26,6 +30,7 @@ install: build ## Build and install the binary to your Go bin path
@echo "Installing to ~/.local/bin/late..."
@go build ${LDFLAGS} -o bin/${BINARY_NAME} ./cmd/late
@mv bin/${BINARY_NAME} ~/.local/bin/late
@install -m 0755 late-podman ~/.local/bin/late-podman

run: build ## Build and run the project
@./bin/${BINARY_NAME}
34 changes: 34 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,40 @@ You can also create an `.llmignore` file alongside your `.gitignore` to specific
| `--append-system-prompt "..."` | Append text to the system prompt (e.g. further instructions) |
| `--enable-images` | Treat models as supporting images (for none llama.cpp servers) |

## Podman compatibility command

On Linux systems with rootless Podman, `late-podman` runs Late in a glibc-based
development image and mounts the current directory at `/workspace`:

```bash
late-podman --image registry.example/my-project-dev
```

The image must contain Bash and every language or SDK required by the project.
If no `--image` is supplied, Late automatically searches for configuration in the following order:
1. `.late/podman-image`
2. `.devcontainer/devcontainer.json` (or `.devcontainer.json`) — reads `image` or `build.dockerfile`, `postCreateCommand`, and `containerEnv`
3. Interactive terminal prompt

Use `--exec` to prepare the container before Late starts. It is a Bash command,
may be repeated, and Late starts only if every command succeeds:

```bash
late-podman --image fedora:latest \
--exec "dnf install -y golang nodejs npm" \
--exec "npm install" \
-- --continue
```

If your project contains a `.devcontainer/devcontainer.json`, any `postCreateCommand` commands are run automatically before `--exec` commands.

Alpine and other musl-based images are unsupported. The launcher uses rootless
Podman, does not relabel the host workspace, and does not mount the host home or
container socket. It uses the host network so model servers listening on
`localhost`, including loopback-only servers, remain reachable with the same
Late configuration. Consequently, processes inside the development container
can also reach other services listening on the host.

## Sessions

Late automatically saves your session history. Resume or manage sessions:
Expand Down
12 changes: 11 additions & 1 deletion install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ chmod +x "$INSTALL_DIR/late.tmp"
# Rename atomically to avoid workspace pollution or half-written binaries
mv "$INSTALL_DIR/late.tmp" "$INSTALL_DIR/late"

if [ "$OS" = "linux" ]; then
echo "=> Installing late-podman..."
curl -sfL https://raw.githubusercontent.com/mlhher/late-cli/main/late-podman -o "$INSTALL_DIR/late-podman.tmp"
chmod +x "$INSTALL_DIR/late-podman.tmp"
mv "$INSTALL_DIR/late-podman.tmp" "$INSTALL_DIR/late-podman"
fi

echo "=> Success! Late is installed at $INSTALL_DIR/late"

# Crucial friction check: Is it actually in their PATH?
Expand All @@ -61,4 +68,7 @@ if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo "Then restart your terminal or run: source ~/.bashrc (or ~/.zshrc)"
else
echo "=> Run 'late' to get started."
fi
if [ "$OS" = "linux" ]; then
echo "=> Run 'late-podman --image IMAGE' to use a development container."
fi
fi
277 changes: 277 additions & 0 deletions late-podman
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
#!/usr/bin/env bash
set -euo pipefail

usage() {
cat <<'EOF'
Usage: late-podman [--image IMAGE] [--exec COMMAND] [--] [LATE_ARGS...]

Run Late in a rootless Podman container with the current directory mounted at
/workspace. The image must be Linux, glibc-based, and contain bash plus the
development tools required by the project.

Options:
--image IMAGE OCI image to run (otherwise read .late/podman-image)
--exec COMMAND Run a Bash command before Late (may be specified repeatedly)
-h, --help Show this help

When no image is configured, an interactive prompt offers to save the selected
image in .late/podman-image.
EOF
}

die() {
printf 'late-podman: %s\n' "$*" >&2
exit 1
}

image=""
startup_command=""
late_args=()

while (($#)); do
case "$1" in
--image)
(($# >= 2)) || die "--image requires an argument"
image=$2
shift 2
;;
--image=*)
image=${1#*=}
[[ -n "$image" ]] || die "--image requires an argument"
shift
;;
--exec)
(($# >= 2)) || die "--exec requires an argument"
if [[ -n "$startup_command" ]]; then
startup_command+=$'\n'
fi
startup_command+=$2
shift 2
;;
--exec=*)
command_value=${1#*=}
[[ -n "$command_value" ]] || die "--exec requires an argument"
if [[ -n "$startup_command" ]]; then
startup_command+=$'\n'
fi
startup_command+=$command_value
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
late_args+=("$@")
break
;;
*)
late_args+=("$1")
shift
;;
esac
done

[[ $(uname -s) == Linux ]] || die "only Linux hosts are supported"
command -v podman >/dev/null 2>&1 || die "Podman is required but was not found in PATH"

late_binary=$(command -v late 2>/dev/null || true)
[[ -n "$late_binary" && -x "$late_binary" ]] || die "the late binary was not found in PATH"
late_binary=$(readlink -f "$late_binary")

workspace=$(pwd -P)
image_file="$workspace/.late/podman-image"
devcontainer_env=()
devcontainer_post_create=""

find_devcontainer_file() {
local root="$1"
if [[ -f "$root/.devcontainer/devcontainer.json" ]]; then
printf '%s\n' "$root/.devcontainer/devcontainer.json"
elif [[ -f "$root/.devcontainer.json" ]]; then
printf '%s\n' "$root/.devcontainer.json"
elif [[ -d "$root/.devcontainer" ]]; then
local found
found=$(find "$root/.devcontainer" -mindepth 2 -maxdepth 2 -name "devcontainer.json" 2>/dev/null | sort | head -n 1 || true)
if [[ -n "$found" ]]; then
printf '%s\n' "$found"
fi
fi
}

devcontainer_file=$(find_devcontainer_file "$workspace")

if [[ -n "$devcontainer_file" ]]; then
devcontainer_dir=$(dirname "$devcontainer_file")
if command -v python3 >/dev/null 2>&1; then
eval "$(python3 -c '
import sys, re, json, os, shlex

path = sys.argv[1]
with open(path, "r", encoding="utf-8") as f:
text = f.read()

out = []
i = 0
in_str = False
in_block = False
while i < len(text):
if in_block:
if text[i:i+2] == "*/":
in_block = False
i += 2
else:
i += 1
continue
if in_str:
out.append(text[i])
if text[i] == "\\" and i + 1 < len(text):
out.append(text[i+1])
i += 2
continue
if text[i] == "\"":
in_str = False
i += 1
continue
if text[i:i+2] == "//":
while i < len(text) and text[i] != "\n":
i += 1
continue
if text[i:i+2] == "/*":
in_block = True
i += 2
continue
if text[i] == "\"":
in_str = True
out.append(text[i])
i += 1
continue
out.append(text[i])
i += 1
cleaned = "".join(out)
cleaned = re.sub(r",\s*([\]}])", r"\1", cleaned)

try:
data = json.loads(cleaned)
except Exception as e:
sys.exit(0)

img = data.get("image", "")
build = data.get("build", {})
dockerfile = ""
context = ""
if isinstance(build, str):
dockerfile = build
elif isinstance(build, dict):
dockerfile = build.get("dockerfile", "")
context = build.get("context", "")
elif "dockerfile" in data:
dockerfile = data.get("dockerfile", "")

pcc = data.get("postCreateCommand", "")
post_create = ""
if isinstance(pcc, str):
post_create = pcc
elif isinstance(pcc, list):
post_create = " ".join(shlex.quote(str(x)) for x in pcc)
elif isinstance(pcc, dict):
post_create = "\n".join(str(v) for v in pcc.values())

print(f"dc_image={shlex.quote(img)}")
print(f"dc_dockerfile={shlex.quote(dockerfile)}")
print(f"dc_context={shlex.quote(context)}")
print(f"devcontainer_post_create={shlex.quote(post_create)}")

cenv = data.get("containerEnv", {})
if isinstance(cenv, dict):
for k, v in cenv.items():
kv = f"{k}={v}"
print(f"devcontainer_env+=({shlex.quote(kv)})")
' "$devcontainer_file" 2>/dev/null || true)"
fi
fi

if [[ -z "$image" && -f "$image_file" ]]; then
IFS= read -r image < "$image_file" || true
[[ -n "$image" ]] || die "$image_file is empty"
fi

if [[ -z "$image" && -n "${dc_image:-}" ]]; then
image="$dc_image"
elif [[ -z "$image" && -n "${dc_dockerfile:-}" ]]; then
dc_df_path="$devcontainer_dir/$dc_dockerfile"
if [[ ! -f "$dc_df_path" ]]; then
dc_df_path="$workspace/$dc_dockerfile"
fi
[[ -f "$dc_df_path" ]] || die "Dockerfile not found: $dc_dockerfile"

dc_ctx_path="$devcontainer_dir"
if [[ -n "${dc_context:-}" ]]; then
dc_ctx_path="$devcontainer_dir/$dc_context"
fi

clean_name=$(basename "$workspace" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9_.-' '_')
image="localhost/late-devcontainer-${clean_name}:latest"
echo "=> Building devcontainer image from $dc_df_path..."
podman build -t "$image" -f "$dc_df_path" "$dc_ctx_path"
fi

if [[ -z "$image" ]]; then
[[ -t 0 && -t 1 ]] || die "no image configured; use --image IMAGE, create .devcontainer/devcontainer.json, or create .late/podman-image"
read -r -p "Development container image: " image </dev/tty
[[ -n "$image" ]] || die "an image is required"

save_image=""
read -r -p "Remember this image in .late/podman-image? [y/N] " save_image </dev/tty
if [[ "$save_image" == [yY] || "$save_image" == [yY][eE][sS] ]]; then
mkdir -p "$workspace/.late"
printf '%s\n' "$image" > "$image_file"
printf 'Saved %s\n' "$image_file"
fi
fi

if [[ -n "$devcontainer_post_create" ]]; then
if [[ -n "$startup_command" ]]; then
startup_command="$devcontainer_post_create"$'\n'"$startup_command"
else
startup_command="$devcontainer_post_create"
fi
fi

run_args=(run --rm --network=host --security-opt label=disable)
[[ -t 0 ]] && run_args+=(-i)
[[ -t 1 ]] && run_args+=(-t)

run_args+=(
--mount "type=bind,src=$workspace,target=/workspace"
--workdir /workspace
--mount "type=bind,src=$late_binary,target=/usr/local/bin/late,ro=true"
--volume late-podman-sessions:/root/.local/share/late
--entrypoint bash
-e TERM="${TERM:-xterm-256color}"
)

for env_var in "${devcontainer_env[@]}"; do
run_args+=(-e "$env_var")
done

config_home=${XDG_CONFIG_HOME:-${HOME:?HOME is not set}/.config}
if [[ -d "$config_home/late" ]]; then
run_args+=(--mount "type=bind,src=$config_home/late,target=/root/.config/late,ro=true")
fi

for variable in OPENAI_BASE_URL OPENAI_API_KEY OPENAI_MODEL LATE_SUBAGENT_BASE_URL LATE_SUBAGENT_API_KEY LATE_SUBAGENT_MODEL COLORTERM LANG LC_ALL; do
if [[ -v "$variable" ]]; then
run_args+=(-e "$variable=${!variable}")
fi
done

container_script='set -e
if [[ -n "$1" ]]; then
eval "$1"
fi
shift
exec /usr/local/bin/late "$@"'

exec podman "${run_args[@]}" "$image" -lc "$container_script" late-podman "$startup_command" "${late_args[@]}"
Loading