From 417d86fdc08dc37c5b34540f29db2e502df3218f Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 20:45:01 +0300 Subject: [PATCH 1/2] Add bundled agent skill command --- docs/docs/agent_skills.md | 121 ++++++++++++ docs/docs/changelog.md | 2 + docs/docs/config.md | 6 + docs/sidebars.js | 1 + internal/cmd/self.go | 1 + internal/cmd/self_skills.go | 307 +++++++++++++++++++++++++++++++ internal/cmd/self_skills_test.go | 192 +++++++++++++++++++ internal/skills/lets/SKILL.md | 35 ++++ internal/skills/skills.go | 19 ++ 9 files changed, 684 insertions(+) create mode 100644 docs/docs/agent_skills.md create mode 100644 internal/cmd/self_skills.go create mode 100644 internal/cmd/self_skills_test.go create mode 100644 internal/skills/lets/SKILL.md create mode 100644 internal/skills/skills.go diff --git a/docs/docs/agent_skills.md b/docs/docs/agent_skills.md new file mode 100644 index 00000000..ff92c702 --- /dev/null +++ b/docs/docs/agent_skills.md @@ -0,0 +1,121 @@ +--- +id: agent_skills +title: Agent Skills +--- + +Agent Skills are portable instructions that help AI agents discover how to work with a tool or project. + +`lets` ships one bundled skill named `lets`. It explains how agents should inspect `lets.yaml`, discover available commands, prefer project-defined tasks, and safely modify lets configuration. + +The feature is experimental and might change or be removed in a future release. + +## What gets installed + +The `lets` skill is installed as a standard Agent Skills directory: + +```text +.agents/skills/lets/SKILL.md +``` + +For global installs, the same directory is created under your home directory: + +```text +~/.agents/skills/lets/SKILL.md +``` + +Any compatible agent can discover the skill from those locations. + +## Show the bundled skill + +Print the bundled skill to stdout: + +```bash +lets self skills show +``` + +Use this to inspect exactly what will be installed. + +## Install the skill + +Run the install command: + +```bash +lets self skills install +``` + +Without flags, `lets` prompts you to choose local or global scope and shows the exact install path for each option. + +Install for the current project: + +```bash +lets self skills install --local +``` + +This writes to `.agents/skills/lets/` at the current Git repository root. + +Install for the current user: + +```bash +lets self skills install --global +``` + +This writes to `~/.agents/skills/lets/`. + +Install to a custom skills directory: + +```bash +lets self skills install --path /path/to/.agents/skills +``` + +Overwrite an existing installed skill: + +```bash +lets self skills install --force +``` + +The optional skill name is accepted for compatibility: + +```bash +lets self skills install lets +``` + +`lets` currently ships only the `lets` skill. + +## Update the skill + +Update installed copies to the version bundled in the current `lets` binary: + +```bash +lets self skills update +``` + +You can also pass the skill name: + +```bash +lets self skills update lets +``` + +Update checks the known local and global locations: + +- `.agents/skills/lets/` at the current Git repository root +- `~/.agents/skills/lets/` + +Skills installed with `--path` are not discovered by `update`; reinstall with `--path --force` to refresh a custom location. + +## Remove the skill + +There is no dedicated remove command. Delete the installed skill directory. + +Remove the local project skill: + +```bash +rm -rf .agents/skills/lets +``` + +Remove the global user skill: + +```bash +rm -rf ~/.agents/skills/lets +``` + +After removal, compatible agents will stop discovering the bundled `lets` skill from that location. diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index e6880d93..0b69550c 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -5,6 +5,8 @@ title: Changelog ## [Unreleased](https://github.com/lets-cli/lets/releases/tag/v0.0.X) +* `[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. * `[Fixed]` Make root and `self` help paths delegate through Cobra help handling, and allow `--version` without requiring config. * `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping. diff --git a/docs/docs/config.md b/docs/docs/config.md index 85387e6a..e411bc75 100644 --- a/docs/docs/config.md +++ b/docs/docs/config.md @@ -3,6 +3,7 @@ id: config title: Config reference --- +- [Agent Skills](#agent-skills) - [Top-level directives:](#top-level-directives) - [Version](#version) - [Shell](#shell) @@ -33,6 +34,11 @@ title: Config reference - [Aliasing:](#aliasing) - [Env aliasing](#env-aliasing) +## Agent Skills + +Agent Skills are not configured in `lets.yaml`. They are installed and managed with the `lets self skills` command. + +Use [`lets self skills`](agent_skills.md) to show, install, update, or remove the bundled `lets` agent skill. ## Top-level directives: diff --git a/docs/sidebars.js b/docs/sidebars.js index b756bc32..eaae095b 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -39,6 +39,7 @@ module.exports = { }, "config", "settings", + "agent_skills", { type: "category", label: "API Reference", diff --git a/internal/cmd/self.go b/internal/cmd/self.go index 97b98633..9b0d2398 100644 --- a/internal/cmd/self.go +++ b/internal/cmd/self.go @@ -26,5 +26,6 @@ func initSelfCmd(rootCmd *cobra.Command, version string, openURL func(string) er selfCmd.AddCommand(initDocCommand(openURL)) selfCmd.AddCommand(initLspCommand(version)) + selfCmd.AddCommand(initSkillsCommand()) selfCmd.AddCommand(initUpgradeCommand(version)) } diff --git a/internal/cmd/self_skills.go b/internal/cmd/self_skills.go new file mode 100644 index 00000000..2adf4ec3 --- /dev/null +++ b/internal/cmd/self_skills.go @@ -0,0 +1,307 @@ +package cmd + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + skillpkg "github.com/lets-cli/lets/internal/skills" + "github.com/spf13/cobra" +) + +func initSkillsCommand() *cobra.Command { + skillsCmd := &cobra.Command{ + Use: "skills ", + Short: "Manage lets agent skills. (EXPERIMENTAL)", + Long: strings.TrimSpace(`Install the bundled lets agent skill so that AI agents can discover +and use lets effectively. + +Skills follow the Agent Skills specification and work with any compatible agent, +including Claude Code, Codex, and Gemini CLI, PI, Open Code, etc. + +This feature is an experiment and is not ready for production use. It might be +unstable or removed at any time.`), + Args: validateCommandArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + skillsCmd.AddCommand(initSkillsShowCommand()) + skillsCmd.AddCommand(initSkillsInstallCommand()) + skillsCmd.AddCommand(initSkillsUpdateCommand()) + + return skillsCmd +} + +func initSkillsShowCommand() *cobra.Command { + return &cobra.Command{ + Use: "show", + Short: "Show lets bundled agent skill. (EXPERIMENTAL)", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := cmd.OutOrStdout().Write(skillpkg.LetsSkill()) + return err + }, + } +} + +type skillsInstallOptions struct { + global bool + local bool + path string + force bool +} + +func initSkillsInstallCommand() *cobra.Command { + opts := &skillsInstallOptions{} + + installCmd := &cobra.Command{ + Use: "install [name]", + Short: "Install lets' bundled agent skill. (EXPERIMENTAL)", + Long: strings.TrimSpace(`Install the bundled lets SKILL.md file to .agents/skills/, the +cross-agent standard defined by the Agent Skills specification. + +By default, install prompts for local project scope or global user scope. Local +scope installs to .agents/skills/ at the current Git repository root. Global +scope installs to ~/.agents/skills/.`), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateSkillNameArg(args); err != nil { + return err + } + + targetDir, err := opts.targetDir(cmd) + if err != nil { + return err + } + + return installLetsSkill(cmd.OutOrStdout(), targetDir, opts.force) + }, + } + + installCmd.Flags().BoolVarP(&opts.global, "global", "g", false, "Install the skill at user scope (~/.agents/skills/).") + installCmd.Flags().BoolVarP(&opts.local, "local", "l", false, "Install the skill at local project scope (.agents/skills/).") + installCmd.Flags().StringVar(&opts.path, "path", "", "Install the skill to the directory at .") + installCmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Overwrite an existing skill file.") + installCmd.MarkFlagsMutuallyExclusive("global", "local", "path") + + return installCmd +} + +func (o skillsInstallOptions) targetDir(cmd *cobra.Command) (string, error) { + if o.path != "" { + return o.path, nil + } + + if o.global { + return globalSkillsDir() + } + + if o.local { + return localSkillsDir() + } + + localDir, err := localSkillsDir() + if err != nil { + return "", err + } + + globalDir, err := globalSkillsDir() + if err != nil { + return "", err + } + + scope, err := promptSkillScope(cmd.InOrStdin(), cmd.ErrOrStderr(), localDir, globalDir) + if err != nil { + return "", err + } + + if scope == "global" { + return globalDir, nil + } + + return localDir, nil +} + +func promptSkillScope(in io.Reader, out io.Writer, localDir string, globalDir string) (string, error) { + _, _ = fmt.Fprintf(out, "Install lets skill:\n") + _, _ = fmt.Fprintf(out, " 1. Local %s\n", filepath.Join(localDir, skillpkg.LetsName)) + _, _ = fmt.Fprintf(out, " 2. Global %s\n", filepath.Join(globalDir, skillpkg.LetsName)) + _, _ = fmt.Fprint(out, "Select [1/2]: ") + + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && !(errors.Is(err, io.EOF) && line != "") { + return "", fmt.Errorf("reading install scope: %w", err) + } + + switch strings.ToLower(strings.TrimSpace(line)) { + case "l", "local", "1": + return "local", nil + case "g", "global", "2": + return "global", nil + default: + return "", fmt.Errorf("invalid install scope %q; enter local or global", strings.TrimSpace(line)) + } +} + +func initSkillsUpdateCommand() *cobra.Command { + return &cobra.Command{ + Use: "update [name]", + Short: "Update installed agent skills to the current shipped version. (EXPERIMENTAL)", + Long: strings.TrimSpace(`Update installed lets agent skills in known locations to the current +version bundled in this lets binary. + +Known locations are the current project's .agents/skills/ directory and the +user-scope ~/.agents/skills/ directory.`), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateSkillNameArg(args); err != nil { + return err + } + + return updateLetsSkill(cmd.OutOrStdout()) + }, + } +} + +func validateSkillNameArg(args []string) error { + if len(args) == 0 || args[0] == skillpkg.LetsName { + return nil + } + + return fmt.Errorf("unknown bundled skill %q", args[0]) +} + +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) + return nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("checking %s: %w", skillPath, err) + } + + if err := writeLetsSkill(skillDir); err != nil { + return err + } + + _, _ = fmt.Fprintf(out, "Installed %s\n", skillDir) + return nil +} + +func updateLetsSkill(out io.Writer) error { + dirs, err := knownLetsSkillDirs() + if err != nil { + return err + } + + 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 + } + + if err := writeLetsSkill(skillDir); err != nil { + return err + } + + _, _ = fmt.Fprintf(out, "Updated %s\n", skillDir) + updated++ + } + + if updated == 0 { + return errors.New("lets skill is not installed in any known location. Run 'lets self skills install' first") + } + + return nil +} + +func writeLetsSkill(skillDir string) error { + if err := os.MkdirAll(skillDir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", skillDir, err) + } + + skillPath := filepath.Join(skillDir, skillpkg.SkillFile) + if err := os.WriteFile(skillPath, skillpkg.LetsSkill(), 0o644); err != nil { + return fmt.Errorf("writing %s: %w", skillPath, err) + } + + return nil +} + +func knownLetsSkillDirs() ([]string, error) { + dirs := []string{} + if localDir, err := localSkillsDir(); err == nil { + dirs = append(dirs, filepath.Join(localDir, skillpkg.LetsName)) + } + + globalDir, err := globalSkillsDir() + if err != nil { + return nil, err + } + dirs = append(dirs, filepath.Join(globalDir, skillpkg.LetsName)) + + return dirs, nil +} + +func globalSkillsDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + + return filepath.Join(home, skillpkg.SkillsRelDir), nil +} + +func localSkillsDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("could not determine current directory: %w", err) + } + + root, err := findGitRoot(wd) + if err != nil { + return "", err + } + + return filepath.Join(root, skillpkg.SkillsRelDir), nil +} + +func findGitRoot(start string) (string, error) { + dir, err := filepath.Abs(start) + if err != nil { + return "", fmt.Errorf("resolving current directory: %w", err) + } + + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return "", fmt.Errorf("checking Git root: %w", err) + } + + parent := filepath.Dir(dir) + if parent == dir { + return "", errors.New("not in a Git repository. Use --global or --path to specify a target") + } + dir = parent + } +} diff --git a/internal/cmd/self_skills_test.go b/internal/cmd/self_skills_test.go new file mode 100644 index 00000000..09a29a30 --- /dev/null +++ b/internal/cmd/self_skills_test.go @@ -0,0 +1,192 @@ +package cmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + skillpkg "github.com/lets-cli/lets/internal/skills" +) + +func TestSelfSkillsCmd(t *testing.T) { + t.Run("should show bundled lets skill", func(t *testing.T) { + bufOut := new(bytes.Buffer) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "show"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(new(bytes.Buffer)) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(bufOut.String(), "name: lets") { + t.Fatalf("expected lets skill, got %q", bufOut.String()) + } + }) + + t.Run("should prompt and install local skill", func(t *testing.T) { + repoDir := chdirTempGitRepo(t) + bufOut := new(bytes.Buffer) + bufErr := new(bytes.Buffer) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "install"}) + rootCmd.SetIn(strings.NewReader("local\n")) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(bufErr) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + skillPath := filepath.Join(repoDir, skillpkg.SkillsRelDir, skillpkg.LetsName, skillpkg.SkillFile) + assertSkillFile(t, skillPath) + if !strings.Contains(bufErr.String(), "Install lets skill:") { + t.Fatalf("expected install prompt, got %q", bufErr.String()) + } + if !strings.Contains(bufErr.String(), filepath.Join(repoDir, skillpkg.SkillsRelDir, skillpkg.LetsName)) { + t.Fatalf("expected local install path in prompt, got %q", bufErr.String()) + } + if !strings.Contains(bufOut.String(), "Installed ") { + t.Fatalf("expected install output, got %q", bufOut.String()) + } + }) + + t.Run("should prompt and install global skill", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + bufOut := new(bytes.Buffer) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "install"}) + rootCmd.SetIn(strings.NewReader("global\n")) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(new(bytes.Buffer)) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + skillPath := filepath.Join(homeDir, skillpkg.SkillsRelDir, skillpkg.LetsName, skillpkg.SkillFile) + assertSkillFile(t, skillPath) + }) + + t.Run("should accept numbered picker choice", func(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "install"}) + rootCmd.SetIn(strings.NewReader("2\n")) + rootCmd.SetOut(new(bytes.Buffer)) + rootCmd.SetErr(new(bytes.Buffer)) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + skillPath := filepath.Join(homeDir, skillpkg.SkillsRelDir, skillpkg.LetsName, skillpkg.SkillFile) + assertSkillFile(t, skillPath) + }) + + t.Run("should not overwrite existing skill without force", func(t *testing.T) { + repoDir := chdirTempGitRepo(t) + skillPath := filepath.Join(repoDir, skillpkg.SkillsRelDir, skillpkg.LetsName, skillpkg.SkillFile) + if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(skillPath, []byte("custom"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + bufOut := new(bytes.Buffer) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "install", "--local"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(new(bytes.Buffer)) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + if string(data) != "custom" { + t.Fatalf("expected existing skill to remain unchanged, got %q", string(data)) + } + if !strings.Contains(bufOut.String(), "already exists") { + t.Fatalf("expected already exists output, got %q", bufOut.String()) + } + }) + + t.Run("should update installed skill", func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + repoDir := chdirTempGitRepo(t) + skillPath := filepath.Join(repoDir, skillpkg.SkillsRelDir, skillpkg.LetsName, skillpkg.SkillFile) + if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(skillPath, []byte("old"), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + bufOut := new(bytes.Buffer) + rootCmd := CreateRootCommand("v0.0.0-test", "") + rootCmd.SetArgs([]string{"self", "skills", "update"}) + rootCmd.SetOut(bufOut) + rootCmd.SetErr(new(bytes.Buffer)) + InitSelfCmd(rootCmd, "v0.0.0-test") + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertSkillFile(t, skillPath) + if !strings.Contains(bufOut.String(), "Updated ") { + t.Fatalf("expected update output, got %q", bufOut.String()) + } + }) +} + +func chdirTempGitRepo(t *testing.T) string { + t.Helper() + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error = %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("Chdir(%s) error = %v", oldWd, err) + } + }) + + repoDir := t.TempDir() + if err := os.Mkdir(filepath.Join(repoDir, ".git"), 0o755); err != nil { + t.Fatalf("Mkdir(.git) error = %v", err) + } + if err := os.Chdir(repoDir); err != nil { + t.Fatalf("Chdir(%s) error = %v", repoDir, err) + } + + return repoDir +} + +func assertSkillFile(t *testing.T, skillPath string) { + t.Helper() + + data, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", skillPath, err) + } + if !bytes.Equal(data, skillpkg.LetsSkill()) { + t.Fatalf("unexpected skill content at %s", skillPath) + } +} diff --git a/internal/skills/lets/SKILL.md b/internal/skills/lets/SKILL.md new file mode 100644 index 00000000..05209566 --- /dev/null +++ b/internal/skills/lets/SKILL.md @@ -0,0 +1,35 @@ +--- +name: lets +description: >- + Use lets, the YAML-based CLI task runner. Activate when a repository has + lets.yaml or the user asks to run, inspect, add, or debug lets tasks, + commands, dependencies, options, or mixins. +license: MIT +--- + +# lets Task Runner + +Use this skill when working in a project that uses `lets`, usually indicated by a `lets.yaml` file. + +## Workflow + +1. Inspect `lets.yaml` before changing or running tasks. Also check all files declared in `mixins` - those are lets config files that extend/override main `lets.yaml` file. Mixins with `-` at the beginning of the file name are optional and file may not exist. Used for gitignored mixins +2. Use `lets --help` to list commands and global flags. +3. Use `lets help ` to inspect a command's description, options, and usage before running it. +4. Prefer project-defined `lets` commands for build, test, lint, format, docs, and release workflows instead of invoking underlying tools directly. + +## Configuration Notes + +`lets.yaml` can define top-level `shell`, `env`, `before`, `init`, `mixins`, and `commands` fields. + +Command entries commonly use: + +- `cmd`: shell command to run. +- `depends`: commands that must run first. +- `env`: command-specific environment. +- `options`: docopt-style CLI options exposed as `LETSOPT_*` and `LETSCLI_*` environment variables. +- `work_dir`: working directory for the command. +- `after`: commands to run after the main command. +- `checksum` and `persist_checksum`: skip work when inputs have not changed. +- `ref` and `args`: reuse another command with arguments. +- `shell`: command-specific shell settings. diff --git a/internal/skills/skills.go b/internal/skills/skills.go new file mode 100644 index 00000000..3327aae3 --- /dev/null +++ b/internal/skills/skills.go @@ -0,0 +1,19 @@ +package skills + +import _ "embed" + +const ( + LetsName = "lets" + SkillFile = "SKILL.md" + SkillsRelDir = ".agents/skills" +) + +//go:embed lets/SKILL.md +var letsSkill []byte + +func LetsSkill() []byte { + content := make([]byte, len(letsSkill)) + copy(content, letsSkill) + + return content +} From 594a5d2ac2833c11166d6d2eadc50cffc0b043e5 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 13 Jun 2026 20:57:51 +0300 Subject: [PATCH 2/2] Expand lets agent skill guidance --- docs/docs/changelog.md | 1 + internal/skills/lets/SKILL.md | 286 +++++++++++++++++++++++++++++++++- 2 files changed, 279 insertions(+), 8 deletions(-) diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 0b69550c..18d56899 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -7,6 +7,7 @@ title: Changelog * `[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. * `[Fixed]` Make root and `self` help paths delegate through Cobra help handling, and allow `--version` without requiring config. * `[Refactoring]` Use Go 1.26 `errors.AsType` for type-safe error unwrapping. diff --git a/internal/skills/lets/SKILL.md b/internal/skills/lets/SKILL.md index 05209566..c888dd45 100644 --- a/internal/skills/lets/SKILL.md +++ b/internal/skills/lets/SKILL.md @@ -13,23 +13,293 @@ Use this skill when working in a project that uses `lets`, usually indicated by ## Workflow -1. Inspect `lets.yaml` before changing or running tasks. Also check all files declared in `mixins` - those are lets config files that extend/override main `lets.yaml` file. Mixins with `-` at the beginning of the file name are optional and file may not exist. Used for gitignored mixins +1. Inspect `lets.yaml` before changing or running tasks. Also check all files declared in `mixins`; those files extend or override the main config. Mixins prefixed with `-` are optional and may be gitignored. 2. Use `lets --help` to list commands and global flags. 3. Use `lets help ` to inspect a command's description, options, and usage before running it. 4. Prefer project-defined `lets` commands for build, test, lint, format, docs, and release workflows instead of invoking underlying tools directly. +5. After editing config, run the affected `lets` command or the repository's standard test/lint task. -## Configuration Notes +## Config Basics -`lets.yaml` can define top-level `shell`, `env`, `before`, `init`, `mixins`, and `commands` fields. +The main config is usually `lets.yaml`. Create one with `lets --init` if the project does not have it. -Command entries commonly use: +Minimal config: + +```yaml +shell: bash + +commands: + test: + description: Run tests + cmd: go test ./... +``` + +Top-level fields: + +- `version`: minimum required lets version. +- `shell`: default shell for commands; commonly `bash`. +- `env`: global environment available to every command. +- `env_file`: dotenv files loaded for every command. +- `before`: script prepended to every command and dependency invocation. +- `init`: script run once per lets invocation before the first command. +- `mixins`: additional lets config files or remote mixins. +- `commands`: map of task names to command definitions. + +## Writing Commands + +Use short syntax for simple commands: + +```yaml +commands: + fmt: go fmt ./... +``` + +Use long syntax when you need descriptions, dependencies, env, options, checksums, or cleanup: + +```yaml +commands: + test: + description: Run unit tests + depends: [generate] + env: + GOFLAGS: -count=1 + cmd: go test ./... +``` + +Command fields: - `cmd`: shell command to run. +- `description`: shown in command help. +- `work_dir`: run from a path relative to the config directory. +- `shell`: override shell for one command. +- `after`: cleanup script that runs even if `cmd` fails. - `depends`: commands that must run first. - `env`: command-specific environment. - `options`: docopt-style CLI options exposed as `LETSOPT_*` and `LETSCLI_*` environment variables. -- `work_dir`: working directory for the command. -- `after`: commands to run after the main command. -- `checksum` and `persist_checksum`: skip work when inputs have not changed. +- `env_file`: command-specific dotenv files. +- `checksum` and `persist_checksum`: calculate file checksums and detect whether inputs changed. - `ref` and `args`: reuse another command with arguments. -- `shell`: command-specific shell settings. +- `group`: organize commands in help output. + +`cmd` can be a string, multiline string, array, or experimental map: + +```yaml +commands: + build: + cmd: | + echo "Building" + go build ./cmd/lets + + test: + cmd: + - go + - test + - ./... + + dev: + cmd: + api: go run ./cmd/api + web: npm run dev +``` + +With array `cmd`, extra CLI args are appended. For example `lets test -run TestName` runs `go test ./... -run TestName`. + +For map `cmd`, users can select entries with global flags before the command name, such as `lets --only api dev` or `lets --exclude web dev`. + +## Options And Arguments + +Use `options` for user-facing command arguments. It is a docopt usage block. + +```yaml +commands: + release: + description: Create a release + options: | + Usage: lets release [--dry-run] [--message=] + + Options: + Version to release + --dry-run Print actions without changing anything + --message= Release message + cmd: | + if [[ -n "${LETSOPT_DRY_RUN}" ]]; then + echo "Dry run for ${LETSOPT_VERSION}" + fi + echo "Message: ${LETSOPT_MESSAGE}" +``` + +Rules: + +- Positional args become `LETSOPT_`, for example `` becomes `LETSOPT_VERSION`. +- Long flags become uppercase with `-` converted to `_`, for example `--dry-run` becomes `LETSOPT_DRY_RUN`. +- `LETSOPT_*` contains parsed values such as `true`, `staging`, or positional args. +- `LETSCLI_*` contains the raw CLI fragment, useful when forwarding flags to another command. +- Use kebab-case for CLI flags, not snake_case. + +## Environment + +Global `env` applies to all commands. Command `env` extends or overrides it. + +```yaml +env: + TARGET: dev + IMAGE: + sh: echo "app-${TARGET}" + +commands: + build: + env: + TAG: + sh: git rev-parse --short HEAD + cmd: docker build -t "${IMAGE}:${TAG}" . +``` + +Environment entries are evaluated in declaration order, so later entries can reference earlier ones. Command env can also reference global env. + +Use `env_file` to load dotenv files: + +```yaml +env: + TARGET: dev + +env_file: + - .env + - -.env.local + +commands: + up: + env_file: + - .env.${TARGET} + - name: .env.required + required: true + cmd: docker compose up +``` + +Rules: + +- `-filename` means optional, equivalent to `required: false`. +- `env_file` paths are resolved relative to the config directory, not `work_dir`. +- Values from env files override values from `env`. +- File names are expanded after available env values are resolved. + +## Dependencies And Cleanup + +Use `depends` for prerequisite commands. Dependencies run before the command. + +```yaml +commands: + build: + cmd: docker build -t app . + + test: + depends: [build] + cmd: go test ./... +``` + +Dependencies can pass args or env to the dependency command: + +```yaml +commands: + test: + depends: + - name: build + args: [--verbose] + env: + TARGET: test + cmd: go test ./... +``` + +Use `after` for cleanup that must happen even when the command fails: + +```yaml +commands: + redis: + cmd: docker compose up redis + after: docker compose stop redis +``` + +Use `init` for setup that should run once per lets invocation. Avoid heavy `before` scripts because `before` runs before each command and dependency. + +## Checksums + +Use `checksum` when command behavior depends on file contents. lets calculates SHA1 checksums and exposes them as env vars. + +```yaml +commands: + deps: + checksum: + deps: + - package.json + - package-lock.json + persist_checksum: true + cmd: | + if [[ "${LETS_CHECKSUM_DEPS_CHANGED}" == "true" ]]; then + npm install + fi +``` + +Rules: + +- A list checksum exposes `LETS_CHECKSUM`. +- A named checksum map exposes `LETS_CHECKSUM_` plus combined `LETS_CHECKSUM`. +- `persist_checksum: true` exposes `LETS_CHECKSUM_CHANGED` and named `LETS_CHECKSUM__CHANGED`. +- Persisted checksums update only after a successful command exit. +- Glob patterns are supported in checksum file lists. + +## Mixins + +Use `mixins` to split large configs or share common commands. + +```yaml +shell: bash + +mixins: + - lets.build.yaml + - -lets.my.yaml + +commands: + test: + cmd: go test ./... +``` + +Rules: + +- Local mixins are paths to other lets config files. +- A mixin name prefixed with `-` is optional and may not exist; this is useful for gitignored personal config. +- Remote mixins use `url` and optional `version`; lets caches them under `.lets/mixins`. +- When editing a command, search mixins too because commands may be defined, extended, or overridden there. + +## Reusing Commands + +Use experimental `ref` and `args` to create aliases with predefined arguments. + +```yaml +commands: + hello: + cmd: echo Hello $@ + + hello-world: + ref: hello + args: World +``` + +`ref` is only compatible with `args`; do not combine it with normal command directives. + +Use `group` to organize help output: + +```yaml +commands: + build: + group: Build + cmd: npm run build +``` + +## Common Pitfalls + +- Do not commit `.lets/`, generated binaries, coverage files, dependency directories, or personal mixins such as `lets.my.yaml` unless the repository explicitly tracks them. +- Prefer one clear command over several near-duplicate commands. Use `options`, `args`, or `ref` when that keeps the config simpler. +- Keep `description` first-line concise; help output uses the first line. +- Use multiline `cmd: |` for scripts with conditionals or multiple shell statements. +- Quote env values that look like booleans or numbers when they must stay strings. +- Remember `work_dir` changes where `cmd` runs, but `env_file` remains relative to the config directory. +- Prefer project-defined `lets` tasks for validation, for example `lets test`, `lets lint`, or `lets fmt` when present.