Skip to content
Merged
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
4 changes: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ This is a single-context repo.
| **Init script** | A top-level script run once per lets invocation before the first Project command executes. | Before script |
| **Before script** | A top-level script prepended to each Project command invocation, including dependencies. | Init script |
| **After script** | A command-scoped script run after a Project command execution attempt. | Cleanup hook |
| **Work dir** | The directory where a Project command runs after config and command resolution. | Repo root |
| **Root dir** | The directory lets was invoked from. Project commands run here unless they set `work_dir`. | Project root, config dir |
| **Config dir** | The directory holding the Project config file. Only local mixin paths resolve against it. | Root dir, work dir |
| **Work dir** | The directory where a Project command runs after config and command resolution: the Root dir, or the command's `work_dir`. | Repo root |
| **Download progress indicator** | A user-visible status shown while lets retrieves a Remote config or Remote mixin. | Progress bar |
| **Help surface** | The rendered CLI help for root and Project commands. | Docs page |
| **LSP surface** | The editor-facing language-server features exposed by `lets self lsp`. | CLI help |
Expand Down
105 changes: 105 additions & 0 deletions docs/adr/0004-root-dir-is-invocation-dir.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# ADR-0004 — The Root dir is the invocation dir

**Date:** 2026-08-10
**Status:** Accepted

## Context

`lets` had never specified which directory a **Project command** runs in. The behavior
that shipped was an accident of implementation, and it changed silently in `0.0.63`.

Up to `0.0.62`, `Command.WorkDir` was assigned unconditionally from
`filepath.Abs(cmd.WorkDir)`. For the overwhelming majority of commands — those without a
`work_dir` — that argument was `""`, and `filepath.Abs("")` returns the process cwd. The
executor then preferred `Command.WorkDir` over `Config.WorkDir` whenever it was non-empty,
which it now always was. So commands ran in the invocation dir, and `Config.WorkDir` — the
config file's directory, computed since 2020 — was dead code for its entire life.

`0.0.63` added an `if cmd.WorkDir != ""` guard while fixing checksum handling for
`work_dir`. That was correct in isolation, but it un-shadowed `Config.WorkDir` and moved
every command's working directory to the config file's directory.

Neither version was internally consistent. `Config.WorkDir` was read by some directives
and not others, so a single command definition resolved paths against two directories:

| | `0.0.62` | `0.0.63` |
| --- | --- | --- |
| `cmd` cwd | invocation dir | config dir |
| `env.sh` cwd | invocation dir | invocation dir |
| `checksum` | config dir | config dir / `work_dir` |
| `env_file` | config dir | config dir |
| `work_dir` base | invocation dir | invocation dir |

On `0.0.62`, `checksum: [data.txt]` and `cmd: cat data.txt` in the same command could
refer to different files. On `0.0.63`, `cmd` and `env.sh` disagreed instead. The root
cause of both was one field, `Config.WorkDir`, carrying two meanings: "where the config
is" and "where commands run".

**Remote configs** (ADR-0003) already special-cased this by pinning their root to the
invocation dir, since a cached config has no meaningful local directory. That special case
was evidence that the invocation dir was the right general answer.

## Decision

Split the conflated field into two, and define resolution in terms of when it happens.

- **Root dir** — the directory `lets` was invoked from. Commands run here by default.
- **Config dir** — the directory holding the config file.
- **Work dir** — a command's actual directory: the Root dir, or its `work_dir` if set.

Two rules:

1. **Config assembly** resolves against the config file that declares it. Local `mixins`
paths are the only thing in this category.
2. **Everything a command reads or runs** resolves against that command's Work dir. This
covers `cmd`, `checksum` paths, `env_file` paths at both global and command scope, and
`env.sh` scripts. A relative `work_dir` resolves against the Root dir.

`--config` / `-c`, `--config-dir` and `LETS_CONFIG_DIR` select which config is loaded and
never change the Root dir.

`.lets/` is created in the Root dir, so a persisted checksum stays paired with the files it
was computed from.

Remote configs stop being a special case: their root is the invocation dir under the
general rule. Because their Config dir is a cache directory holding only the downloaded
YAML, a remote config declaring a local `mixins` path is rejected with an explicit error.

## Consequences

- `lets foo` does what typing `foo` at the prompt would do, and a command is readable
without knowing where its config lives.
- `lets -c ../other/lets.yaml build` borrows another project's commands and runs them on
the invoking project's files, which is the only useful reading of `-c`.
- A filename appearing in two directives of one command now means one file.
- Breaking relative to `0.0.63`: commands run in the invocation dir again.
- Breaking relative to every previous version: `checksum` and `env_file` follow the Work
dir, and `.lets/` follows the Root dir.
- A relative `work_dir` now depends on where the user stands, so `work_dir: docs` works
from the project root and fails from a subdirectory. Commands that must always target the
project use `cd "${LETS_CONFIG_DIR}/…"` inside `cmd`. `work_dir` does not expand
environment variables.
- `LETS_CONFIG_DIR` for a remote config reports the cache directory rather than the cwd.
`$PWD` is the way to reach the project.

## Alternatives considered

**Keep `0.0.63` — Root dir is the Config dir.** Every path in a config would mean the same
thing regardless of where `lets` ran, and `lets x` would be identical from any directory.
Rejected because it makes "act on where I am" inexpressible: nothing exposes the invocation
dir, so it would have required a new `LETS_INVOCATION_DIR`. The chosen model needs no new
API — `$LETS_CONFIG_DIR` already provides the inverse escape hatch and always has.

**Make everything cwd-relative, including mixins.** Fully uniform, and the honest version
of pre-`0.0.63` behavior. Rejected because `mixins: [./common.yaml]` would break whenever
`lets` ran from a subdirectory, making configs with mixins unloadable from anywhere but
their own directory.

**Leave `checksum` and `env_file` resolving against the Config dir.** Closest to a literal
revert of `0.0.63`. Rejected because it preserves the original defect — one command
definition resolving the same filename against two directories.

**Put `.lets/` next to the config rather than in the Root dir.** Avoids stray `.lets/`
directories when running from subdirectories. Rejected because a persisted checksum stored
next to the config but computed from an arbitrary directory would describe different files
on different runs, making change detection unreliable.
7 changes: 7 additions & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ title: Changelog

## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X)

* `[Changed]` **Breaking.** Commands run in the directory `lets` was invoked from, whatever config was loaded and wherever that config lives. In `0.0.63` they ran in the config file's directory instead, which changed behaviour for `lets -c some/dir/lets.yaml` and for running `lets` from a subdirectory of a project. See [Where commands run](/docs/where_commands_run).
* `[Changed]` **Breaking.** Everything a command reads or runs now resolves against a single directory — the command's working dir, which is the root dir unless the command sets `work_dir`. This covers `cmd`, `checksum` file paths, `env_file` paths and `env.sh` scripts. Previously these disagreed: `checksum` and `env_file` resolved against the config directory while `env.sh` ran in the invocation directory, so the same filename in one command definition could mean two different directories.
* `[Changed]` **Breaking.** `.lets/` is created in the root dir rather than next to the config file, so persisted checksums stay paired with the files they were computed from.
* `[Changed]` A remote config that declares a local `mixins` path now fails with an explicit error instead of silently resolving it against the invocation directory. Remote configs can only mix in URLs.
* `[Changed]` `LETS_CONFIG_DIR` at command runtime is the config file's real directory for remote configs too (the local cache directory); previously it reported the invocation directory. Use `$PWD` for the root dir.
* `[Fixed]` `work_dir` no longer resolves inconsistently with the rest of the command: relative paths resolve against the root dir, and `work_dir` now also moves `checksum`, `env_file` and `env.sh` resolution.
* `[Fixed]` A remote config whose `RemoteSource` was only recorded after parsing meant remote-specific mixin handling never applied during load.
* `[Changed]` Group and delay Dependabot version updates, enable updates for docs and examples, and validate those projects in pull request CI.
* `[Fixed]` Restore the documentation and Python example builds after dependency updates.

Expand Down
37 changes: 33 additions & 4 deletions docs/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ title: Config reference
---

- [Agent Skills](#agent-skills)
- [Where commands run](where_commands_run.md)
- [Top-level directives:](#top-level-directives)
- [Version](#version)
- [Shell](#shell)
Expand Down Expand Up @@ -41,6 +42,17 @@ Agent Skills are not configured in `lets.yaml`. They are installed and managed w

Use [`lets self skills`](agent_skills.md) to show, install, update, or remove the bundled `lets` agent skill.

## Where commands run

Commands run in the directory you ran `lets` from, not in the directory the config lives
in. Everything a command reads or runs — `cmd`, [`checksum`](#checksum) paths,
[`env_file`](#env_file) paths and `env.sh` — resolves against that one directory, or
against [`work_dir`](#work_dir) if the command sets one. Local [`mixins`](#mixins) paths
are the exception: they resolve against the config file that declares them.

See **[Where commands run](where_commands_run.md)** for the full rules, every way of
pointing `lets` at a config, and the reasoning.

## Top-level directives:

### Version
Expand Down Expand Up @@ -134,7 +146,7 @@ env_file:
Rules:

- `-filename` is a short form of `required: false`
- files are resolved relative to the config directory
- files are resolved relative to the [root dir](where_commands_run.md) — the directory you ran `lets` from
- file names are expanded after global `env` is resolved, so `env_file` can depend on global `env`
- values loaded from `env_file` have higher precedence than values from `env`
- missing files fail by default
Expand Down Expand Up @@ -356,7 +368,8 @@ lets -c https://example.com/lets.yaml build
Lets will download the config and cache it in `~/.config/lets/remote-configs`.
Use `--no-cache` to force lets to re-download the remote config instead of using the cached copy.

Commands from a remote config run from the directory where `lets` was invoked unless the command specifies `work_dir`.
Commands from a remote config run in the [root dir](where_commands_run.md), exactly like commands from a local one.
A remote config can only mix in other URLs — a local `mixins` path is an error, since the config has no local directory to resolve it against.
When stderr is an interactive terminal, lets shows download progress for remote config downloads. Cache hits do not show progress.


Expand Down Expand Up @@ -518,7 +531,12 @@ Usage: lets hello <name>

`type: string`

Specify work directory to run in. Path must be relative to project root. Be default command's workdir is project root (where lets.yaml located).
Specify the directory to run the command in. A relative path resolves against the
[root dir](where_commands_run.md) — the directory you ran `lets` from. Absolute paths are
used as-is. By default a command runs in the root dir itself.

`work_dir` moves everything the command touches, not just `cmd`: [`checksum`](#checksum)
file paths, [`env_file`](#env_file) paths and `env.sh` scripts all resolve against it too.

Example:

Expand All @@ -530,6 +548,17 @@ commands:
cmd: npm start
```

Since the path is relative to where you ran `lets`, `lets run-docs` works from the
project root and fails from a subdirectory. `work_dir` does not expand env variables,
so anchor the command itself when it should always target the same place regardless of
where it is run from:

```yaml
commands:
run-docs:
cmd: cd "${LETS_CONFIG_DIR}/docs" && npm start
```

### `shell`

`key: shell`
Expand Down Expand Up @@ -786,7 +815,7 @@ Rules:
- command `env` is resolved first
- command `env_file` file names are expanded using builtin lets vars, merged global env, and resolved command `env`
- values loaded from command `env_file` override values from command `env`
- paths are resolved relative to the config directory, not `work_dir`
- paths are resolved relative to the command's working dir, so they follow `work_dir`

Example:

Expand Down
6 changes: 3 additions & 3 deletions docs/docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ title: Environment

* `LETS_COMMAND_NAME` - string name of launched command
* `LETS_COMMAND_ARGS` - positional arguments for launched command, e.g. for `lets run --debug --config=test.ini` it will contain `--debug --config=test.ini`
* `LETS_COMMAND_WORK_DIR` - absolute path to `work_dir` specified in command.
* `LETS_CONFIG` - absolute path to lets config file.
* `LETS_CONFIG_DIR` - absolute path to lets config file firectory.
* `LETS_COMMAND_WORK_DIR` - absolute path to the directory the command runs in: the root dir, or the command's `work_dir` if it sets one.
* `LETS_CONFIG` - absolute path to lets config file. For a remote config this is the URL it was loaded from.
* `LETS_CONFIG_DIR` - absolute path to the directory holding the config file. Use it to target the project rather than the directory you ran `lets` from. For a remote config this is the local cache directory.
* `LETS_OS` - current operating system name from Go runtime, for example `linux`, `darwin`, `windows`
* `LETS_ARCH` - current architecture name from Go runtime, for example `amd64`, `arm64`, `386`
* `LETS_SHELL` - shell from config or command.
Expand Down
Loading
Loading