Compare commits

..

6 Commits

Author SHA1 Message Date
franzap
6daf0f4e5c Update zapstore.yaml 2025-05-20 23:15:31 -03:00
Alex Gleason
bfeaf0710f Allow --prompt-sec to be used with pipes 2025-05-03 07:27:37 -03:00
fiatjaf
e45b54ea62 fix nak mcp. 2025-04-10 16:59:56 -03:00
fiatjaf
35da063c30 precheck for validity of relay URLs and prevent unwanted crash otherwise. 2025-04-07 23:13:32 -03:00
fiatjaf
15aefe3df4 more examples on readme. 2025-04-03 22:17:30 -03:00
fiatjaf
55fd631787 fix term.GetSize() when piping. 2025-04-03 22:08:11 -03:00
5 changed files with 154 additions and 100 deletions

View File

@@ -229,6 +229,39 @@ type the password to decrypt your secret key: ********
~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"') ~> aria2c $(nak fetch nevent1qqsdsg6x7uujekac4ga7k7qa9q9sx8gqj7xzjf5w9us0dm0ghvf4ugspp4mhxue69uhkummn9ekx7mq6dw9y4 | jq -r '"magnet:?xt=urn:btih:\(tag_value("x"))&dn=\(tag_value("title"))&tr=http%3A%2F%2Ftracker.loadpeers.org%3A8080%2FxvRKfvAlnfuf5EfxTT5T0KIVPtbqAHnX%2Fannounce&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969%2Fannounce&tr=udp%3A%2F%2Ftracker.openbittorrent.com%3A6969%2Fannounce&tr=udp%3A%2F%2Fopen.stealth.si%3A80%2Fannounce&tr=udp%3A%2F%2Ftracker.torrent.eu.org%3A451%2Fannounce&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=\(tags("tracker") | map(.[1] | @uri) | join("&tr="))"')
``` ```
### mount Nostr as a FUSE filesystem and publish a note
```shell
~> nak fs --sec 01 ~/nostr
- mounting at /home/user/nostr... ok.
~> cd ~/nostr/npub1xxxxxx/notes/
~> echo "satellites are bad!" > new
pending note updated, timer reset.
- `touch publish` to publish immediately
- `rm new` to erase and cancel the publication.
~> touch publish
publishing now!
{"id":"f1cbfa6...","pubkey":"...","content":"satellites are bad!","sig":"..."}
publishing to 3 relays... offchain.pub: ok, nostr.wine: ok, pyramid.fiatjaf.com: ok
event published as f1cbfa6... and updated locally.
```
### list NIP-60 wallet tokens and send some
```shell
~> nak wallet tokens
91a10b6fc8bbe7ef2ad9ad0142871d80468b697716d9d2820902db304ff1165e 500 cashu.space
cac7f89f0611021984d92a7daca219e4cd1c9798950e50e952bba7cde1ac1337 1000 legend.lnbits.com
~> nak wallet send 100
cashuA1psxqyry8...
~> nak wallet pay lnbc1...
```
### upload and download files with blossom
```shell
~> nak blossom --server blossom.azzamo.net --sec 01 upload image.png
{"sha256":"38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf","url":"https://blossom.azzamo.net/38c51756f3e9fedf039488a1f6e513286f6743194e7a7f25effdc84a0ee4c2cf.png"}
~> nak blossom --server aegis.utxo.one download acc8ea43d4e6b706f68b249144364f446854b7f63ba1927371831c05dcf0256c -o downloaded.png
```
## contributing to this repository ## contributing to this repository
Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`. Use NIP-34 to send your patches to `naddr1qqpkucttqy28wumn8ghj7un9d3shjtnwdaehgu3wvfnsz9nhwden5te0wfjkccte9ehx7um5wghxyctwvsq3gamnwvaz7tmjv4kxz7fwv3sk6atn9e5k7q3q80cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsxpqqqpmej2wctpn`.

View File

@@ -158,6 +158,14 @@ func connectToAllRelays(
preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error), // if this exists we will force preauth preAuthSigner func(ctx context.Context, c *cli.Command, log func(s string, args ...any), authEvent nostr.RelayEvent) (err error), // if this exists we will force preauth
opts ...nostr.PoolOption, opts ...nostr.PoolOption,
) []*nostr.Relay { ) []*nostr.Relay {
// first pass to check if these are valid relay URLs
for _, url := range relayUrls {
if !nostr.IsValidRelayURL(nostr.NormalizeURL(url)) {
log("invalid relay URL: %s\n", url)
os.Exit(4)
}
}
sys.Pool = nostr.NewSimplePool(context.Background(), sys.Pool = nostr.NewSimplePool(context.Background(),
append(opts, append(opts,
nostr.WithEventMiddleware(sys.TrackEventHints), nostr.WithEventMiddleware(sys.TrackEventHints),
@@ -305,7 +313,7 @@ func supportsDynamicMultilineMagic() bool {
return false return false
} }
width, _, err := term.GetSize(0) width, _, err := term.GetSize(int(os.Stderr.Fd()))
if err != nil { if err != nil {
return false return false
} }
@@ -373,8 +381,9 @@ func unwrapAll(err error) error {
} }
func clampMessage(msg string, prefixAlreadyPrinted int) string { func clampMessage(msg string, prefixAlreadyPrinted int) string {
termSize, _, _ := term.GetSize(0) termSize, _, _ := term.GetSize(int(os.Stderr.Fd()))
if len(msg) > termSize-prefixAlreadyPrinted {
if len(msg) > termSize-prefixAlreadyPrinted && prefixAlreadyPrinted+1 < termSize {
msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…" msg = msg[0:termSize-prefixAlreadyPrinted-1] + "…"
} }
return msg return msg

View File

@@ -15,6 +15,7 @@ import (
"github.com/nbd-wtf/go-nostr/nip19" "github.com/nbd-wtf/go-nostr/nip19"
"github.com/nbd-wtf/go-nostr/nip46" "github.com/nbd-wtf/go-nostr/nip46"
"github.com/nbd-wtf/go-nostr/nip49" "github.com/nbd-wtf/go-nostr/nip49"
"golang.org/x/term"
) )
var defaultKeyFlags = []cli.Flag{ var defaultKeyFlags = []cli.Flag{
@@ -84,9 +85,6 @@ func gatherSecretKeyOrBunkerFromArguments(ctx context.Context, c *cli.Command) (
} }
if c.Bool("prompt-sec") { if c.Bool("prompt-sec") {
if isPiped() {
return "", nil, fmt.Errorf("can't prompt for a secret key when processing data from a pipe, try again without --prompt-sec")
}
sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil) sec, err = askPassword("type your secret key as ncryptsec, nsec or hex: ", nil)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("failed to get secret key: %w", err) return "", nil, fmt.Errorf("failed to get secret key: %w", err)
@@ -133,29 +131,59 @@ func promptDecrypt(ncryptsec string) (string, error) {
} }
func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) { func askPassword(msg string, shouldAskAgain func(answer string) bool) (string, error) {
config := &readline.Config{ if isPiped() {
Stdout: color.Error, // Use TTY method when stdin is piped
Prompt: color.YellowString(msg), tty, err := os.Open("/dev/tty")
InterruptPrompt: "^C", if err != nil {
DisableAutoSaveHistory: true, return "", fmt.Errorf("can't prompt for a secret key when processing data from a pipe on this system (failed to open /dev/tty: %w), try again without --prompt-sec or provide the key via --sec or NOSTR_SECRET_KEY environment variable", err)
EnableMask: true, }
MaskRune: '*', defer tty.Close()
}
rl, err := readline.NewEx(config) for {
if err != nil { // Print the prompt to stderr so it's visible to the user
return "", err fmt.Fprintf(color.Error, color.YellowString(msg))
}
for { // Read password from TTY with masking
answer, err := rl.Readline() password, err := term.ReadPassword(int(tty.Fd()))
if err != nil {
return "", err
}
// Print newline after password input
fmt.Fprintln(color.Error)
answer := strings.TrimSpace(string(password))
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, nil
}
} else {
// Use normal readline method when stdin is not piped
config := &readline.Config{
Stdout: color.Error,
Prompt: color.YellowString(msg),
InterruptPrompt: "^C",
DisableAutoSaveHistory: true,
EnableMask: true,
MaskRune: '*',
}
rl, err := readline.NewEx(config)
if err != nil { if err != nil {
return "", err return "", err
} }
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) { for {
continue answer, err := rl.Readline()
if err != nil {
return "", err
}
answer = strings.TrimSpace(answer)
if shouldAskAgain != nil && shouldAskAgain(answer) {
continue
}
return answer, err
} }
return answer, err
} }
} }

115
mcp.go
View File

@@ -28,25 +28,13 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("publish_note", s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("Publish a short note event to Nostr with the given text content"), mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("relay", mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
mcp.Description("Relay to publish the note to"), mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
), mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
mcp.WithString("content", ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Required(), content := required[string](r, "content")
mcp.Description("Arbitrary string to be published"), mention, _ := optional[string](r, "mention")
), relay, _ := optional[string](r, "relay")
mcp.WithString("mention",
mcp.Required(),
mcp.Description("Nostr user's public key to be mentioned"),
),
), 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) { 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 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
@@ -81,7 +69,9 @@ var mcpServer = &cli.Command{
} }
// extra relay specified // extra relay specified
relays = append(relays, relay) if relay != "" {
relays = append(relays, relay)
}
result := strings.Builder{} result := strings.Builder{}
result.WriteString( result.WriteString(
@@ -111,12 +101,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("resolve_nostr_uri", s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."), mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri", mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("URI to be resolved"), uri := required[string](r, "uri")
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri, _ := request.Params.Arguments["uri"].(string)
if strings.HasPrefix(uri, "nostr:") { if strings.HasPrefix(uri, "nostr:") {
uri = uri[6:] uri = uri[6:]
} }
@@ -159,12 +146,9 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("search_profile", s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"), mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("Name to be searched"), name := required[string](r, "name")
),
), 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}}) re := sys.Pool.QuerySingle(ctx, []string{"relay.nostr.band", "nostr.wine"}, nostr.Filter{Search: name, Kinds: []int{0}})
if re == nil { if re == nil {
return mcp.NewToolResultError("couldn't find anyone with that name"), nil return mcp.NewToolResultError("couldn't find anyone with that name"), nil
@@ -175,42 +159,24 @@ var mcpServer = &cli.Command{
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey", s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"), mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey", mcp.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
mcp.Required(), ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("Public key of Nostr user we want to know the relay from where to read"), pubkey := required[string](r, "pubkey")
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, _ := request.Params.Arguments["pubkey"].(string)
res := sys.FetchOutboxRelays(ctx, pubkey, 1) res := sys.FetchOutboxRelays(ctx, pubkey, 1)
return mcp.NewToolResultText(res[0]), nil return mcp.NewToolResultText(res[0]), nil
}) })
s.AddTool(mcp.NewTool("read_events_from_relay", s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"), mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithNumber("kind", mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.Required(), mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.Description("event kind number to include in the 'kinds' field"), mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
), mcp.WithString("pubkey", mcp.Description("pubkey to include in the 'authors' field")),
mcp.WithString("pubkey", ), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
mcp.Description("pubkey to include in the 'authors' field"), relay := required[string](r, "relay")
), kind := int(required[float64](r, "kind"))
mcp.WithNumber("limit", limit := int(required[float64](r, "limit"))
mcp.Required(), pubkey, _ := optional[string](r, "pubkey")
mcp.Description("maximum number of events to query"),
),
mcp.WithString("relay",
mcp.Required(),
mcp.Description("relay URL to send the query to"),
),
), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
relay, _ := request.Params.Arguments["relay"].(string)
limit, _ := request.Params.Arguments["limit"].(int)
kind, _ := request.Params.Arguments["kind"].(int)
pubkeyI, ok := request.Params.Arguments["pubkey"]
var pubkey string
if ok {
pubkey, _ = pubkeyI.(string)
}
if pubkey != "" && !nostr.IsValidPublicKey(pubkey) { 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 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
@@ -242,3 +208,28 @@ var mcpServer = &cli.Command{
return server.ServeStdio(s) return server.ServeStdio(s)
}, },
} }
func required[T comparable](r mcp.CallToolRequest, p string) T {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero
}
if r.Params.Arguments[p].(T) == zero {
return zero
}
return r.Params.Arguments[p].(T)
}
func optional[T any](r mcp.CallToolRequest, p string) (T, bool) {
var zero T
if _, ok := r.Params.Arguments[p]; !ok {
return zero, false
}
if _, ok := r.Params.Arguments[p].(T); !ok {
return zero, false
}
return r.Params.Arguments[p].(T), true
}

View File

@@ -1,14 +1,7 @@
nak: repository: https://github.com/fiatjaf/nak
cli: assets:
name: nak - nak-v\d+\.\d+\.\d+-darwin-arm64
summary: a command line tool for doing all things nostr - nak-v\d+\.\d+\.\d+-linux-amd64
repository: https://github.com/fiatjaf/nak - nak-v\d+\.\d+\.\d+-linux-arm64
artifacts: remote_metadata:
nak-v%v-darwin-arm64: - github
platforms: [darwin-arm64]
nak-v%v-darwin-amd64:
platforms: [darwin-x86_64]
nak-v%v-linux-arm64:
platforms: [linux-aarch64]
nak-v%v-linux-amd64:
platforms: [linux-x86_64]