alt/apps/cli/internal/cli/cli.go
toki d770a195ff feat: backtest scenario automation 구현
- cli: scenario 실행 명령어 추가
- operator: scenario 처리 로직 구현 및 테스트
2026-06-15 21:13:32 +09:00

156 lines
4.8 KiB
Go

// Package cli implements the ALT operator CLI dispatch. It is a thin command
// layer: argument parsing and exit-code/output contracts live here, while the
// scenario schema, validation, and run execution live in the operator package.
package cli
import (
"context"
"flag"
"fmt"
"io"
"git.toki-labs.com/toki/alt/apps/cli/internal/operator"
)
// Exit codes form the CLI contract consumed by scripts and tests.
const (
exitOK = 0 // success
exitUsage = 1 // usage or unknown-command error
exitParseError = 2 // scenario parse/validation/flag failure
)
// Run dispatches the operator CLI and returns a process exit code. stdout
// carries machine-readable status lines; stderr carries human-readable
// usage and error detail.
func Run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
usage(stderr)
return exitUsage
}
switch args[0] {
case "version":
fmt.Fprintln(stdout, "alt dev")
return exitOK
case "operator":
return runOperator(args[1:], stdout, stderr)
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
usage(stderr)
return exitUsage
}
}
// runOperator routes the `operator scenario <validate|run>` subcommands.
func runOperator(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "scenario" {
usageOperator(stderr)
return exitUsage
}
if len(args) < 2 {
usageOperator(stderr)
return exitUsage
}
switch args[1] {
case "validate":
return runScenarioValidate(args[2:], stdout, stderr)
case "run":
return runScenarioRun(args[2:], stdout, stderr)
default:
usageOperator(stderr)
return exitUsage
}
}
// runScenarioValidate handles `operator scenario validate --file <path>`.
func runScenarioValidate(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("operator scenario validate", flag.ContinueOnError)
fs.SetOutput(stderr)
file := fs.String("file", "", "path to operator scenario YAML file")
if err := fs.Parse(args); err != nil {
return exitUsage
}
if *file == "" {
fmt.Fprintln(stderr, "scenario file is required: --file <path>")
return exitUsage
}
sc, err := operator.LoadScenario(*file)
if err != nil {
fmt.Fprintln(stdout, "scenario=unknown status=parse_error")
fmt.Fprintf(stderr, "validate failed: %v\n", err)
return exitParseError
}
runs, err := sc.MatrixRuns()
if err != nil {
fmt.Fprintf(stderr, "validate failed: %v\n", err)
return exitParseError
}
if len(runs) > 0 {
fmt.Fprintf(stdout, "scenario=%s status=valid steps=%d matrix_runs=%d\n", sc.Name, len(sc.Steps), len(runs))
return exitOK
}
fmt.Fprintf(stdout, "scenario=%s status=valid steps=%d\n", sc.Name, len(sc.Steps))
return exitOK
}
// runScenarioRun handles `operator scenario run --file <path> --api-url <ws-url>`.
// It returns the runner's exit code (0 ok, 1 typed/expectation failure, 2
// parse/flag error, 3 transport failure).
func runScenarioRun(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("operator scenario run", flag.ContinueOnError)
fs.SetOutput(stderr)
file := fs.String("file", "", "path to operator scenario YAML file")
apiURL := fs.String("api-url", "", "API socket URL, e.g. ws://127.0.0.1:18030/socket")
timeout := fs.Duration("timeout", 0, "per-step request timeout; overrides the scenario timeout")
output := fs.String("output", string(operator.OutputText), "output format: text or jsonl")
if err := fs.Parse(args); err != nil {
return exitParseError
}
if *file == "" {
fmt.Fprintln(stderr, "scenario file is required: --file <path>")
return exitParseError
}
if *apiURL == "" {
fmt.Fprintln(stderr, "api url is required: --api-url <ws-url>")
return exitParseError
}
format := operator.OutputFormat(*output)
if format != operator.OutputText && format != operator.OutputJSONL {
fmt.Fprintf(stderr, "unknown output format %q (want text or jsonl)\n", *output)
return exitParseError
}
sc, err := operator.LoadScenario(*file)
if err != nil {
fmt.Fprintln(stdout, "scenario=unknown status=parse_error")
fmt.Fprintf(stderr, "run failed: %v\n", err)
return exitParseError
}
return operator.RunScenario(context.Background(), sc, operator.RunOptions{
APIURL: *apiURL,
Timeout: *timeout,
Format: format,
}, stdout)
}
// usage prints the top-level command summary.
func usage(w io.Writer) {
fmt.Fprintln(w, "usage: alt <command>")
fmt.Fprintln(w, "commands:")
fmt.Fprintln(w, " version")
fmt.Fprintln(w, " operator scenario validate --file <path>")
fmt.Fprintln(w, " operator scenario run --file <path> --api-url <ws-url> [--timeout <dur>] [--output text|jsonl]")
}
// usageOperator prints the operator subcommand summary.
func usageOperator(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " alt operator scenario validate --file <path>")
fmt.Fprintln(w, " alt operator scenario run --file <path> --api-url <ws-url> [--timeout <dur>] [--output text|jsonl]")
}