diff --git a/README.md b/README.md index e5496a0..90fdd13 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Go](https://1tt.dev/badge/Go-1.23+-00ADD8.svg?logo=go&logoColor=white)](https://go.dev/) [![Version](https://1tt.dev/badge/version-0.9.1-blue.svg)](https://github.com/n1rna/ee-cli/releases) [![License](https://1tt.dev/badge/license-MIT-green.svg)](https://github.com/n1rna/ee-cli/blob/main/LICENSE) -[![llms.txt](https://1tt.dev/badge/llms.txt-available-brightgreen.svg)](https://github.com/n1rna/ee-cli/blob/main/llms.txt) +[![AI Skill](https://1tt.dev/badge/AI%20skill-ee%20skill-brightgreen.svg)](#ai-coding-agent-integration) [![Platform](https://1tt.dev/badge/platform-linux%20%7C%20macOS%20%7C%20windows-lightgrey.svg)](https://github.com/n1rna/ee-cli/releases) `ee` is a CLI tool that brings structure and validation to environment variable management. It enables teams to define, validate, and manage environment variables across different environments with schema-based validation and inheritance support. @@ -108,13 +108,42 @@ variables: ## Commands -- `ee new [project-name]` - Create a new project -- `ee new [project-name] --env [env-name]` - Add new environment -- `ee edit [project-name] --env [env-name]` - Edit environment variables -- `ee apply [project-name] --env [env-name] [-- command]` - Apply variables -- `ee export [project-name] --env [env-name] -f [format]` - Export configuration -- `ee set [project-name] --env [env-name] KEY=VALUE...` - Set variables -- `ee env [-e envfile] [-- command]` - Apply from .env file +- `ee init [project-name]` - Initialize a new ee project (creates `.ee` + sample `.env` files) +- `ee apply [-- command]` - Apply an environment (or `.env` file) and run a command +- `ee verify [--fix]` - Validate the project against its schema and environment files +- `ee hydrate ` - Generate an env file from the shell environment + schema defaults +- `ee push [origin] ` - Push secrets to a remote origin (GitHub, Cloudflare) +- `ee auth [tool]` - Check authentication status for origin CLI tools (`gh`, `wrangler`) +- `ee skill ` - Install the ee usage guide for your AI coding agent (see below) +- `ee` - Inspect/filter the current shell's environment variables + +## AI Coding Agent Integration + +`ee` can teach your AI coding agent how to work with environment variables in a +project by installing a usage guide (an "ee-usage" skill) into the location each +agent expects. The guide explains how to add `ee` to a new project, how to work +with a project that already has `ee` set up, the `.ee`/schema/`.env` formats, and +the full command reference. + +```bash +# Install the skill for your agent of choice +ee skill claude # -> .claude/skills/ee-usage/SKILL.md +ee skill cursor # -> .cursor/rules/ee-usage.mdc +ee skill copilot # -> .github/copilot-instructions.md +ee skill codex # -> AGENTS.md +ee skill opencode # -> AGENTS.md + +# Install for every supported agent at once +ee skill all + +# List supported agents, or preview the guide without writing a file +ee skill --list +ee skill claude --print +``` + +Use `--force` to overwrite an existing file (for example, to refresh the guide +after upgrading `ee`). Commit the generated file so your whole team's agents pick +it up. ## Configuration diff --git a/cmd/ee/main.go b/cmd/ee/main.go index f2dc450..c170d0f 100644 --- a/cmd/ee/main.go +++ b/cmd/ee/main.go @@ -74,6 +74,7 @@ func main() { command.NewApplyCommand("global"), // Apply environment variables command.NewHydrateCommand("global"), // Generate env file from schema + shell env command.NewVerifyCommand("global"), // Verify project configuration + command.NewSkillCommand("global"), // Install ee usage guide for AI coding agents command.NewAuthCommand("global"), // Authentication // Remote Operations - push secrets to origins diff --git a/internal/command/assets/ee-usage.md b/internal/command/assets/ee-usage.md new file mode 100644 index 0000000..7cac416 --- /dev/null +++ b/internal/command/assets/ee-usage.md @@ -0,0 +1,378 @@ +# Working with the `ee` environment variable manager + +`ee` is a CLI that brings structure and validation to environment variables. It +uses a `.ee` project file (JSON), standard `.env` files, and optional schema +files to define, validate, hydrate, and deploy environment variables across +environments (development, staging, production, ...). + +Use this guide whenever a task involves environment variables, `.env` files, a +`.ee` file, secrets, or configuration for a project that has (or should have) +`ee` set up. + +## Is `ee` installed? + +Check before running commands: + +```bash +ee --version +``` + +If it is missing, install it: + +```bash +curl -sSfL https://raw.githubusercontent.com/n1rna/ee-cli/main/install.sh | sh +# or +go install github.com/n1rna/ee-cli/cmd/ee@latest +``` + +## First: does this project already use `ee`? + +Look for a `.ee` file in the project root. + +- **`.ee` exists** → the project already uses `ee`. Jump to + "Working with an existing `ee` project". +- **No `.ee` file** → jump to "Adding `ee` to a new project". + +```bash +ls .ee 2>/dev/null && echo "ee is set up" || echo "no .ee file yet" +``` + +--- + +## Adding `ee` to a new project + +Follow these steps when the project has no `.ee` file yet. + +1. **Initialize the project.** From the project root: + + ```bash + # Use the current directory name as the project name + ee init + + # Or name it explicitly + ee init my-api + ``` + + This creates a `.ee` file, plus sample `.env.development` and + `.env.production` files, and a default inline schema (`NODE_ENV`, `PORT`, + `DEBUG`) unless you provide your own. + +2. **Define the schema.** Prefer a dedicated schema file for anything beyond a + toy project. Create `schema.yaml`: + + ```yaml + name: my-api + description: API service configuration + variables: + - name: DATABASE_URL + type: string + title: Database connection string + required: true + - name: PORT + type: number + title: Server port + required: false + default: "3000" + - name: API_KEY + type: string + title: API authentication key + required: true + regex: "^[a-zA-Z0-9_-]+$" + ``` + + Then reference it when initializing (or edit the `.ee` file's `schema` + section to `{ "ref": "./schema.yaml" }`): + + ```bash + ee init my-api --schema ./schema.yaml + ``` + + Alternatively, define variables inline without a file: + + ```bash + ee init my-api \ + --var "DATABASE_URL:string:Database URL:true" \ + --var "PORT:number:Server port:false:3000" + ``` + + The `--var` format is `name:type:title:required[:default]`. + +3. **Fill in the `.env` files.** Edit `.env.development` / `.env.production` + (created by `ee init`) with real values. Keep secrets out of committed files + (see "Handling secrets" below). + +4. **Verify the setup.** Confirm the schema loads and required variables are + present: + + ```bash + ee verify --verbose + # Auto-create missing files / append missing required vars: + ee verify --fix + ``` + +5. **Use it.** Run the app with an environment applied: + + ```bash + ee apply development -- npm start + ``` + +6. **Recommended `.gitignore` additions** (keep real secrets out of git): + + ```gitignore + .env.local + .env.*.local + .env.secrets + *.secret.env + ``` + + Commit `.ee`, `schema.yaml`, and non-secret `.env` files. + +--- + +## Working with an existing `ee` project + +When a `.ee` file is present, do **not** re-run `ee init`. Instead: + +1. **Read the configuration.** Open the `.ee` file to learn the project name, + schema location, defined environments, and any remote origins. Then confirm + everything is consistent: + + ```bash + ee verify --verbose + ``` + +2. **List the environments** by inspecting the `environments` object in `.ee` + (e.g. `development`, `staging`, `production`). + +3. **Run commands with an environment applied** instead of manually exporting + variables: + + ```bash + # Start a subshell with the environment loaded + ee apply development + + # Or run a single command with it + ee apply production -- npm run build + ``` + +4. **Inspect what an environment resolves to** without applying it (safe, + read-only): + + ```bash + ee apply production --dry-run + ee apply production --dry-run --format json + ee apply production --dry-run --format dotenv + ``` + +5. **Add a new variable to the project:** + - Add it to the schema (`schema.yaml` or the inline `schema.variables` in + `.ee`). + - Add its value to the relevant `.env` file(s). + - Run `ee verify --fix` to append any missing required variables to the + `.env` files with their defaults. + +6. **Add a new environment:** add an entry under `environments` in `.ee` + pointing at an `.env` file (single `env`) or multiple `sources`, then create + the file(s) and run `ee verify`. + +7. **Deploy / push secrets** to a configured remote origin (GitHub Actions + secrets or Cloudflare Workers) — see "Pushing secrets to origins". + +> Important for coding agents: never print or commit real secret values. When +> showing an environment, prefer `ee --mask` or `--dry-run` output and keep +> secret files gitignored. + +--- + +## The `.ee` file format + +The `.ee` file is JSON at the project root: + +```json +{ + "project": "my-api", + "schema": { "ref": "./schema.yaml" }, + "environments": { + "development": { "env": ".env.development" }, + "production": { + "sources": [".env", ".env.production", ".env.secrets"] + } + }, + "origins": { + "github": { + "type": "github", + "mode": "bundled", + "secret_name": "ENV_PRODUCTION", + "repo": "myorg/my-app", + "environment": "production" + } + } +} +``` + +- **`schema`** — either inline (`"variables": { ... }`) or a file reference + (`"ref": "./schema.yaml"`). Refs accept relative paths, absolute paths, or + `file://` URIs; plain filenames need a `.yaml`/`.yml`/`.json` extension. +- **`environments`** — each maps to a single `env` file or a `sources` array + that is merged left-to-right (later values override earlier ones). A source + can also be an inline `{ "KEY": "value" }` object. +- **`origins`** — remote push targets (`github`, `cloudflare`). + +## Schema file format + +```yaml +name: web-service +description: Schema for web service applications +variables: + - name: DATABASE_URL + title: Database connection URL + type: string # string | number | boolean | url + required: true + - name: PORT + type: number + required: false + default: "3000" + - name: API_KEY + type: string + required: true + regex: "^[a-zA-Z0-9_-]+$" +``` + +Variable properties: `name` (required), `type` (`string`/`number`/`boolean`/ +`url`), `title` (optional), `required` (bool), `default` (optional string), +`regex` (optional validation pattern). + +## `.env` file format + +Standard `KEY=VALUE`. Optional annotation comments document each variable and +are written by `ee init` / `ee verify --fix`: + +```bash +# schema: ./schema.yaml + +# title: Database connection URL +# type: string +# required: true +DATABASE_URL=postgres://localhost:5432/myapp + +# title: Server port +# type: number +# default: 3000 +PORT=3000 +``` + +--- + +## Command reference + +### `ee` (root) — inspect the current shell environment + +```bash +ee # print all env vars +ee --filter 'DB_*,DATABASE_*' # wildcard include; separators , | / +ee --filter 'NODE*,!NODE_OPTIONS' # prefix ! to exclude +ee --format json # env (default) | json | dotenv +ee --mask # mask sensitive values (KEY/SECRET/TOKEN/...) +``` + +### `ee init [project-name]` — create a new project + +Flags: `-s/--schema `, `--var ` +(repeatable), `-f/--force`, `-q/--quiet`. + +### `ee apply [-- command [args...]]` — load an environment + +Detects a file path when the argument starts with `.`, `/`, or `~`, or the file +exists; otherwise treats it as a project environment name (needs `.ee`). Without +a trailing command it starts a subshell. Flags: `-d/--dry-run`, +`-f/--format `, `-q/--quiet`. Alias: `ee a`. + +### `ee verify` — validate the project + +Checks the schema loads, every environment has an `.env` file, and required +variables are present. Flags: `--fix` (create missing files / append missing +required vars), `--verbose`, `--env `, `--quiet`. + +### `ee hydrate ` — build an env file from the shell + schema + +Resolves each schema variable from the current shell env, then the schema +default, then empty (warns for required). Flags: `-o/--output `, +`-f/--format `. Useful in CI. + +### `ee push [origin] ` — push secrets to a remote origin + +Pushes to GitHub Actions secrets or Cloudflare Workers. Flags: `--dry-run`, +`--mode `, `--quiet`. If only one origin is configured the +name can be omitted. + +### `ee auth [tool]` — check origin CLI authentication + +Checks `gh` (GitHub) and `wrangler` (Cloudflare). Run before `ee push`. + +### `ee skill ` — install this guide for a coding agent + +Writes this usage guide into the convention expected by the selected coding +agent (`claude`, `cursor`, `copilot`, `codex`, `opencode`, or `all`). + +--- + +## Handling secrets + +- Keep secret values in a gitignored file such as `.env.secrets` and stack it + via `sources`: + + ```json + { "environments": { "production": { + "sources": [".env", ".env.production", ".env.secrets"] } } } + ``` + +- Never commit real secrets. Never echo secret values into logs, chat, or + commits. Use `ee --mask` or `--dry-run` when you need to show a config. + +## Pushing secrets to origins + +```bash +ee auth gh # verify GitHub CLI is authenticated +ee push github production --dry-run # preview +ee push github production # push (bundled multi-line secret) + +ee auth wrangler # verify Cloudflare wrangler +ee push cloudflare production # push individual secrets to a Worker +``` + +- **bundled** (GitHub default): all vars combined into one multi-line + `KEY=VALUE` secret, consumable by the [`ee-action`](https://github.com/n1rna/ee-action) + GitHub Action. +- **individual** (Cloudflare default): each variable pushed separately. + +## CI / GitHub Actions + +Hydrate an env file at deploy time from a bundled secret: + +```yaml +- uses: actions/checkout@v4 +- name: Prepare environment + uses: n1rna/ee-action@v1 + with: + environment: production + config_path: .ee + env_file: .env.production + gh_secret: ${{ secrets.ENV_PRODUCTION }} +- run: docker build --env-file .env.production -t myapp . +``` + +## Quick recipes + +```bash +# Compare two environments +diff <(ee apply development --dry-run) <(ee apply production --dry-run) + +# Export an environment to a file +ee apply production --dry-run --format dotenv > .env.prod + +# Audit secrets in the current shell (masked) +ee --filter '*KEY*,*SECRET*,*TOKEN*,*PASSWORD*' --mask + +# Validate before deploying +ee verify --env production +``` diff --git a/internal/command/skill.go b/internal/command/skill.go new file mode 100644 index 0000000..7609d46 --- /dev/null +++ b/internal/command/skill.go @@ -0,0 +1,272 @@ +// Package command implements the ee skill command for installing the ee usage +// guide into the convention expected by a given AI coding agent. +package command + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/n1rna/ee-cli/internal/output" +) + +// eeUsageSkillBody is the canonical, agent-oriented ee usage guide. It is the +// single source of truth shipped with the binary and installed by `ee skill`. +// +//go:embed assets/ee-usage.md +var eeUsageSkillBody string + +const ( + // skillName is the slug used for the installed skill. + skillName = "ee-usage" + // skillDescription is a one-line summary used in agent frontmatter. + skillDescription = "Manage environment variables with the ee CLI: .ee project files, " + + "schemas, .env environments, hydration and pushing secrets to origins. " + + "Use when working with environment variables, .env files, secrets, or a .ee file." +) + +// agentTarget describes where and how to install the skill for a coding agent. +type agentTarget struct { + // Name is the identifier passed on the command line (e.g. "claude"). + Name string + // DisplayName is the human-readable agent name. + DisplayName string + // Path is the file path (relative to the project root) to write. + Path string + // Wrap turns the shared skill body into the final file contents, adding any + // agent-specific frontmatter or heading. + Wrap func(body string) string +} + +// noFrontmatter returns the body unchanged, for agents that read plain markdown. +func noFrontmatter(body string) string { + return body +} + +// claudeFrontmatter wraps the body in a Claude skill (SKILL.md) frontmatter. +func claudeFrontmatter(body string) string { + return fmt.Sprintf( + "---\nname: %s\ndescription: %s\n---\n\n%s", + skillName, + skillDescription, + body, + ) +} + +// cursorFrontmatter wraps the body in a Cursor rule (.mdc) frontmatter. +func cursorFrontmatter(body string) string { + return fmt.Sprintf( + "---\ndescription: %s\nglobs:\nalwaysApply: false\n---\n\n%s", + skillDescription, + body, + ) +} + +// skillTargets returns the supported coding agents keyed by their command-line +// name. Ordering-sensitive callers should use sortedAgentNames. +func skillTargets() map[string]agentTarget { + return map[string]agentTarget{ + "claude": { + Name: "claude", + DisplayName: "Claude Code", + Path: filepath.Join(".claude", "skills", skillName, "SKILL.md"), + Wrap: claudeFrontmatter, + }, + "cursor": { + Name: "cursor", + DisplayName: "Cursor", + Path: filepath.Join(".cursor", "rules", skillName+".mdc"), + Wrap: cursorFrontmatter, + }, + "copilot": { + Name: "copilot", + DisplayName: "GitHub Copilot", + Path: filepath.Join(".github", "copilot-instructions.md"), + Wrap: noFrontmatter, + }, + "codex": { + Name: "codex", + DisplayName: "OpenAI Codex", + Path: "AGENTS.md", + Wrap: noFrontmatter, + }, + "opencode": { + Name: "opencode", + DisplayName: "opencode", + Path: "AGENTS.md", + Wrap: noFrontmatter, + }, + } +} + +// sortedAgentNames returns the supported agent names in a stable order. +func sortedAgentNames() []string { + targets := skillTargets() + names := make([]string, 0, len(targets)) + for name := range targets { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// SkillCommand handles the ee skill command. +type SkillCommand struct{} + +// NewSkillCommand creates a new ee skill command. +func NewSkillCommand(groupId string) *cobra.Command { + sc := &SkillCommand{} + + cmd := &cobra.Command{ + Use: "skill [agent]", + Short: "Install the ee usage guide as a skill for your AI coding agent", + Long: `Install the ee usage guide into the location your AI coding agent expects, +so it knows how to work with ee in this project. + +Supported agents: + claude -> .claude/skills/ee-usage/SKILL.md + cursor -> .cursor/rules/ee-usage.mdc + copilot -> .github/copilot-instructions.md + codex -> AGENTS.md + opencode -> AGENTS.md + all -> install for every supported agent + +Examples: + # Install the skill for Claude Code + ee skill claude + + # Install for Cursor, overwriting an existing rule + ee skill cursor --force + + # Install for every supported agent + ee skill all + + # List supported agents + ee skill --list + + # Print the guide to stdout instead of writing a file + ee skill claude --print +`, + RunE: sc.Run, + GroupID: groupId, + } + + cmd.Flags().BoolP("force", "f", false, "Overwrite an existing skill file") + cmd.Flags().BoolP("list", "l", false, "List supported coding agents and exit") + cmd.Flags(). + BoolP("print", "p", false, "Print the skill contents to stdout instead of writing a file") + cmd.Flags().BoolP("quiet", "q", false, "Suppress non-error output") + + return cmd +} + +// Run executes the skill command. +func (c *SkillCommand) Run(cmd *cobra.Command, args []string) error { + quiet, _ := cmd.Flags().GetBool("quiet") + force, _ := cmd.Flags().GetBool("force") + list, _ := cmd.Flags().GetBool("list") + printOnly, _ := cmd.Flags().GetBool("print") + + printer := output.NewPrinter(output.FormatTable, quiet) + + if list { + c.printAgentList(printer) + return nil + } + + if len(args) == 0 { + c.printAgentList(printer) + return fmt.Errorf("no agent specified (choose one of: %s, all)", + strings.Join(sortedAgentNames(), ", ")) + } + + agent := strings.ToLower(strings.TrimSpace(args[0])) + + // Resolve the set of targets to install. + var targets []agentTarget + if agent == "all" { + for _, name := range sortedAgentNames() { + t := skillTargets()[name] + targets = append(targets, t) + } + } else { + t, ok := skillTargets()[agent] + if !ok { + return fmt.Errorf("unknown agent %q (supported: %s, all)", + agent, strings.Join(sortedAgentNames(), ", ")) + } + targets = []agentTarget{t} + } + + // Print mode: write to stdout and stop. + if printOnly { + // When multiple agents are selected the body is identical, so print once + // using the first target's wrapper. + printer.Printf("%s", targets[0].Wrap(eeUsageSkillBody)) + return nil + } + + written := make(map[string]bool) + for _, target := range targets { + // Deduplicate targets that share a path (e.g. codex and opencode both + // use AGENTS.md) so "all" doesn't fail on the second write. + if written[target.Path] { + continue + } + + if err := c.installTarget(target, force, printer); err != nil { + return err + } + written[target.Path] = true + } + + if !quiet { + printer.Info("\nThe ee usage guide is now available to your coding agent.") + printer.Info("Re-run with --force after upgrading ee to refresh it.") + } + + return nil +} + +// installTarget writes the skill file for a single agent target. +func (c *SkillCommand) installTarget( + target agentTarget, + force bool, + printer *output.Printer, +) error { + if _, err := os.Stat(target.Path); err == nil && !force { + return fmt.Errorf("%s already exists (use --force to overwrite)", target.Path) + } + + if dir := filepath.Dir(target.Path); dir != "." && dir != "" { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + } + + content := target.Wrap(eeUsageSkillBody) + if err := os.WriteFile(target.Path, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", target.Path, err) + } + + printer.Success( + fmt.Sprintf("Installed %s skill -> %s", target.DisplayName, target.Path), + ) + return nil +} + +// printAgentList prints the supported agents and their install paths. +func (c *SkillCommand) printAgentList(printer *output.Printer) { + printer.Info("Supported coding agents:") + targets := skillTargets() + for _, name := range sortedAgentNames() { + t := targets[name] + printer.Printf(" %-10s %-16s -> %s\n", t.Name, "("+t.DisplayName+")", t.Path) + } + printer.Printf(" %-10s %-16s -> %s\n", "all", "(every agent)", "all of the above") +} diff --git a/internal/command/skill_test.go b/internal/command/skill_test.go new file mode 100644 index 0000000..bc9828f --- /dev/null +++ b/internal/command/skill_test.go @@ -0,0 +1,98 @@ +package command + +import ( + "strings" + "testing" +) + +func TestSkillTargetsCoverExpectedAgents(t *testing.T) { + targets := skillTargets() + expected := map[string]string{ + "claude": ".claude/skills/ee-usage/SKILL.md", + "cursor": ".cursor/rules/ee-usage.mdc", + "copilot": ".github/copilot-instructions.md", + "codex": "AGENTS.md", + "opencode": "AGENTS.md", + } + + for name, wantPath := range expected { + target, ok := targets[name] + if !ok { + t.Fatalf("expected agent %q to be supported", name) + } + // filepath.Join uses the OS separator; normalise for comparison. + gotPath := strings.ReplaceAll(target.Path, "\\", "/") + if gotPath != wantPath { + t.Errorf("agent %q: expected path %q, got %q", name, wantPath, gotPath) + } + if target.Wrap == nil { + t.Errorf("agent %q: Wrap function must not be nil", name) + } + if target.DisplayName == "" { + t.Errorf("agent %q: DisplayName must not be empty", name) + } + } +} + +func TestSkillBodyIsEmbedded(t *testing.T) { + if !strings.Contains(eeUsageSkillBody, "ee") { + t.Fatal("embedded skill body appears empty or missing") + } + // Sanity check that the special sections requested are present. + for _, marker := range []string{ + "Adding `ee` to a new project", + "Working with an existing `ee` project", + "Command reference", + } { + if !strings.Contains(eeUsageSkillBody, marker) { + t.Errorf("embedded skill body missing section %q", marker) + } + } +} + +func TestClaudeFrontmatter(t *testing.T) { + out := claudeFrontmatter("BODY") + if !strings.HasPrefix(out, "---\n") { + t.Fatal("claude output must start with frontmatter delimiter") + } + if !strings.Contains(out, "name: "+skillName) { + t.Error("claude frontmatter must contain the skill name") + } + if !strings.Contains(out, "description: ") { + t.Error("claude frontmatter must contain a description") + } + if !strings.HasSuffix(out, "BODY") { + t.Error("claude output must end with the body") + } +} + +func TestCursorFrontmatter(t *testing.T) { + out := cursorFrontmatter("BODY") + for _, want := range []string{"description: ", "globs:", "alwaysApply: false"} { + if !strings.Contains(out, want) { + t.Errorf("cursor frontmatter missing %q", want) + } + } + if !strings.Contains(out, "\nBODY") { + t.Error("cursor output must contain the body") + } +} + +func TestNoFrontmatterIsPassthrough(t *testing.T) { + if got := noFrontmatter("BODY"); got != "BODY" { + t.Errorf("noFrontmatter should return body unchanged, got %q", got) + } +} + +func TestSortedAgentNamesStable(t *testing.T) { + got := sortedAgentNames() + want := []string{"claude", "codex", "copilot", "cursor", "opencode"} + if len(got) != len(want) { + t.Fatalf("expected %d agents, got %d", len(want), len(got)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("sortedAgentNames[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/llms.txt b/llms.txt index 3c55b50..f84614e 100644 --- a/llms.txt +++ b/llms.txt @@ -16,6 +16,24 @@ cd ee-cli go build -o ee ./cmd/ee ``` +## AI Coding Agent Skill + +Instead of copying this file around, install the ee usage guide directly into the +convention your AI coding agent expects with `ee skill `: + +```bash +ee skill claude # -> .claude/skills/ee-usage/SKILL.md +ee skill cursor # -> .cursor/rules/ee-usage.mdc +ee skill copilot # -> .github/copilot-instructions.md +ee skill codex # -> AGENTS.md +ee skill opencode # -> AGENTS.md +ee skill all # install for every supported agent +``` + +The installed guide covers adding ee to a new project, working with a project that +already has ee configured, the file formats, and the full command reference. See +the `ee skill` command below for details. + ## Core Concepts ### Project Configuration (`.ee` file) @@ -530,6 +548,45 @@ ee auth wrangler --- +### `ee skill ` + +Install the ee usage guide as a skill for an AI coding agent, so it knows how to +work with ee in the current project. + +```bash +# Install for a specific agent +ee skill claude +ee skill cursor --force + +# Install for every supported agent +ee skill all + +# List supported agents +ee skill --list + +# Print the guide to stdout instead of writing a file +ee skill claude --print +``` + +**Supported agents and install paths:** + +| Agent | Path | Format | +|------------|---------------------------------------|----------------------------| +| `claude` | `.claude/skills/ee-usage/SKILL.md` | Skill with YAML frontmatter | +| `cursor` | `.cursor/rules/ee-usage.mdc` | Rule with YAML frontmatter | +| `copilot` | `.github/copilot-instructions.md` | Plain markdown | +| `codex` | `AGENTS.md` | Plain markdown | +| `opencode` | `AGENTS.md` | Plain markdown | +| `all` | all of the above | — | + +**Flags:** +- `-f, --force` — Overwrite an existing skill file +- `-l, --list` — List supported agents and exit +- `-p, --print` — Print the guide to stdout instead of writing a file +- `-q, --quiet` — Suppress non-error output + +--- + ## Common Workflows ### Setting Up a New Project diff --git a/tests/test_skill.py b/tests/test_skill.py new file mode 100644 index 0000000..2dacb3e --- /dev/null +++ b/tests/test_skill.py @@ -0,0 +1,107 @@ +""" +Integration tests for the `ee skill` command that installs the ee usage guide +into the location expected by each supported AI coding agent. +""" +from pathlib import Path +import pytest + + +AGENT_PATHS = { + "claude": ".claude/skills/ee-usage/SKILL.md", + "cursor": ".cursor/rules/ee-usage.mdc", + "copilot": ".github/copilot-instructions.md", + "codex": "AGENTS.md", + "opencode": "AGENTS.md", +} + + +class TestSkillInstall: + """Test installing the skill for individual agents.""" + + @pytest.mark.parametrize("agent,rel_path", sorted(AGENT_PATHS.items())) + def test_install_single_agent(self, ee_runner, temp_project_dir, agent, rel_path): + result = ee_runner(["skill", agent], cwd=temp_project_dir) + + assert result.returncode == 0 + assert "Installed" in result.stdout + + target = Path(temp_project_dir) / rel_path + assert target.exists(), f"expected {rel_path} to be created" + + content = target.read_text() + # Every install contains the shared body. + assert "Working with the `ee` environment variable manager" in content + assert "Adding `ee` to a new project" in content + assert "Working with an existing `ee` project" in content + + def test_claude_has_skill_frontmatter(self, ee_runner, temp_project_dir): + ee_runner(["skill", "claude"], cwd=temp_project_dir) + content = (Path(temp_project_dir) / AGENT_PATHS["claude"]).read_text() + assert content.startswith("---\n") + assert "name: ee-usage" in content + assert "description:" in content + + def test_cursor_has_rule_frontmatter(self, ee_runner, temp_project_dir): + ee_runner(["skill", "cursor"], cwd=temp_project_dir) + content = (Path(temp_project_dir) / AGENT_PATHS["cursor"]).read_text() + assert content.startswith("---\n") + assert "alwaysApply: false" in content + assert "globs:" in content + + def test_copilot_is_plain_markdown(self, ee_runner, temp_project_dir): + ee_runner(["skill", "copilot"], cwd=temp_project_dir) + content = (Path(temp_project_dir) / AGENT_PATHS["copilot"]).read_text() + # No YAML frontmatter for plain-markdown agents. + assert not content.startswith("---\n") + assert content.lstrip().startswith("# Working with the `ee`") + + +class TestSkillInstallAll: + """Test installing the skill for every agent at once.""" + + def test_install_all(self, ee_runner, temp_project_dir): + result = ee_runner(["skill", "all"], cwd=temp_project_dir) + assert result.returncode == 0 + + for rel_path in set(AGENT_PATHS.values()): + assert (Path(temp_project_dir) / rel_path).exists(), rel_path + + +class TestSkillFlags: + """Test skill command flags and error handling.""" + + def test_list_agents(self, ee_runner, temp_project_dir): + result = ee_runner(["skill", "--list"], cwd=temp_project_dir) + assert result.returncode == 0 + for agent in AGENT_PATHS: + assert agent in result.stdout + # Listing must not create files. + assert not (Path(temp_project_dir) / "AGENTS.md").exists() + + def test_print_does_not_write(self, ee_runner, temp_project_dir): + result = ee_runner(["skill", "claude", "--print"], cwd=temp_project_dir) + assert result.returncode == 0 + assert "name: ee-usage" in result.stdout + assert not (Path(temp_project_dir) / AGENT_PATHS["claude"]).exists() + + def test_unknown_agent_fails(self, ee_runner, temp_project_dir): + result = ee_runner(["skill", "notanagent"], cwd=temp_project_dir, check=False) + assert result.returncode != 0 + assert "unknown agent" in result.stderr.lower() + + def test_no_agent_fails(self, ee_runner, temp_project_dir): + result = ee_runner(["skill"], cwd=temp_project_dir, check=False) + assert result.returncode != 0 + assert "no agent specified" in result.stderr.lower() + + def test_existing_file_requires_force(self, ee_runner, temp_project_dir): + ee_runner(["skill", "claude"], cwd=temp_project_dir) + + # Second run without --force should fail. + result = ee_runner(["skill", "claude"], cwd=temp_project_dir, check=False) + assert result.returncode != 0 + assert "already exists" in result.stderr.lower() + + # With --force it should succeed. + result = ee_runner(["skill", "claude", "--force"], cwd=temp_project_dir) + assert result.returncode == 0