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
31 changes: 31 additions & 0 deletions cmd/obol/input.go
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 27 additions & 34 deletions cmd/obol/model.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"bufio"
"context"
"fmt"
"os"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 <model>)")
}

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")
}
Expand Down
67 changes: 67 additions & 0 deletions cmd/obol/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"encoding/json"
"fmt"
"slices"
"sort"
Expand All @@ -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"
)

Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down
5 changes: 3 additions & 2 deletions cmd/obol/openclaw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
},
{
Expand Down
85 changes: 85 additions & 0 deletions cmd/obol/sell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -168,6 +169,9 @@ Examples:
return fmt.Errorf("name required: obol sell inference <name> --wallet <addr>")
}
}
if err := validate.Name(name); err != nil {
return err
}

wallet := cmd.String("wallet")
if wallet == "" {
Expand Down Expand Up @@ -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() {
Expand All @@ -419,6 +471,9 @@ Example:
return fmt.Errorf("name required: obol sell http <name> --wallet <addr> --chain <chain>")
}
}
if err := validate.Name(name); err != nil {
return err
}

// Auto-discover wallet from remote-signer if not set.
wallet := cmd.String("wallet")
Expand Down Expand Up @@ -763,6 +818,9 @@ func sellStopCommand(cfg *config.Config) *cli.Command {
return fmt.Errorf("name required: obol sell stop <name> -n <ns>")
}
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)
Expand Down Expand Up @@ -810,6 +868,9 @@ func sellDeleteCommand(cfg *config.Config) *cli.Command {
return fmt.Errorf("name required: obol sell delete <name> -n <ns>")
}
name := cmd.Args().First()
if err := validate.Name(name); err != nil {
return err
}
ns := cmd.String("namespace")

if !cmd.Bool("force") {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions docs/plans/cli-agent-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading