From aaaadff7981705b320b6fa299f4f0a9b5a91ea5c Mon Sep 17 00:00:00 2001 From: Simon KP Date: Wed, 22 Apr 2026 05:01:06 +1000 Subject: [PATCH 1/2] feat(35): add kh wallet add/info/fund/link wrappers around @keeperhub/wallet - runNpxWallet helper spawns npx @keeperhub/wallet via os/exec stdlib with argv slice (no shell, no injection vector) - Forwards stdio + injects KEEPERHUB_API_URL derived from --host / hosts.yml - link subcommand pre-checks KH_SESSION_COOKIE env var is set - Wires 4 new subcommands into kh wallet; preserves creator-wallet balance + tokens unchanged - Closes DIST-04 --- cmd/wallet/add.go | 24 +++++++++++++ cmd/wallet/agentic_wrapper.go | 63 +++++++++++++++++++++++++++++++++++ cmd/wallet/fund.go | 23 +++++++++++++ cmd/wallet/info.go | 22 ++++++++++++ cmd/wallet/link.go | 33 ++++++++++++++++++ cmd/wallet/wallet.go | 29 +++++++++++++--- 6 files changed, 190 insertions(+), 4 deletions(-) create mode 100644 cmd/wallet/add.go create mode 100644 cmd/wallet/agentic_wrapper.go create mode 100644 cmd/wallet/fund.go create mode 100644 cmd/wallet/info.go create mode 100644 cmd/wallet/link.go diff --git a/cmd/wallet/add.go b/cmd/wallet/add.go new file mode 100644 index 0000000..6deabb9 --- /dev/null +++ b/cmd/wallet/add.go @@ -0,0 +1,24 @@ +package wallet + +import ( + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewAddCmd returns the `kh wallet add` subcommand -- a thin wrapper around +// `npx @keeperhub/wallet add` that provisions a new agentic wallet. +func NewAddCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "add", + Short: "Provision a new agentic wallet (no KeeperHub account required)", + Long: `Provision a new agentic wallet by calling POST /api/agentic-wallet/provision. + +This is a thin wrapper around ` + "`npx @keeperhub/wallet add`" + ` -- the npm package is the +canonical tool. Writes {subOrgId, walletAddress, hmacSecret} to ~/.keeperhub/wallet.json +(chmod 0o600) and prints subOrgId + walletAddress (hmacSecret is NEVER printed).`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runNpxWallet(f, cmd, "add", nil) + }, + } +} diff --git a/cmd/wallet/agentic_wrapper.go b/cmd/wallet/agentic_wrapper.go new file mode 100644 index 0000000..cc50c2c --- /dev/null +++ b/cmd/wallet/agentic_wrapper.go @@ -0,0 +1,63 @@ +package wallet + +import ( + "errors" + "fmt" + "os" + "os/exec" + + khhttp "github.com/keeperhub/cli/internal/http" + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// execCommand is a package-level var so tests can override with a fake builder. +// It wraps stdlib os/exec.Command: argv is a fixed slice, NO shell interpolation. +var execCommand = exec.Command + +// lookPath is a package-level var so tests can override the PATH probe. +var lookPath = exec.LookPath + +// runNpxWallet invokes `npx @keeperhub/wallet [args...]` via the Go +// stdlib os/exec package. Argv is a fixed slice built from hard-coded strings, +// so there is no command-injection vector (exec.Command is the execFile-equivalent +// in Go -- it does not spawn a shell). +// +// Forwards stdio, injects KEEPERHUB_API_URL derived from --host / hosts.yml, and +// returns any child process exit code as the Cobra command's error. +// +// Does NOT parse the child's stdout -- the npm package is the canonical tool; +// this wrapper treats it as an opaque CLI to avoid reimplementing signing logic. +func runNpxWallet(f *cmdutil.Factory, cmd *cobra.Command, subcmd string, args []string) error { + if _, err := lookPath("npx"); err != nil { + return fmt.Errorf("`npx` not found on PATH: install Node.js 20+ from https://nodejs.org and retry (npx ships with npm)") + } + + cfg, cfgErr := f.Config() + if cfgErr != nil { + return fmt.Errorf("reading kh config: %w", cfgErr) + } + host := cmdutil.ResolveHost(cmd, cfg) + baseURL := khhttp.BuildBaseURL(host) + + childArgs := append([]string{"@keeperhub/wallet", subcmd}, args...) + child := execCommand("npx", childArgs...) + child.Stdin = f.IOStreams.In + child.Stdout = f.IOStreams.Out + child.Stderr = f.IOStreams.ErrOut + + // Base env: inherit from parent so KH_SESSION_COOKIE / HOME / PATH propagate. + // Append KEEPERHUB_API_URL last so it wins over any pre-existing env entry. + env := os.Environ() + env = append(env, "KEEPERHUB_API_URL="+baseURL) + child.Env = env + + if runErr := child.Run(); runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + return fmt.Errorf("npx @keeperhub/wallet %s exited with code %d", subcmd, exitErr.ExitCode()) + } + return fmt.Errorf("failed to run npx @keeperhub/wallet %s: %w", subcmd, runErr) + } + return nil +} diff --git a/cmd/wallet/fund.go b/cmd/wallet/fund.go new file mode 100644 index 0000000..3356550 --- /dev/null +++ b/cmd/wallet/fund.go @@ -0,0 +1,23 @@ +package wallet + +import ( + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewFundCmd returns the `kh wallet fund` subcommand -- a thin wrapper around +// `npx @keeperhub/wallet fund` that prints funding instructions for the agentic wallet. +func NewFundCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "fund", + Short: "Print Coinbase Onramp URL (Base USDC) and Tempo deposit address for the agentic wallet", + Long: `Print a Coinbase Onramp URL for Base USDC funding plus the Tempo deposit address. + +Thin wrapper around ` + "`npx @keeperhub/wallet fund`" + `. No HTTP calls, no browser launch -- +prints copy-paste instructions only.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runNpxWallet(f, cmd, "fund", nil) + }, + } +} diff --git a/cmd/wallet/info.go b/cmd/wallet/info.go new file mode 100644 index 0000000..fdbaa59 --- /dev/null +++ b/cmd/wallet/info.go @@ -0,0 +1,22 @@ +package wallet + +import ( + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewInfoCmd returns the `kh wallet info` subcommand -- a thin wrapper around +// `npx @keeperhub/wallet info` that prints the local agentic wallet identity. +func NewInfoCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "info", + Short: "Print subOrgId and walletAddress from local agentic wallet config", + Long: `Print subOrgId and walletAddress from ~/.keeperhub/wallet.json. + +Thin wrapper around ` + "`npx @keeperhub/wallet info`" + `. Exits non-zero if the config is missing.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runNpxWallet(f, cmd, "info", nil) + }, + } +} diff --git a/cmd/wallet/link.go b/cmd/wallet/link.go new file mode 100644 index 0000000..b7f4c16 --- /dev/null +++ b/cmd/wallet/link.go @@ -0,0 +1,33 @@ +package wallet + +import ( + "fmt" + "os" + + "github.com/keeperhub/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// NewLinkCmd returns the `kh wallet link` subcommand -- a thin wrapper around +// `npx @keeperhub/wallet link` that links an agentic wallet to a KeeperHub account. +// +// Requires KH_SESSION_COOKIE to be set (v0.1.0 env-var contract from Phase 34 Plan 06). +func NewLinkCmd(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "link", + Short: "Link the agentic wallet to a KeeperHub account (requires KH_SESSION_COOKIE)", + Long: `Link the current agentic wallet to your KeeperHub account by calling POST /api/agentic-wallet/link. + +Thin wrapper around ` + "`npx @keeperhub/wallet link`" + `. Requires the KH_SESSION_COOKIE env var +set to a valid kh session cookie (sign in at app.keeperhub.com, copy the session cookie, export it). + +v0.1.0 does not launch a browser session handshake; this env-var contract matches the npm CLI.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if os.Getenv("KH_SESSION_COOKIE") == "" { + return fmt.Errorf("KH_SESSION_COOKIE env var is required: sign in at app.keeperhub.com, copy the session cookie, and re-run with KH_SESSION_COOKIE='' kh wallet link") + } + return runNpxWallet(f, cmd, "link", nil) + }, + } +} diff --git a/cmd/wallet/wallet.go b/cmd/wallet/wallet.go index 9c0326c..9394945 100644 --- a/cmd/wallet/wallet.go +++ b/cmd/wallet/wallet.go @@ -8,20 +8,41 @@ import ( func NewWalletCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "wallet", - Short: "Manage wallets", + Short: "Manage wallets (creator-wallet REST API or agentic-wallet npm package)", Aliases: []string{"w"}, - Example: ` # Show wallet balance + Long: `Manage wallets. + +Creator wallet (REST): + kh w balance show creator-wallet on-chain balances via KeeperHub REST API + kh w tokens list supported tokens + +Agentic wallet (thin wrappers around npx @keeperhub/wallet): + kh w add provision a new agentic wallet (no account required) + kh w info print agentic subOrgId + walletAddress + kh w fund print Coinbase Onramp URL + Tempo deposit address + kh w link link agentic wallet to a KeeperHub account (needs KH_SESSION_COOKIE)`, + Example: ` # Creator wallet balance (REST): kh w balance - # List supported tokens - kh w tokens`, + # Provision an agentic wallet (npx wrapper): + kh w add + + # Check balance on the agentic wallet: + npx @keeperhub/wallet balance`, } cmd.PersistentFlags().Bool("json", false, "Output as JSON") cmd.PersistentFlags().String("jq", "", "Filter JSON output with a jq expression") + // Creator wallet (REST) -- unchanged from pre-35 kh. cmd.AddCommand(NewBalanceCmd(f)) cmd.AddCommand(NewTokensCmd(f)) + // Agentic wallet (npx @keeperhub/wallet) -- new in phase 35. + cmd.AddCommand(NewAddCmd(f)) + cmd.AddCommand(NewInfoCmd(f)) + cmd.AddCommand(NewFundCmd(f)) + cmd.AddCommand(NewLinkCmd(f)) + return cmd } From e0aa428e1d6b9f989f7cc440b5c8f15317c61eca Mon Sep 17 00:00:00 2001 From: Simon KP Date: Wed, 22 Apr 2026 05:02:03 +1000 Subject: [PATCH 2/2] test(35): assert kh wallet agentic-wrapper help, dispatch, and subcommand registration - 3 help tests ensure add/info/fund descriptions mention agentic wallet and reference the underlying npm package - link test verifies KH_SESSION_COOKIE pre-check errors out when unset - registration test asserts all 6 subcommands (balance, tokens, add, info, fund, link) are wired into kh wallet --- cmd/wallet/agentic_wrapper_test.go | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 cmd/wallet/agentic_wrapper_test.go diff --git a/cmd/wallet/agentic_wrapper_test.go b/cmd/wallet/agentic_wrapper_test.go new file mode 100644 index 0000000..20da8c2 --- /dev/null +++ b/cmd/wallet/agentic_wrapper_test.go @@ -0,0 +1,99 @@ +package wallet_test + +import ( + "testing" + + "github.com/keeperhub/cli/cmd/wallet" + "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" +) + +// newAgenticFactory builds a Factory suitable for the npx-wrapper subcommands. +// HTTPClient is unused by these commands but stubbed for interface completeness. +func newAgenticFactory(ios *iostreams.IOStreams) *cmdutil.Factory { + return &cmdutil.Factory{ + AppVersion: "1.0.0", + IOStreams: ios, + HTTPClient: func() (*khhttp.Client, error) { + return khhttp.NewClient(khhttp.ClientOptions{Host: "https://app.keeperhub.com", AppVersion: "1.0.0"}), nil + }, + Config: func() (config.Config, error) { + return config.Config{DefaultHost: "app.keeperhub.com"}, nil + }, + } +} + +func TestNewAddCmd_Help(t *testing.T) { + ios, outBuf, _, _ := iostreams.Test() + f := newAgenticFactory(ios) + root := wallet.NewWalletCmd(f) + // Route Cobra's help output into our captured buffer. + root.SetOut(outBuf) + root.SetErr(outBuf) + root.SetArgs([]string{"add", "--help"}) + err := root.Execute() + require.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "agentic wallet", "help should describe agentic wallet, not creator wallet") + assert.Contains(t, out, "npx @keeperhub/wallet", "help should reference the underlying npm package") +} + +func TestNewInfoCmd_Help(t *testing.T) { + ios, outBuf, _, _ := iostreams.Test() + f := newAgenticFactory(ios) + root := wallet.NewWalletCmd(f) + root.SetOut(outBuf) + root.SetErr(outBuf) + root.SetArgs([]string{"info", "--help"}) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, outBuf.String(), "subOrgId") +} + +func TestNewFundCmd_Help(t *testing.T) { + ios, outBuf, _, _ := iostreams.Test() + f := newAgenticFactory(ios) + root := wallet.NewWalletCmd(f) + root.SetOut(outBuf) + root.SetErr(outBuf) + root.SetArgs([]string{"fund", "--help"}) + err := root.Execute() + require.NoError(t, err) + assert.Contains(t, outBuf.String(), "Coinbase Onramp") +} + +func TestNewLinkCmd_RequiresSessionCookie(t *testing.T) { + ios, outBuf, errBuf, _ := iostreams.Test() + f := newAgenticFactory(ios) + root := wallet.NewWalletCmd(f) + root.SetOut(outBuf) + root.SetErr(errBuf) + root.SetArgs([]string{"link"}) + // Ensure KH_SESSION_COOKIE is unset for this test. + t.Setenv("KH_SESSION_COOKIE", "") + // Silence Cobra's auto-usage printing so the assertion is against the RunE error. + root.SilenceUsage = true + root.SilenceErrors = true + err := root.Execute() + require.Error(t, err) + combined := err.Error() + errBuf.String() + outBuf.String() + assert.Contains(t, combined, "KH_SESSION_COOKIE") +} + +func TestWalletCmd_PreservesCreatorWalletSubcommands(t *testing.T) { + ios, _, _, _ := iostreams.Test() + f := newAgenticFactory(ios) + root := wallet.NewWalletCmd(f) + // Walk the direct subcommand list; assert balance + tokens still present. + subs := map[string]bool{} + for _, c := range root.Commands() { + subs[c.Name()] = true + } + for _, expected := range []string{"balance", "tokens", "add", "info", "fund", "link"} { + assert.True(t, subs[expected], "expected subcommand %q on kh wallet", expected) + } +}