Skip to content
Draft
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test test-podman vet lint vulncheck check clean install run help
.PHONY: build test test-podman vet lint vulncheck check clean install install-dev run help

# Project variables
BINARY_NAME=late
Expand Down Expand Up @@ -46,5 +46,8 @@ install: build ## Build and install the binary to your Go bin path
@mv bin/${BINARY_NAME} ~/.local/bin/late
@install -m 0755 late-podman ~/.local/bin/late-podman

install-dev: ## Interactive multi-source installer (dev/pinned/fork/upstream/official)
@./install-dev.sh

run: build ## Build and run the project
@./bin/${BINARY_NAME}
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,72 @@ export OPENAI_MODEL="model-name"

---

## Development

### Development installer (`install-dev.sh`)
`./install-dev.sh` is a strictly **development** installer: it builds from a git
source and can install the unstable `main` branches. It is interactive by
default and autodetects platform, current install, remotes and conflicts.

Headless (dev boxes / CI):
```bash
./install-dev.sh --choice 1 # local unstable dev (current branch, symlink)
./install-dev.sh --choice 6 # detection report only
./install-dev.sh --choice 2 --target /tmp/bin --dry-run
```
`--choice N` maps to the menu entries (1 local-dev, 2 pinned, 3 fork-main,
4 upstream-main, 5 official, 6 check), implies `--yes`, and requires no human
supervision. Safety: the previous binary is archived as `.bak-<timestamp>`,
brew-owned installs warn, running as root is refused.

The detection report also lists every package manager found (brew, apt-get,
dnf, pacman, zypper, apk, npm), whether `podman` is installed (with its
version), and whether the `late-podman` launcher is already installed.

**`late-podman` parity:** `local-dev`, `pinned`, `fork-main` and
`upstream-main` also install the `late-podman` launcher into the target dir —
a symlink for `local-dev`, a copy for the others — matching `make install`,
which ships both commands, with the same `.bak-<timestamp>` archiving on
transitions. Note that `late-podman`'s *runtime* requires a **Linux host with
podman**: on macOS the launcher refuses to run (`only Linux hosts are
supported`); it is meant for Linux machines or Linux containers.

**Uninstalling:** `./install-dev.sh uninstall` removes what the installer
manages (name-invoked only — it is deliberately *not* in the `--choice`
menu, because it is destructive):

```bash
./install-dev.sh uninstall --yes # remove late + late-podman + .bak archives
./install-dev.sh uninstall --purge --dry-run # preview, including user-data removal
./install-dev.sh uninstall --purge --yes # also delete user data
./install-dev.sh uninstall --with-deps # print (never run) dependency removal commands
```

Semantics:

* Removes the installed `late` (symlink or copy) in the target dir and
`late-podman` in the target dir / `~/.local/bin` — but only the installer's
own copies (a symlink into this repo, or a copy matching this repo's
launcher). Files inside the brew prefix are **never** deleted; the script
prints `brew uninstall late` as advice instead. A symlink pointing into a
different repo is removed while that repo is kept.
* All `late.bak-<timestamp>` / `late-podman.bak-<timestamp>` archives in the
target dir are removed too.
* `--purge` additionally deletes **user data**: the late config dir
(`~/Library/Application Support/late` on macOS; `~/.config/late` on Linux,
honoring `XDG_CONFIG_HOME` — contains `config.json`, `mcp_config.json`,
`plugins/` and `skills/`) and the data dir (`~/.local/share/late`, session
history). Every path is printed before deletion; interactive runs confirm
with y/N (default **N**), headless runs require `--yes`.
* `--with-deps` **prints** the exact package-manager removal commands for
late-relevant dependencies (`podman`, `go` — e.g. `brew uninstall podman`,
`sudo apt remove podman`). It never executes them: those packages are
often shared with other projects.
* Idempotent: uninstalling an already-uninstalled system prints
"nothing to uninstall" and exits 0.

---

## License

Built to create engineering leverage, not to supply free infrastructure for AI startups.
Expand Down
17 changes: 16 additions & 1 deletion cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ import (
"golang.org/x/term"
)

// forceRevaluateUsage is the -h description of
// -force-revaluate-dangerous-commands.
//
// IMPORTANT: this string must contain no back-quoted word. flag.PrintDefaults
// renders the first back-quoted word of a usage string as the flag's value
// name, which would advertise the flag as taking an argument. The OTP code is
// never passed on the CLI: late generates a random single-use code at runtime
// and hands it to the agent in the tool-result block message; the agent
// re-runs the command passing it in the bash tool's otp_code parameter.
const forceRevaluateUsage = "Unsupervised execution, but the first attempt to run a potentially dangerous command is blocked; late issues the agent a random single-use OTP code, bound to that exact command, which it must pass in the bash tool's otp_code parameter to re-run."

// pluginInlineTool adapts a plugin.InlineTool (defined in internal/plugin/tools.go)
// into a common.Tool so the CLI's session registry can dispatch invocations to
// plugin-declared runners. It exists because upstream repurposed
Expand Down Expand Up @@ -90,6 +101,7 @@ func main() {
appendSystemPromptReq := flag.String("append-system-prompt", "", "Append text to the system prompt after processing")
versionReq := flag.Bool("version", false, "Show version")
unsupervisedReq := flag.Bool("i-promise-i-have-backups-and-will-not-file-issues", false, "Unsupported: Execute all tools without supervision. Do not use this, bad things will happen. You have been warned.")
forceRevaluateReq := flag.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage)
enableImagesReq := flag.Bool("enable-images", false, "Force enable support for image attachments for unsupported servers.")
continueReq := flag.Bool("continue", false, "Load and start the latest session")
showCWDReq := flag.Bool("show-cwd", true, "Show current working directory in status bar")
Expand Down Expand Up @@ -653,9 +665,12 @@ func main() {

// Create context with InputProvider
ctx := context.WithValue(context.Background(), common.InputProviderKey, tui.NewTUIInputProvider(p))
if *unsupervisedReq {
if *unsupervisedReq || *forceRevaluateReq {
ctx = context.WithValue(ctx, common.SkipConfirmationKey, true)
}
if *forceRevaluateReq {
ctx = context.WithValue(ctx, common.ForceRevaluateKey, true)
}
rootAgent.SetContext(ctx)

// Set middlewares (see buildMiddlewares for ordering rationale).
Expand Down
22 changes: 22 additions & 0 deletions cmd/late/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

import (
"encoding/json"
"flag"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -392,3 +394,23 @@ func TestRunBootstrap_DynamicLogitBias(t *testing.T) {
t.Errorf("user bias 999 bled into subagentClient: %v", subBiases)
}
}

// TestForceRevaluateUsageRendersWithoutValueName guards the -h output of
// -force-revaluate-dangerous-commands: the usage string must contain no
// back-quoted word, because flag.UnquoteUsage turns the first back-quoted
// word into the flag's value name and PrintDefaults would then render the
// boolean flag as taking an argument (e.g. "-force-revaluate-dangerous-commands otp_code"),
// wrongly implying the OTP is passed on the CLI. The
// OTP is generated by late at runtime and delivered to the agent in the
// block message; it is never a flag argument.
func TestForceRevaluateUsageRendersWithoutValueName(t *testing.T) {
if strings.ContainsRune(forceRevaluateUsage, '`') {
t.Fatalf("forceRevaluateUsage must not contain backquotes (flag.UnquoteUsage would render the quoted word as the flag's value name): %q", forceRevaluateUsage)
}
fs := flag.NewFlagSet("usage-test", flag.ContinueOnError)
fs.Bool("force-revaluate-dangerous-commands", false, forceRevaluateUsage)
name, _ := flag.UnquoteUsage(fs.Lookup("force-revaluate-dangerous-commands"))
if name != "" {
t.Errorf("expected no rendered value name for this boolean flag, got %q (help would show -force-revaluate-dangerous-commands %s)", name, name)
}
}
Loading