diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts
index b1babf0..3b80ad5 100644
--- a/src/lib/req-parser.test.ts
+++ b/src/lib/req-parser.test.ts
@@ -216,8 +216,8 @@ describe("parseReqCommand", () => {
});
describe("event ID flag (-e) with nevent/naddr support", () => {
- describe("nevent support", () => {
- it("should parse nevent and populate filter.ids", () => {
+ describe("nevent support (tag filtering)", () => {
+ it("should parse nevent and populate filter['#e'] (tag filtering)", () => {
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
const nevent = nip19.neventEncode({
@@ -225,10 +225,10 @@ describe("parseReqCommand", () => {
});
const result = parseReqCommand(["-e", nevent]);
- expect(result.filter.ids).toBeDefined();
- expect(result.filter.ids).toHaveLength(1);
- expect(result.filter.ids).toEqual([eventId]);
- expect(result.filter["#e"]).toBeUndefined();
+ expect(result.filter["#e"]).toBeDefined();
+ expect(result.filter["#e"]).toHaveLength(1);
+ expect(result.filter["#e"]).toEqual([eventId]);
+ expect(result.filter.ids).toBeUndefined();
});
it("should extract relay hints from nevent", () => {
@@ -267,7 +267,7 @@ describe("parseReqCommand", () => {
});
const result = parseReqCommand(["-e", nevent]);
- expect(result.filter.ids).toHaveLength(1);
+ expect(result.filter["#e"]).toHaveLength(1);
expect(result.relays).toBeUndefined();
});
});
@@ -363,8 +363,95 @@ describe("parseReqCommand", () => {
});
});
+ describe("raw coordinate support (kind:pubkey:d)", () => {
+ it("should parse raw coordinate and populate filter['#a']", () => {
+ const pubkey = "a".repeat(64);
+ const coordinate = `30023:${pubkey}:my-article`;
+ const result = parseReqCommand(["-e", coordinate]);
+
+ expect(result.filter["#a"]).toBeDefined();
+ expect(result.filter["#a"]).toHaveLength(1);
+ expect(result.filter["#a"]).toEqual([coordinate]);
+ expect(result.filter["#e"]).toBeUndefined();
+ });
+
+ it("should normalize pubkey to lowercase", () => {
+ const pubkey = "A".repeat(64);
+ const coordinate = `30023:${pubkey}:my-article`;
+ const result = parseReqCommand(["-e", coordinate]);
+
+ expect(result.filter["#a"]).toEqual([
+ `30023:${"a".repeat(64)}:my-article`,
+ ]);
+ });
+
+ it("should handle empty d-tag identifier", () => {
+ const pubkey = "a".repeat(64);
+ const coordinate = `30023:${pubkey}:`;
+ const result = parseReqCommand(["-e", coordinate]);
+
+ expect(result.filter["#a"]).toEqual([coordinate]);
+ });
+
+ it("should handle d-tag with special characters", () => {
+ const pubkey = "a".repeat(64);
+ const coordinate = `30023:${pubkey}:my-article/with:special-chars`;
+ const result = parseReqCommand(["-e", coordinate]);
+
+ expect(result.filter["#a"]).toEqual([coordinate]);
+ });
+
+ it("should handle different kind numbers", () => {
+ const pubkey = "a".repeat(64);
+ const result = parseReqCommand([
+ "-e",
+ `0:${pubkey}:,30000:${pubkey}:list,30023:${pubkey}:article`,
+ ]);
+
+ expect(result.filter["#a"]).toHaveLength(3);
+ expect(result.filter["#a"]).toContain(`0:${pubkey}:`);
+ expect(result.filter["#a"]).toContain(`30000:${pubkey}:list`);
+ expect(result.filter["#a"]).toContain(`30023:${pubkey}:article`);
+ });
+
+ it("should combine with naddr coordinates", () => {
+ const pubkey = "a".repeat(64);
+ const rawCoord = `30023:${pubkey}:raw-article`;
+ const naddr = nip19.naddrEncode({
+ kind: 30023,
+ pubkey: pubkey,
+ identifier: "encoded-article",
+ });
+
+ const result = parseReqCommand(["-e", `${rawCoord},${naddr}`]);
+
+ expect(result.filter["#a"]).toHaveLength(2);
+ expect(result.filter["#a"]).toContain(rawCoord);
+ expect(result.filter["#a"]).toContain(
+ `30023:${pubkey}:encoded-article`,
+ );
+ });
+
+ it("should ignore invalid coordinate formats", () => {
+ // Missing parts
+ const result1 = parseReqCommand(["-e", "30023:abc"]);
+ expect(result1.filter["#a"]).toBeUndefined();
+
+ // Invalid pubkey (not 64 hex chars)
+ const result2 = parseReqCommand(["-e", "30023:abc123:article"]);
+ expect(result2.filter["#a"]).toBeUndefined();
+
+ // Invalid kind (not a number)
+ const result3 = parseReqCommand([
+ "-e",
+ `abc:${"a".repeat(64)}:article`,
+ ]);
+ expect(result3.filter["#a"]).toBeUndefined();
+ });
+ });
+
describe("mixed format support", () => {
- it("should handle comma-separated mix of all formats", () => {
+ it("should handle comma-separated mix of all formats (all to tags)", () => {
const hex = "a".repeat(64);
const eventId =
"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
@@ -383,14 +470,13 @@ describe("parseReqCommand", () => {
`${hex},${note},${nevent},${naddr}`,
]);
- // hex and note should go to filter["#e"]
+ // hex, note, and nevent all go to filter["#e"] (deduplicated: eventId appears twice)
expect(result.filter["#e"]).toHaveLength(2);
expect(result.filter["#e"]).toContain(hex);
expect(result.filter["#e"]).toContain(eventId);
- // nevent should go to filter.ids
- expect(result.filter.ids).toHaveLength(1);
- expect(result.filter.ids).toContain(eventId);
+ // No direct ID lookup for -e flag
+ expect(result.filter.ids).toBeUndefined();
// naddr should go to filter["#a"]
expect(result.filter["#a"]).toHaveLength(1);
@@ -408,8 +494,9 @@ describe("parseReqCommand", () => {
const result = parseReqCommand(["-e", `${nevent1},${nevent2}`]);
- // Both nevent decode to same event ID, should deduplicate
- expect(result.filter.ids).toHaveLength(1);
+ // Both nevent decode to same event ID, should deduplicate in #e
+ expect(result.filter["#e"]).toHaveLength(1);
+ expect(result.filter.ids).toBeUndefined();
});
it("should collect relay hints from mixed formats", () => {
@@ -447,10 +534,12 @@ describe("parseReqCommand", () => {
const result = parseReqCommand(["-e", `${nevent1},${hex}`]);
- // nevent goes to filter.ids
- expect(result.filter.ids).toHaveLength(1);
- // hex goes to filter["#e"]
+ // Both nevent and hex go to filter["#e"]
+ expect(result.filter["#e"]).toHaveLength(2);
+ expect(result.filter["#e"]).toContain(eventId);
expect(result.filter["#e"]).toContain(hex);
+ // No direct ID lookup for -e flag
+ expect(result.filter.ids).toBeUndefined();
// relays extracted from nevent
expect(result.relays).toBeDefined();
expect(result.relays).toContain("wss://relay.damus.io/");
@@ -499,7 +588,8 @@ describe("parseReqCommand", () => {
const result = parseReqCommand(["-k", "1", "-e", nevent]);
expect(result.filter.kinds).toEqual([1]);
- expect(result.filter.ids).toHaveLength(1);
+ expect(result.filter["#e"]).toHaveLength(1);
+ expect(result.filter.ids).toBeUndefined();
});
it("should work with explicit relays", () => {
@@ -539,13 +629,199 @@ describe("parseReqCommand", () => {
expect(result.filter.kinds).toEqual([1]);
expect(result.filter.authors).toEqual([hex]);
- expect(result.filter.ids).toHaveLength(1);
+ expect(result.filter["#e"]).toHaveLength(1);
+ expect(result.filter.ids).toBeUndefined();
expect(result.filter.since).toBeDefined();
expect(result.filter.limit).toBe(50);
});
});
});
+ describe("direct ID lookup flag (-i, --id)", () => {
+ describe("basic parsing", () => {
+ it("should parse hex event ID to filter.ids", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["-i", hex]);
+ expect(result.filter.ids).toEqual([hex]);
+ });
+
+ it("should parse note to filter.ids", () => {
+ const eventId =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const note = nip19.noteEncode(eventId);
+ const result = parseReqCommand(["-i", note]);
+ expect(result.filter.ids).toEqual([eventId]);
+ });
+
+ it("should parse nevent to filter.ids", () => {
+ const eventId =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const nevent = nip19.neventEncode({ id: eventId });
+ const result = parseReqCommand(["-i", nevent]);
+ expect(result.filter.ids).toEqual([eventId]);
+ });
+
+ it("should handle --id long form", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["--id", hex]);
+ expect(result.filter.ids).toEqual([hex]);
+ });
+ });
+
+ describe("comma-separated values", () => {
+ it("should parse comma-separated hex IDs", () => {
+ const hex1 = "a".repeat(64);
+ const hex2 = "b".repeat(64);
+ const result = parseReqCommand(["-i", `${hex1},${hex2}`]);
+ expect(result.filter.ids).toEqual([hex1, hex2]);
+ });
+
+ it("should parse comma-separated mixed formats", () => {
+ const hex = "a".repeat(64);
+ const eventId =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const note = nip19.noteEncode(eventId);
+ const nevent = nip19.neventEncode({ id: eventId });
+
+ const result = parseReqCommand(["-i", `${hex},${note},${nevent}`]);
+
+ // hex is unique, note and nevent decode to same eventId (deduplicated)
+ expect(result.filter.ids).toHaveLength(2);
+ expect(result.filter.ids).toContain(hex);
+ expect(result.filter.ids).toContain(eventId);
+ });
+
+ it("should deduplicate IDs", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["-i", `${hex},${hex}`]);
+ expect(result.filter.ids).toEqual([hex]);
+ });
+ });
+
+ describe("relay hints", () => {
+ it("should extract relay hints from nevent", () => {
+ const eventId =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const nevent = nip19.neventEncode({
+ id: eventId,
+ relays: ["wss://relay.damus.io"],
+ });
+ const result = parseReqCommand(["-i", nevent]);
+
+ expect(result.filter.ids).toEqual([eventId]);
+ expect(result.relays).toBeDefined();
+ expect(result.relays).toContain("wss://relay.damus.io/");
+ });
+
+ it("should normalize relay URLs from nevent", () => {
+ const eventId =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const nevent = nip19.neventEncode({
+ id: eventId,
+ relays: ["wss://relay.damus.io"],
+ });
+ const result = parseReqCommand(["-i", nevent]);
+
+ result.relays?.forEach((url) => {
+ expect(url).toMatch(/^wss?:\/\//);
+ expect(url).toMatch(/\/$/);
+ });
+ });
+
+ it("should collect relay hints from multiple nevents", () => {
+ const eventId1 =
+ "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d";
+ const eventId2 = "b".repeat(64);
+ const nevent1 = nip19.neventEncode({
+ id: eventId1,
+ relays: ["wss://relay.damus.io"],
+ });
+ const nevent2 = nip19.neventEncode({
+ id: eventId2,
+ relays: ["wss://nos.lol"],
+ });
+
+ const result = parseReqCommand(["-i", `${nevent1},${nevent2}`]);
+
+ expect(result.relays).toBeDefined();
+ expect(result.relays).toContain("wss://relay.damus.io/");
+ expect(result.relays).toContain("wss://nos.lol/");
+ });
+ });
+
+ describe("error handling", () => {
+ it("should ignore invalid bech32", () => {
+ const result = parseReqCommand(["-i", "note1invalid"]);
+ expect(result.filter.ids).toBeUndefined();
+ });
+
+ it("should ignore invalid nevent", () => {
+ const result = parseReqCommand(["-i", "nevent1invalid"]);
+ expect(result.filter.ids).toBeUndefined();
+ });
+
+ it("should ignore naddr (not valid for direct ID lookup)", () => {
+ const pubkey = "b".repeat(64);
+ const naddr = nip19.naddrEncode({
+ kind: 30023,
+ pubkey: pubkey,
+ identifier: "test-article",
+ });
+ const result = parseReqCommand(["-i", naddr]);
+ expect(result.filter.ids).toBeUndefined();
+ });
+
+ it("should skip empty values", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["-i", `${hex},,`]);
+ expect(result.filter.ids).toEqual([hex]);
+ });
+
+ it("should continue parsing after invalid values", () => {
+ const hex1 = "a".repeat(64);
+ const hex2 = "b".repeat(64);
+ const result = parseReqCommand(["-i", `${hex1},invalid,${hex2}`]);
+ expect(result.filter.ids).toEqual([hex1, hex2]);
+ });
+ });
+
+ describe("integration with other flags", () => {
+ it("should work alongside -e flag (both IDs and tags)", () => {
+ const directId = "a".repeat(64);
+ const tagEventId = "b".repeat(64);
+
+ const result = parseReqCommand(["-i", directId, "-e", tagEventId]);
+
+ expect(result.filter.ids).toEqual([directId]);
+ expect(result.filter["#e"]).toEqual([tagEventId]);
+ });
+
+ it("should work with kind and limit", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["-i", hex, "-k", "1", "-l", "10"]);
+
+ expect(result.filter.ids).toEqual([hex]);
+ expect(result.filter.kinds).toEqual([1]);
+ expect(result.filter.limit).toBe(10);
+ });
+
+ it("should work with relays", () => {
+ const hex = "a".repeat(64);
+ const result = parseReqCommand(["-i", hex, "wss://relay.example.com"]);
+
+ expect(result.filter.ids).toEqual([hex]);
+ expect(result.relays).toContain("wss://relay.example.com/");
+ });
+
+ it("should accumulate across multiple -i flags", () => {
+ const hex1 = "a".repeat(64);
+ const hex2 = "b".repeat(64);
+ const result = parseReqCommand(["-i", hex1, "-i", hex2]);
+ expect(result.filter.ids).toEqual([hex1, hex2]);
+ });
+ });
+ });
+
describe("pubkey tag flag (-p)", () => {
it("should parse hex pubkey for #p tag", () => {
const hex = "a".repeat(64);
diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts
index df9eb1a..d233572 100644
--- a/src/lib/req-parser.ts
+++ b/src/lib/req-parser.ts
@@ -51,7 +51,7 @@ function parseCommaSeparated
(
/**
* Parse REQ command arguments into a Nostr filter
* Supports:
- * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -e (note/nevent/naddr/hex), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
+ * - Filters: -k (kinds), -a (authors: hex/npub/nprofile/NIP-05), -l (limit), -i/--id (direct event lookup), -e (tag filtering: #e/#a), -p (#p: hex/npub/nprofile/NIP-05), -P (#P: hex/npub/nprofile/NIP-05), -t (#t), -d (#d), --tag/-T (any #tag)
* - Time: --since, --until
* - Search: --search
* - Relays: wss://relay.com or relay.com (auto-adds wss://), relay hints from nprofile/nevent/naddr are automatically extracted
@@ -183,8 +183,40 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
break;
}
+ case "-i":
+ case "--id": {
+ // Direct event lookup via filter.ids
+ // Support comma-separated: -i note1...,nevent1...,hex
+ if (!nextArg) {
+ i++;
+ break;
+ }
+
+ let addedAny = false;
+ const values = nextArg.split(",").map((v) => v.trim());
+
+ for (const val of values) {
+ if (!val) continue;
+
+ const parsed = parseIdIdentifier(val);
+ if (parsed) {
+ ids.add(parsed.id);
+ addedAny = true;
+
+ // Collect relay hints from nevent
+ if (parsed.relays) {
+ relays.push(...parsed.relays);
+ }
+ }
+ }
+
+ i += addedAny ? 2 : 1;
+ break;
+ }
+
case "-e": {
- // Support comma-separated event identifiers: -e note1...,nevent1...,naddr1...,hex
+ // Tag-based filtering: -e note1...,nevent1...,naddr1...,hex
+ // Events go to #e tag, addresses go to #a tag
if (!nextArg) {
i++;
break;
@@ -198,13 +230,11 @@ export function parseReqCommand(args: string[]): ParsedReqCommand {
const parsed = parseEventIdentifier(val);
if (parsed) {
- // Route to appropriate filter field based on type
- if (parsed.type === "direct-event") {
- ids.add(parsed.value);
- } else if (parsed.type === "direct-address") {
- aTags.add(parsed.value);
- } else if (parsed.type === "tag-event") {
+ // Route to appropriate tag filter based on type
+ if (parsed.type === "tag-event") {
eventIds.add(parsed.value);
+ } else if (parsed.type === "tag-address") {
+ aTags.add(parsed.value);
}
// Collect relay hints
@@ -564,24 +594,26 @@ function parseNpubOrHex(value: string): {
}
interface ParsedEventIdentifier {
- type: "direct-event" | "direct-address" | "tag-event";
+ type: "tag-event" | "tag-address";
value: string;
relays?: string[];
}
/**
- * Parse event identifier - supports note, nevent, naddr, and hex event ID
+ * Parse event identifier for -e flag (tag filtering)
+ * All event IDs go to #e, addresses go to #a
+ * Supports: note, nevent, naddr, and hex event ID
*/
function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
if (!value) return null;
- // nevent: direct event lookup with relay hints
+ // nevent: decode and route to #e tag
if (value.startsWith("nevent")) {
try {
const decoded = nip19.decode(value);
if (decoded.type === "nevent") {
return {
- type: "direct-event",
+ type: "tag-event",
value: decoded.data.id,
relays: decoded.data.relays
?.map((url) => {
@@ -599,14 +631,14 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
}
}
- // naddr: coordinate-based lookup with relay hints
+ // naddr: coordinate-based lookup with relay hints → #a tag
if (value.startsWith("naddr")) {
try {
const decoded = nip19.decode(value);
if (decoded.type === "naddr") {
const coordinate = `${decoded.data.kind}:${decoded.data.pubkey}:${decoded.data.identifier}`;
return {
- type: "direct-address",
+ type: "tag-address",
value: coordinate,
relays: decoded.data.relays
?.map((url) => {
@@ -624,7 +656,7 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
}
}
- // note1: tag-based filtering (existing behavior)
+ // note1: decode to event ID → #e tag
if (value.startsWith("note")) {
try {
const decoded = nip19.decode(value);
@@ -639,7 +671,7 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
}
}
- // Hex: tag-based filtering (existing behavior)
+ // Hex: → #e tag
if (isValidHexEventId(value)) {
return {
type: "tag-event",
@@ -647,5 +679,78 @@ function parseEventIdentifier(value: string): ParsedEventIdentifier | null {
};
}
+ // Raw coordinate: kind:pubkey:identifier → #a tag
+ // Format: :: (e.g., 30023:abc123...:article-name)
+ const coordinateMatch = value.match(/^(\d+):([a-fA-F0-9]{64}):(.*)$/);
+ if (coordinateMatch) {
+ const [, kindStr, pubkey, identifier] = coordinateMatch;
+ const kind = parseInt(kindStr, 10);
+ if (!isNaN(kind)) {
+ return {
+ type: "tag-address",
+ value: `${kind}:${pubkey.toLowerCase()}:${identifier}`,
+ };
+ }
+ }
+
+ return null;
+}
+
+interface ParsedIdIdentifier {
+ id: string;
+ relays?: string[];
+}
+
+/**
+ * Parse event identifier for -i/--id flag (direct ID lookup via filter.ids)
+ * Supports: note, nevent, and hex event ID
+ */
+function parseIdIdentifier(value: string): ParsedIdIdentifier | null {
+ if (!value) return null;
+
+ // nevent: decode and extract event ID
+ if (value.startsWith("nevent")) {
+ try {
+ const decoded = nip19.decode(value);
+ if (decoded.type === "nevent") {
+ return {
+ id: decoded.data.id,
+ relays: decoded.data.relays
+ ?.map((url) => {
+ try {
+ return normalizeRelayURL(url);
+ } catch {
+ return null;
+ }
+ })
+ .filter((url): url is string => url !== null),
+ };
+ }
+ } catch {
+ // Not valid nevent, continue
+ }
+ }
+
+ // note1: decode to event ID
+ if (value.startsWith("note")) {
+ try {
+ const decoded = nip19.decode(value);
+ if (decoded.type === "note") {
+ return {
+ id: decoded.data,
+ };
+ }
+ } catch {
+ // Not valid note, continue
+ }
+ }
+
+ // Hex event ID
+ if (isValidHexEventId(value)) {
+ return {
+ id: normalizeHex(value),
+ };
+ }
+
return null;
}
diff --git a/src/types/man.ts b/src/types/man.ts
index 319765c..7e30314 100644
--- a/src/types/man.ts
+++ b/src/types/man.ts
@@ -176,9 +176,14 @@ export const manPages: Record = {
description: "Maximum number of events to return",
},
{
- flag: "-e ",
+ flag: "-i, --id ",
description:
- "Filter by event ID or coordinate. Supports note1 (bare event ID), nevent1 (event with relay hints), naddr1 (addressable event coordinate), or raw hex. Comma-separated values supported: -e note1...,nevent1...,naddr1...",
+ "Direct event lookup by ID (filter.ids). Fetch specific events by their ID. Supports note1, nevent1 (with relay hints), or raw hex. Comma-separated values supported: -i note1...,nevent1...,abc123...",
+ },
+ {
+ flag: "-e ",
+ description:
+ "Tag-based filtering (#e/#a tags). Find events that reference the specified events or addresses. Supports note1, nevent1, naddr1, raw coordinates (kind:pubkey:d-tag), or hex. Comma-separated values supported: -e note1...,30023:pubkey:article",
},
{
flag: "-p ",
@@ -256,6 +261,10 @@ export const manPages: Record = {
"req -k 1 --since 1h relay.damus.io Get notes from last hour (manual relay override)",
"req -k 1 --since 7d --until now Get notes from last week up to now",
"req -k 1 --close-on-eose Get recent notes and close after EOSE",
+ "req -i note1abc123... Direct lookup: fetch event by ID",
+ "req -i nevent1... Direct lookup: fetch event by nevent (uses relay hints)",
+ "req -e note1abc123... -k 1 Tag filtering: find notes that reply to or reference event",
+ "req -e 30023:pubkey...:article-name -k 1,7 Tag filtering: find events referencing addressable event",
"req -t nostr,grimoire,bitcoin -l 50 Get 50 events tagged #nostr, #grimoire, or #bitcoin",
"req --tag a 30023:7fa56f5d6962ab1e3cd424e758c3002b8665f7b0d8dcee9fe9e288d7751ac194:grimoire Get events referencing addressable event (#a tag)",
"req -T r grimoire.rocks Get events referencing URL (#r tag)",