From d4abdf9d65dd3617dd74f3c288edf8de7ccf73c2 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Thu, 26 Mar 2026 01:24:49 +1100 Subject: [PATCH 1/4] feat(KEEP-143): add kh read, chain list, and remove auth wall for public commands - Add kh read for direct contract reads via public RPC (no auth needed) - Add kh chain list/ls for listing supported chains - Add RPC resolver with config > env > platform fallback chain - Add rpc map to config.yml for persistent custom RPC endpoints - Fix auth wall: commands work on fresh install without login - Template list supports search/filter by query --- cmd/chain/chain.go | 21 ++ cmd/chain/list.go | 97 ++++++++ cmd/chain/list_test.go | 107 +++++++++ cmd/kh/main.go | 12 +- cmd/read/read.go | 392 +++++++++++++++++++++++++++++++++ cmd/read/read_test.go | 266 ++++++++++++++++++++++ cmd/root.go | 4 + cmd/root_test.go | 7 +- cmd/template/list.go | 38 +++- internal/config/config.go | 17 +- internal/config/config_test.go | 67 ++++++ internal/rpc/resolver.go | 91 ++++++++ internal/rpc/resolver_test.go | 133 +++++++++++ 13 files changed, 1241 insertions(+), 11 deletions(-) create mode 100644 cmd/chain/chain.go create mode 100644 cmd/chain/list.go create mode 100644 cmd/chain/list_test.go create mode 100644 cmd/read/read.go create mode 100644 cmd/read/read_test.go create mode 100644 internal/rpc/resolver.go create mode 100644 internal/rpc/resolver_test.go diff --git a/cmd/chain/chain.go b/cmd/chain/chain.go new file mode 100644 index 0000000..e95e31f --- /dev/null +++ b/cmd/chain/chain.go @@ -0,0 +1,21 @@ +package chain + +import ( + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewChainCmd creates the top-level chain command group. +func NewChainCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "chain", + Short: "Manage blockchain chains", + Aliases: []string{"ch"}, + Example: ` # List supported chains + kh ch ls`, + } + + cmd.AddCommand(NewListCmd(f)) + + return cmd +} diff --git a/cmd/chain/list.go b/cmd/chain/list.go new file mode 100644 index 0000000..779f53d --- /dev/null +++ b/cmd/chain/list.go @@ -0,0 +1,97 @@ +package chain + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/jedib0t/go-pretty/v6/table" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/internal/output" + "github.com/keeperhub/cli/internal/rpc" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewListCmd creates the chain list subcommand. +func NewListCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List supported blockchain chains", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, + Example: ` # List all chains + kh ch ls + + # List chains as JSON + kh ch ls --json`, + RunE: func(cmd *cobra.Command, args []string) error { + chains, err := fetchChains(f, cmd) + if err != nil { + return err + } + + p := output.NewPrinter(f.IOStreams, cmd) + if len(chains) == 0 && !p.IsJSON() { + fmt.Fprintln(f.IOStreams.Out, "No chains found.") + return nil + } + return p.PrintData(chains, func(tw table.Writer) { + tw.AppendHeader(table.Row{"CHAIN ID", "NAME", "TYPE", "STATUS"}) + for _, ch := range chains { + tw.AppendRow(table.Row{ch.ChainID, ch.Name, ch.Type, ch.Status}) + } + tw.Render() + }) + }, + } + + return cmd +} + +// fetchChains calls /api/chains and caches the result for RPC resolution. +func fetchChains(f *cmdutil.Factory, cmd *cobra.Command) ([]rpc.ChainInfo, error) { + client, err := f.HTTPClient() + if err != nil { + return nil, fmt.Errorf("creating HTTP client: %w", err) + } + + cfg, err := f.Config() + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + host := cmdutil.ResolveHost(cmd, cfg) + url := khhttp.BuildBaseURL(host) + "/api/chains" + + req, err := client.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("building request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("executing request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, khhttp.NewAPIError(resp) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + // Cache the chain data for RPC resolution + _ = rpc.CacheChains(json.RawMessage(body)) + + var chains []rpc.ChainInfo + if err := json.Unmarshal(body, &chains); err != nil { + return nil, fmt.Errorf("decoding chains response: %w", err) + } + + return chains, nil +} diff --git a/cmd/chain/list_test.go b/cmd/chain/list_test.go new file mode 100644 index 0000000..b39cb77 --- /dev/null +++ b/cmd/chain/list_test.go @@ -0,0 +1,107 @@ +package chain_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/keeperhub/cli/cmd/chain" + "github.com/keeperhub/cli/internal/config" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/keeperhub/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newChainFactory(server *httptest.Server, ios *iostreams.IOStreams) *cmdutil.Factory { + client := khhttp.NewClient(khhttp.ClientOptions{Host: server.URL, AppVersion: "1.0.0"}) + return &cmdutil.Factory{ + AppVersion: "1.0.0", + IOStreams: ios, + HTTPClient: func() (*khhttp.Client, error) { return client, nil }, + Config: func() (config.Config, error) { return config.Config{DefaultHost: server.URL}, nil }, + } +} + +func sampleChainsResponse() []map[string]interface{} { + return []map[string]interface{}{ + {"chainId": 1, "name": "Ethereum", "type": "mainnet", "status": "active", "primaryRpcUrl": "https://eth.example.com", "fallbackRpcUrl": ""}, + {"chainId": 137, "name": "Polygon", "type": "mainnet", "status": "active", "primaryRpcUrl": "https://polygon.example.com", "fallbackRpcUrl": ""}, + {"chainId": 42161, "name": "Arbitrum", "type": "mainnet", "status": "active", "primaryRpcUrl": "", "fallbackRpcUrl": ""}, + } +} + +func TestChainListCmd(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + called := false + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/chains" { + called = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(sampleChainsResponse()) + return + } + http.Error(w, "not found", http.StatusNotFound) + })) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newChainFactory(server, ios) + + cmd := chain.NewChainCmd(f) + cmd.SetArgs([]string{"list"}) + err := cmd.Execute() + require.NoError(t, err) + + assert.True(t, called, "expected GET /api/chains to be called") + out := outBuf.String() + assert.Contains(t, out, "Ethereum") + assert.Contains(t, out, "Polygon") + assert.Contains(t, out, "Arbitrum") +} + +func TestChainListCmd_Empty(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]interface{}{}) + })) + defer server.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newChainFactory(server, ios) + + cmd := chain.NewChainCmd(f) + cmd.SetArgs([]string{"ls"}) + err := cmd.Execute() + require.NoError(t, err) + assert.Contains(t, outBuf.String(), "No chains found.") +} + +func TestChainListCmd_ServerError(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "server error", http.StatusInternalServerError) + })) + defer server.Close() + + ios, _, _, _ := iostreams.Test() + f := newChainFactory(server, ios) + + cmd := chain.NewChainCmd(f) + cmd.SetArgs([]string{"ls"}) + err := cmd.Execute() + assert.Error(t, err) +} + +func TestChainCmd_HasAlias(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{AppVersion: "1.0.0", IOStreams: ios} + cmd := chain.NewChainCmd(f) + assert.Contains(t, cmd.Aliases, "ch") +} diff --git a/cmd/kh/main.go b/cmd/kh/main.go index 954618e..2736aea 100644 --- a/cmd/kh/main.go +++ b/cmd/kh/main.go @@ -68,13 +68,19 @@ func main() { hosts, err := config.ReadHosts() if err != nil { - return nil, err + // If hosts.yml is unreadable, fall back to an unauthenticated + // client so public endpoints (chains, protocols, templates) work + // on a fresh install without login. + hosts = config.HostsConfig{} } - // Resolve token using the auth chain: KH_API_KEY > keyring > hosts.yml + // Resolve token using the auth chain: KH_API_KEY > keyring > hosts.yml. + // If resolution fails (e.g. keyring unavailable on fresh install), + // proceed with an empty token; the HTTP client already skips the + // Authorization header when token is empty. resolved, err := auth.ResolveToken(activeHost) if err != nil { - return nil, err + resolved = auth.ResolvedToken{Token: "", Host: activeHost} } entry, _ := hosts.HostEntry(activeHost) diff --git a/cmd/read/read.go b/cmd/read/read.go new file mode 100644 index 0000000..ce6d08d --- /dev/null +++ b/cmd/read/read.go @@ -0,0 +1,392 @@ +package read + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strings" + + "golang.org/x/crypto/sha3" + + "github.com/keeperhub/cli/internal/config" + "github.com/keeperhub/cli/internal/rpc" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewReadCmd creates the top-level read command for calling smart contract view functions. +func NewReadCmd(f *cmdutil.Factory) *cobra.Command { + var chainID string + var rpcURL string + var block string + var raw bool + + cmd := &cobra.Command{ + Use: "read [args...]", + Short: "Read a smart contract view function", + Long: `Call a read-only smart contract function via eth_call. + +The function signature should be in Solidity format (e.g. "balanceOf(address)"). +Arguments are positional and must match the function signature types. + +Supported argument types: address, uint256, bool, bytes32. +No auth required; uses public RPC endpoints by default.`, + Aliases: []string{"call"}, + Args: cobra.MinimumNArgs(2), + Example: ` # Read USDT total supply + kh read 0xdAC17F958D2ee523a2206206994597C96e3cFa0e "totalSupply()" --chain 1 + + # Read ERC-20 balance + kh read 0x6B175474E89094C44Da98b954EedeAC495271d0F "balanceOf(address)" 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045 --chain 1 + + # Read token decimals + kh read 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "decimals()" --chain 1 + + # Use a custom RPC endpoint + kh read 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 "decimals()" --chain 1 --rpc-url https://eth.llamarpc.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if chainID == "" { + return cmdutil.FlagError{Err: fmt.Errorf("--chain is required")} + } + + contractAddr := args[0] + funcSig := args[1] + funcArgs := args[2:] + + cfg, err := f.Config() + if err != nil { + cfg = config.DefaultConfig() + } + + chains := loadChainsForRPC(f) + + endpoint, err := rpc.Resolve(chainID, rpcURL, cfg, chains) + if err != nil { + return err + } + + calldata, err := encodeCall(funcSig, funcArgs) + if err != nil { + return fmt.Errorf("encoding call: %w", err) + } + + blockTag := "latest" + if block != "" { + blockTag = block + } + + result, err := ethCall(endpoint, contractAddr, calldata, blockTag) + if err != nil { + return err + } + + if raw { + fmt.Fprintln(f.IOStreams.Out, result) + return nil + } + + decoded := decodeResult(funcSig, result) + fmt.Fprintln(f.IOStreams.Out, decoded) + return nil + }, + } + + cmd.Flags().StringVar(&chainID, "chain", "", "Chain ID (required)") + cmd.Flags().StringVar(&rpcURL, "rpc-url", "", "Override RPC endpoint") + cmd.Flags().StringVar(&block, "block", "", "Block number or tag (default: latest)") + cmd.Flags().BoolVar(&raw, "raw", false, "Output raw hex instead of decoded") + + return cmd +} + +// loadChainsForRPC attempts to load cached chain data for RPC resolution. +// Returns nil on any error since chain data is optional for RPC resolution +// (the user may have --rpc-url or config entries). +func loadChainsForRPC(f *cmdutil.Factory) []rpc.ChainInfo { + chains, err := rpc.LoadChains() + if err != nil { + // Try fetching from platform + chains = fetchAndCacheChains(f) + } + return chains +} + +// fetchAndCacheChains fetches chain data from the platform API and caches it. +// Returns nil if the fetch fails. +func fetchAndCacheChains(f *cmdutil.Factory) []rpc.ChainInfo { + client, err := f.HTTPClient() + if err != nil { + return nil + } + + baseURL := f.BaseURL() + url := baseURL + "/api/chains" + + req, err := client.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil + } + + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + + _ = rpc.CacheChains(json.RawMessage(body)) + + var chains []rpc.ChainInfo + if err := json.Unmarshal(body, &chains); err != nil { + return nil + } + return chains +} + +// keccak256 computes the Keccak-256 hash of data. +func keccak256(data []byte) []byte { + h := sha3.NewLegacyKeccak256() + h.Write(data) + return h.Sum(nil) +} + +// functionSelector returns the 4-byte function selector for a Solidity function signature. +func functionSelector(sig string) []byte { + hash := keccak256([]byte(sig)) + return hash[:4] +} + +// parseParamTypes extracts parameter type names from a function signature. +// e.g. "balanceOf(address)" -> ["address"], "totalSupply()" -> [] +func parseParamTypes(sig string) []string { + openParen := strings.Index(sig, "(") + closeParen := strings.LastIndex(sig, ")") + if openParen < 0 || closeParen < 0 || closeParen <= openParen+1 { + return nil + } + inner := sig[openParen+1 : closeParen] + if strings.TrimSpace(inner) == "" { + return nil + } + parts := strings.Split(inner, ",") + result := make([]string, len(parts)) + for i, p := range parts { + result[i] = strings.TrimSpace(p) + } + return result +} + +// encodeCall encodes a function call into ABI-encoded calldata. +func encodeCall(funcSig string, args []string) (string, error) { + selector := functionSelector(funcSig) + paramTypes := parseParamTypes(funcSig) + + if len(args) != len(paramTypes) { + return "", fmt.Errorf("expected %d argument(s) for %s, got %d", len(paramTypes), funcSig, len(args)) + } + + var buf bytes.Buffer + buf.Write(selector) + + for i, arg := range args { + encoded, err := abiEncodeParam(paramTypes[i], arg) + if err != nil { + return "", fmt.Errorf("encoding argument %d (%s): %w", i, paramTypes[i], err) + } + buf.Write(encoded) + } + + return "0x" + hex.EncodeToString(buf.Bytes()), nil +} + +// abiEncodeParam encodes a single parameter value as a 32-byte ABI word. +func abiEncodeParam(paramType, value string) ([]byte, error) { + word := make([]byte, 32) + + switch { + case paramType == "address": + addr := strings.TrimPrefix(value, "0x") + if len(addr) != 40 { + return nil, fmt.Errorf("invalid address length: %s", value) + } + decoded, err := hex.DecodeString(addr) + if err != nil { + return nil, fmt.Errorf("invalid address hex: %w", err) + } + copy(word[12:], decoded) + + case strings.HasPrefix(paramType, "uint"): + n := new(big.Int) + if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") { + _, ok := n.SetString(value[2:], 16) + if !ok { + return nil, fmt.Errorf("invalid hex uint: %s", value) + } + } else { + _, ok := n.SetString(value, 10) + if !ok { + return nil, fmt.Errorf("invalid uint: %s", value) + } + } + b := n.Bytes() + if len(b) > 32 { + return nil, fmt.Errorf("uint value too large for 32 bytes") + } + copy(word[32-len(b):], b) + + case strings.HasPrefix(paramType, "int"): + n := new(big.Int) + _, ok := n.SetString(value, 10) + if !ok { + return nil, fmt.Errorf("invalid int: %s", value) + } + if n.Sign() >= 0 { + b := n.Bytes() + if len(b) > 32 { + return nil, fmt.Errorf("int value too large for 32 bytes") + } + copy(word[32-len(b):], b) + } else { + // Two's complement for negative numbers + twos := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), n) + b := twos.Bytes() + // Fill with 0xFF for sign extension + for j := range word { + word[j] = 0xFF + } + copy(word[32-len(b):], b) + } + + case paramType == "bool": + switch strings.ToLower(value) { + case "true", "1": + word[31] = 1 + case "false", "0": + // already zero + default: + return nil, fmt.Errorf("invalid bool: %s", value) + } + + case paramType == "bytes32": + b := strings.TrimPrefix(value, "0x") + decoded, err := hex.DecodeString(b) + if err != nil { + return nil, fmt.Errorf("invalid bytes32 hex: %w", err) + } + if len(decoded) > 32 { + return nil, fmt.Errorf("bytes32 value too long") + } + copy(word[:len(decoded)], decoded) + + default: + return nil, fmt.Errorf("unsupported parameter type: %s", paramType) + } + + return word, nil +} + +// jsonRPCRequest is the JSON-RPC 2.0 request envelope. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params []interface{} `json:"params"` + ID int `json:"id"` +} + +// jsonRPCResponse is the JSON-RPC 2.0 response envelope. +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result string `json:"result"` + Error *jsonRPCError `json:"error,omitempty"` +} + +// jsonRPCError is a JSON-RPC error object. +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// ethCallParams matches the eth_call params object. +type ethCallParams struct { + To string `json:"to"` + Data string `json:"data"` +} + +// ethCall performs an eth_call JSON-RPC request. +func ethCall(rpcEndpoint, to, data, blockTag string) (string, error) { + reqBody := jsonRPCRequest{ + JSONRPC: "2.0", + Method: "eth_call", + Params: []interface{}{ + ethCallParams{To: to, Data: data}, + blockTag, + }, + ID: 1, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", fmt.Errorf("marshalling request: %w", err) + } + + resp, err := http.Post(rpcEndpoint, "application/json", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("RPC request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("RPC returned HTTP %d: %s", resp.StatusCode, string(respBody)) + } + + var rpcResp jsonRPCResponse + if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil { + return "", fmt.Errorf("decoding RPC response: %w", err) + } + + if rpcResp.Error != nil { + return "", fmt.Errorf("RPC error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message) + } + + return rpcResp.Result, nil +} + +// decodeResult attempts to decode a hex result based on the function signature's return type. +// For simple return types (single uint256, address, bool), it decodes the value. +// For complex or unknown types, it returns the raw hex. +func decodeResult(funcSig string, hexResult string) string { + if hexResult == "" || hexResult == "0x" { + return "0x (empty result)" + } + + // Strip 0x prefix + clean := strings.TrimPrefix(hexResult, "0x") + if len(clean) == 0 { + return "0x (empty result)" + } + + // For a single 32-byte word, try common return types + if len(clean) == 64 { + // Try as uint256 + n := new(big.Int) + n.SetString(clean, 16) + return n.String() + } + + // Multiple words or non-standard length: return raw hex + return hexResult +} diff --git a/cmd/read/read_test.go b/cmd/read/read_test.go new file mode 100644 index 0000000..0ead9f4 --- /dev/null +++ b/cmd/read/read_test.go @@ -0,0 +1,266 @@ +package read_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/keeperhub/cli/cmd/read" + "github.com/keeperhub/cli/internal/config" + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/keeperhub/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newReadFactory(rpcServer *httptest.Server, chainsServer *httptest.Server, ios *iostreams.IOStreams) *cmdutil.Factory { + client := khhttp.NewClient(khhttp.ClientOptions{Host: "test", AppVersion: "1.0.0"}) + baseURL := "" + if chainsServer != nil { + baseURL = chainsServer.URL + } + return &cmdutil.Factory{ + AppVersion: "1.0.0", + IOStreams: ios, + HTTPClient: func() (*khhttp.Client, error) { return client, nil }, + Config: func() (config.Config, error) { + return config.Config{ + RPC: map[string]string{ + "1": rpcServer.URL, + }, + }, nil + }, + BaseURL: func() string { return baseURL }, + } +} + +func TestReadCmd_MissingChainFlag(t *testing.T) { + ios, _, _, _ := iostreams.Test() + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer rpcServer.Close() + + f := newReadFactory(rpcServer, nil, ios) + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{"0xdAC17F958D2ee523a2206206994597C96e3cFa0e", "totalSupply()"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "--chain is required") +} + +func TestReadCmd_NoArgFunction(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + // Mock RPC server that returns a uint256 value + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&req) + + // Verify method is eth_call + assert.Equal(t, "eth_call", req["method"]) + + params := req["params"].([]interface{}) + callObj := params[0].(map[string]interface{}) + + // Verify the function selector for "totalSupply()" + data := callObj["data"].(string) + // keccak256("totalSupply()") first 4 bytes = 0x18160ddd + assert.True(t, strings.HasPrefix(data, "0x18160ddd"), "expected totalSupply() selector, got %s", data) + + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x000000000000000000000000000000000000000000000000000000174876e800", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer rpcServer.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newReadFactory(rpcServer, nil, ios) + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{"0xdAC17F958D2ee523a2206206994597C96e3cFa0e", "totalSupply()", "--chain", "1"}) + err := cmd.Execute() + require.NoError(t, err) + + out := strings.TrimSpace(outBuf.String()) + // 0x174876e800 = 100000000000 in decimal + assert.Equal(t, "100000000000", out) +} + +func TestReadCmd_AddressArgFunction(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + _ = json.NewDecoder(r.Body).Decode(&req) + + params := req["params"].([]interface{}) + callObj := params[0].(map[string]interface{}) + data := callObj["data"].(string) + + // keccak256("balanceOf(address)") first 4 bytes = 0x70a08231 + assert.True(t, strings.HasPrefix(data, "0x70a08231"), "expected balanceOf(address) selector, got %s", data) + + // Verify it has 4 bytes selector + 32 bytes address = 72 hex chars + "0x" prefix + assert.Equal(t, 2+8+64, len(data), "expected 4-byte selector + 32-byte address arg") + + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000064", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer rpcServer.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newReadFactory(rpcServer, nil, ios) + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{ + "0x6B175474E89094C44Da98b954EedeAC495271d0F", + "balanceOf(address)", + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + "--chain", "1", + }) + err := cmd.Execute() + require.NoError(t, err) + + out := strings.TrimSpace(outBuf.String()) + assert.Equal(t, "100", out) +} + +func TestReadCmd_RawOutput(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000006", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer rpcServer.Close() + + ios, outBuf, _, _ := iostreams.Test() + f := newReadFactory(rpcServer, nil, ios) + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "decimals()", + "--chain", "1", + "--raw", + }) + err := cmd.Execute() + require.NoError(t, err) + + out := strings.TrimSpace(outBuf.String()) + assert.Equal(t, "0x0000000000000000000000000000000000000000000000000000000000000006", out) +} + +func TestReadCmd_RPCError(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "error": map[string]interface{}{"code": -32000, "message": "execution reverted"}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer rpcServer.Close() + + ios, _, _, _ := iostreams.Test() + f := newReadFactory(rpcServer, nil, ios) + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{ + "0xdAC17F958D2ee523a2206206994597C96e3cFa0e", + "totalSupply()", + "--chain", "1", + }) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "execution reverted") +} + +func TestReadCmd_WrongArgCount(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer rpcServer.Close() + + ios, _, _, _ := iostreams.Test() + f := newReadFactory(rpcServer, nil, ios) + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{ + "0xdAC17F958D2ee523a2206206994597C96e3cFa0e", + "balanceOf(address)", + // Missing the address argument + "--chain", "1", + }) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "expected 1 argument(s)") +} + +func TestReadCmd_RpcUrlFlag(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + rpcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "result": "0x0000000000000000000000000000000000000000000000000000000000000001", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer rpcServer.Close() + + ios, outBuf, _, _ := iostreams.Test() + // Config has no RPC for chain 999 + f := &cmdutil.Factory{ + AppVersion: "1.0.0", + IOStreams: ios, + HTTPClient: func() (*khhttp.Client, error) { + return khhttp.NewClient(khhttp.ClientOptions{Host: "test", AppVersion: "1.0.0"}), nil + }, + Config: func() (config.Config, error) { return config.Config{}, nil }, + BaseURL: func() string { return "" }, + } + + cmd := read.NewReadCmd(f) + cmd.SetArgs([]string{ + "0xdAC17F958D2ee523a2206206994597C96e3cFa0e", + "totalSupply()", + "--chain", "999", + "--rpc-url", rpcServer.URL, + }) + err := cmd.Execute() + require.NoError(t, err) + assert.Equal(t, "1", strings.TrimSpace(outBuf.String())) +} + +func TestReadCmd_HasCallAlias(t *testing.T) { + cmd := read.NewReadCmd(&cmdutil.Factory{IOStreams: &iostreams.IOStreams{}}) + assert.Contains(t, cmd.Aliases, "call") +} diff --git a/cmd/root.go b/cmd/root.go index 0a8e1c7..1f2ccd0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,6 +4,7 @@ import ( "github.com/keeperhub/cli/cmd/action" "github.com/keeperhub/cli/cmd/auth" "github.com/keeperhub/cli/cmd/billing" + "github.com/keeperhub/cli/cmd/chain" "github.com/keeperhub/cli/cmd/completion" "github.com/keeperhub/cli/cmd/config" "github.com/keeperhub/cli/cmd/doctor" @@ -12,6 +13,7 @@ import ( "github.com/keeperhub/cli/cmd/org" "github.com/keeperhub/cli/cmd/project" "github.com/keeperhub/cli/cmd/protocol" + "github.com/keeperhub/cli/cmd/read" "github.com/keeperhub/cli/cmd/run" "github.com/keeperhub/cli/cmd/serve" "github.com/keeperhub/cli/cmd/tag" @@ -44,6 +46,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(action.NewActionCmd(f)) cmd.AddCommand(auth.NewAuthCmd(f)) cmd.AddCommand(billing.NewBillingCmd(f)) + cmd.AddCommand(chain.NewChainCmd(f)) cmd.AddCommand(completion.NewCompletionCmd()) cmd.AddCommand(config.NewConfigCmd(f)) cmd.AddCommand(doctor.NewDoctorCmd(f)) @@ -51,6 +54,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(org.NewOrgCmd(f)) cmd.AddCommand(project.NewProjectCmd(f)) cmd.AddCommand(protocol.NewProtocolCmd(f)) + cmd.AddCommand(read.NewReadCmd(f)) cmd.AddCommand(run.NewRunCmd(f)) cmd.AddCommand(serve.NewServeCmd(f)) cmd.AddCommand(tag.NewTagCmd(f)) diff --git a/cmd/root_test.go b/cmd/root_test.go index 64d4a00..b0ffb2e 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -132,11 +132,11 @@ func TestRootCmdParsesHostFlag(t *testing.T) { assert.Equal(t, "app-staging.keeperhub.com", hostVal) } -func TestRootCmdHas21Subcommands(t *testing.T) { +func TestRootCmdHas23Subcommands(t *testing.T) { f := newTestFactory() root := cmd.NewRootCmd(f) cmds := root.Commands() - assert.Equal(t, 21, len(cmds), "expected 21 subcommands registered on root (18 commands + 3 help topics)") + assert.Equal(t, 23, len(cmds), "expected 23 subcommands registered on root (20 commands + 3 help topics)") } func TestRootCmdHelpIncludesAllCommands(t *testing.T) { @@ -155,6 +155,7 @@ func TestRootCmdHelpIncludesAllCommands(t *testing.T) { "tag", "org", "action", "protocol", "wallet", "template", "billing", "doctor", "version", "auth", "config", "completion", "update", + "chain", "read", } for _, cmdName := range expectedCommands { assert.True(t, strings.Contains(helpOutput, cmdName), @@ -178,7 +179,7 @@ func TestRootCmdHelpDoesNotIncludeAPIKey(t *testing.T) { } func TestAllNounAliasesResolve(t *testing.T) { - aliases := []string{"wf", "r", "ex", "p", "t", "o", "a", "pr", "w", "tp", "b", "doc", "v"} + aliases := []string{"wf", "r", "ex", "p", "t", "o", "a", "pr", "w", "tp", "b", "doc", "v", "ch", "call"} for _, alias := range aliases { t.Run(alias, func(t *testing.T) { diff --git a/cmd/template/list.go b/cmd/template/list.go index 8712455..d7e2bd9 100644 --- a/cmd/template/list.go +++ b/cmd/template/list.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/jedib0t/go-pretty/v6/table" khhttp "github.com/keeperhub/cli/internal/http" @@ -44,17 +45,28 @@ func categoryFromTags(tags []PublicTag) string { } func NewListCmd(f *cmdutil.Factory) *cobra.Command { + var query string + cmd := &cobra.Command{ - Use: "list", + Use: "list [query]", Short: "List workflow templates", - Aliases: []string{"ls"}, - Args: cobra.NoArgs, + Aliases: []string{"ls", "search"}, + Args: cobra.MaximumNArgs(1), Example: ` # List featured templates kh tp ls + # Search templates by keyword + kh tp ls defi + kh tp ls --query monitor + # List templates as JSON kh tp ls --json`, RunE: func(cmd *cobra.Command, args []string) error { + // Positional arg takes precedence over --query flag + if len(args) > 0 { + query = args[0] + } + client, err := f.HTTPClient() if err != nil { return fmt.Errorf("creating HTTP client: %w", err) @@ -88,6 +100,10 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("decoding response: %w", err) } + if query != "" { + templates = filterTemplates(templates, query) + } + p := output.NewPrinter(f.IOStreams, cmd) if len(templates) == 0 && !p.IsJSON() { fmt.Fprintln(f.IOStreams.Out, "No templates found.") @@ -107,5 +123,21 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { }, } + cmd.Flags().StringVarP(&query, "query", "q", "", "Filter templates by name or description") + return cmd } + +// filterTemplates returns templates whose name or description contains the query +// (case-insensitive substring match). +func filterTemplates(templates []Template, query string) []Template { + q := strings.ToLower(query) + var result []Template + for _, tpl := range templates { + if strings.Contains(strings.ToLower(tpl.Name), q) || + strings.Contains(strings.ToLower(tpl.Description), q) { + result = append(result, tpl) + } + } + return result +} diff --git a/internal/config/config.go b/internal/config/config.go index d977a43..59920b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,21 @@ import ( // Config holds the top-level application configuration stored in config.yml. type Config struct { - ConfigVersion string `yaml:"config_version"` - DefaultHost string `yaml:"default_host,omitempty"` + ConfigVersion string `yaml:"config_version"` + DefaultHost string `yaml:"default_host,omitempty"` + DefaultOrg string `yaml:"default_org,omitempty"` + RPC map[string]string `yaml:"rpc,omitempty"` +} + +// RPCEndpoint returns the user-configured RPC URL for the given chain ID, +// or an empty string if none is configured. +func (c Config) RPCEndpoint(chainID string) string { + if c.RPC != nil { + if url, ok := c.RPC[chainID]; ok { + return url + } + } + return "" } // DefaultConfig returns a Config with built-in defaults applied. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b5ee95f..ec2448b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -88,3 +88,70 @@ func TestConfigDirUsesXDG(t *testing.T) { t.Errorf("ConfigDir: got %q, want %q", got, want) } } + +func TestRPCEndpoint_Found(t *testing.T) { + cfg := config.Config{ + RPC: map[string]string{ + "1": "https://eth-mainnet.example.com", + "137": "https://polygon.example.com", + }, + } + got := cfg.RPCEndpoint("1") + if got != "https://eth-mainnet.example.com" { + t.Errorf("RPCEndpoint(1): got %q, want %q", got, "https://eth-mainnet.example.com") + } +} + +func TestRPCEndpoint_NotFound(t *testing.T) { + cfg := config.Config{ + RPC: map[string]string{ + "1": "https://eth-mainnet.example.com", + }, + } + got := cfg.RPCEndpoint("999") + if got != "" { + t.Errorf("RPCEndpoint(999): got %q, want empty string", got) + } +} + +func TestRPCEndpoint_NilMap(t *testing.T) { + cfg := config.Config{} + got := cfg.RPCEndpoint("1") + if got != "" { + t.Errorf("RPCEndpoint(1) with nil map: got %q, want empty string", got) + } +} + +func TestWriteReadConfigRoundTripWithRPC(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + want := config.Config{ + ConfigVersion: "1", + DefaultHost: "app.keeperhub.com", + DefaultOrg: "org_test123", + RPC: map[string]string{ + "1": "https://eth.example.com", + "137": "https://polygon.example.com", + }, + } + + if err := config.WriteConfig(want); err != nil { + t.Fatalf("WriteConfig: %v", err) + } + + got, err := config.ReadConfig() + if err != nil { + t.Fatalf("ReadConfig: %v", err) + } + + if got.DefaultOrg != want.DefaultOrg { + t.Errorf("DefaultOrg: got %q, want %q", got.DefaultOrg, want.DefaultOrg) + } + if got.RPCEndpoint("1") != "https://eth.example.com" { + t.Errorf("RPCEndpoint(1): got %q, want %q", got.RPCEndpoint("1"), "https://eth.example.com") + } + if got.RPCEndpoint("137") != "https://polygon.example.com" { + t.Errorf("RPCEndpoint(137): got %q, want %q", got.RPCEndpoint("137"), "https://polygon.example.com") + } +} diff --git a/internal/rpc/resolver.go b/internal/rpc/resolver.go new file mode 100644 index 0000000..de150cb --- /dev/null +++ b/internal/rpc/resolver.go @@ -0,0 +1,91 @@ +package rpc + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/keeperhub/cli/internal/cache" + "github.com/keeperhub/cli/internal/config" +) + +const ( + // ChainsCacheName is the filename for the cached /api/chains response. + ChainsCacheName = "chains.json" + + // ChainsCacheTTL is the TTL for chain cache entries. + ChainsCacheTTL = 1 * time.Hour +) + +// ChainInfo holds the RPC endpoint data for a single chain as returned by /api/chains. +type ChainInfo struct { + ChainID int `json:"chainId"` + Name string `json:"name"` + Type string `json:"type"` + Status string `json:"status"` + PrimaryRpcURL string `json:"primaryRpcUrl"` + FallbackRpcURL string `json:"fallbackRpcUrl"` +} + +// Resolve returns the RPC endpoint URL for a given chain ID. +// Resolution order: +// 1. flagValue (from --rpc-url flag) +// 2. KH_RPC_URL env var +// 3. Config file rpc. +// 4. Platform /api/chains primaryRpcUrl (from cached chain data) +// 5. Platform /api/chains fallbackRpcUrl +// 6. Error if nothing found +func Resolve(chainID string, flagValue string, cfg config.Config, chains []ChainInfo) (string, error) { + if flagValue != "" { + return flagValue, nil + } + + if envURL := os.Getenv("KH_RPC_URL"); envURL != "" { + return envURL, nil + } + + if url := cfg.RPCEndpoint(chainID); url != "" { + return url, nil + } + + for _, ch := range chains { + if fmt.Sprintf("%d", ch.ChainID) == chainID { + if ch.PrimaryRpcURL != "" { + return ch.PrimaryRpcURL, nil + } + if ch.FallbackRpcURL != "" { + return ch.FallbackRpcURL, nil + } + } + } + + return "", fmt.Errorf("no RPC endpoint found for chain %s. Set one with --rpc-url, KH_RPC_URL, or config rpc.%s", chainID, chainID) +} + +// LoadChains reads chain data from cache, returning nil if no cached data is available. +// The caller is responsible for fetching and caching fresh data when this returns nil. +func LoadChains() ([]ChainInfo, error) { + entry, err := cache.ReadCache(ChainsCacheName) + if err != nil { + return nil, err + } + if cache.IsStale(entry, ChainsCacheTTL) { + return nil, fmt.Errorf("chain cache is stale") + } + return unmarshalChains(entry.Data) +} + +// CacheChains writes chain data to the local cache. +func CacheChains(data json.RawMessage) error { + return cache.WriteCache(ChainsCacheName, data) +} + +// unmarshalChains parses the raw JSON array of chain objects. +func unmarshalChains(raw json.RawMessage) ([]ChainInfo, error) { + var chains []ChainInfo + if err := json.Unmarshal(raw, &chains); err != nil { + return nil, fmt.Errorf("decoding chains data: %w", err) + } + return chains, nil +} diff --git a/internal/rpc/resolver_test.go b/internal/rpc/resolver_test.go new file mode 100644 index 0000000..723cf74 --- /dev/null +++ b/internal/rpc/resolver_test.go @@ -0,0 +1,133 @@ +package rpc_test + +import ( + "encoding/json" + "testing" + + "github.com/keeperhub/cli/internal/cache" + "github.com/keeperhub/cli/internal/config" + "github.com/keeperhub/cli/internal/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleChains() []rpc.ChainInfo { + return []rpc.ChainInfo{ + {ChainID: 1, Name: "Ethereum", Type: "mainnet", Status: "active", PrimaryRpcURL: "https://eth-rpc.example.com", FallbackRpcURL: "https://eth-fallback.example.com"}, + {ChainID: 137, Name: "Polygon", Type: "mainnet", Status: "active", PrimaryRpcURL: "", FallbackRpcURL: "https://polygon-fallback.example.com"}, + {ChainID: 42161, Name: "Arbitrum", Type: "mainnet", Status: "active", PrimaryRpcURL: "", FallbackRpcURL: ""}, + } +} + +func TestResolve_FlagValueFirst(t *testing.T) { + cfg := config.Config{RPC: map[string]string{"1": "https://config-rpc.example.com"}} + chains := sampleChains() + + result, err := rpc.Resolve("1", "https://flag-rpc.example.com", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://flag-rpc.example.com", result) +} + +func TestResolve_EnvVarSecond(t *testing.T) { + t.Setenv("KH_RPC_URL", "https://env-rpc.example.com") + cfg := config.Config{RPC: map[string]string{"1": "https://config-rpc.example.com"}} + chains := sampleChains() + + result, err := rpc.Resolve("1", "", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://env-rpc.example.com", result) +} + +func TestResolve_ConfigThird(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{RPC: map[string]string{"1": "https://config-rpc.example.com"}} + chains := sampleChains() + + result, err := rpc.Resolve("1", "", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://config-rpc.example.com", result) +} + +func TestResolve_PrimaryRPCFourth(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{} + chains := sampleChains() + + result, err := rpc.Resolve("1", "", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://eth-rpc.example.com", result) +} + +func TestResolve_FallbackRPCFifth(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{} + chains := sampleChains() + + result, err := rpc.Resolve("137", "", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://polygon-fallback.example.com", result) +} + +func TestResolve_ErrorWhenNothingFound(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{} + chains := sampleChains() + + _, err := rpc.Resolve("42161", "", cfg, chains) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no RPC endpoint found for chain 42161") +} + +func TestResolve_UnknownChainError(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{} + + _, err := rpc.Resolve("999", "", cfg, nil) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no RPC endpoint found for chain 999") +} + +func TestResolve_NilConfigRPC(t *testing.T) { + t.Setenv("KH_RPC_URL", "") + cfg := config.Config{} + chains := sampleChains() + + result, err := rpc.Resolve("1", "", cfg, chains) + require.NoError(t, err) + assert.Equal(t, "https://eth-rpc.example.com", result) +} + +func TestLoadChains_MissingCache(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + + chains, err := rpc.LoadChains() + assert.Error(t, err) + assert.Nil(t, chains) +} + +func TestLoadChains_ValidCache(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tmp) + + data, _ := json.Marshal(sampleChains()) + require.NoError(t, cache.WriteCache(rpc.ChainsCacheName, data)) + + chains, err := rpc.LoadChains() + require.NoError(t, err) + assert.Len(t, chains, 3) + assert.Equal(t, "Ethereum", chains[0].Name) +} + +func TestCacheChains(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CACHE_HOME", tmp) + + data, _ := json.Marshal(sampleChains()) + err := rpc.CacheChains(data) + require.NoError(t, err) + + // Verify we can read it back + chains, err := rpc.LoadChains() + require.NoError(t, err) + assert.Len(t, chains, 3) +} From 2f543533b6b46517a24cf16eaa0d5fda05682062 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Thu, 26 Mar 2026 01:41:41 +1100 Subject: [PATCH 2/4] fix: match chain API field names (defaultPrimaryRpc, chainType, isEnabled) --- cmd/chain/list.go | 4 ++-- internal/rpc/resolver.go | 8 ++++---- internal/rpc/resolver_test.go | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/chain/list.go b/cmd/chain/list.go index 779f53d..c983995 100644 --- a/cmd/chain/list.go +++ b/cmd/chain/list.go @@ -38,9 +38,9 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return nil } return p.PrintData(chains, func(tw table.Writer) { - tw.AppendHeader(table.Row{"CHAIN ID", "NAME", "TYPE", "STATUS"}) + tw.AppendHeader(table.Row{"CHAIN ID", "NAME", "TYPE", "ENABLED"}) for _, ch := range chains { - tw.AppendRow(table.Row{ch.ChainID, ch.Name, ch.Type, ch.Status}) + tw.AppendRow(table.Row{ch.ChainID, ch.Name, ch.Type, ch.IsEnabled}) } tw.Render() }) diff --git a/internal/rpc/resolver.go b/internal/rpc/resolver.go index de150cb..e28fb2e 100644 --- a/internal/rpc/resolver.go +++ b/internal/rpc/resolver.go @@ -22,10 +22,10 @@ const ( type ChainInfo struct { ChainID int `json:"chainId"` Name string `json:"name"` - Type string `json:"type"` - Status string `json:"status"` - PrimaryRpcURL string `json:"primaryRpcUrl"` - FallbackRpcURL string `json:"fallbackRpcUrl"` + Type string `json:"chainType"` + IsEnabled bool `json:"isEnabled"` + PrimaryRpcURL string `json:"defaultPrimaryRpc"` + FallbackRpcURL string `json:"defaultFallbackRpc"` } // Resolve returns the RPC endpoint URL for a given chain ID. diff --git a/internal/rpc/resolver_test.go b/internal/rpc/resolver_test.go index 723cf74..f8c38b5 100644 --- a/internal/rpc/resolver_test.go +++ b/internal/rpc/resolver_test.go @@ -13,9 +13,9 @@ import ( func sampleChains() []rpc.ChainInfo { return []rpc.ChainInfo{ - {ChainID: 1, Name: "Ethereum", Type: "mainnet", Status: "active", PrimaryRpcURL: "https://eth-rpc.example.com", FallbackRpcURL: "https://eth-fallback.example.com"}, - {ChainID: 137, Name: "Polygon", Type: "mainnet", Status: "active", PrimaryRpcURL: "", FallbackRpcURL: "https://polygon-fallback.example.com"}, - {ChainID: 42161, Name: "Arbitrum", Type: "mainnet", Status: "active", PrimaryRpcURL: "", FallbackRpcURL: ""}, + {ChainID: 1, Name: "Ethereum", Type: "evm", IsEnabled: true, PrimaryRpcURL: "https://eth-rpc.example.com", FallbackRpcURL: "https://eth-fallback.example.com"}, + {ChainID: 137, Name: "Polygon", Type: "evm", IsEnabled: true, PrimaryRpcURL: "", FallbackRpcURL: "https://polygon-fallback.example.com"}, + {ChainID: 42161, Name: "Arbitrum", Type: "evm", IsEnabled: true, PrimaryRpcURL: "", FallbackRpcURL: ""}, } } From 378c41e62cd87c6e78a6ce43c8addbf17162f79e Mon Sep 17 00:00:00 2001 From: Simon KP Date: Thu, 26 Mar 2026 01:57:05 +1100 Subject: [PATCH 3/4] fix: parse protocol list from actions map instead of plugins array --- cmd/protocol/get.go | 136 ++++++++++++++++++++++++++------------ cmd/protocol/get_test.go | 6 +- cmd/protocol/list.go | 70 ++++++++++++-------- cmd/protocol/list_test.go | 62 ++++++++--------- 4 files changed, 164 insertions(+), 110 deletions(-) diff --git a/cmd/protocol/get.go b/cmd/protocol/get.go index e32ebe7..2e3e298 100644 --- a/cmd/protocol/get.go +++ b/cmd/protocol/get.go @@ -1,89 +1,139 @@ package protocol import ( + "encoding/json" "fmt" + "sort" "strings" "github.com/jedib0t/go-pretty/v6/table" + "github.com/keeperhub/cli/internal/cache" "github.com/keeperhub/cli/internal/output" "github.com/keeperhub/cli/pkg/cmdutil" "github.com/spf13/cobra" ) +// ProtocolDetail holds a protocol name and its actions for the get command. +type ProtocolDetail struct { + Name string `json:"name"` + Actions []ActionDetail `json:"actions"` +} + +// ActionDetail holds a single action's metadata for display. +type ActionDetail struct { + ActionType string `json:"actionType"` + Label string `json:"label"` + Description string `json:"description"` + Fields map[string]string `json:"requiredFields,omitempty"` +} + func NewGetCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "get ", - Short: "Get a protocol", + Use: "get ", + Short: "Get protocol details and actions", Aliases: []string{"g"}, Args: cobra.ExactArgs(1), Example: ` # Get protocol reference card - kh pr g uniswap + kh pr g aave # Get protocol details as JSON - kh pr g aave --json`, + kh pr g morpho --json`, RunE: func(cmd *cobra.Command, args []string) error { - slug := args[0] + name := strings.ToLower(args[0]) + refresh, _ := cmd.Flags().GetBool("refresh") - protocols, err := loadProtocols(f, false, cmd) + detail, err := loadProtocolDetail(f, name, refresh, cmd) if err != nil { return err } - var found *Protocol - for i := range protocols { - if protocols[i].Slug == slug { - found = &protocols[i] - break - } - } - - if found == nil { - return cmdutil.NotFoundError{Err: fmt.Errorf("protocol %q not found", slug)} + if detail == nil { + return cmdutil.NotFoundError{Err: fmt.Errorf("protocol %q not found", name)} } p := output.NewPrinter(f.IOStreams, cmd) - return p.PrintData(found, func(_ table.Writer) { - renderProtocolDetail(f, found) + return p.PrintData(detail, func(_ table.Writer) { + renderProtocolDetail(f, detail) }) }, } + cmd.Flags().Bool("refresh", false, "Bypass local cache and fetch fresh data") + return cmd } -// renderProtocolDetail writes a full reference card for the protocol to stdout. -func renderProtocolDetail(f *cmdutil.Factory, proto *Protocol) { - fmt.Fprintf(f.IOStreams.Out, "%s\n", proto.Name) - if proto.Description != "" { - fmt.Fprintf(f.IOStreams.Out, "%s\n", proto.Description) +// loadProtocolDetail extracts actions for a specific integration from the schemas cache. +func loadProtocolDetail(f *cmdutil.Factory, name string, refresh bool, cmd *cobra.Command) (*ProtocolDetail, error) { + var raw []byte + + if !refresh { + entry, err := cache.ReadCache(cache.ProtocolCacheName) + if err == nil && !cache.IsStale(entry, cache.ProtocolCacheTTL) { + raw = entry.Data + } } - fmt.Fprintln(f.IOStreams.Out) - for _, action := range proto.Actions { - fmt.Fprintf(f.IOStreams.Out, " %s\n", action.Name) - if action.Description != "" { - fmt.Fprintf(f.IOStreams.Out, " %s\n", action.Description) + if raw == nil { + fetched, err := fetchSchemas(f, cmd) + if err != nil { + entry, cacheErr := cache.ReadCache(cache.ProtocolCacheName) + if cacheErr != nil { + return nil, fmt.Errorf("could not fetch protocols: %w", err) + } + raw = entry.Data + } else { + raw = fetched + _ = cache.WriteCache(cache.ProtocolCacheName, fetched) } + } - if len(action.Fields) > 0 { - fmt.Fprintln(f.IOStreams.Out) - printFieldsTable(f, action.Fields) + var schemas SchemasResponse + if err := unmarshalSchemasRaw(raw, &schemas); err != nil { + return nil, err + } + + var actions []ActionDetail + for _, action := range schemas.Actions { + integration := strings.ToLower(action.Integration) + if integration == "" { + integration = strings.ToLower(action.Category) } - fmt.Fprintln(f.IOStreams.Out) + if integration != name { + continue + } + actions = append(actions, ActionDetail{ + ActionType: action.ActionType, + Label: action.Label, + Description: action.Description, + }) } + + if len(actions) == 0 { + return nil, nil + } + + sort.Slice(actions, func(i, j int) bool { + return actions[i].ActionType < actions[j].ActionType + }) + + return &ProtocolDetail{Name: name, Actions: actions}, nil } -// printFieldsTable renders an action's fields as a compact table. -func printFieldsTable(f *cmdutil.Factory, fields []Field) { - tw := output.NewTable(f.IOStreams.Out, false) - tw.AppendHeader(table.Row{"NAME", "TYPE", "REQUIRED", "DESCRIPTION"}) - for _, field := range fields { - req := "no" - if field.Required { - req = "yes" +func unmarshalSchemasRaw(raw []byte, out *SchemasResponse) error { + return json.Unmarshal(raw, out) +} + +// renderProtocolDetail writes a reference card for the protocol to stdout. +func renderProtocolDetail(f *cmdutil.Factory, detail *ProtocolDetail) { + fmt.Fprintf(f.IOStreams.Out, "%s (%d actions)\n\n", detail.Name, len(detail.Actions)) + + for _, action := range detail.Actions { + fmt.Fprintf(f.IOStreams.Out, " %s\n", action.Label) + fmt.Fprintf(f.IOStreams.Out, " %s\n", action.ActionType) + if action.Description != "" { + fmt.Fprintf(f.IOStreams.Out, " %s\n", action.Description) } - desc := strings.TrimSpace(field.Description) - tw.AppendRow(table.Row{field.Name, field.Type, req, desc}) + fmt.Fprintln(f.IOStreams.Out) } - tw.Render() } diff --git a/cmd/protocol/get_test.go b/cmd/protocol/get_test.go index 7ec02b3..7a5750b 100644 --- a/cmd/protocol/get_test.go +++ b/cmd/protocol/get_test.go @@ -49,9 +49,9 @@ func TestGetCmd(t *testing.T) { require.NoError(t, err) out := outBuf.String() - assert.Contains(t, out, "Aave", "expected protocol name in output") - assert.Contains(t, out, "supply", "expected action name in output") - assert.Contains(t, out, "amount", "expected field name in output") + assert.Contains(t, out, "aave", "expected protocol name in output") + assert.Contains(t, out, "aave/supply", "expected action type in output") + assert.Contains(t, out, "Supply", "expected action label in output") } func TestGetCmd_NotFound(t *testing.T) { diff --git a/cmd/protocol/list.go b/cmd/protocol/list.go index 26adaed..a804057 100644 --- a/cmd/protocol/list.go +++ b/cmd/protocol/list.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "sort" "github.com/jedib0t/go-pretty/v6/table" "github.com/keeperhub/cli/internal/cache" @@ -16,36 +17,28 @@ import ( // SchemasResponse is the shape of the /api/mcp/schemas response. type SchemasResponse struct { - Plugins []Protocol `json:"plugins"` + Actions map[string]SchemaAction `json:"actions"` } -// Protocol represents a single blockchain protocol with its available actions. -type Protocol struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - Actions []Action `json:"actions"` -} - -// Action represents a single action available on a protocol. -type Action struct { - Name string `json:"name"` - Description string `json:"description"` - Fields []Field `json:"fields"` +// SchemaAction represents a single action from the schemas API. +type SchemaAction struct { + ActionType string `json:"actionType"` + Label string `json:"label"` + Description string `json:"description"` + Category string `json:"category"` + Integration string `json:"integration"` } -// Field describes a single input field for an action. -type Field struct { +// Protocol represents a grouped set of actions under one integration. +type Protocol struct { Name string `json:"name"` - Type string `json:"type"` - Required bool `json:"required"` - Description string `json:"description"` + ActionCount int `json:"actionCount"` } func NewListCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List blockchain protocols", + Short: "List blockchain protocols and integrations", Aliases: []string{"ls"}, Args: cobra.NoArgs, Example: ` # List all protocols (cached) @@ -64,15 +57,16 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command { return err } + if len(protocols) == 0 { + fmt.Fprintln(f.IOStreams.Out, "No protocols found.") + return nil + } + p := output.NewPrinter(f.IOStreams, cmd) return p.PrintData(protocols, func(tw table.Writer) { tw.AppendHeader(table.Row{"NAME", "ACTIONS"}) for _, proto := range protocols { - tw.AppendRow(table.Row{proto.Name, len(proto.Actions)}) - } - if len(protocols) == 0 { - fmt.Fprintln(f.IOStreams.Out, "No protocols found.") - return + tw.AppendRow(table.Row{proto.Name, proto.ActionCount}) } tw.Render() }) @@ -110,7 +104,6 @@ func loadProtocols(f *cmdutil.Factory, refresh bool, cmd *cobra.Command) ([]Prot } if err := cache.WriteCache(cache.ProtocolCacheName, raw); err != nil { - // Non-fatal: warn but continue with the freshly fetched data fmt.Fprintf(f.IOStreams.ErrOut, "Warning: could not write cache: %v\n", err) } @@ -153,11 +146,32 @@ func fetchSchemas(f *cmdutil.Factory, cmd *cobra.Command) (json.RawMessage, erro return json.RawMessage(body), nil } -// unmarshalProtocols extracts the plugins slice from a raw SchemasResponse. +// unmarshalProtocols groups actions by integration to produce a protocol list. func unmarshalProtocols(raw json.RawMessage) ([]Protocol, error) { var schemas SchemasResponse if err := json.Unmarshal(raw, &schemas); err != nil { return nil, fmt.Errorf("decoding schemas response: %w", err) } - return schemas.Plugins, nil + + counts := make(map[string]int) + for _, action := range schemas.Actions { + name := action.Integration + if name == "" { + name = action.Category + } + if name == "" { + continue + } + counts[name]++ + } + + protocols := make([]Protocol, 0, len(counts)) + for name, count := range counts { + protocols = append(protocols, Protocol{Name: name, ActionCount: count}) + } + sort.Slice(protocols, func(i, j int) bool { + return protocols[i].Name < protocols[j].Name + }) + + return protocols, nil } diff --git a/cmd/protocol/list_test.go b/cmd/protocol/list_test.go index 1c252b7..8d9ab8e 100644 --- a/cmd/protocol/list_test.go +++ b/cmd/protocol/list_test.go @@ -34,37 +34,27 @@ func makeSchemasServer(t *testing.T, handler http.HandlerFunc) *httptest.Server func sampleSchemasResponse() map[string]interface{} { return map[string]interface{}{ - "plugins": []interface{}{ - map[string]interface{}{ - "name": "Aave", - "slug": "aave", - "description": "Aave lending protocol", - "actions": []interface{}{ - map[string]interface{}{ - "name": "supply", - "description": "Supply assets", - "fields": []interface{}{ - map[string]interface{}{"name": "amount", "type": "string", "required": true, "description": "Amount to supply"}, - }, - }, - map[string]interface{}{ - "name": "borrow", - "description": "Borrow assets", - "fields": []interface{}{}, - }, - }, + "actions": map[string]interface{}{ + "aave/supply": map[string]interface{}{ + "actionType": "aave/supply", + "label": "Aave: Supply", + "description": "Supply assets", + "category": "Aave", + "integration": "aave", }, - map[string]interface{}{ - "name": "Uniswap", - "slug": "uniswap", - "description": "Uniswap DEX", - "actions": []interface{}{ - map[string]interface{}{ - "name": "swap", - "description": "Swap tokens", - "fields": []interface{}{}, - }, - }, + "aave/borrow": map[string]interface{}{ + "actionType": "aave/borrow", + "label": "Aave: Borrow", + "description": "Borrow assets", + "category": "Aave", + "integration": "aave", + }, + "uniswap/swap": map[string]interface{}{ + "actionType": "uniswap/swap", + "label": "Uniswap: Swap", + "description": "Swap tokens", + "category": "Uniswap", + "integration": "uniswap", }, }, } @@ -95,8 +85,8 @@ func TestListCmd_CacheMiss(t *testing.T) { assert.True(t, called, "expected GET /api/mcp/schemas to be called on cache miss") out := outBuf.String() - assert.Contains(t, out, "Aave") - assert.Contains(t, out, "Uniswap") + assert.Contains(t, out, "aave") + assert.Contains(t, out, "uniswap") } func TestListCmd_CacheHit(t *testing.T) { @@ -123,7 +113,7 @@ func TestListCmd_CacheHit(t *testing.T) { require.NoError(t, err) assert.False(t, networkCalled, "expected no network request on cache hit") - assert.Contains(t, outBuf.String(), "Aave") + assert.Contains(t, outBuf.String(), "aave") } func TestListCmd_Refresh(t *testing.T) { @@ -183,7 +173,7 @@ func TestListCmd_StaleWithError(t *testing.T) { err := cmd.Execute() require.NoError(t, err, "stale cache with error should not return error") - assert.Contains(t, outBuf.String(), "Aave", "expected stale data to be served") + assert.Contains(t, outBuf.String(), "aave", "expected stale data to be served") assert.Contains(t, errBuf.String(), "Warning", "expected warning on stderr") } @@ -229,9 +219,9 @@ func TestListCmd_Table(t *testing.T) { out := outBuf.String() // Test streams are non-TTY, so tsvWriter outputs data rows without headers. // Verify protocol names and action counts are present. - assert.Contains(t, out, "Aave") + assert.Contains(t, out, "aave") assert.Contains(t, out, "2") - assert.Contains(t, out, "Uniswap") + assert.Contains(t, out, "uniswap") assert.Contains(t, out, "1") } From 8d42178a5e7350b74d96591365fa2925b1082900 Mon Sep 17 00:00:00 2001 From: Simon KP Date: Thu, 26 Mar 2026 02:05:18 +1100 Subject: [PATCH 4/4] refactor: rename protocol command to plugin (keep pr alias for backward compat) --- cmd/protocol/get.go | 12 ++++++------ cmd/protocol/list.go | 8 ++++---- cmd/protocol/protocol.go | 14 +++++++------- cmd/root_test.go | 2 +- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/cmd/protocol/get.go b/cmd/protocol/get.go index 2e3e298..fdda82e 100644 --- a/cmd/protocol/get.go +++ b/cmd/protocol/get.go @@ -29,15 +29,15 @@ type ActionDetail struct { func NewGetCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "get ", - Short: "Get protocol details and actions", + Use: "get ", + Short: "Get plugin details and available actions", Aliases: []string{"g"}, Args: cobra.ExactArgs(1), - Example: ` # Get protocol reference card - kh pr g aave + Example: ` # Get plugin reference card + kh plugin g aave - # Get protocol details as JSON - kh pr g morpho --json`, + # Get plugin details as JSON + kh plugin g morpho --json`, RunE: func(cmd *cobra.Command, args []string) error { name := strings.ToLower(args[0]) refresh, _ := cmd.Flags().GetBool("refresh") diff --git a/cmd/protocol/list.go b/cmd/protocol/list.go index a804057..074748e 100644 --- a/cmd/protocol/list.go +++ b/cmd/protocol/list.go @@ -38,14 +38,14 @@ type Protocol struct { func NewListCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "list", - Short: "List blockchain protocols and integrations", + Short: "List available plugins and integrations", Aliases: []string{"ls"}, Args: cobra.NoArgs, - Example: ` # List all protocols (cached) - kh pr ls + Example: ` # List all plugins (cached) + kh plugin ls # Force refresh from API - kh pr ls --refresh`, + kh plugin ls --refresh`, RunE: func(cmd *cobra.Command, args []string) error { refresh, err := cmd.Flags().GetBool("refresh") if err != nil { diff --git a/cmd/protocol/protocol.go b/cmd/protocol/protocol.go index b2fef10..fdbec8d 100644 --- a/cmd/protocol/protocol.go +++ b/cmd/protocol/protocol.go @@ -7,14 +7,14 @@ import ( func NewProtocolCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ - Use: "protocol", - Short: "Browse blockchain protocols", - Aliases: []string{"pr"}, - Example: ` # List all protocols - kh pr ls + Use: "plugin", + Short: "Browse available plugins and integrations", + Aliases: []string{"plugins", "protocol", "pr", "proto"}, + Example: ` # List all plugins + kh plugin ls - # Get details for a protocol - kh pr g uniswap`, + # Get details for a plugin + kh plugin g aave`, } cmd.AddCommand(NewListCmd(f)) diff --git a/cmd/root_test.go b/cmd/root_test.go index b0ffb2e..9694b1f 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -152,7 +152,7 @@ func TestRootCmdHelpIncludesAllCommands(t *testing.T) { helpOutput := buf.String() expectedCommands := []string{ "workflow", "run", "execute", "project", - "tag", "org", "action", "protocol", + "tag", "org", "action", "plugin", "wallet", "template", "billing", "doctor", "version", "auth", "config", "completion", "update", "chain", "read",