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
10 changes: 4 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Use `lets` task runner for all build/test/lint operations instead of raw command

```bash
lets build [bin] # build CLI with version metadata
lets build-and-install # build and install lets-dev locally
lets build-and-install # build and install lets binary locally
lets test # full suite: unit + bats + completions
lets test-unit # Go unit tests only
lets test-bats [test] # Docker-based Bats integration tests
Expand Down Expand Up @@ -61,18 +61,16 @@ This is a Single-context repo; skills should read the root Context and root ADRs
- `internal/set/` — generic Set data structure
- `internal/test/` — test utilities (temp files, args helpers)

## Key lets.yaml Fields

- Top-level: `shell`, `env`, `before`, `init`, `mixins`, `commands`
- Command: `cmd`, `description`, `depends`, `env`, `options` (docopt), `work_dir`, `after`, `checksum`, `persist_checksum`, `ref`, `args`, `shell`

## Project Rules

- Follow `gofmt` exactly; tabs for indentation, ~120 char lines
- Unit tests as `*_test.go` next to source; Bats tests in `tests/*.bats`
- Fixtures in matching `tests/<scenario>/` folder, use `lets.yaml` unless variant needed
- Bats tests use `run` + `assert_success`/`assert_line` pattern
- Run at least `go test ./...` before considering work complete; `lets test-bats` for CLI-path changes
- Run `lets lint` to verify code quality before commit/push/PR creation
- If you discover non-obvious knowledge needed to make something work or avoid a known issue, document it in code comments or docs so it is not lost
- Add concise code comments for non-obvious logic, invariants, and surprising decisions; do not comment self-explanatory code
- **Golden tests** — `internal/cmd/testdata/*` are snapshot of the rendered help and error output. If you change anything that affects help or error rendering (flags, styles, section titles, error messages), regenerate them with `go test ./internal/cmd/ -run -update` (or `lets test-unit --update-golden` in Docker), then commit the updated `.golden` files. If you add a new rendering behaviour (new section, new error type, new command layout), add a corresponding golden test in `internal/cmd/help_golden_test.go` with a fixture YAML in `internal/cmd/testdata/fixtures/` if needed, then run with `-update` to create the golden file.
- Commits: short imperative subjects (`Add ...`, `Fix ...`, `Use ...`), explain non-obvious context in body
- **Changelog workflow**: add entries to the `Unreleased` section in `docs/docs/changelog.md` with each commit/PR. At release time, rename `Unreleased` to the new tag version
Expand Down
3 changes: 3 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ This is a single-context repo.
| Term | Definition | Aliases to avoid |
| ---- | ---------- | ---------------- |
| **Project config** | The repository-level declaration in `lets.yaml` that defines commands and shared execution rules. | Settings, user config |
| **Remote config** | A main Project config loaded from a URL instead of from a local `lets.yaml` file. | Remote root config, URL config |
| **Settings** | Per-user lets behavior stored in `~/.config/lets/config.yaml`. | Project config |
| **Mixin** | An additional config file merged additively into a Project config. | Override, patch |
| **Remote mixin** | A Mixin loaded from a URL and merged into the main Project config. | Remote config, URL mixin |
| **Global env** | Top-level environment entries shared by all Project commands. | Process env |
| **Env file** | A dotenv-style file loaded into command execution at global or command scope. | Settings file, config file |
| **Theme** | A named style for lets help and styled error output. | Color scheme, palette |
Expand All @@ -47,6 +49,7 @@ This is a single-context repo.
| **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 |
| **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
3 changes: 2 additions & 1 deletion docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ title: Changelog

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

* `[Added]` Remote config support: `lets -c https://url` downloads and caches config to `~/.config/lets/remote-configs/`. Use `--no-cache` to force re-download. Only standalone configs (no `mixins:`) are supported for now.
* `[Added]` Show interactive download progress for remote configs and remote mixins. Issue [#360](https://github.com/lets-cli/lets/issues/360)
* `[Added]` Remote config support: `lets -c https://url` downloads and caches config to `~/.config/lets/remote-configs/`. Use `--no-cache` to force re-download.
* `[Added]` Add `lets self skills` commands to show, install, and update the bundled lets agent skill.
* `[Docs]` Document the bundled lets Agent Skill and link it from the config reference.
* `[Changed]` Expand the bundled lets agent skill with config authoring guidance.
Expand Down
19 changes: 19 additions & 0 deletions docs/docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ title: Config reference
- [Mixins](#mixins)
- [Ignored mixins](#ignored-mixins)
- [Remote mixins `(experimental)`](#remote-mixins-experimental)
- [Remote configs](#remote-configs)
- [Commands](#commands)
- [Command directives:](#command-directives)
- [Short syntax](#short-syntax)
Expand Down Expand Up @@ -326,6 +327,7 @@ Now if `my.yaml` exists - it will be loaded as a mixin. If it is not exist - `le

It is possible to specify mixin as url. Lets will download it and load it as a mixin.
File will be stored in `.lets/mixins` directory.
When stderr is an interactive terminal, lets shows download progress for remote mixin downloads. Cache hits do not show progress.

By default mixin filename will be sha256 hash of url.
Comment on lines 328 to 332

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Capitalize URL and consider adding missing articles in the mixin URL sentence.

For example, you could write: “It is possible to specify a mixin as a URL. lets will download it and load it as a mixin.”

Suggested change
It is possible to specify mixin as url. Lets will download it and load it as a mixin.
File will be stored in `.lets/mixins` directory.
When stderr is an interactive terminal, lets shows download progress for remote mixin downloads. Cache hits do not show progress.
By default mixin filename will be sha256 hash of url.
It is possible to specify a mixin as a URL. lets will download it and load it as a mixin.
The file will be stored in the `.lets/mixins` directory.
When stderr is an interactive terminal, lets shows download progress for remote mixin downloads. Cache hits do not show progress.
By default, the mixin filename will be the SHA256 hash of the URL.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (typo): Capitalize URL and add article for readability.

Rephrase to: "By default, the mixin filename will be the SHA256 hash of the URL."

Suggested change
By default mixin filename will be sha256 hash of url.
By default, the mixin filename will be the SHA256 hash of the URL.


Expand All @@ -341,6 +343,23 @@ mixins:
```


### Remote configs

It is possible to load the main lets config from a url with `--config` / `-c`.

For example:

```bash
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`.
When stderr is an interactive terminal, lets shows download progress for remote config downloads. Cache hits do not show progress.


### Commands

`key: commands`
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26
toolchain go1.26.0

require (
charm.land/bubbles/v2 v2.1.0
charm.land/lipgloss/v2 v2.0.2
github.com/charmbracelet/colorprofile v0.4.2
github.com/charmbracelet/x/ansi v0.11.6
Expand All @@ -27,7 +28,6 @@ require (
)

require (
charm.land/bubbles/v2 v2.1.0 // indirect
charm.land/bubbletea/v2 v2.0.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
Expand Down
4 changes: 0 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand Down Expand Up @@ -106,8 +104,6 @@ github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBq
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
Expand Down
18 changes: 15 additions & 3 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ import (

"github.com/lets-cli/fang"
"github.com/lets-cli/lets/internal/cmd"
"github.com/lets-cli/lets/internal/config/config"
loader "github.com/lets-cli/lets/internal/config"
"github.com/lets-cli/lets/internal/config/config"
"github.com/lets-cli/lets/internal/downloadprogress"
"github.com/lets-cli/lets/internal/env"
"github.com/lets-cli/lets/internal/logging"
"github.com/lets-cli/lets/internal/set"
Expand Down Expand Up @@ -79,15 +80,26 @@ func Main(version string, buildDate string) int {
}

var cfg *config.Config

loadOptions := []loader.LoadOption{}
if isInteractiveStderr() {
loadOptions = append(loadOptions, loader.WithProgress(downloadprogress.New(
os.Stderr,
downloadprogress.WithNoColor(appSettings.NoColor),
downloadprogress.WithTheme(appSettings.Theme),
)))
}

if isRemoteURL(rootFlags.config) {
if configDir != "" {
log.Warnf("LETS_CONFIG_DIR is ignored when using a remote config URL")
}

cfg, err = loader.LoadRemote(ctx, rootFlags.config, rootFlags.noCache, version)
cfg, err = loader.LoadRemote(ctx, rootFlags.config, rootFlags.noCache, version, loadOptions...)
} else {
cfg, err = loader.Load(rootFlags.config, configDir, version)
cfg, err = loader.LoadWithContext(ctx, rootFlags.config, configDir, version, loadOptions...)
}

if err != nil {
if failOnConfigError(rootCmd, command, rootFlags) {
log.Errorf("config error: %s", err)
Expand Down
10 changes: 9 additions & 1 deletion internal/cmd/self_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func promptSkillScope(in io.Reader, out io.Writer, localDir string, globalDir st
_, _ = fmt.Fprint(out, "Select [1/2]: ")

line, err := bufio.NewReader(in).ReadString('\n')
if err != nil && !(errors.Is(err, io.EOF) && line != "") {
if err != nil && (!errors.Is(err, io.EOF) || line == "") {
return "", fmt.Errorf("reading install scope: %w", err)
}

Expand Down Expand Up @@ -180,6 +180,7 @@ func validateSkillNameArg(args []string) error {

func installLetsSkill(out io.Writer, targetDir string, force bool) error {
skillDir := filepath.Join(targetDir, skillpkg.LetsName)

skillPath := filepath.Join(skillDir, skillpkg.SkillFile)
if _, err := os.Stat(skillPath); err == nil && !force {
_, _ = fmt.Fprintf(out, "%s already exists. Use --force to overwrite.\n", skillPath)
Expand All @@ -193,6 +194,7 @@ func installLetsSkill(out io.Writer, targetDir string, force bool) error {
}

_, _ = fmt.Fprintf(out, "Installed %s\n", skillDir)

return nil
}

Expand All @@ -203,19 +205,23 @@ func updateLetsSkill(out io.Writer) error {
}

updated := 0

for _, skillDir := range dirs {
skillPath := filepath.Join(skillDir, skillpkg.SkillFile)

current, err := os.ReadFile(skillPath)
if errors.Is(err, os.ErrNotExist) {
continue
}

if err != nil {
return fmt.Errorf("reading %s: %w", skillPath, err)
}

if bytes.Equal(current, skillpkg.LetsSkill()) {
_, _ = fmt.Fprintf(out, "%s already up to date.\n", skillDir)
updated++

continue
}

Expand Down Expand Up @@ -257,6 +263,7 @@ func knownLetsSkillDirs() ([]string, error) {
if err != nil {
return nil, err
}

dirs = append(dirs, filepath.Join(globalDir, skillpkg.LetsName))

return dirs, nil
Expand Down Expand Up @@ -302,6 +309,7 @@ func findGitRoot(start string) (string, error) {
if parent == dir {
return "", errors.New("not in a Git repository. Use --global or --path to specify a target")
}

dir = parent
}
}
24 changes: 21 additions & 3 deletions internal/config/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"bytes"
"context"
"errors"
"fmt"
"maps"
Expand All @@ -10,6 +11,7 @@ import (
"strings"

"github.com/lets-cli/lets/internal/config/path"
"github.com/lets-cli/lets/internal/fetch"
"github.com/lets-cli/lets/internal/set"
"github.com/lets-cli/lets/internal/util"
"gopkg.in/yaml.v3"
Expand Down Expand Up @@ -52,8 +54,23 @@ type Config struct {
RemoteSource string

// cached env after config.SetupEnv, used in config.GetEnv
cachedEnv map[string]string
isMixin bool // if true, we consider config as mixin and apply different parsing and validation
cachedEnv map[string]string
downloadContext context.Context
downloadProgress fetch.ProgressObserver
isMixin bool // if true, we consider config as mixin and apply different parsing and validation
}

func (c *Config) SetDownloadOptions(ctx context.Context, progress fetch.ProgressObserver) {
c.downloadContext = ctx
c.downloadProgress = progress
}

func (c *Config) context() context.Context {
if c.downloadContext == nil {
return context.Background()
}

return c.downloadContext
}

func (c *Config) UnmarshalYAML(unmarshal func(any) error) error {
Expand Down Expand Up @@ -225,7 +242,7 @@ func (c *Config) readMixin(mixin *Mixin) error {
}

if data == nil {
data, err = rm.download()
data, err = rm.download(c.context(), c.downloadProgress)
if err != nil {
return err
}
Expand Down Expand Up @@ -356,6 +373,7 @@ func NewConfig(workDir string, configAbsPath string, dotLetsDir string) *Config
func NewMixinConfig(cfg *Config, configAbsPath string) *Config {
mixin := NewConfig(cfg.WorkDir, configAbsPath, cfg.DotLetsDir)
mixin.isMixin = true
mixin.SetDownloadOptions(cfg.context(), cfg.downloadProgress)

return mixin
}
Expand Down
4 changes: 2 additions & 2 deletions internal/config/config/mixin.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ func (rm *RemoteMixin) tryRead() ([]byte, error) {
return data, nil
}

func (rm *RemoteMixin) download() ([]byte, error) {
return fetch.Download(context.Background(), rm.URL)
func (rm *RemoteMixin) download(ctx context.Context, progress fetch.ProgressObserver) ([]byte, error) {
return fetch.Download(ctx, rm.URL, fetch.WithProgress(fetch.SourceRemoteMixin, progress))
}

// Trim `-` prefix.
Expand Down
Loading
Loading