diff --git a/README.md b/README.md index 1b7fdb8..210df1a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,18 @@ bun run index.ts --client-id 123456789 bun run index.ts --help ``` +> [!NOTE] +> **Current Implementation Status** +> +> - `discord-search --help`: Shows help message ✓ +> - `discord-search --version`: Shows version number ✓ +> - Interactive mode: Not yet implemented (see `src/index.ts`) +> - Search command: Not yet implemented (see `src/index.ts`) +> - Preset command: Not yet implemented (see `src/index.ts`) +> - Settings command: Not yet implemented (see `src/index.ts`) +> +> Refer to `src/index.ts` for the current implementation status of each command. + Interactive mode The CLI guides you through setting up searches: 1. Choose "New search" to start @@ -74,6 +86,9 @@ The CLI guides you through setting up searches: 3. Optionally filter by content, author, mentions, or content types 4. Browse results or export them +> [!WARNING] +> Interactive mode and all subcommands (search, preset, settings) are currently unimplemented. Running these will display an error message and exit with code 1. Only `--help` and `--version` are functional in this release. + **Search filters** - Content text search - Author IDs (comma-separated) diff --git a/__tests__/index.test.ts b/__tests__/index.test.ts index e2d7f6f..cbb3de6 100644 --- a/__tests__/index.test.ts +++ b/__tests__/index.test.ts @@ -1,13 +1,36 @@ -/** - * Placeholder test file - * - * This file exists to ensure CI passes during infrastructure setup. - * Actual tests will be added in subsequent PRs. - */ - -import { test } from "bun:test"; - -test("placeholder", () => { - // This is a placeholder test to make CI pass - // Real tests will be added as functionality is developed +import { expect, test } from "bun:test"; +import { parseArgs } from "@/cli/args-parse.ts"; +import type { HelpArgs } from "@/cli/args-types.ts"; + +test("global --help returns help command", () => { + const args = parseArgs(["--help"]); + expect(args.command).toBe("help"); + expect((args as HelpArgs).help).toBe(true); +}); + +test("search --help returns help command with targetCommand search", () => { + const args = parseArgs(["search", "--help"]); + expect(args.command).toBe("help"); + const helpArgs = args as HelpArgs; + expect(helpArgs.targetCommand).toBe("search"); + expect(helpArgs.help).toBe(true); +}); + +test("search --help with --guild still returns help command", () => { + const args = parseArgs(["search", "--help", "--guild", "123"]); + expect(args.command).toBe("help"); + const helpArgs = args as HelpArgs; + expect(helpArgs.targetCommand).toBe("search"); + expect(helpArgs.help).toBe(true); +}); + +test("no subcommand without --help returns interactive command", () => { + const args = parseArgs([]); + expect(args.command).toBe("interactive"); +}); + +test("no subcommand with --help returns help command (not interactive)", () => { + const args = parseArgs(["--help"]); + expect(args.command).toBe("help"); + expect(args.command).not.toBe("interactive"); }); diff --git a/src/cli/args-help.ts b/src/cli/args-help.ts index 23a9a4a..201acee 100644 --- a/src/cli/args-help.ts +++ b/src/cli/args-help.ts @@ -26,6 +26,8 @@ const GLOBAL_OPTIONS = section("GLOBAL OPTIONS", [ opt("--help, -h", "Show this help message"), opt("--version, -v", "Show version number"), opt("--token, -t ", "Bot token (overrides DISCORD_BOT_TOKEN)"), + opt("--guild, -g ", "Default guild/server ID"), + opt("--client-id, -c ", "Bot client ID (for invite link)"), ]); const SEARCH_FILTERING = section("FILTERING", [ diff --git a/src/cli/args-parse.ts b/src/cli/args-parse.ts index 9b94314..70eeba6 100644 --- a/src/cli/args-parse.ts +++ b/src/cli/args-parse.ts @@ -9,8 +9,8 @@ import { type PresetSaveArgs, type SearchArgs, } from "@/cli/args-types.ts"; -import { parseCommaSeparated } from "@/cli/utils.ts"; -import type { SearchParams } from "@/discord/schemas.ts"; +import { INTEGER_REGEX, parseCommaSeparated } from "@/cli/utils.ts"; +import { type SearchParams, SNOWFLAKE_REGEX } from "@/discord/schemas.ts"; const parseWithError = (data: unknown, subcommand: string): T => { const result = ParsedArgsSchema.safeParse(data); @@ -107,8 +107,6 @@ const BOOLEAN_FLAGS: Record = { "--mention-everyone": "mentionEveryone", }; -const INTEGER_REGEX = /^\d+$/; - type SearchFlagsResult = { params: Omit & { guildId?: string }; export?: string; @@ -313,26 +311,39 @@ const parseSearchCommand = ( global: GlobalFlags & { guild?: string } ): SearchArgs | HelpArgs => { const guildId = global.guild; - if (!(global.help || guildId)) { - return exitWithError( - "Missing required --guild/-g (guildId) for search command", + if (global.help) { + return parseWithError( + { + command: "help", + targetCommand: "search", + help: true, + version: global.version, + token: global.token, + }, "search" ); } - if (global.help && !guildId) { + if (global.version) { return parseWithError( { command: "help", targetCommand: "search", - help: true, - version: global.version, + help: global.help, + version: true, token: global.token, }, "search" ); } + if (!guildId) { + return exitWithError( + "Missing required --guild/-g (guildId) for search command", + "search" + ); + } + const parsed = parseSearchFlags(remaining.slice(1), guildId, "search"); return parseWithError( { @@ -395,6 +406,13 @@ const parsePresetRunAllAction = ( } } + if (all && names.length > 0) { + exitWithError( + "Cannot specify both --all and named presets", + "preset run-all" + ); + } + if (!all && names.length === 0) { exitWithError("You must pass --all or at least one preset name", "preset"); } @@ -478,6 +496,19 @@ const parsePresetCommand = ( ); } + if (global.version) { + return parseWithError( + { + command: "preset", + action: "list", + help: global.help, + version: true, + token: global.token, + }, + "preset" + ); + } + const action = remaining[1]; if (action === "list") { @@ -551,6 +582,19 @@ const parseSettingsCommand = ( ); } + if (global.version) { + return parseWithError( + { + command: "settings", + action: "show", + help: global.help, + version: true, + token: global.token, + }, + "settings" + ); + } + const action = remaining[1]; if (action === "show") { @@ -583,6 +627,15 @@ const parseSettingsCommand = ( "settings" ); } + if ( + (key === "guild" || key === "client-id") && + !SNOWFLAKE_REGEX.test(value) + ) { + return exitWithError( + `Invalid value for ${key}: must be a 17-20 digit snowflake ID`, + "settings set" + ); + } checkNoLeftovers(remaining.slice(4), "settings set"); return parseWithError( { @@ -623,6 +676,12 @@ export const parseArgs = (args: string[]): ParsedArgs => { const subcommand = remaining[0]; if (!subcommand) { + if (global.help) { + return parseWithError( + { command: "help", ...global }, + "interactive" + ); + } return parseWithError( { command: "interactive", ...global }, "interactive" diff --git a/src/cli/browser.ts b/src/cli/browser.ts new file mode 100644 index 0000000..24836e5 --- /dev/null +++ b/src/cli/browser.ts @@ -0,0 +1,197 @@ +import { + ANSI_CLEAR_SCREEN, + ANSI_CURSOR_HOME, + ANSI_CYAN, + ANSI_DIM, + ANSI_RESET, + ANSI_YELLOW, +} from "@/cli/ansi.ts"; +import { createKeyListener } from "@/cli/keys.ts"; +import type { Message } from "@/discord/schemas.ts"; + +const formatTimestamp = (timestamp: string): string => { + const date = new Date(timestamp); + return date.toLocaleString([], { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +}; + +const formatMessagePreview = (msg: Message): string => { + const author = msg.author.bot + ? `${msg.author.username} [BOT]` + : msg.author.username; + const embedCount = msg.embeds?.length ?? 0; + const attachCount = msg.attachments?.length ?? 0; + const extras: string[] = []; + if (embedCount > 0) { + extras.push(`${embedCount} embed(s)`); + } + if (attachCount > 0) { + extras.push(`${attachCount} attachment(s)`); + } + const suffix = extras.length > 0 ? ` [${extras.join(", ")}]` : ""; + const content = + msg.content.length > 100 + ? `${msg.content.slice(0, 100)}...` + : msg.content || "(no text content)"; + return `${ANSI_DIM}${formatTimestamp(msg.timestamp)}${ANSI_RESET} ${ANSI_CYAN}${author}${ANSI_RESET}: ${content}${suffix}`; +}; + +const truncate = (text: string, maxLen: number): string => + text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; + +const renderField = (field: { name: string; value: string }): void => { + const name = truncate(field.name, 40); + const value = truncate(field.value, 100); + process.stdout.write(` ${ANSI_CYAN}${name}:${ANSI_RESET} ${value}\n`); +}; + +const renderEmbedPreview = (msg: Message): void => { + if (!msg.embeds || msg.embeds.length === 0) { + return; + } + + for (const embed of msg.embeds) { + if (embed.title) { + const title = truncate(embed.title, 80); + process.stdout.write(` Embed: ${title}\n`); + } + if (embed.description) { + const desc = truncate(embed.description, 200); + process.stdout.write(` ${ANSI_DIM}${desc}${ANSI_RESET}\n`); + } + if (embed.fields) { + for (const field of embed.fields) { + renderField(field); + } + } + process.stdout.write("\n"); + } +}; + +const renderMessageView = ( + msg: Message, + index: number, + total: number, + viewMode: "preview" | "json" +): void => { + process.stdout.write(ANSI_CLEAR_SCREEN + ANSI_CURSOR_HOME); + process.stdout.write( + `${ANSI_YELLOW}Message ${index + 1} of ${total}${ANSI_RESET}\n\n` + ); + + if (viewMode === "json") { + process.stdout.write(`${JSON.stringify(msg, null, 2)}\n`); + } else { + process.stdout.write(`${formatMessagePreview(msg)}\n\n`); + renderEmbedPreview(msg); + } + + process.stdout.write( + `\n${ANSI_DIM}[j/↓] next [k/↑] prev [v] toggle JSON [q/Esc] back${ANSI_RESET}\n` + ); +}; + +const write = (text: string): void => { + process.stdout.write(text); +}; + +const handleRenderError = ( + err: unknown, + reject: (reason?: unknown) => void, + cleanup: () => void +): void => { + try { + cleanup(); + } catch { + // Swallow cleanup errors to preserve original error + } + reject(err); +}; + +const handleQuit = ( + cleanup: () => void, + resolve: () => void, + reject: (reason?: unknown) => void +): void => { + try { + cleanup(); + write(ANSI_CLEAR_SCREEN + ANSI_CURSOR_HOME); + resolve(); + } catch (err) { + reject(err); + } +}; + +const navigate = ( + key: string, + state: { index: number; viewMode: "preview" | "json" }, + total: number +): void => { + if (key === "j" || key === "\x1b[B") { + state.index = Math.min(state.index + 1, total - 1); + } else if (key === "k" || key === "\x1b[A") { + state.index = Math.max(state.index - 1, 0); + } else if (key === "v") { + state.viewMode = state.viewMode === "preview" ? "json" : "preview"; + } +}; + +export const browseMessages = (messages: Message[]): Promise => { + if (!process.stdin.isTTY) { + write("Interactive browsing requires a TTY terminal.\n"); + return Promise.resolve(); + } + + return new Promise((resolve, reject) => { + if (messages.length === 0) { + try { + write(`${ANSI_DIM}No messages to browse.${ANSI_RESET}\n`); + resolve(); + } catch (err) { + reject(err); + } + return; + } + + const state = { index: 0, viewMode: "preview" as const }; + + let cleanup: () => void = () => { + // No-op initially, replaced by actual cleanup after first render + }; + + const render = (): boolean => { + const msg = messages[state.index]; + if (!msg) { + return true; + } + try { + renderMessageView(msg, state.index, messages.length, state.viewMode); + return true; + } catch (err) { + handleRenderError(err, reject, cleanup); + return false; + } + }; + + const renderSucceeded = render(); + if (!renderSucceeded) { + return; + } + + cleanup = createKeyListener((key) => { + if (key === "q" || key === "\x1b") { + handleQuit(cleanup, resolve, reject); + return; + } + + navigate(key, state, messages.length); + render(); + }); + }); +}; diff --git a/src/cli/keys.ts b/src/cli/keys.ts new file mode 100644 index 0000000..86b6d60 --- /dev/null +++ b/src/cli/keys.ts @@ -0,0 +1,43 @@ +type KeyHandler = (key: string) => void; + +export const createKeyListener = (onKey: KeyHandler): (() => void) => { + if (!process.stdin.isTTY) { + return () => { + // no-op: not a TTY + }; + } + + const wasRaw = process.stdin.isRaw; + const previousEncoding = process.stdin.readableEncoding; + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + let cleanup: () => void; + + const handler = (data: string) => { + // Ctrl+C — exit + if (data === "\x03") { + cleanup(); + process.exit(0); + } + onKey(data); + }; + + process.stdin.on("data", handler); + + cleanup = () => { + process.stdin.removeListener("data", handler); + if (!wasRaw) { + process.stdin.setRawMode(false); + } + // Note: Node.js does not support un-setting encoding back to null (raw Buffer mode). + // If stdin had no encoding before, it remains in "utf8" after cleanup. + if (previousEncoding !== null) { + process.stdin.setEncoding(previousEncoding); + } + process.stdin.pause(); + }; + + return cleanup; +}; diff --git a/src/cli/prompts.ts b/src/cli/prompts.ts new file mode 100644 index 0000000..b66d918 --- /dev/null +++ b/src/cli/prompts.ts @@ -0,0 +1,207 @@ +import { + cancel, + confirm, + isCancel, + multiselect, + password, + select, + text, +} from "@clack/prompts"; +import { INTEGER_REGEX, parseCommaSeparated } from "@/cli/utils.ts"; +import { type SearchParams, SNOWFLAKE_REGEX } from "@/discord/schemas.ts"; + +export function handleCancel(value: T | symbol): asserts value is T { + if (isCancel(value)) { + cancel("Search cancelled."); + process.exit(0); + } +} + +export const promptForToken = async (): Promise => { + const token = await password({ + message: "Enter your Discord Bot Token:", + validate: (v) => (v?.trim() ? undefined : "Token cannot be empty"), + }); + handleCancel(token); + return token as string; +}; + +export const promptForSearchParams = async ( + defaultGuildId?: string +): Promise => { + const guildId = await text({ + message: "Guild (Server) ID:", + initialValue: defaultGuildId, + validate: (v) => { + const trimmed = v?.trim(); + if (!trimmed) { + return "Guild ID is required"; + } + if (!SNOWFLAKE_REGEX.test(trimmed)) { + return "Guild ID must be a 17–20 digit Discord snowflake"; + } + }, + }); + handleCancel(guildId); + + const content = await text({ + message: "Content filter (leave empty for all):", + placeholder: "search text...", + }); + handleCancel(content); + + const authorIds = await text({ + message: "Author IDs (comma-separated, leave empty for all):", + placeholder: "123456789,987654321", + }); + handleCancel(authorIds); + + const filterByAuthorType = await confirm({ + message: "Filter by author type?", + initialValue: false, + }); + handleCancel(filterByAuthorType); + + let authorType: string[] | undefined; + if (filterByAuthorType) { + const selected = await multiselect({ + message: "Author type:", + options: [ + { value: "user", label: "Users" }, + { value: "bot", label: "Bots" }, + { value: "webhook", label: "Webhooks" }, + ], + required: false, + }); + handleCancel(selected); + const selectedArray = selected as string[] | undefined; + if (selectedArray && selectedArray.length > 0) { + authorType = selectedArray as ("user" | "bot" | "webhook")[]; + } + } + + const mentionIds = await text({ + message: "Mentions user IDs (comma-separated, leave empty to skip):", + placeholder: "123456789", + }); + handleCancel(mentionIds); + + const channelIds = await text({ + message: "Channel IDs (comma-separated, leave empty for all):", + placeholder: "123456789,987654321", + }); + handleCancel(channelIds); + + const hasFilter = await multiselect({ + message: "Has content type (select none to skip):", + options: [ + { value: "embed", label: "Embed" }, + { value: "image", label: "Image" }, + { value: "video", label: "Video" }, + { value: "file", label: "File" }, + { value: "link", label: "Link" }, + { value: "sticker", label: "Sticker" }, + { value: "sound", label: "Sound" }, + { value: "poll", label: "Poll" }, + { value: "snapshot", label: "Snapshot" }, + ], + required: false, + }); + handleCancel(hasFilter); + + const sortBy = await select({ + message: "Sort by:", + options: [ + { value: "timestamp", label: "Timestamp" }, + { value: "relevance", label: "Relevance" }, + ], + }); + handleCancel(sortBy); + + const sortOrder = await select({ + message: "Sort order:", + options: [ + { value: "desc", label: "Newest first" }, + { value: "asc", label: "Oldest first" }, + ], + }); + handleCancel(sortOrder); + + const includeNsfw = await confirm({ + message: "Include NSFW channels?", + initialValue: false, + }); + handleCancel(includeNsfw); + + const limitInput = await text({ + message: "Max messages to fetch (leave empty for all):", + placeholder: "e.g. 100", + validate: (v) => { + const trimmed = v?.trim(); + if (trimmed && !INTEGER_REGEX.test(trimmed)) { + return "Must be a number"; + } + }, + }); + handleCancel(limitInput); + + const offsetInput = await text({ + message: "Offset / skip first N results (leave empty for 0):", + placeholder: "e.g. 50", + validate: (v) => { + const trimmed = v?.trim(); + if (trimmed && !INTEGER_REGEX.test(trimmed)) { + return "Must be a number"; + } + }, + }); + handleCancel(offsetInput); + + const params: SearchParams = { + guildId: (guildId as string).trim(), + sortBy: sortBy as "timestamp" | "relevance", + sortOrder: sortOrder as "asc" | "desc", + includeNsfw: includeNsfw as boolean, + }; + + const contentStr = (content as string).trim(); + if (contentStr) { + params.content = contentStr; + } + + const authorIdList = parseCommaSeparated(authorIds as string); + if (authorIdList && authorIdList.length > 0) { + params.authorId = authorIdList; + } + + if (authorType) { + params.authorType = authorType as ("user" | "bot" | "webhook")[]; + } + + const mentionList = parseCommaSeparated(mentionIds as string); + if (mentionList && mentionList.length > 0) { + params.mentions = mentionList; + } + + const channelList = parseCommaSeparated(channelIds as string); + if (channelList && channelList.length > 0) { + params.channelId = channelList; + } + + const hasFilterArr = hasFilter as string[]; + if (hasFilterArr.length > 0) { + params.has = hasFilterArr as SearchParams["has"]; + } + + const limitStr = (limitInput as string).trim(); + if (limitStr) { + params.limit = Number.parseInt(limitStr, 10); + } + + const offsetStr = (offsetInput as string).trim(); + if (offsetStr) { + params.offset = Number.parseInt(offsetStr, 10); + } + + return params; +}; diff --git a/src/cli/utils.ts b/src/cli/utils.ts index d17f593..d09301a 100644 --- a/src/cli/utils.ts +++ b/src/cli/utils.ts @@ -1,6 +1,12 @@ -/** Split a comma-separated string into trimmed, non-empty tokens. */ -export const parseCommaSeparated = (input: string): string[] => - input +export const INTEGER_REGEX = /^\d+$/; + +export const parseCommaSeparated = (input: string): string[] | undefined => { + const trimmed = input.trim(); + if (!trimmed) { + return undefined; + } + return trimmed .split(",") .map((s) => s.trim()) .filter(Boolean); +}; diff --git a/src/discord/client.ts b/src/discord/client.ts index 69978cd..d0543e2 100644 --- a/src/discord/client.ts +++ b/src/discord/client.ts @@ -25,6 +25,12 @@ type RateLimitState = { type BucketKey = string; +/** + * Module-level rate limit state shared across all calls to discordFetch. + * Keyed per bucket (endpoint path + token hash), so different endpoints + * maintain independent rate limit tracking. Assumes sequential usage + * per bucket — concurrent requests to the same bucket may race. + */ const rateLimitBuckets = new Map(); const bucketKeyMap = new Map(); @@ -59,17 +65,19 @@ const updateRateLimitState = ( headers: Headers, bucketKey: BucketKey, computedKey: BucketKey -): void => { +): BucketKey => { const state = getBucketState(bucketKey); const remaining = headers.get("X-RateLimit-Remaining"); const resetAfter = headers.get("X-RateLimit-Reset-After"); const bucket = headers.get("X-RateLimit-Bucket"); + let activeBucketKey = bucketKey; if (bucket !== null && `discord:${bucket}` !== bucketKey) { const discordBucketKey = `discord:${bucket}`; rateLimitBuckets.delete(bucketKey); rateLimitBuckets.set(discordBucketKey, state); bucketKeyMap.set(computedKey, discordBucketKey); + activeBucketKey = discordBucketKey; } if (remaining !== null) { @@ -79,6 +87,7 @@ const updateRateLimitState = ( state.resetAfterMs = Number.parseFloat(resetAfter) * 1000; } state.lastRequestTime = Date.now(); + return activeBucketKey; }; const waitForRateLimit = async (bucketKey: BucketKey): Promise => { @@ -215,11 +224,12 @@ const handle202 = async ( schema: z.ZodType, maxRetries: number, maxRetries429: number, - bucketKey: BucketKey, + initialBucketKey: BucketKey, computedKey: BucketKey, rateLimitCounter: RetryCounter ): Promise> => { let retryAfter = await parseRetryAfterFrom202(response); + let bucketKey = initialBucketKey; for (let attempt = 0; attempt < maxRetries; attempt++) { await sleep(retryAfter * 1000); @@ -231,7 +241,11 @@ const handle202 = async ( } const retryResponse = retryResult.value; - updateRateLimitState(retryResponse.headers, bucketKey, computedKey); + bucketKey = updateRateLimitState( + retryResponse.headers, + bucketKey, + computedKey + ); if (retryResponse.status === 429) { const rateLimitResult = await handle429( @@ -325,7 +339,7 @@ export const discordFetch = async ( ): Promise> => { const url = `${DISCORD_API_BASE}${path}`; const computedKey = computeBucketKey(path, token); - const bucketKey = bucketKeyMap.get(computedKey) ?? computedKey; + let bucketKey = bucketKeyMap.get(computedKey) ?? computedKey; const rateLimitCounter: RetryCounter = { value: 0 }; for (; rateLimitCounter.value <= maxRetries429; rateLimitCounter.value++) { @@ -337,7 +351,7 @@ export const discordFetch = async ( } const response = fetchResult.value; - updateRateLimitState(response.headers, bucketKey, computedKey); + bucketKey = updateRateLimitState(response.headers, bucketKey, computedKey); if (response.status === 429) { const result = await handle429( diff --git a/src/discord/schemas.ts b/src/discord/schemas.ts index 97e0cb4..09aa036 100644 --- a/src/discord/schemas.ts +++ b/src/discord/schemas.ts @@ -3,14 +3,11 @@ import { z } from "zod"; export const MAX_OFFSET = 9975; export const MAX_PAGE_SIZE = 25; -const SNOWFLAKE_REGEX = /^\d{17,20}$/; +export const SNOWFLAKE_REGEX = /^\d{17,20}$/; -const snowflakeSchema = z - .string() - .regex(SNOWFLAKE_REGEX, { - message: "Invalid Discord ID: must be a 17-20 digit numeric snowflake", - }) - .max(20, { message: "Discord ID exceeds maximum length of 20 characters" }); +const snowflakeSchema = z.string().regex(SNOWFLAKE_REGEX, { + message: "Invalid Discord ID: must be a 17-20 digit numeric snowflake", +}); const snowflakeArraySchema = z.array(snowflakeSchema); @@ -205,7 +202,7 @@ export const SearchParamsSchema = z.object({ sortBy: z.enum(["timestamp", "relevance"]).optional(), sortOrder: z.enum(["asc", "desc"]).optional(), offset: z.number().int().min(0).max(MAX_OFFSET).optional(), - limit: z.number().int().min(1).max(MAX_PAGE_SIZE).optional(), + limit: z.number().int().min(1).optional(), }); // Inferred types diff --git a/src/discord/search.ts b/src/discord/search.ts index 7cb27f0..3decbda 100644 --- a/src/discord/search.ts +++ b/src/discord/search.ts @@ -165,6 +165,9 @@ const fetchPage = async ( ? Math.min(params.limit, MAX_PAGE_SIZE) : MAX_PAGE_SIZE; + // Only capture totalResults from the first (unfiltered) response. + // Subsequent partitions with max_id return a smaller total_results + // scoped to the filtered range, which would cause early termination. if (state.totalResults === 0) { state.totalResults = maxMessages ? Math.min(data.total_results, maxMessages) @@ -194,7 +197,9 @@ const fetchPage = async ( ); } - Array.prototype.push.apply(state.allMessages, pageMessages); + for (const msg of pageMessages) { + state.allMessages.push(msg); + } onPage?.(pageMessages); onProgress?.({ fetched: state.allMessages.length, diff --git a/src/index.ts b/src/index.ts index 1a5c2a6..b4a6035 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,70 @@ -/** - * Discord Search CLI - * - * Entry point for the Discord message search tool. - * This is a placeholder stub added during infrastructure setup. - */ - -export {}; +#!/usr/bin/env node + +import { HELP_TEXT, SUBCOMMAND_HELP } from "@/cli/args-help.ts"; +import { parseArgs } from "@/cli/args-parse.ts"; +import type { ParsedArgs } from "@/cli/args-types.ts"; +import pkg from "../package.json"; + +const VERSION = pkg.version; + +const printHelp = (parsed: ParsedArgs & { command: "help" }) => { + const target = parsed.targetCommand; + const helpText = target ? SUBCOMMAND_HELP[target] : HELP_TEXT; + process.stdout.write(`${helpText}\n`); +}; + +const printVersion = () => { + process.stdout.write(`discord-search ${VERSION}\n`); +}; + +const run = () => { + const parsed = parseArgs(process.argv.slice(2)); + + if (parsed.version) { + printVersion(); + process.exitCode = 0; + return; + } + + if (parsed.command === "help") { + printHelp(parsed); + process.exitCode = 0; + return; + } + + if (parsed.command === "interactive") { + process.stderr.write( + "Interactive mode is not yet implemented. Use 'discord-search search --help' for CLI usage.\n" + ); + process.exitCode = 1; + return; + } + + if (parsed.command === "search") { + process.stderr.write("Search command execution is not yet implemented.\n"); + process.exitCode = 1; + return; + } + + if (parsed.command === "preset") { + process.stderr.write("Preset command execution is not yet implemented.\n"); + process.exitCode = 1; + return; + } + + if (parsed.command === "settings") { + process.stderr.write( + "Settings command execution is not yet implemented.\n" + ); + process.exitCode = 1; + return; + } + + const _exhaustive: never = parsed; + process.stderr.write( + `Unhandled command: ${(_exhaustive as ParsedArgs).command}\n` + ); + process.exitCode = 1; +}; + +run();