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
24 changes: 24 additions & 0 deletions cmd/wallet/add.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
63 changes: 63 additions & 0 deletions cmd/wallet/agentic_wrapper.go
Original file line number Diff line number Diff line change
@@ -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 <subcmd> [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
}
99 changes: 99 additions & 0 deletions cmd/wallet/agentic_wrapper_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
23 changes: 23 additions & 0 deletions cmd/wallet/fund.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
22 changes: 22 additions & 0 deletions cmd/wallet/info.go
Original file line number Diff line number Diff line change
@@ -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)
},
}
}
33 changes: 33 additions & 0 deletions cmd/wallet/link.go
Original file line number Diff line number Diff line change
@@ -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='<cookie>' kh wallet link")
}
return runNpxWallet(f, cmd, "link", nil)
},
}
}
29 changes: 25 additions & 4 deletions cmd/wallet/wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading