Skip to content
Closed
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
50 changes: 50 additions & 0 deletions cmd/hpphub/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/hpp-io/hpphub-cli/internal/auth"
"github.com/hpp-io/hpphub-cli/internal/config"
"github.com/hpp-io/hpphub-cli/internal/openclaw"
"github.com/hpp-io/hpphub-cli/internal/topup"
"github.com/spf13/cobra"
)

Expand All @@ -36,6 +37,7 @@ func newCLI() *cobra.Command {
modelsCmd(),
launchCmd(),
setupCmd(),
topupCmd(),
uninstallCmd(),
)

Expand Down Expand Up @@ -584,6 +586,54 @@ func setupTelegram() error {
return nil
}

// ─── topup ──────────────────────────────────────────────────

func topupCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "topup <subcommand>",
Short: "Manage x402 credit auto-top-up",
}
cmd.AddCommand(topupSetupCmd(), topupStatusCmd())
return cmd
}

func topupStatusCmd() *cobra.Command {
var hubURL string
cmd := &cobra.Command{
Use: "status",
Short: "Check whether credit auto-top-up is wired and working",
RunE: func(cmd *cobra.Command, args []string) error {
return topup.Status(hubURL)
},
}
cmd.Flags().StringVar(&hubURL, "hub-url", "", "Hub URL to check (default: from login)")
return cmd
}

func topupSetupCmd() *cobra.Command {
var o topup.Options
cmd := &cobra.Command{
Use: "setup",
Short: "Configure credit auto-top-up from your existing login",
RunE: func(cmd *cobra.Command, args []string) error {
return topup.Setup(o)
},
}
f := cmd.Flags()
f.StringVar(&o.HubURL, "hub-url", "", "Hub URL (default: from login)")
f.StringVar(&o.Safe, "safe", "", "Safe address that funds payments")
f.StringVar(&o.ResourceServer, "resource-server", "", "Upstream MCP resource server URL")
f.StringVar(&o.RPC, "rpc", "", "HPP RPC URL")
f.StringVar(&o.Network, "network", "", "CAIP-2 network id (e.g. eip155:181228)")
f.StringVar(&o.USDCe, "usdce", "", "USDC.e token address (override; defaults to HPP Sepolia)")
f.StringVar(&o.AllowanceMod, "allowance-module", "", "AllowanceModule address (override; defaults to HPP Sepolia)")
f.BoolVar(&o.EnableAuto, "enable-auto", false, "Also enable periodic auto-top-up")
f.Float64Var(&o.Watermark, "watermark", 2, "Auto-top-up when remaining credit is below this")
f.Float64Var(&o.Target, "target", 5, "Auto-top-up up to this balance")
f.BoolVar(&o.DryRun, "dry-run", false, "Preview changes without writing")
return cmd
}

// ─── uninstall ──────────────────────────────────────────────

func uninstallCmd() *cobra.Command {
Expand Down
117 changes: 117 additions & 0 deletions internal/topup/keychain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package topup

import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"time"
)

const delegateAccount = "delegate-default"

var (
addrRe = regexp.MustCompile(`0x[0-9a-fA-F]{40}`)
hex64Re = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`)
)

// runBridgeBin runs a bridge CLI bin via npx (combined output). Best-effort:
// callers degrade to guidance if npx/network is absent.
func runBridgeBin(args ...string) (string, error) {
return runBridgeBinStdin("", args...)
}

func runBridgeBinStdin(stdin string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "npx", append([]string{"-y", "-p", bridgePackage}, args...)...)
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin + "\n")
}
out, err := cmd.CombinedOutput()
return string(out), err
}

// keychainShow returns the existing delegate address, or "" if none/unavailable.
func keychainShow() string {
out, err := runBridgeBin("hpp-x402-keychain", "show", delegateAccount)
if err != nil {
return ""
}
return addrRe.FindString(out)
}

func keychainGenerate() (string, error) {
out, err := runBridgeBin("hpp-x402-keychain", "generate", delegateAccount)
if err != nil {
return "", fmt.Errorf("keychain generate failed: %w (%s)", err, truncate(out, 200))
}
if a := addrRe.FindString(out); a != "" {
return a, nil
}
return "", fmt.Errorf("could not parse delegate address")
}

func keychainImport(hexKey string) (string, error) {
out, err := runBridgeBinStdin(hexKey, "hpp-x402-keychain", "set", delegateAccount, "--stdin")
if err != nil {
return "", fmt.Errorf("keychain import failed: %w (%s)", err, truncate(out, 200))
}
if a := addrRe.FindString(out); a != "" {
return a, nil
}
return "", fmt.Errorf("could not parse delegate address after import")
}

func isTTY() bool {
fi, err := os.Stdin.Stat()
return err == nil && fi.Mode()&os.ModeCharDevice != 0
}

// resolveDelegate returns the delegate address, letting the user choose how to
// provide the key:
// - existing key present → keep it (Enter), or paste a 0x key to replace
// - no key present → generate (Enter), or paste a 0x key to import
//
// Non-interactive (piped/CI): keep existing if present, else generate.
func resolveDelegate(interactive bool) (string, error) {
existing := keychainShow()

if !interactive {
if existing != "" {
return existing, nil
}
return keychainGenerate()
}

if existing != "" {
fmt.Printf("Delegate payment key found: %s\n", existing)
fmt.Print(" Press Enter to keep it, or paste a different 0x<64hex> key to replace: ")
} else {
fmt.Println("No delegate payment key found.")
fmt.Print(" Press Enter to generate one, or paste an existing 0x<64hex> key to import: ")
}
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.TrimSpace(line)

switch {
case line == "" && existing != "":
return existing, nil
case line == "":
return keychainGenerate()
case hex64Re.MatchString(line):
return keychainImport(line)
default:
return "", fmt.Errorf("expected Enter, or a 0x + 64 hex private key")
}
}

func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
Loading
Loading