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
20 changes: 14 additions & 6 deletions docs/best-practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,9 @@ Minimal shape:

If you want the highest-value rollout, structure configs by pipeline stage:

- `cleanr-pr.yaml`: assertions, security, token optimization, light drift
- `cleanr-main.yaml`: adds trend tracking and moderate trend gates
- `cleanr-release.yaml`: adds full drift, load, chaos, replay artifacts, attestation, and `release_policy`
- `.cleanr/pr.yaml`: assertions, security, token optimization, light drift
- `.cleanr/main.yaml`: adds trend tracking and moderate trend gates
- `.cleanr/release.yaml`: adds full drift, load, chaos, replay artifacts, attestation, and `release_policy`

Reference examples live in:

Expand All @@ -124,9 +124,17 @@ Reference examples live in:
The same rollout is now available directly from the setup flow:

```bash
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output cleanr-pr.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output cleanr-main.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output cleanr-release.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output .cleanr/pr.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output .cleanr/main.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output .cleanr/release.yaml
```

Then select the desired stage at runtime:

```bash
cleanr run -profile pr
cleanr run -profile main
CLEANR_PROFILE=release cleanr validate
```

For agent-oriented configs:
Expand Down
2 changes: 1 addition & 1 deletion docs/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ cleanr setup agent --ci \

That generated config points to standard env var names instead of embedding credentials, so a user can set secrets once and run without editing the config.

`cleanr-connected.yml` also supports staged setup profiles via `CLEANR_PROFILE`:
`cleanr-connected.yml` also supports staged setup profiles via `CLEANR_PROFILE`. The same variable now selects staged local configs such as `.cleanr/pr.yaml`, `.cleanr/main.yaml`, and `.cleanr/release.yaml` for CLI commands like `cleanr run`, `cleanr validate`, and `cleanr snapshot`:

- `pr`: light drift, security, token optimization, exploratory trend gates
- `main`: retained trend history and moderate trend gates
Expand Down
14 changes: 11 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,17 @@ cleanr setup --ci -provider openai -model gpt-4.1-mini -output cleanr.yaml
For staged pipeline scaffolding:

```bash
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output cleanr-pr.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output cleanr-main.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output cleanr-release.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile pr -output .cleanr/pr.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile main -output .cleanr/main.yaml
cleanr setup --ci -provider openai -model gpt-4.1-mini -profile release -output .cleanr/release.yaml
```

When you keep staged configs under `.cleanr/`, select them with `-profile` or `CLEANR_PROFILE`:

```bash
cleanr validate -profile pr
cleanr run -profile main
CLEANR_PROFILE=release cleanr snapshot
```

If you prefer to start from checked-in examples instead of the setup flow, use one of:
Expand Down
12 changes: 8 additions & 4 deletions internal/cli/cli_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ func trendsCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("trends", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
trendFile := fs.String("trend-file", "", "Path to trend history file")
format := fs.String("format", "text", "Output format: text or json")
output := fs.String("output", "", "Optional output file")
Expand All @@ -29,7 +30,7 @@ func trendsCmd(args []string, stdout, stderr io.Writer) int {
return 2
}

trendPath, err := resolveTrendPath(*configPath, *trendFile)
trendPath, err := resolveTrendPath(*configPath, *profile, *trendFile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "trends error: %v\n", err)
return 2
Expand Down Expand Up @@ -81,14 +82,15 @@ func datasetExportCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("dataset export", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
replayPath := fs.String("replay-artifact", "", "Path to replay artifact file")
output := fs.String("output", "cleanr.dataset.yaml", "Path to write the exported scenario dataset")
includeAll := fs.Bool("all", false, "Include all scenarios instead of only reviewed replay failures")
if err := fs.Parse(args); err != nil {
return 2
}

resolvedConfigPath, err := resolveConfigPath(*configPath)
resolvedConfigPath, err := resolveConfigPath(*configPath, *profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "dataset export error: %v\n", err)
return 2
Expand Down Expand Up @@ -173,12 +175,13 @@ func pluginsCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("plugins", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
format := fs.String("format", "text", "Output format: text or json")
if err := fs.Parse(args); err != nil {
return 2
}

resolvedConfigPath, err := resolveConfigPath(*configPath)
resolvedConfigPath, err := resolveConfigPath(*configPath, *profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "plugins error: %v\n", err)
return 2
Expand Down Expand Up @@ -224,11 +227,12 @@ func validateCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("validate", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
if err := fs.Parse(args); err != nil {
return 2
}

resolvedConfigPath, err := resolveConfigPath(*configPath)
resolvedConfigPath, err := resolveConfigPath(*configPath, *profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "invalid: %v\n", err)
return 2
Expand Down
79 changes: 72 additions & 7 deletions internal/cli/cli_paths.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,34 @@ import (
"github.com/devr-tools/cleanr/cleanr"
)

func resolveConfigPath(configPath string) (string, error) {
if configPath != "" {
const configProfileEnvName = "CLEANR_PROFILE"

var defaultConfigCandidates = []string{"cleanr.json", "cleanr.yaml", "cleanr.yml"}

func resolveConfigPath(configPath, profile string) (string, error) {
if strings.TrimSpace(configPath) != "" {
return configPath, nil
}

candidates := []string{"cleanr.json", "cleanr.yaml", "cleanr.yml"}
for _, candidate := range candidates {
resolvedProfile, err := resolveConfigProfile(profile)
if err != nil {
return "", err
}
if resolvedProfile != "" {
return resolveStagedConfigPath(resolvedProfile)
}

for _, candidate := range defaultConfigCandidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}

return "", fmt.Errorf("no config file found; expected one of %s in %s", joinCandidates(candidates), mustGetwd())
err = fmt.Errorf("no config file found; expected one of %s in %s", joinCandidates(defaultConfigCandidates), mustGetwd())
if hasStagedConfigFiles() {
return "", fmt.Errorf("%w; found staged configs under .cleanr, rerun with -profile pr|main|release or set %s", err, configProfileEnvName)
}
return "", err
}

func joinCandidates(paths []string) string {
Expand All @@ -42,6 +57,47 @@ func mustGetwd() string {
return wd
}

func resolveConfigProfile(profile string) (string, error) {
profile = strings.ToLower(strings.TrimSpace(firstNonEmpty(profile, os.Getenv(configProfileEnvName))))
switch profile {
case "":
return "", nil
case "pr", "main", "release":
return profile, nil
default:
return "", fmt.Errorf("unsupported profile %q; expected pr, main, or release", profile)
}
}

func resolveStagedConfigPath(profile string) (string, error) {
candidates := []string{
filepath.Join(".cleanr", profile+".json"),
filepath.Join(".cleanr", profile+".yaml"),
filepath.Join(".cleanr", profile+".yml"),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("no config file found for profile %q; expected one of %s in %s", profile, strings.Join(candidates, ", "), mustGetwd())
}

func hasStagedConfigFiles() bool {
for _, profile := range []string{"pr", "main", "release"} {
for _, candidate := range []string{
filepath.Join(".cleanr", profile+".json"),
filepath.Join(".cleanr", profile+".yaml"),
filepath.Join(".cleanr", profile+".yml"),
} {
if _, err := os.Stat(candidate); err == nil {
return true
}
}
}
return false
}

func resolveConfigRelativePath(configPath, path string) string {
path = strings.TrimSpace(path)
if path == "" || filepath.IsAbs(path) {
Expand All @@ -50,11 +106,11 @@ func resolveConfigRelativePath(configPath, path string) string {
return filepath.Join(filepath.Dir(configPath), path)
}

func resolveTrendPath(configPath, explicitTrendPath string) (string, error) {
func resolveTrendPath(configPath, profile, explicitTrendPath string) (string, error) {
if strings.TrimSpace(explicitTrendPath) != "" {
return explicitTrendPath, nil
}
resolvedConfigPath, err := resolveConfigPath(configPath)
resolvedConfigPath, err := resolveConfigPath(configPath, profile)
if err != nil {
return "", err
}
Expand All @@ -77,3 +133,12 @@ func writeJSON(w io.Writer, value any) int {
}
return 0
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
10 changes: 7 additions & 3 deletions internal/cli/cli_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

type runOptions struct {
configPath string
profile string
format string
output string
trendFile string
Expand All @@ -30,7 +31,7 @@ func runCmd(args []string, stdout, stderr io.Writer) int {
return 2
}

resolvedConfigPath, err := resolveConfigPath(opts.configPath)
resolvedConfigPath, err := resolveConfigPath(opts.configPath, opts.profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "config error: %v\n", err)
return 2
Expand Down Expand Up @@ -81,6 +82,7 @@ func parseRunOptions(args []string, stderr io.Writer) (runOptions, error) {
fs.SetOutput(stderr)
opts := runOptions{}
fs.StringVar(&opts.configPath, "config", "", "Path to cleanr config")
fs.StringVar(&opts.profile, "profile", "", "Optional staged config profile: pr, main, or release")
fs.StringVar(&opts.format, "format", "", "Report format: text, json, junit")
fs.StringVar(&opts.output, "output", "", "Optional output file")
fs.StringVar(&opts.trendFile, "trend-file", "", "Optional trend history file")
Expand Down Expand Up @@ -201,13 +203,14 @@ func snapshotCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("snapshot", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
output := fs.String("output", "", "Path to write snapshot baseline")
timeout := fs.Duration("timeout", 0, "Overall execution timeout")
if err := fs.Parse(args); err != nil {
return 2
}

resolvedConfigPath, err := resolveConfigPath(*configPath)
resolvedConfigPath, err := resolveConfigPath(*configPath, *profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "config error: %v\n", err)
return 2
Expand Down Expand Up @@ -258,14 +261,15 @@ func generateCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("generate", flag.ContinueOnError)
fs.SetOutput(stderr)
configPath := fs.String("config", "", "Path to cleanr config")
profile := fs.String("profile", "", "Optional staged config profile: pr, main, or release")
output := fs.String("output", "", "Path to write the generated scenario dataset")
count := fs.Int("count", 0, "Optional override for scenario_generation.count")
timeout := fs.Duration("timeout", 0, "Overall execution timeout")
if err := fs.Parse(args); err != nil {
return 2
}

resolvedConfigPath, err := resolveConfigPath(*configPath)
resolvedConfigPath, err := resolveConfigPath(*configPath, *profile)
if err != nil {
_, _ = fmt.Fprintf(stderr, "generate error: %v\n", err)
return 2
Expand Down
Loading
Loading