Compare commits

...

8 Commits

Author SHA1 Message Date
fiatjaf
95bed5d5a8 nak req --ids-only 2025-02-12 16:37:17 -03:00
fiatjaf
2e30dfe2eb wallet: fix nutzap error message. 2025-02-12 15:51:00 -03:00
fiatjaf
55c6f75b8a mcp: fix a bunch of stupid bugs. 2025-02-07 16:54:23 -03:00
fiatjaf
1f2492c9b1 fix multiline handler thing for when we're don't have any stdin. 2025-02-05 20:42:55 -03:00
fiatjaf
d00976a669 curl: assume POST when there is data and no method is specified. 2025-02-05 10:39:30 -03:00
fiatjaf
4392293ed6 curl method and negative make fixes. 2025-02-05 10:22:04 -03:00
fiatjaf
60d1292f80 parse multiline json from input on nak event and nak req, use iterators instead of channels for more efficient stdin parsing. 2025-02-05 09:44:16 -03:00
fiatjaf
6c634d8081 nak curl 2025-02-04 23:20:35 -03:00
9 changed files with 295 additions and 78 deletions

131
curl.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"strings"
"github.com/fiatjaf/cli/v3"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
var curlFlags []string
var curl = &cli.Command{
Name: "curl",
Usage: "calls curl but with a nip98 header",
Description: "accepts all flags and arguments exactly as they would be passed to curl.",
Flags: defaultKeyFlags,
Action: func(ctx context.Context, c *cli.Command) error {
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
// cowboy parsing of curl flags to get the data we need for nip98
var url string
var method string
var presumedMethod string
curlBodyBuildingFlags := []string{
"-d",
"--data",
"--data-binary",
"--data-ascii",
"--data-raw",
"--data-urlencode",
"-F",
"--form",
"--form-string",
"--form-escape",
"--upload-file",
}
nextIsMethod := false
for _, f := range curlFlags {
if nextIsMethod {
method = f
method, _ = strings.CutPrefix(method, `"`)
method, _ = strings.CutSuffix(method, `"`)
method = strings.ToUpper(method)
} else if strings.HasPrefix(f, "https://") || strings.HasPrefix(f, "http://") {
url = f
} else if f == "--request" || f == "-X" {
nextIsMethod = true
continue
} else if slices.Contains(curlBodyBuildingFlags, f) ||
slices.ContainsFunc(curlBodyBuildingFlags, func(s string) bool {
return strings.HasPrefix(f, s)
}) {
presumedMethod = "POST"
}
nextIsMethod = false
}
if url == "" {
return fmt.Errorf("can't create nip98 event: target url is empty")
}
if method == "" {
if presumedMethod != "" {
method = presumedMethod
} else {
method = "GET"
}
}
// make and sign event
evt := nostr.Event{
Kind: 27235,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"u", url},
{"method", method},
},
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return err
}
// the first 2 indexes of curlFlags were reserved for this
curlFlags[0] = "-H"
curlFlags[1] = fmt.Sprintf("Authorization: Nostr %s", base64.StdEncoding.EncodeToString([]byte(evt.String())))
// call curl
cmd := exec.Command("curl", curlFlags...)
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Run()
return nil
},
}
func realCurl() error {
curlFlags = make([]string, 2, max(len(os.Args)-4, 2))
keyFlags := make([]string, 0, 5)
for i := 0; i < len(os.Args[2:]); i++ {
arg := os.Args[i+2]
if slices.ContainsFunc(defaultKeyFlags, func(f cli.Flag) bool {
bareArg, _ := strings.CutPrefix(arg, "-")
bareArg, _ = strings.CutPrefix(bareArg, "-")
return slices.Contains(f.Names(), bareArg)
}) {
keyFlags = append(keyFlags, arg)
if arg != "--prompt-sec" {
i++
val := os.Args[i+2]
keyFlags = append(keyFlags, val)
}
} else {
curlFlags = append(curlFlags, arg)
}
}
return curl.Run(context.Background(), keyFlags)
}

View File

@@ -154,21 +154,20 @@ example:
doAuth := c.Bool("auth")
// then process input and generate events
for stdinEvent := range getStdinLinesOrBlank() {
evt := nostr.Event{
Tags: make(nostr.Tags, 0, 3),
}
// then process input and generate events:
kindWasSupplied := false
// will reuse this
var evt nostr.Event
// this is called when we have a valid json from stdin
handleEvent := func(stdinEvent string) error {
evt.Content = ""
kindWasSupplied := strings.Contains(stdinEvent, `"kind"`)
mustRehashAndResign := false
if stdinEvent != "" {
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
ctx = lineProcessingError(ctx, "invalid event received from stdin: %s", err)
continue
}
kindWasSupplied = strings.Contains(stdinEvent, `"kind"`)
if err := easyjson.Unmarshal([]byte(stdinEvent), &evt); err != nil {
return fmt.Errorf("invalid event received from stdin: %s", err)
}
if kind := c.Uint("kind"); slices.Contains(c.FlagNames(), "kind") {
@@ -324,6 +323,14 @@ example:
log(nevent + "\n")
}
}
return nil
}
for stdinEvent := range getJsonsOrBlank() {
if err := handleEvent(stdinEvent); err != nil {
ctx = lineProcessingError(ctx, err.Error())
}
}
exitIfLineProcessingError(ctx)

4
go.mod
View File

@@ -17,7 +17,8 @@ require (
github.com/mailru/easyjson v0.9.0
github.com/mark3labs/mcp-go v0.8.3
github.com/markusmobius/go-dateparser v1.2.3
github.com/nbd-wtf/go-nostr v0.49.3
github.com/nbd-wtf/go-nostr v0.49.7
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8
)
require (
@@ -62,7 +63,6 @@ require (
github.com/wasilibs/go-re2 v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect

4
go.sum
View File

@@ -128,8 +128,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nbd-wtf/go-nostr v0.49.3 h1:7tsEdMZOtJ764JuMLffkbhVUi4yyf688dbqArLvItPs=
github.com/nbd-wtf/go-nostr v0.49.3/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
github.com/nbd-wtf/go-nostr v0.49.7 h1:4D9XCqjTJYqUPMuNJI27W5gaiklnTI12IzzWIAOFepE=
github.com/nbd-wtf/go-nostr v0.49.7/go.mod h1:M50QnhkraC5Ol93v3jqxSMm1aGxUQm5mlmkYw5DJzh8=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=

View File

@@ -4,11 +4,13 @@ import (
"bufio"
"context"
"fmt"
"iter"
"math/rand"
"net/http"
"net/textproto"
"net/url"
"os"
"slices"
"strings"
"time"
@@ -43,60 +45,79 @@ func isPiped() bool {
return stat.Mode()&os.ModeCharDevice == 0
}
func getStdinLinesOrBlank() chan string {
multi := make(chan string)
if hasStdinLines := writeStdinLinesOrNothing(multi); !hasStdinLines {
single := make(chan string, 1)
single <- ""
close(single)
return single
} else {
return multi
func getJsonsOrBlank() iter.Seq[string] {
var curr strings.Builder
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
// we're look for an event, but it may be in multiple lines, so if json parsing fails
// we'll try the next line until we're successful
curr.WriteString(stdinLine)
stdinEvent := curr.String()
var dummy any
if err := json.Unmarshal([]byte(stdinEvent), &dummy); err != nil {
return true
}
if !yield(stdinEvent) {
return false
}
curr.Reset()
return true
})
if !hasStdin {
yield("{}")
}
}
}
func getStdinLinesOrArguments(args cli.Args) chan string {
func getStdinLinesOrBlank() iter.Seq[string] {
return func(yield func(string) bool) {
hasStdin := writeStdinLinesOrNothing(func(stdinLine string) bool {
if !yield(stdinLine) {
return false
}
return true
})
if !hasStdin {
yield("")
}
}
}
func getStdinLinesOrArguments(args cli.Args) iter.Seq[string] {
return getStdinLinesOrArgumentsFromSlice(args.Slice())
}
func getStdinLinesOrArgumentsFromSlice(args []string) chan string {
func getStdinLinesOrArgumentsFromSlice(args []string) iter.Seq[string] {
// try the first argument
if len(args) > 0 {
argsCh := make(chan string, 1)
go func() {
for _, arg := range args {
argsCh <- arg
}
close(argsCh)
}()
return argsCh
return slices.Values(args)
}
// try the stdin
multi := make(chan string)
if !writeStdinLinesOrNothing(multi) {
close(multi)
return func(yield func(string) bool) {
writeStdinLinesOrNothing(yield)
}
return multi
}
func writeStdinLinesOrNothing(ch chan string) (hasStdinLines bool) {
func writeStdinLinesOrNothing(yield func(string) bool) (hasStdinLines bool) {
if isPiped() {
// piped
go func() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
hasEmittedAtLeastOne := false
for scanner.Scan() {
ch <- strings.TrimSpace(scanner.Text())
hasEmittedAtLeastOne = true
scanner := bufio.NewScanner(os.Stdin)
scanner.Buffer(make([]byte, 16*1024*1024), 256*1024*1024)
hasEmittedAtLeastOne := false
for scanner.Scan() {
if !yield(strings.TrimSpace(scanner.Text())) {
return
}
if !hasEmittedAtLeastOne {
ch <- ""
}
close(ch)
}()
return true
hasEmittedAtLeastOne = true
}
return hasEmittedAtLeastOne
} else {
// not piped
return false

11
main.go
View File

@@ -39,6 +39,7 @@ var app = &cli.Command{
outbox,
wallet,
mcpServer,
curl,
},
Version: version,
Flags: []cli.Flag{
@@ -140,6 +141,16 @@ func main() {
Usage: "prints the version",
}
// a megahack to enable this curl command proxy
if len(os.Args) > 2 && os.Args[1] == "curl" {
if err := realCurl(); err != nil {
stdout(err)
colors.reset()
os.Exit(1)
}
return
}
if err := app.Run(context.Background(), os.Args); err != nil {
stdout(err)
colors.reset()

52
mcp.go
View File

@@ -27,6 +27,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("relay",
mcp.Description("Relay to publish the note to"),
),
mcp.WithString("content",
mcp.Required(),
mcp.Description("Arbitrary string to be published"),
@@ -38,6 +41,11 @@ var mcpServer = &cli.Command{
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content, _ := request.Params.Arguments["content"].(string)
mention, _ := request.Params.Arguments["mention"].(string)
relayI, ok := request.Params.Arguments["relay"]
var relay string
if ok {
relay, _ = relayI.(string)
}
if mention != "" && !nostr.IsValidPublicKey(mention) {
return mcp.NewToolResultError("the given mention isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -71,16 +79,33 @@ var mcpServer = &cli.Command{
relays = []string{"nos.lol", "relay.damus.io"}
}
for res := range sys.Pool.PublishMany(ctx, []string{"nos.lol"}, evt) {
// extra relay specified
relays = append(relays, relay)
result := strings.Builder{}
result.WriteString(
fmt.Sprintf("the event we generated has id '%s', kind '%d' and is signed by pubkey '%s'. ",
evt.ID,
evt.Kind,
evt.PubKey,
),
)
for res := range sys.Pool.PublishMany(ctx, relays, evt) {
if res.Error != nil {
return mcp.NewToolResultError(
fmt.Sprintf("there was an error publishing the event to the relay %s",
result.WriteString(
fmt.Sprintf("there was an error publishing the event to the relay %s. ",
res.RelayURL),
), nil
)
} else {
result.WriteString(
fmt.Sprintf("the event was successfully published to the relay %s. ",
res.RelayURL),
)
}
}
return mcp.NewToolResultText("event was successfully published with id " + evt.ID), nil
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("resolve_nostr_uri",
@@ -152,13 +177,9 @@ var mcpServer = &cli.Command{
mcp.Description("Public key of Nostr user we want to know the relay from where to read"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name, _ := request.Params.Arguments["name"].(string)
re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil
}
return mcp.NewToolResultText(re.PubKey), nil
pubkey, _ := request.Params.Arguments["pubkey"].(string)
res := sys.FetchOutboxRelays(ctx, pubkey, 1)
return mcp.NewToolResultText(res[0]), nil
})
s.AddTool(mcp.NewTool("read_events_from_relay",
@@ -182,7 +203,11 @@ var mcpServer = &cli.Command{
relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int)
pubkey, _ := request.Params.Arguments["pubkey"].(string)
pubkeyI, ok := request.Params.Arguments["pubkey"]
var pubkey string
if ok {
pubkey, _ = pubkeyI.(string)
}
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) {
return mcp.NewToolResultError("the given pubkey isn't a valid public key, it must be 32 bytes hex, like the ones returned by search_profile"), nil
@@ -198,7 +223,6 @@ var mcpServer = &cli.Command{
events := sys.Pool.SubManyEose(ctx, []string{relay}, nostr.Filters{filter})
result := strings.Builder{}
for ie := range events {
result.WriteString("author public key: ")

41
req.go
View File

@@ -9,6 +9,7 @@ import (
"github.com/fiatjaf/cli/v3"
"github.com/mailru/easyjson"
"github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip77"
)
const (
@@ -32,6 +33,10 @@ example:
DisableSliceFlagSeparator: true,
Flags: append(defaultKeyFlags,
append(reqFilterFlags,
&cli.BoolFlag{
Name: "ids-only",
Usage: "use nip77 to fetch just a list of ids",
},
&cli.BoolFlag{
Name: "stream",
Usage: "keep the subscription open, printing all events as they are returned",
@@ -107,7 +112,7 @@ example:
}()
}
for stdinFilter := range getStdinLinesOrBlank() {
for stdinFilter := range getJsonsOrBlank() {
filter := nostr.Filter{}
if stdinFilter != "" {
if err := easyjson.Unmarshal([]byte(stdinFilter), &filter); err != nil {
@@ -121,15 +126,33 @@ example:
}
if len(relayUrls) > 0 {
fn := sys.Pool.SubManyEose
if c.Bool("paginate") {
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
} else if c.Bool("stream") {
fn = sys.Pool.SubMany
}
if c.Bool("ids-only") {
seen := make(map[string]struct{}, max(500, filter.Limit))
for _, url := range relayUrls {
ch, err := nip77.FetchIDsOnly(ctx, url, filter)
if err != nil {
log("negentropy call to %s failed: %s", url, err)
continue
}
for id := range ch {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
stdout(id)
}
}
} else {
fn := sys.Pool.SubManyEose
if c.Bool("paginate") {
fn = paginateWithParams(c.Duration("paginate-interval"), c.Uint("paginate-global-limit"))
} else if c.Bool("stream") {
fn = sys.Pool.SubMany
}
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event)
for ie := range fn(ctx, relayUrls, nostr.Filters{filter}) {
stdout(ie.Event)
}
}
} else {
// no relays given, will just print the filter

View File

@@ -316,8 +316,8 @@ var wallet = &cli.Command{
},
Action: func(ctx context.Context, c *cli.Command) error {
args := c.Args().Slice()
if len(args) >= 2 {
return fmt.Errorf("must be called as `nak wallet send <amount> <target>...")
if len(args) < 2 {
return fmt.Errorf("must be called as `nak wallet nutzap <amount> <target>...")
}
w, closew, err := prepareWallet(ctx, c)