package cli import ( "context" "errors" "flag" "fmt" "io" "net/http" "os" "path/filepath" "strings" "time" "gitea.maximumdirect.net/eric/promptkit" httpadapter "gitea.maximumdirect.net/eric/scriptorium/internal/adapter/http" appconfig "gitea.maximumdirect.net/eric/scriptorium/internal/config" "gitea.maximumdirect.net/eric/scriptorium/internal/defaults" renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format" ) const ( ExitOK = 0 ExitRuntimeError = 1 ExitValidationFailed = 2 ) const ( errPromptDirRequired = "prompt directory is required; provide --prompt-dir or config.yml prompt_dir" ) type runConfig struct { configPath string promptDir string profileDir string promptID string promptVersion string profileID string sessionID string inputRaw listFlag varRaw listFlag outputPath string llmBaseURL string apiKeyEnv string model string temperature float64 maxTokens int topP float64 reasoningEffort string schemaDir string backends []appconfig.BackendSettings timeout time.Duration defaultRenderFormat renderformat.PreparedRunOutputFormat llmBaseURLSet bool apiKeyEnvSet bool modelSet bool temperatureSet bool maxTokensSet bool topPSet bool reasoningEffortSet bool timeoutSet bool } type renderConfig struct { runConfig outputFormat renderformat.PreparedRunOutputFormat } type serveConfig struct { configPath string addr string promptDir string profileDir string schemaDir string artifactRoot string maxRequestBytes int64 maxArtifactBytes int64 maxResponseBytes int64 backends []appconfig.BackendSettings } type commonCommandSettings struct { promptDir string profileDir string schemaDir string serverAddr string artifactRoot string maxRequestBytes int64 maxArtifactBytes int64 maxResponseBytes int64 defaultRenderFormat renderformat.PreparedRunOutputFormat backends []appconfig.BackendSettings } type listFlag []string func (l *listFlag) String() string { return strings.Join(*l, ",") } func (l *listFlag) Set(v string) error { *l = append(*l, v) return nil } func Run(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { printUsage(stderr) return ExitRuntimeError } switch args[0] { case "run": return runCommand(args[1:], stdout, stderr) case "render": return renderCommand(args[1:], stdout, stderr) case "serve": return serveCommand(args[1:], stderr) case "inspect": return inspectCommand(args[1:], stdout, stderr) default: fmt.Fprintf(stderr, "unknown command %q\n", args[0]) printUsage(stderr) return ExitRuntimeError } } func runCommand(args []string, stdout, stderr io.Writer) int { cfg, err := parseRunArgs(args) if err != nil { fmt.Fprintf(stderr, "run parse error: %v\n", err) return ExitRuntimeError } req, err := buildRunRequestFromConfig(cfg) if err != nil { fmt.Fprintf(stderr, "run parse error: %v\n", err) return ExitRuntimeError } engine, err := newEngine(cfg.engineSettings()) if err != nil { fmt.Fprintf(stderr, "engine error: %v\n", err) return ExitRuntimeError } res, runErr := engine.Run(context.Background(), req) if runErr != nil { fmt.Fprintln(stderr, runErrorMessage(runErr)) return ExitRuntimeError } if err := writeOutput(stdout, cfg.outputPath, res.Artifact.Body); err != nil { fmt.Fprintf(stderr, "output write error: %v\n", err) return ExitRuntimeError } printSummary(stderr, res) return determineExitCode(nil, res) } func renderCommand(args []string, stdout, stderr io.Writer) int { cfg, err := parseRenderArgs(args) if err != nil { fmt.Fprintf(stderr, "render parse error: %v\n", err) return ExitRuntimeError } req, err := buildRunRequestFromConfig(&cfg.runConfig) if err != nil { fmt.Fprintf(stderr, "render parse error: %v\n", err) return ExitRuntimeError } engine, err := newEngine(cfg.runConfig.engineSettings()) if err != nil { fmt.Fprintf(stderr, "engine error: %v\n", err) return ExitRuntimeError } prepared, prepErr := engine.Prepare(context.Background(), req) if prepErr != nil { fmt.Fprintf(stderr, "render error: %v\n", prepErr) return ExitRuntimeError } out, err := renderformat.FormatPreparedRun(prepared, cfg.outputFormat) if err != nil { fmt.Fprintf(stderr, "render format error: %v\n", err) return ExitRuntimeError } if err := writeOutput(stdout, cfg.outputPath, out); err != nil { fmt.Fprintf(stderr, "output write error: %v\n", err) return ExitRuntimeError } return ExitOK } func serveCommand(args []string, stderr io.Writer) int { cfg, err := parseServeArgs(args) if err != nil { fmt.Fprintf(stderr, "serve parse error: %v\n", err) return ExitRuntimeError } artifactReader, err := httpadapter.NewRestrictedArtifactReader(cfg.artifactRoot, cfg.maxArtifactBytes) if err != nil { fmt.Fprintf(stderr, "artifact root error: %v\n", err) return ExitRuntimeError } engine, err := newEngine(cfg.engineSettings(), promptkit.WithArtifactReader(artifactReader)) if err != nil { fmt.Fprintf(stderr, "engine error: %v\n", err) return ExitRuntimeError } h := httpadapter.NewHandlerWithOptions(engine, httpadapter.HandlerOptions{ MaxRequestBytes: cfg.maxRequestBytes, MaxResponseBytes: cfg.maxResponseBytes, }) srv := &http.Server{ Addr: cfg.addr, Handler: h, ReadHeaderTimeout: defaults.HTTPReadHeaderTimeoutDefault, } fmt.Fprintf(stderr, "serving on %s\n", cfg.addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { fmt.Fprintf(stderr, "server error: %v\n", err) return ExitRuntimeError } return ExitOK } func parseRunArgs(args []string) (*runConfig, error) { cfg := &runConfig{} fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.SetOutput(io.Discard) registerConfigPathFlag(fs, &cfg.configPath) registerExecutionRequestFlags(fs, cfg) fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") if err := fs.Parse(args); err != nil { return nil, err } if err := finalizeExecutionRequestConfig(fs, cfg); err != nil { return nil, err } cfg.schemaDir = filepath.Clean(cfg.schemaDir) return cfg, nil } func parseRenderArgs(args []string) (*renderConfig, error) { cfg := &renderConfig{ outputFormat: renderformat.DefaultPreparedRunOutputFormat, } fs := flag.NewFlagSet("render", flag.ContinueOnError) fs.SetOutput(io.Discard) registerConfigPathFlag(fs, &cfg.configPath) registerExecutionRequestFlags(fs, &cfg.runConfig) var rawFormat string fs.StringVar(&rawFormat, "format", "", "render output format (text|json)") if err := fs.Parse(args); err != nil { return nil, err } if err := finalizeExecutionRequestConfig(fs, &cfg.runConfig); err != nil { return nil, err } if flagWasSet(fs, "format") { format, err := renderformat.ParsePreparedRunOutputFormat(rawFormat) if err != nil { return nil, err } cfg.outputFormat = format } else { cfg.outputFormat = cfg.defaultRenderFormat } return cfg, nil } func parseServeArgs(args []string) (*serveConfig, error) { cfg := &serveConfig{} fs := flag.NewFlagSet("serve", flag.ContinueOnError) fs.SetOutput(io.Discard) registerConfigPathFlag(fs, &cfg.configPath) fs.StringVar(&cfg.addr, "addr", "", "HTTP listen address") fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") fs.StringVar(&cfg.schemaDir, "schema-dir", "", "base directory for validation schemas") fs.StringVar(&cfg.artifactRoot, "artifact-root", "", "base directory for HTTP file input artifacts") fs.Int64Var(&cfg.maxRequestBytes, "max-request-bytes", 0, "maximum HTTP request body bytes; 0 disables the limit") fs.Int64Var(&cfg.maxArtifactBytes, "max-artifact-bytes", 0, "maximum HTTP file artifact bytes; 0 disables the limit") fs.Int64Var(&cfg.maxResponseBytes, "max-response-bytes", 0, "maximum HTTP response body bytes; 0 disables the limit") if err := fs.Parse(args); err != nil { return nil, err } if fs.NArg() > 0 { return nil, fmt.Errorf("unexpected positional args: %v", fs.Args()) } settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{ PromptDir: cfg.promptDirIfSet(fs), ProfileDir: cfg.profileDirIfSet(fs), SchemaDir: cfg.schemaDirIfSet(fs), ServerAddr: cfg.addrIfSet(fs), ArtifactRoot: cfg.artifactRootIfSet(fs), MaxRequestBytes: cfg.maxRequestBytesIfSet(fs), MaxArtifactBytes: cfg.maxArtifactBytesIfSet(fs), MaxResponseBytes: cfg.maxResponseBytesIfSet(fs), }) if err != nil { return nil, err } cfg.promptDir = settings.promptDir cfg.profileDir = settings.profileDir cfg.schemaDir = settings.schemaDir cfg.addr = settings.serverAddr cfg.artifactRoot = settings.artifactRoot cfg.maxRequestBytes = settings.maxRequestBytes cfg.maxArtifactBytes = settings.maxArtifactBytes cfg.maxResponseBytes = settings.maxResponseBytes cfg.backends = settings.backends if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil { return nil, err } cfg.promptDir = filepath.Clean(cfg.promptDir) if strings.TrimSpace(cfg.profileDir) != "" { cfg.profileDir = filepath.Clean(cfg.profileDir) } cfg.schemaDir = filepath.Clean(cfg.schemaDir) if strings.TrimSpace(cfg.artifactRoot) != "" { cfg.artifactRoot = filepath.Clean(cfg.artifactRoot) } return cfg, nil } func registerExecutionRequestFlags(fs *flag.FlagSet, cfg *runConfig) { fs.StringVar(&cfg.promptDir, "prompt-dir", "", "directory containing prompt definition YAML files") fs.StringVar(&cfg.profileDir, "profile-dir", "", "directory containing execution profile YAML files") fs.StringVar(&cfg.promptID, "prompt", "", "prompt ID to run") fs.StringVar(&cfg.promptVersion, "prompt-version", "", "optional prompt definition version") fs.StringVar(&cfg.profileID, "profile", "", "optional execution profile ID; if omitted, prompt default_profile is used") fs.StringVar(&cfg.sessionID, "session-id", "", "optional session ID") fs.Var(&cfg.inputRaw, "input", "input mapping(s): name=path (repeatable, comma-separated)") fs.Var(&cfg.varRaw, "var", "variable mapping(s): name=value (repeatable, comma-separated)") fs.StringVar(&cfg.outputPath, "out", "", "optional output file path") fs.StringVar(&cfg.llmBaseURL, "llm-base-url", "", "OpenAI-compatible base URL including /v1") fs.StringVar(&cfg.apiKeyEnv, "api-key-env", "", "environment variable name containing API key") fs.StringVar(&cfg.model, "model", "", "model name") fs.Float64Var(&cfg.temperature, "temperature", 0, "optional temperature override") fs.IntVar(&cfg.maxTokens, "max-tokens", 0, "optional max tokens override") fs.Float64Var(&cfg.topP, "top-p", 0, "optional top_p override") fs.StringVar(&cfg.reasoningEffort, "reasoning-effort", "", "optional reasoning effort override") fs.DurationVar(&cfg.timeout, "timeout", 0, "LLM request timeout") fs.StringVar(&cfg.promptID, "prompt-id", "", "deprecated alias for --prompt") fs.StringVar(&cfg.profileID, "profile-id", "", "deprecated alias for --profile") } func finalizeExecutionRequestConfig(fs *flag.FlagSet, cfg *runConfig) error { if fs.NArg() > 0 { return fmt.Errorf("unexpected positional args: %v", fs.Args()) } settings, err := resolveCommonSettings(fs, cfg.configPath, appconfig.CLIOverrides{ PromptDir: cfg.promptDirIfSet(fs), ProfileDir: cfg.profileDirIfSet(fs), SchemaDir: cfg.schemaDirIfSet(fs), }) if err != nil { return err } cfg.promptDir = settings.promptDir cfg.profileDir = settings.profileDir cfg.schemaDir = settings.schemaDir cfg.defaultRenderFormat = settings.defaultRenderFormat cfg.backends = settings.backends if err := validateRequiredLibraryDirs(cfg.promptDir); err != nil { return err } if strings.TrimSpace(cfg.promptID) == "" { return errors.New("--prompt is required") } cfg.promptDir = filepath.Clean(cfg.promptDir) if strings.TrimSpace(cfg.profileDir) != "" { cfg.profileDir = filepath.Clean(cfg.profileDir) } if cfg.outputPath != "" { cfg.outputPath = filepath.Clean(cfg.outputPath) } cfg.llmBaseURLSet = flagWasSet(fs, "llm-base-url") cfg.apiKeyEnvSet = flagWasSet(fs, "api-key-env") cfg.modelSet = flagWasSet(fs, "model") cfg.temperatureSet = flagWasSet(fs, "temperature") cfg.maxTokensSet = flagWasSet(fs, "max-tokens") cfg.topPSet = flagWasSet(fs, "top-p") cfg.reasoningEffortSet = flagWasSet(fs, "reasoning-effort") cfg.timeoutSet = flagWasSet(fs, "timeout") return nil } func (c *runConfig) promptDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "prompt-dir") { return c.promptDir } return "" } func (c *runConfig) profileDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "profile-dir") { return c.profileDir } return "" } func (c *runConfig) schemaDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "schema-dir") { return c.schemaDir } return "" } func (c *serveConfig) promptDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "prompt-dir") { return c.promptDir } return "" } func (c *serveConfig) profileDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "profile-dir") { return c.profileDir } return "" } func (c *serveConfig) schemaDirIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "schema-dir") { return c.schemaDir } return "" } func (c *serveConfig) addrIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "addr") { return c.addr } return "" } func (c *serveConfig) artifactRootIfSet(fs *flag.FlagSet) string { if flagWasSet(fs, "artifact-root") { return c.artifactRoot } return "" } func (c *serveConfig) maxRequestBytesIfSet(fs *flag.FlagSet) *int64 { if flagWasSet(fs, "max-request-bytes") { return &c.maxRequestBytes } return nil } func (c *serveConfig) maxArtifactBytesIfSet(fs *flag.FlagSet) *int64 { if flagWasSet(fs, "max-artifact-bytes") { return &c.maxArtifactBytes } return nil } func (c *serveConfig) maxResponseBytesIfSet(fs *flag.FlagSet) *int64 { if flagWasSet(fs, "max-response-bytes") { return &c.maxResponseBytes } return nil } func registerConfigPathFlag(fs *flag.FlagSet, target *string) { fs.StringVar( target, "config", "", fmt.Sprintf( "application config path (default search: %s, then %s)", appconfig.DefaultConfigPathLocal, appconfig.DefaultConfigPath, ), ) } func resolveAppSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (appconfig.AppSettings, error) { settings, err := appconfig.LoadConfig(configPath, flagWasSet(fs, "config")) if err != nil { return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err) } merged, err := appconfig.ApplyCLIOverrides(settings, overrides) if err != nil { return appconfig.AppSettings{}, fmt.Errorf("application config error: %w", err) } return merged, nil } func resolveCommonSettings(fs *flag.FlagSet, configPath string, overrides appconfig.CLIOverrides) (commonCommandSettings, error) { settings, err := resolveAppSettings(fs, configPath, overrides) if err != nil { return commonCommandSettings{}, err } return commonCommandSettings{ promptDir: settings.PromptDir, profileDir: settings.ProfileDir, schemaDir: settings.SchemaDir, serverAddr: settings.ServerAddr, artifactRoot: settings.ArtifactRoot, maxRequestBytes: settings.MaxRequestBytes, maxArtifactBytes: settings.MaxArtifactBytes, maxResponseBytes: settings.MaxResponseBytes, defaultRenderFormat: settings.DefaultRenderFormat, backends: settings.Backends, }, nil } func validateRequiredLibraryDirs(promptDir string) error { if strings.TrimSpace(promptDir) == "" { return errors.New(errPromptDirRequired) } return nil } type engineSettings struct { promptDir string profileDir string schemaDir string backends []appconfig.BackendSettings } func (c runConfig) engineSettings() engineSettings { return engineSettings{promptDir: c.promptDir, profileDir: c.profileDir, schemaDir: c.schemaDir, backends: c.backends} } func (c serveConfig) engineSettings() engineSettings { return engineSettings{promptDir: c.promptDir, profileDir: c.profileDir, schemaDir: c.schemaDir, backends: c.backends} } func newEngine(settings engineSettings, options ...promptkit.Option) (*promptkit.Engine, error) { engineOptions := make([]promptkit.Option, 0, len(settings.backends)+len(options)) for _, configured := range settings.backends { engineOptions = append(engineOptions, promptkit.WithBackend(promptkit.Backend{ ID: configured.ID, Endpoint: configured.Endpoint, APIKeyEnv: configured.APIKeyEnv, ExtraParams: configured.ExtraParams, ConcurrencyLimit: configured.ConcurrencyLimit, QueueCapacity: configured.QueueCapacity, })) } engineOptions = append(engineOptions, options...) engine, err := promptkit.NewEngine(promptkit.Config{ PromptDir: settings.promptDir, ProfileDir: settings.profileDir, SchemaDir: settings.schemaDir, }, engineOptions...) if err != nil { return nil, fmt.Errorf("engine initialization from application configuration: %w", err) } return engine, nil } func buildRunRequestFromConfig(cfg *runConfig) (promptkit.RunRequest, error) { var inputMappings map[string]string var err error if len(cfg.inputRaw) > 0 { inputMappings, err = parseMappings(cfg.inputRaw, false) if err != nil { return promptkit.RunRequest{}, fmt.Errorf("input parse error: %w", err) } } varMappings := map[string]string{} if len(cfg.varRaw) > 0 { varMappings, err = parseMappings(cfg.varRaw, false) if err != nil { return promptkit.RunRequest{}, fmt.Errorf("var parse error: %w", err) } } var inputs map[string]promptkit.ArtifactRef if len(inputMappings) > 0 { inputs = make(map[string]promptkit.ArtifactRef, len(inputMappings)) for name, path := range inputMappings { inputs[name] = promptkit.File(path) } } var modelOverride *promptkit.ExecutionTargetOverride if cfg.llmBaseURLSet || cfg.modelSet || cfg.temperatureSet || cfg.maxTokensSet || cfg.topPSet || cfg.reasoningEffortSet || cfg.apiKeyEnvSet || cfg.timeoutSet { modelOverride = &promptkit.ExecutionTargetOverride{ Endpoint: cfg.llmBaseURL, Model: cfg.model, APIKeyEnv: cfg.apiKeyEnv, } if cfg.temperatureSet { modelOverride.Temperature = &cfg.temperature } if cfg.maxTokensSet { modelOverride.MaxTokens = &cfg.maxTokens } if cfg.topPSet { modelOverride.TopP = &cfg.topP } if cfg.reasoningEffortSet { modelOverride.ReasoningEffort = &cfg.reasoningEffort } if cfg.timeoutSet { timeoutSeconds := int(cfg.timeout.Seconds()) modelOverride.TimeoutSeconds = &timeoutSeconds } } return promptkit.RunRequest{ PromptID: cfg.promptID, PromptVersion: cfg.promptVersion, ProfileID: cfg.profileID, SessionID: cfg.sessionID, Inputs: inputs, Vars: varMappings, Execution: modelOverride, }, nil } func parseMappings(raw []string, allowEmptyValue bool) (map[string]string, error) { out := make(map[string]string) for _, entry := range raw { for _, piece := range strings.Split(entry, ",") { piece = strings.TrimSpace(piece) if piece == "" { continue } key, value, err := parseMapping(piece) if err != nil { return nil, err } if !allowEmptyValue && strings.TrimSpace(value) == "" { return nil, fmt.Errorf("mapping %q has empty value", piece) } out[key] = value } } if len(out) == 0 { return nil, errors.New("no valid mappings provided") } return out, nil } func parseMapping(value string) (string, string, error) { idx := strings.IndexRune(value, '=') if idx <= 0 { return "", "", fmt.Errorf("invalid mapping %q, expected name=value", value) } key := strings.TrimSpace(value[:idx]) val := strings.TrimSpace(value[idx+1:]) if key == "" { return "", "", fmt.Errorf("invalid mapping %q, empty name", value) } return key, val, nil } func flagWasSet(fs *flag.FlagSet, name string) bool { set := false fs.Visit(func(f *flag.Flag) { if f.Name == name { set = true } }) return set } func writeOutput(stdout io.Writer, outputPath string, body []byte) error { if outputPath == "" { _, err := stdout.Write(body) return err } return os.WriteFile(outputPath, body, 0644) } func determineExitCode(runErr error, result *promptkit.RunResult) int { if runErr != nil { return ExitRuntimeError } if result != nil && result.Validation.Status == promptkit.ValidationFailed { return ExitValidationFailed } return ExitOK } func printSummary(stderr io.Writer, res *promptkit.RunResult) { if res == nil { return } fmt.Fprintf(stderr, "prompt=%s@%s selected_profile=%s model=%s validation=%s mode=%s validation_errors=%d prompt_hash=%s inputs=%d usage=%d/%d/%d", res.PromptID, res.PromptVersion, res.SelectedProfileID, res.ModelName, res.Validation.Status, res.Validation.Mode, len(res.Validation.Errors), res.RenderedPromptHash, len(res.InputHashes), res.Usage.PromptTokens, res.Usage.CompletionTokens, res.Usage.TotalTokens, ) if res.Usage.CachedTokens != 0 || res.Usage.CacheWriteTokens != 0 { fmt.Fprintf(stderr, " cached_tokens=%d cache_write_tokens=%d", res.Usage.CachedTokens, res.Usage.CacheWriteTokens) } if res.SelectedBackendID != "" { fmt.Fprintf(stderr, " backend=%s", res.SelectedBackendID) } fmt.Fprintln(stderr) } func runErrorMessage(err error) string { if errors.Is(err, promptkit.ErrCapacityExceeded) { return "run error: model backend capacity is exhausted" } return fmt.Sprintf("run error: %v", err) } func printUsage(w io.Writer) { fmt.Fprintln(w, "usage: scriptorium ...") fmt.Fprintln(w, " run: scriptorium run [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--out path] [--timeout 10m]") fmt.Fprintln(w, " render: scriptorium render [--config PATH] [--prompt-dir DIR] [--profile-dir DIR] --prompt ID [--prompt-version VERSION] [--input name=path] [--profile ID] [--session-id ID] [--llm-base-url URL] [--model NAME] [--api-key-env ENV] [--temperature N] [--max-tokens N] [--top-p N] [--reasoning-effort VALUE] [--var k=v] [--format text|json] [--out path] [--timeout 10m]") fmt.Fprintf(w, " serve: scriptorium serve [--config PATH] [--addr %s] [--prompt-dir DIR] [--profile-dir DIR] [--schema-dir DIR] [--artifact-root DIR] [--max-request-bytes N] [--max-artifact-bytes N] [--max-response-bytes N]\n", defaults.HTTPAddrDefault) fmt.Fprintln(w, " inspect prompt: scriptorium inspect prompt --prompt ID [--prompt-version VERSION] [--config PATH] [--prompt-dir DIR] [--format text|json] [--out path]") fmt.Fprintln(w, " inspect profile: scriptorium inspect profile --profile ID [--config PATH] [--profile-dir DIR] [--format text|json] [--out path]") }