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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:

- uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25.10"
cache: true

- name: Run tests
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ coverage/
/.tmp
.claude/
.playwright-mcp/
bin/
1 change: 1 addition & 0 deletions .structlint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ file_naming_pattern:
- "*.jpg"
- "*.svg"
- "*.gif"
- "*.woff2"
- "README*"
- "LICENSE*"
- "CHANGELOG*"
Expand Down
100 changes: 99 additions & 1 deletion COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,38 @@ Key flags:
| `--input`, `-i` | YAML/JSON values file |
| `--noCache`, `--nc` | Bypass cached GitHub/template data |

## `scan`

Report the placeholders in a directory or repo without writing anything — the
read-only entry point for scripts and agents.

```sh
yankrun scan --dir ./project
yankrun scan --dir ./project --json
yankrun scan --repo https://github.com/AxeForging/template-tester.git --json
```

`--json` prints a versioned envelope (see [Agent & automation](#agent--automation)).
The payload carries discovered keys, per-file counts, and the `yankrun.yaml`
manifest when present.

## `mcp`

Serve the templating engine over the Model Context Protocol (stdio) so agents
can drive it natively.

```sh
yankrun mcp
```

Tools: `yankrun_scan`, `yankrun_evaluate`, `yankrun_apply` (dry-run returns
per-file diffs), `yankrun_clone`, `yankrun_generate`, `yankrun_manifest`,
`yankrun_templates`. Register with an MCP client:

```json
{ "mcpServers": { "yankrun": { "command": "yankrun", "args": ["mcp"] } } }
```

## `serve`

Open the local web workbench.
Expand Down Expand Up @@ -119,7 +151,8 @@ yankrun tui --dir ./project --input values.yaml
yankrun tui --dir ./project --dryRun
```

The TUI scans, summarizes, previews replacement counts, and writes only when not in dry-run mode.
The TUI is a full-screen workbench (the terminal twin of `serve`): scan → fill
values → preview per-file diffs → apply. It requires an interactive terminal.

## `setup`

Expand All @@ -133,3 +166,68 @@ yankrun setup --reset

Use this to configure default delimiters, file size limits, template repos, and GitHub discovery.

## Agent & automation

`template`, `clone`, and `generate` (plus `scan`) are safe to run unattended.

| Feature | How |
|---------|-----|
| Machine-readable output | `--json` — one envelope on stdout, logs on stderr |
| Never prompt | `--yes` (alias `--no-input`); fails fast if a required value is missing |
| Values from stdin | `--input -` (YAML or JSON) |
| Values from the environment | `YANKRUN_VAR_<KEY>=value` |

Value precedence, lowest to highest: **manifest defaults < `--input` file < `YANKRUN_VAR_*` < interactive answers.**

The `--json` envelope is versioned and stable:

```json
{ "schemaVersion": 1, "command": "template", "ok": true, "data": { /* ApplyResult or Summary */ } }
```

On failure: `{ "schemaVersion": 1, "command": "...", "ok": false, "error": { "code": 3, "message": "..." } }`.

### Exit codes

| Code | Meaning |
|------|---------|
| 0 | success |
| 1 | unexpected internal error |
| 2 | usage error (bad flags / invalid invocation) |
| 3 | invalid input values or manifest violation (e.g. missing required in non-interactive mode) |
| 4 | directory, template, or branch not found |
| 5 | git clone / network failure |
| 130 | user cancelled an interactive prompt |

## Template manifest (`yankrun.yaml`)

A template repo may ship an optional `yankrun.yaml` at its root that declares the
variables it expects. It powers richer prompts (TUI, web, forms), pre-apply
validation, and agent self-discovery via `scan --json`. Templates without a
manifest keep working unchanged.

```yaml
version: 1
name: go-service
description: A sample Go microservice template
variables:
- key: APP_NAME
description: Human-friendly application name
required: true
- key: MODULE
description: Go module path
required: true
pattern: "^[a-z0-9./-]+$"
- key: ENV
default: dev
enum: [dev, staging, prod]
ignore_patterns:
- "*.secret"
post_generate:
hints:
- run go mod tidy
```

A `required` value that is missing in non-interactive mode, or a value that
violates an `enum` or `pattern`, exits with code 3.

39 changes: 39 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,45 @@ Constant: MY APP
URL slug: my-project
```

## Agent & scripting recipes

Discover what a template needs, then apply — no temp files, no prompts:

```sh
# 1. Learn the variables (and manifest) as JSON
yankrun scan --dir ./project --json | jq '.data.keys, .data.manifest.variables'

# 2. Apply values piped from stdin, machine-readable result, never prompts
echo '{"variables":[{"key":"APP_NAME","value":"Widget"}]}' \
| yankrun template --dir ./project --input - --yes --json

# 3. Or inject values from the environment
YANKRUN_VAR_APP_NAME=Widget YANKRUN_VAR_ENV=prod \
yankrun template --dir ./project --yes --json
```

Preview the exact edits without writing — the dry-run JSON carries per-file diffs:

```sh
yankrun template --dir ./project --input values.yaml --dryRun --json \
| jq -r '.data.summary.files[] | .diff'
```

Re-running is idempotent: once placeholders are replaced, a second apply reports
`0` matches and exits `0`.

### Drive it from an MCP agent

Register the stdio server with your MCP client (e.g. Claude Code):

```json
{ "mcpServers": { "yankrun": { "command": "yankrun", "args": ["mcp"] } } }
```

The agent can then call `yankrun_scan`, `yankrun_apply` (with `dryRun` for
diffs), `yankrun_clone`, `yankrun_generate`, `yankrun_manifest`, and
`yankrun_templates` directly.

## Save and reuse presets in `serve`

1. Run `yankrun serve --dir ./project --input values.yaml`.
Expand Down
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: all build clean version
.PHONY: all build build-local test clean version

GOOS_ARCH := linux/amd64 linux/arm64 linux/386 linux/arm darwin/amd64 darwin/arm64 windows/amd64 windows/arm64 windows/386
DIST_DIR := dist
Expand Down Expand Up @@ -34,6 +34,17 @@ build:
done
@echo "Build complete. Binaries in $(DIST_DIR)/"

# build-local builds a single binary for the host into bin/yankrun.
build-local:
@mkdir -p bin
go build $(LDFLAGS) -o bin/yankrun .
@echo "Built bin/yankrun ($(VERSION))"

# test runs the full suite with the race detector. Integration tests build and
# exercise the real binary and may reach the network (clone/generate).
test:
go test -race ./...

clean:
@echo "Cleaning build artifacts..."
rm -rf $(DIST_DIR)
Expand Down
19 changes: 15 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@

**Template smarter**: Clone repos, replace tokens, or template existing projects — safely, with custom delimiters that won't clash with Helm, Jinja, or any other template language.

![yankrun serve workbench: scan, fill placeholders, preview, apply](doc/serve-demo.gif)
<p align="center">
<img src="doc/serve-demo.gif" alt="yankrun serve workbench: scan, fill placeholders, preview, apply" width="880">
</p>

## TL;DR

Expand Down Expand Up @@ -49,8 +51,10 @@ yankrun template --dir ./project --input values.yaml --startDelim "<%" --endDeli
- **Transformation functions** (`toUpperCase`, `toLowerCase`, `gsub`)
- **Template file processing** (`.tpl` files processed and renamed)
- **Caching** for `generate` - caches GitHub repos and template variables in `~/.yankrun/cache.yaml`
- **Interactive workbench** (`serve`) for local/clone/generate workflows with file trees, evaluated transform previews, saved presets, JSON import/export, and in-browser custom delimiters
- **Safe terminal workflow** (`tui`) for preview-first directory templating
- **Interactive workbench** (`serve`) — brand-themed dark UI with file trees, evaluated transform previews, per-file dry-run diffs, saved presets, JSON import/export, and in-browser custom delimiters
- **Full-screen TUI** (`tui`) — the terminal twin of `serve`: scan → fill → preview diffs → apply
- **Optional `yankrun.yaml` manifest** — declare variables (description, default, required, enum, pattern) for validated prompts and agent self-discovery
- **Agent-friendly** — `scan` command, `--json` output, stdin/`YANKRUN_VAR_*` value injection, a stable exit-code taxonomy, and a `yankrun mcp` server that exposes the engine over the Model Context Protocol

## Documentation

Expand Down Expand Up @@ -631,10 +635,17 @@ yankrun template --dir ./project --input values.yaml \

## Exit Codes

Stable taxonomy — scripts and agents can branch on these.

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Error (invalid flags, clone failed, etc.) |
| `1` | Unexpected internal error |
| `2` | Usage error (bad flags / invalid invocation) |
| `3` | Invalid input values or manifest violation (e.g. missing required in non-interactive mode) |
| `4` | Directory, template, or branch not found |
| `5` | Git clone / network failure |
| `130` | User cancelled an interactive prompt |

---

Expand Down
109 changes: 109 additions & 0 deletions actions/apply.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package actions

import (
"fmt"
"os"

"github.com/AxeForging/yankrun/domain"
"github.com/AxeForging/yankrun/helpers"
"github.com/AxeForging/yankrun/internal/ui"
"github.com/AxeForging/yankrun/internal/workflow"
"github.com/AxeForging/yankrun/services"
)

// applyOptions configures a scan → resolve → validate → apply run over a
// prepared directory. It is shared by template, clone, and generate so all
// three honor the manifest, value precedence, and validation identically.
type applyOptions struct {
dir string
provided domain.InputReplacement
settings workflow.TemplateSettings
interactive bool // allow interactive prompting (human, TTY only)
dryRun bool
}

// runApply scans dir, resolves values by precedence (manifest defaults < file <
// env < prompts), validates them against the manifest, and applies (or previews
// on dryRun). All errors are exit-code tagged.
func runApply(engine workflow.Engine, opts applyOptions) (workflow.ApplyResult, *domain.Manifest, error) {
summary, err := engine.ScanDir(opts.dir, opts.settings, opts.provided)
if err != nil {
return workflow.ApplyResult{}, nil, err
}
if len(summary.Keys) == 0 {
return workflow.ApplyResult{Summary: summary}, summary.Manifest, nil
}

fileValues := valuesFromInput(opts.provided)
envValues := services.EnvValues()
answers := map[string]string{}
if opts.interactive && helpers.IsInteractive() {
base := workflow.ResolveValues(summary.Manifest, fileValues, envValues, nil)
ans, err := ui.PromptValues(summary.Manifest, summary.Keys, base)
if err != nil {
return workflow.ApplyResult{}, summary.Manifest, err
}
answers = ans
}
resolved := workflow.ResolveValues(summary.Manifest, fileValues, envValues, answers)

if err := services.ValidateValues(summary.Manifest, resolved); err != nil {
return workflow.ApplyResult{}, summary.Manifest, helpers.ValidationErr("%v", err)
}

result, err := engine.ApplyDir(opts.dir, opts.settings, domain.InputReplacement{}, resolved, opts.dryRun, false)
if err != nil {
return workflow.ApplyResult{}, summary.Manifest, err
}
return result, summary.Manifest, nil
}

// printApply renders a human-readable apply result: the placeholder summary,
// totals, dry-run diffs, and post-generate hints.
func printApply(result workflow.ApplyResult, manifest *domain.Manifest, dryRun bool) {
if len(result.Summary.Keys) == 0 {
helpers.Log.Info().Msg("No placeholders found.")
return
}
ui.PrintScanSummary(os.Stdout, result.Summary)
fmt.Fprintln(os.Stdout)
ui.PrintApplyResult(os.Stdout, result, dryRun)
if dryRun {
printDiffs(result)
}
if !dryRun && result.Applied {
ui.PrintHints(os.Stdout, manifest)
}
}

// parseInput parses a values file, treating an empty path as no input and "-"
// as stdin. Parse failures are validation errors (exit 3).
func parseInput(parser services.ReplacementParser, input string) (domain.InputReplacement, error) {
if input == "" {
return domain.InputReplacement{}, nil
}
parsed, err := parser.Parse(input)
if err != nil {
return domain.InputReplacement{}, helpers.ValidationErr("%v", err)
}
return parsed, nil
}

// valuesFromInput flattens an InputReplacement into a key/value map.
func valuesFromInput(in domain.InputReplacement) map[string]string {
values := map[string]string{}
for _, r := range in.Variables {
values[r.Key] = r.Value
}
return values
}

// printDiffs renders any per-file dry-run diffs attached to the result.
func printDiffs(result workflow.ApplyResult) {
for _, f := range result.Summary.Files {
if f.Diff != "" {
fmt.Fprintln(os.Stdout)
ui.PrintDiff(os.Stdout, f.Path, f.Diff)
}
}
}
Loading
Loading