diff --git a/cmd/obol/input.go b/cmd/obol/input.go new file mode 100644 index 0000000..fc611aa --- /dev/null +++ b/cmd/obol/input.go @@ -0,0 +1,31 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" +) + +// readJSONInput reads JSON from a file path or stdin (when path is "-"). +// Returns the raw bytes for the caller to unmarshal as needed. +func readJSONInput(path string) ([]byte, error) { + var data []byte + var err error + + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, fmt.Errorf("read JSON input: %w", err) + } + + // Validate that it's valid JSON. + if !json.Valid(data) { + return nil, fmt.Errorf("input is not valid JSON") + } + + return data, nil +} diff --git a/cmd/obol/model.go b/cmd/obol/model.go index 3ec644c..04f18ba 100644 --- a/cmd/obol/model.go +++ b/cmd/obol/model.go @@ -1,7 +1,6 @@ package main import ( - "bufio" "context" "fmt" "os" @@ -312,11 +311,12 @@ func modelPullCommand() *cli.Command { Usage: "Pull an Ollama model to the local machine", ArgsUsage: "[model]", Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) modelName := cmd.Args().First() if modelName == "" { var err error - modelName, err = promptModelPull() + modelName, err = promptModelPull(u) if err != nil { return err } @@ -504,48 +504,41 @@ func detectCredentials() map[string]detectedCredential { } // promptModelPull interactively asks the user which Ollama model to pull. -func promptModelPull() (string, error) { - type suggestion struct { - name string - size string - desc string - } - suggestions := []suggestion{ - {"qwen3.5:4b", "2.7 GB", "Fast general-purpose (recommended)"}, - {"qwen2.5-coder:7b", "4.7 GB", "Code generation"}, - {"deepseek-r1:8b", "4.9 GB", "Reasoning"}, - {"gemma3:4b", "3.3 GB", "Lightweight, multilingual"}, +// When the UI is non-interactive (piped, CI, or JSON mode), it returns an +// error instructing the user to specify --model via flag. +func promptModelPull(u *ui.UI) (string, error) { + if !u.IsTTY() || u.IsJSON() { + return "", fmt.Errorf("model name required: use positional arg (obol model pull )") } - reader := bufio.NewReader(os.Stdin) - - fmt.Println("Popular models:") - fmt.Println() - for i, s := range suggestions { - fmt.Printf(" [%d] %-25s (%s) — %s\n", i+1, s.name, s.size, s.desc) + suggestions := []string{ + "qwen3.5:4b (2.7 GB) — Fast general-purpose (recommended)", + "qwen2.5-coder:7b (4.7 GB) — Code generation", + "deepseek-r1:8b (4.9 GB) — Reasoning", + "gemma3:4b (3.3 GB) — Lightweight, multilingual", + "Other (enter name)", } - fmt.Printf(" [%d] Other (enter name)\n", len(suggestions)+1) - fmt.Printf("\nChoice [1]: ") - - line, _ := reader.ReadString('\n') - choice := strings.TrimSpace(line) - if choice == "" { - choice = "1" + modelNames := []string{ + "qwen3.5:4b", + "qwen2.5-coder:7b", + "deepseek-r1:8b", + "gemma3:4b", } - idx := 0 - if _, err := fmt.Sscanf(choice, "%d", &idx); err != nil || idx < 1 || idx > len(suggestions)+1 { - return "", fmt.Errorf("invalid choice: %s", choice) + idx, err := u.Select("Select a model to pull:", suggestions, 0) + if err != nil { + return "", err } - if idx <= len(suggestions) { - return suggestions[idx-1].name, nil + if idx < len(modelNames) { + return modelNames[idx], nil } // Custom model name - fmt.Printf("Model name (e.g. mistral:7b): ") - name, _ := reader.ReadString('\n') - name = strings.TrimSpace(name) + name, err := u.Input("Model name (e.g. mistral:7b)", "") + if err != nil { + return "", err + } if name == "" { return "", fmt.Errorf("model name is required") } diff --git a/cmd/obol/network.go b/cmd/obol/network.go index 4a20cf4..d02a414 100644 --- a/cmd/obol/network.go +++ b/cmd/obol/network.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "slices" "sort" @@ -10,6 +11,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/embed" "github.com/ObolNetwork/obol-stack/internal/network" + "github.com/ObolNetwork/obol-stack/internal/validate" "github.com/urfave/cli/v3" ) @@ -292,8 +294,70 @@ Examples: Name: "allow-writes", Usage: "Allow write methods (eth_sendRawTransaction, eth_sendTransaction)", }, + &cli.StringFlag{ + Name: "from-json", + Usage: "Read network config from JSON file (or - for stdin) instead of flags", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + // --from-json: read network add config from file/stdin. + if jsonPath := cmd.String("from-json"); jsonPath != "" { + data, err := readJSONInput(jsonPath) + if err != nil { + return err + } + var netCfg struct { + Chain string `json:"chain"` + Endpoint string `json:"endpoint"` + AllowWrites bool `json:"allowWrites"` + } + if err := json.Unmarshal(data, &netCfg); err != nil { + return fmt.Errorf("parse JSON network config: %w", err) + } + if netCfg.Chain == "" { + return fmt.Errorf("chain is required in JSON input") + } + + chainID, chainName, err := network.ResolveChainID(netCfg.Chain) + if err != nil { + return err + } + + readOnly := !netCfg.AllowWrites + + if netCfg.Endpoint != "" { + fmt.Printf("Adding custom RPC for %s (chain ID: %d): %s\n", chainName, chainID, netCfg.Endpoint) + if err := network.AddCustomRPC(cfg, chainID, chainName, netCfg.Endpoint, readOnly); err != nil { + return fmt.Errorf("failed to add custom RPC: %w", err) + } + fmt.Printf("Added custom RPC for %s (chain ID: %d) to eRPC\n", chainName, chainID) + return nil + } + + // ChainList mode with defaults. + maxCount := 3 + fmt.Printf("Fetching public RPCs for %s (chain ID: %d) from ChainList...\n", chainName, chainID) + endpoints, displayName, err := network.FetchChainListRPCs(chainID, nil) + if err != nil { + return fmt.Errorf("failed to fetch RPCs: %w", err) + } + if len(endpoints) == 0 { + return fmt.Errorf("no free public RPCs found for chain ID %d", chainID) + } + if len(endpoints) > maxCount { + endpoints = endpoints[:maxCount] + } + if displayName != "" { + chainName = displayName + } + fmt.Printf("Found %d quality RPCs for %s\n", len(endpoints), chainName) + if err := network.AddPublicRPCs(cfg, chainID, chainName, endpoints, readOnly); err != nil { + return fmt.Errorf("failed to add RPCs: %w", err) + } + fmt.Printf("Added %d RPCs for %s (chain ID: %d) to eRPC\n", len(endpoints), chainName, chainID) + return nil + } + if cmd.NArg() == 0 { return fmt.Errorf("chain name or ID required\n\nExamples:\n obol network add base\n obol network add base-sepolia --endpoint http://host.k3d.internal:8545") } @@ -308,6 +372,9 @@ Examples: // Custom endpoint mode. if endpoint := cmd.String("endpoint"); endpoint != "" { + if err := validate.URL(endpoint); err != nil { + return fmt.Errorf("invalid --endpoint: %w", err) + } fmt.Printf("Adding custom RPC for %s (chain ID: %d): %s\n", chainName, chainID, endpoint) if readOnly { fmt.Printf(" Write methods blocked (use --allow-writes to enable)\n") diff --git a/cmd/obol/openclaw.go b/cmd/obol/openclaw.go index e403f8d..3b16482 100644 --- a/cmd/obol/openclaw.go +++ b/cmd/obol/openclaw.go @@ -34,12 +34,13 @@ func openclawCommand(cfg *config.Config) *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + u := getUI(cmd) return openclaw.Onboard(cfg, openclaw.OnboardOptions{ ID: cmd.String("id"), Force: cmd.Bool("force"), Sync: !cmd.Bool("no-sync"), - Interactive: true, - }, getUI(cmd)) + Interactive: u.IsTTY() && !u.IsJSON(), + }, u) }, }, { diff --git a/cmd/obol/sell.go b/cmd/obol/sell.go index 6ba3fec..a14024c 100644 --- a/cmd/obol/sell.go +++ b/cmd/obol/sell.go @@ -27,6 +27,7 @@ import ( "github.com/ObolNetwork/obol-stack/internal/schemas" "github.com/ObolNetwork/obol-stack/internal/stack" "github.com/ObolNetwork/obol-stack/internal/tee" + "github.com/ObolNetwork/obol-stack/internal/validate" "github.com/ObolNetwork/obol-stack/internal/tunnel" "github.com/ObolNetwork/obol-stack/internal/ui" x402verifier "github.com/ObolNetwork/obol-stack/internal/x402" @@ -168,6 +169,9 @@ Examples: return fmt.Errorf("name required: obol sell inference --wallet ") } } + if err := validate.Name(name); err != nil { + return err + } wallet := cmd.String("wallet") if wallet == "" { @@ -404,9 +408,57 @@ Example: Name: "register-domains", Usage: "OASF domains for discovery (e.g. technology/artificial_intelligence)", }, + &cli.StringFlag{ + Name: "from-json", + Usage: "Read ServiceOffer spec from JSON file (or - for stdin) instead of flags", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { u := getUI(cmd) + + // --from-json: read spec from file/stdin and apply directly. + if jsonPath := cmd.String("from-json"); jsonPath != "" { + data, err := readJSONInput(jsonPath) + if err != nil { + return err + } + var spec map[string]interface{} + if err := json.Unmarshal(data, &spec); err != nil { + return fmt.Errorf("parse JSON spec: %w", err) + } + + name := cmd.Args().First() + if name == "" { + // Try metadata.name from the JSON if it looks like a full manifest. + if md, ok := spec["metadata"].(map[string]interface{}); ok { + if n, ok := md["name"].(string); ok { + name = n + } + } + } + if name == "" { + return fmt.Errorf("name required: provide as positional arg or metadata.name in JSON") + } + + ns := cmd.String("namespace") + + manifest := map[string]interface{}{ + "apiVersion": "obol.org/v1alpha1", + "kind": "ServiceOffer", + "metadata": map[string]interface{}{ + "name": name, + "namespace": ns, + }, + "spec": spec, + } + + if err := kubectlApply(cfg, manifest); err != nil { + return err + } + fmt.Printf("ServiceOffer %s/%s created from JSON\n", ns, name) + return nil + } + name := cmd.Args().First() if name == "" { if u.IsTTY() { @@ -419,6 +471,9 @@ Example: return fmt.Errorf("name required: obol sell http --wallet --chain ") } } + if err := validate.Name(name); err != nil { + return err + } // Auto-discover wallet from remote-signer if not set. wallet := cmd.String("wallet") @@ -763,6 +818,9 @@ func sellStopCommand(cfg *config.Config) *cli.Command { return fmt.Errorf("name required: obol sell stop -n ") } name := cmd.Args().First() + if err := validate.Name(name); err != nil { + return err + } ns := cmd.String("namespace") fmt.Printf("Stopping the service offering %s/%s...\n", ns, name) @@ -810,6 +868,9 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command { return fmt.Errorf("name required: obol sell delete -n ") } name := cmd.Args().First() + if err := validate.Name(name); err != nil { + return err + } ns := cmd.String("namespace") if !cmd.Bool("force") { @@ -902,8 +963,32 @@ Reloads the payment verifier when configuration is changed.`, Usage: "x402 facilitator URL", Sources: cli.EnvVars("X402_FACILITATOR_URL"), }, + &cli.StringFlag{ + Name: "from-json", + Usage: "Read pricing config from JSON file (or - for stdin) instead of flags", + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + // --from-json: read pricing config from file/stdin. + if jsonPath := cmd.String("from-json"); jsonPath != "" { + data, err := readJSONInput(jsonPath) + if err != nil { + return err + } + var pricingCfg struct { + Wallet string `json:"wallet"` + Chain string `json:"chain"` + FacilitatorURL string `json:"facilitatorUrl"` + } + if err := json.Unmarshal(data, &pricingCfg); err != nil { + return fmt.Errorf("parse JSON pricing config: %w", err) + } + if pricingCfg.Wallet == "" { + return fmt.Errorf("wallet is required in JSON input") + } + return x402verifier.Setup(cfg, pricingCfg.Wallet, pricingCfg.Chain, pricingCfg.FacilitatorURL) + } + wallet := cmd.String("wallet") if wallet == "" { if resolved, err := openclaw.ResolveWalletAddress(cfg); err == nil { diff --git a/docs/plans/cli-agent-readiness.md b/docs/plans/cli-agent-readiness.md index 2c50169..90a5d7a 100644 --- a/docs/plans/cli-agent-readiness.md +++ b/docs/plans/cli-agent-readiness.md @@ -12,10 +12,11 @@ - Phase 2: `sell delete` migrated from raw `fmt.Scanln` to `u.Confirm()` - Phase 6: `CONTEXT.md` — agent-facing context document +- Phase 1D: `--from-json` on sell http, sell pricing, network add (`cmd/obol/input.go` helper) +- Phase 2B: `validate.Name()` wired into sell inference/http/stop/delete, `validate.URL()` in network add +- Phase 2C: model.go `promptModelPull()` migrated from bufio to `u.Select()`/`u.Input()`, openclaw onboard headless via `u.IsTTY() && !u.IsJSON()` + **Deferred to follow-up**: -- Phase 1D: `--from-json` raw JSON input -- Phase 2B: `validate.*` wired into all command handlers -- Phase 2C: model.go bufio migration, openclaw onboard headless path - Phase 3: `obol describe` schema introspection - Phase 4: `--fields` field filtering - Phase 5: `--dry-run` for mutating commands