Compare commits

...

9 Commits

Author SHA1 Message Date
fiatjaf
ba9a5badc6 uselessly change some words. 2026-01-27 09:25:34 -03:00
fiatjaf
c0bbf73961 update nostrlib to fix decoding of "note1".
fixes https://github.com/fiatjaf/nak/issues/99
2026-01-26 23:45:12 -03:00
fiatjaf
5320feee4f more installation formulas. 2026-01-26 22:46:36 -03:00
fiatjaf
58c1fab0f0 mcp: search method. 2026-01-26 22:34:33 -03:00
fiatjaf
5f30009e72 decode: print error when failed. 2026-01-26 22:34:33 -03:00
fiatjaf
548918578b add new capabilities to README. 2026-01-26 22:34:33 -03:00
Alex Gleason
7757400ab3 Add easy install script 2026-01-26 22:27:28 -03:00
fiatjaf
3ee6320312 bunker: ignore duplicates caused by switch_relays. 2026-01-21 23:17:00 -03:00
fiatjaf
91474d65eb bunker: set default relays so switch_relays works. 2026-01-21 22:19:00 -03:00
7 changed files with 208 additions and 27 deletions

View File

@@ -1,9 +1,19 @@
# nak, the nostr army knife
install with `go install github.com/fiatjaf/nak@latest` or
[download a binary](https://github.com/fiatjaf/nak/releases).
install with this one-liner:
or get the source with `git clone https://github.com/fiatjaf/nak` then install with `go install` or run with docker using `docker build -t nak . && docker run nak event`.
```sh
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install nak` if you use **Nix**.
## what can you do with it?
@@ -34,7 +44,7 @@ publishing to wss://relay.damus.io... success.
"Activando modo zen…\n\n#GM #Nostr #Hispano"
```
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
### decode a NIP-19 note1 code, add a relay hint, encode it back to nevent1
```shell
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
@@ -200,6 +210,18 @@ or give it a named profile:
~> nak bunker --profile myself ...
```
### send a `nostrconnect://` client URI to a running bunker
```shell
~> nak bunker connect 'nostrconnect://...'
```
or, if you're using a persisted profile
```shell
~> nak bunker connect --profile default 'nostrconnect://...'
```
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
```shell
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
@@ -227,7 +249,7 @@ or give it a named profile:
• events stored: 4, subscriptions opened: 1
```
### enable negentropy (nip77) support in your development relay
### enable negentropy (NIP-77) support in your development relay
```shell
~> nak serve --negentropy
```
@@ -442,3 +464,11 @@ gitnostr.com... ok.
1a851afaa70a26faa82c5b4422ce967c07e278efc56a1413b9719b662f86551a
8031621a54b2502f5bd4dbb87c971c0a69675d252a64d69e22224f3aee6dd2b2
```
### interact with a NIP-29 group
```shell
~> nak group info "<relay>'<id>"
~> nak group admin "<relay>'<id>"
~> nak group chat "<relay>'<id>"
~> nak group chat send "<relay>'<id>" "<message>"
```

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
@@ -347,6 +348,7 @@ var bunker = &cli.Command{
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
signer := nip46.NewStaticKeySigner(sec)
signer.DefaultRelays = config.Relays
// unix socket nostrconnect:// handling
go func() {
@@ -355,7 +357,7 @@ var bunker = &cli.Command{
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey), uri.String())
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
@@ -447,7 +449,11 @@ var bunker = &cli.Command{
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
log("< failed to handle request from %s: %s\n", from, err.Error())
if errors.Is(err, nip46.AlreadyHandled) {
continue
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
continue
}

View File

@@ -86,7 +86,7 @@ var decode = &cli.Command{
continue
}
ctx = lineProcessingError(ctx, "couldn't decode input '%s'", input)
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
}
exitIfLineProcessingError(ctx)

2
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6

4
go.sum
View File

@@ -1,7 +1,7 @@
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4 h1:DF/4NSbCvXqIIRrwYp7L3S0SqC7/IhQl8mHkmYA5uXM=
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817 h1:Zp6rPetvwYFOLD+36RtmWmns2C0CLbtphD3DLu3cxCo=
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=

73
install.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env sh
set -e
# detect OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux";;
Darwin*) echo "darwin";;
FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";;
*)
echo "error: unsupported OS $(uname -s)" >&2
exit 1
;;
esac
}
# detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";;
*)
echo "error: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
}
# set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# check if curl is available
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}"
TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# create install directory and download
mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH"
echo "installed nak $TAG to $TARGET_PATH"
# check if install dir is in PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "note: add $INSTALL_DIR to your PATH" ;;
esac
else
echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1
fi

104
mcp.go
View File

@@ -6,6 +6,7 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
@@ -33,10 +34,10 @@ 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("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
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.WithDescription("publish a short note event to Nostr with the given text content"),
mcp.WithString("content", mcp.Description("arbitrary string to be published"), mcp.Required()),
mcp.WithString("relay", mcp.Description("relay to publish the note to")),
mcp.WithString("mention", mcp.Description("nostr user's public key to be mentioned")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content := required[string](r, "content")
mention, _ := optional[string](r, "mention")
@@ -105,7 +106,7 @@ var mcpServer = &cli.Command{
})
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.Description("URI to be resolved"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri := required[string](r, "uri")
@@ -136,23 +137,23 @@ var mcpServer = &cli.Command{
WithRelays: false,
})
if err != nil {
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
return mcp.NewToolResultError("couldn't find this event anywhere"), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr event: %s", event),
), nil
case "naddr":
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
return mcp.NewToolResultError("for now we can't handle this kind of Nostr uri"), nil
default:
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
return mcp.NewToolResultError("we don't know how to handle this Nostr uri"), nil
}
})
s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("How many results to return")),
mcp.WithDescription("search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.Description("name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("how many results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name := required[string](r, "name")
limit, _ := optional[float64](r, "limit")
@@ -163,7 +164,7 @@ var mcpServer = &cli.Command{
}
res := strings.Builder{}
res.WriteString("Search results: ")
res.WriteString("search results: ")
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
@@ -178,14 +179,14 @@ var mcpServer = &cli.Command{
}
}
if l == 0 {
return mcp.NewToolResultError("Couldn't find anyone with that name."), nil
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
}
return mcp.NewToolResultText(res.String()), nil
})
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.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
mcp.WithDescription("get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey", mcp.Description("public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
if err != nil {
@@ -197,7 +198,7 @@ var mcpServer = &cli.Command{
})
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.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
@@ -238,6 +239,77 @@ var mcpServer = &cli.Command{
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("search_events",
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
mcp.WithString("author", mcp.Description("author public key to filter by")),
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
search := required[string](r, "search")
author, hasAuthor := optional[string](r, "author")
kind, hasKind := optional[float64](r, "kind")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 50
}
filter := nostr.Filter{Search: search, Limit: int(limit)}
if hasKind {
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
}
var relays []string
if hasAuthor {
if pk, err := nostr.PubKeyFromHex(author); err != nil {
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
pk, _ := nostr.PubKeyFromHex(author)
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
for _, relayURL := range writeRelays {
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
for _, nip := range info.SupportedNIPs {
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
relays = append(relays, relayURL)
break
}
}
}
}
}
if len(relays) == 0 {
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
}
result := strings.Builder{}
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
l := 0
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("no events found matching the search criteria."), nil
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}