diff --git a/packages/native/src/json-parse/index.ts b/packages/native/src/json-parse/index.ts index 62f21f9dc..f8c4dc4cc 100644 --- a/packages/native/src/json-parse/index.ts +++ b/packages/native/src/json-parse/index.ts @@ -1,17 +1,53 @@ /** - * Streaming JSON parser via native Rust bindings. + * Streaming JSON parser via native Rust bindings with JS fallback. * * Provides fast JSON parsing with recovery for incomplete/partial JSON, * used during LLM streaming tool call argument parsing. + * + * Falls back to pure-JS implementation when native functions are not + * available (e.g. addon was compiled before json-parse was added). */ import { native } from "../native.js"; +const hasNativeJson = typeof native.parseStreamingJson === "function"; + +/** + * JS fallback: attempt JSON.parse, return {} on failure. + */ +function jsFallbackStreamingJson(text: string): T { + try { + return JSON.parse(text) as T; + } catch { + // Try to salvage partial JSON by closing open structures + let patched = text.trim(); + // Close unclosed strings + const quotes = (patched.match(/"/g) || []).length; + if (quotes % 2 !== 0) patched += '"'; + // Close unclosed brackets/braces + const opens = (patched.match(/[{[]/g) || []).length; + const closes = (patched.match(/[}\]]/g) || []).length; + for (let i = 0; i < opens - closes; i++) { + // Guess which closer based on last opener + const lastOpen = patched.lastIndexOf("{") > patched.lastIndexOf("[") ? "}" : "]"; + patched += lastOpen; + } + try { + return JSON.parse(patched) as T; + } catch { + return {} as T; + } + } +} + /** * Parse a complete JSON string. Throws on invalid JSON. */ export function parseJson(text: string): T { - return native.parseJson(text) as T; + if (hasNativeJson) { + return native.parseJson(text) as T; + } + return JSON.parse(text) as T; } /** @@ -19,7 +55,10 @@ export function parseJson(text: string): T { * Handles unclosed strings, objects, arrays, trailing commas, and truncated literals. */ export function parsePartialJson(text: string): T { - return native.parsePartialJson(text) as T; + if (hasNativeJson) { + return native.parsePartialJson(text) as T; + } + return jsFallbackStreamingJson(text); } /** @@ -30,5 +69,8 @@ export function parseStreamingJson(text: string | undefined): T { if (!text || text.trim() === "") { return {} as T; } - return native.parseStreamingJson(text) as T; + if (hasNativeJson) { + return native.parseStreamingJson(text) as T; + } + return jsFallbackStreamingJson(text); } diff --git a/packages/pi-coding-agent/src/cli/list-models.ts b/packages/pi-coding-agent/src/cli/list-models.ts index 8a7feafd6..72c276cda 100644 --- a/packages/pi-coding-agent/src/cli/list-models.ts +++ b/packages/pi-coding-agent/src/cli/list-models.ts @@ -43,8 +43,10 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s return; } - // Sort by provider, then by model id + // Sort by model name descending (newest first), then provider, then id filteredModels.sort((a, b) => { + const nameCmp = b.name.localeCompare(a.name); + if (nameCmp !== 0) return nameCmp; const providerCmp = a.provider.localeCompare(b.provider); if (providerCmp !== 0) return providerCmp; return a.id.localeCompare(b.id); @@ -54,6 +56,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s const rows = filteredModels.map((m) => ({ provider: m.provider, model: m.id, + name: m.name, context: formatTokenCount(m.contextWindow), maxOut: formatTokenCount(m.maxTokens), thinking: m.reasoning ? "yes" : "no", @@ -63,6 +66,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s const headers = { provider: "provider", model: "model", + name: "name", context: "context", maxOut: "max-out", thinking: "thinking", @@ -72,6 +76,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s const widths = { provider: Math.max(headers.provider.length, ...rows.map((r) => r.provider.length)), model: Math.max(headers.model.length, ...rows.map((r) => r.model.length)), + name: Math.max(headers.name.length, ...rows.map((r) => r.name.length)), context: Math.max(headers.context.length, ...rows.map((r) => r.context.length)), maxOut: Math.max(headers.maxOut.length, ...rows.map((r) => r.maxOut.length)), thinking: Math.max(headers.thinking.length, ...rows.map((r) => r.thinking.length)), @@ -82,6 +87,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s const headerLine = [ headers.provider.padEnd(widths.provider), headers.model.padEnd(widths.model), + headers.name.padEnd(widths.name), headers.context.padEnd(widths.context), headers.maxOut.padEnd(widths.maxOut), headers.thinking.padEnd(widths.thinking), @@ -94,6 +100,7 @@ export async function listModels(modelRegistry: ModelRegistry, searchPattern?: s const line = [ row.provider.padEnd(widths.provider), row.model.padEnd(widths.model), + row.name.padEnd(widths.name), row.context.padEnd(widths.context), row.maxOut.padEnd(widths.maxOut), row.thinking.padEnd(widths.thinking), diff --git a/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts b/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts index 3ec0fae26..06ef5ac2e 100644 --- a/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +++ b/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts @@ -15,6 +15,18 @@ import { theme } from "../theme/theme.js"; import { DynamicBorder } from "./dynamic-border.js"; import { keyHint } from "./keybinding-hints.js"; +function formatTokenCount(count: number): string { + if (count >= 1_000_000) { + const millions = count / 1_000_000; + return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; + } + if (count >= 1_000) { + const thousands = count / 1_000; + return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; + } + return count.toString(); +} + interface ModelItem { provider: string; id: string; @@ -178,12 +190,15 @@ export class ModelSelectorComponent extends Container implements Focusable { private sortModels(models: ModelItem[]): ModelItem[] { const sorted = [...models]; - // Sort: current model first, then by provider + // Sort: current model first, then by name descending (newest first), then by provider sorted.sort((a, b) => { const aIsCurrent = modelsAreEqual(this.currentModel, a.model); const bIsCurrent = modelsAreEqual(this.currentModel, b.model); if (aIsCurrent && !bIsCurrent) return -1; if (!aIsCurrent && bIsCurrent) return 1; + // Group by model name (display name), newest/largest first + const nameCmp = b.model.name.localeCompare(a.model.name); + if (nameCmp !== 0) return nameCmp; return a.provider.localeCompare(b.provider); }); return sorted; @@ -236,18 +251,17 @@ export class ModelSelectorComponent extends Container implements Focusable { const isSelected = i === this.selectedIndex; const isCurrent = modelsAreEqual(this.currentModel, item.model); + const ctx = formatTokenCount(item.model.contextWindow); + const ctxBadge = theme.fg("muted", `${ctx}`); + const providerBadge = theme.fg("muted", `[${item.provider}]`); + const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; + let line = ""; if (isSelected) { const prefix = theme.fg("accent", "→ "); - const modelText = `${item.id}`; - const providerBadge = theme.fg("muted", `[${item.provider}]`); - const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${prefix + theme.fg("accent", modelText)} ${providerBadge}${checkmark}`; + line = `${prefix}${theme.fg("accent", item.id)} ${ctxBadge} ${providerBadge}${checkmark}`; } else { - const modelText = ` ${item.id}`; - const providerBadge = theme.fg("muted", `[${item.provider}]`); - const checkmark = isCurrent ? theme.fg("success", " ✓") : ""; - line = `${modelText} ${providerBadge}${checkmark}`; + line = ` ${item.id} ${ctxBadge} ${providerBadge}${checkmark}`; } this.listContainer.addChild(new Text(line, 0, 0)); @@ -270,8 +284,18 @@ export class ModelSelectorComponent extends Container implements Focusable { this.listContainer.addChild(new Text(theme.fg("muted", " No matching models"), 0, 0)); } else { const selected = this.filteredModels[this.selectedIndex]; - this.listContainer.addChild(new Spacer(1)); - this.listContainer.addChild(new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`), 0, 0)); + if (selected) { + const m = selected.model; + const details = [ + m.name, + `ctx: ${formatTokenCount(m.contextWindow)}`, + `out: ${formatTokenCount(m.maxTokens)}`, + m.reasoning ? "thinking" : "", + m.input.includes("image") ? "vision" : "", + ].filter(Boolean).join(" · "); + this.listContainer.addChild(new Spacer(1)); + this.listContainer.addChild(new Text(theme.fg("muted", ` ${details}`), 0, 0)); + } } } diff --git a/src/cli.ts b/src/cli.ts index 971203d58..e5142cd57 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,6 +28,7 @@ interface CliFlags { continue?: boolean noSession?: boolean model?: string + listModels?: string | true extensions: string[] appendSystemPrompt?: string tools?: string[] @@ -56,6 +57,8 @@ function parseCliArgs(argv: string[]): CliFlags { flags.appendSystemPrompt = args[++i] } else if (arg === '--tools' && i + 1 < args.length) { flags.tools = args[++i].split(',') + } else if (arg === '--list-models') { + flags.listModels = (i + 1 < args.length && !args[i + 1].startsWith('-')) ? args[++i] : true } else if (arg === '--version' || arg === '-v') { process.stdout.write((process.env.GSD_VERSION || '0.0.0') + '\n') process.exit(0) @@ -70,6 +73,7 @@ function parseCliArgs(argv: string[]): CliFlags { process.stdout.write(' --no-session Disable session persistence\n') process.stdout.write(' --extension Load additional extension\n') process.stdout.write(' --tools Restrict available tools\n') + process.stdout.write(' --list-models [search] List available models and exit\n') process.stdout.write(' --version, -v Print version and exit\n') process.stdout.write(' --help, -h Print this help and exit\n') process.stdout.write('\nSubcommands:\n') @@ -122,6 +126,49 @@ if (!isPrintMode) { const modelRegistry = new ModelRegistry(authStorage) const settingsManager = SettingsManager.create(agentDir) +// --list-models: print available models and exit (no TTY needed) +if (cliFlags.listModels !== undefined) { + const models = modelRegistry.getAvailable() + if (models.length === 0) { + console.log('No models available. Set API keys in environment variables.') + process.exit(0) + } + + const searchPattern = typeof cliFlags.listModels === 'string' ? cliFlags.listModels : undefined + let filtered = models + if (searchPattern) { + const q = searchPattern.toLowerCase() + filtered = models.filter((m) => `${m.provider} ${m.id} ${m.name}`.toLowerCase().includes(q)) + } + + // Sort by name descending (newest first), then provider, then id + filtered.sort((a, b) => { + const nameCmp = b.name.localeCompare(a.name) + if (nameCmp !== 0) return nameCmp + const provCmp = a.provider.localeCompare(b.provider) + if (provCmp !== 0) return provCmp + return a.id.localeCompare(b.id) + }) + + const fmt = (n: number) => n >= 1_000_000 ? `${n / 1_000_000}M` : n >= 1_000 ? `${n / 1_000}K` : `${n}` + const rows = filtered.map((m) => [ + m.provider, + m.id, + m.name, + fmt(m.contextWindow), + fmt(m.maxTokens), + m.reasoning ? 'yes' : 'no', + ]) + const hdrs = ['provider', 'model', 'name', 'context', 'max-out', 'thinking'] + const widths = hdrs.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))) + const pad = (s: string, w: number) => s.padEnd(w) + console.log(hdrs.map((h, i) => pad(h, widths[i])).join(' ')) + for (const row of rows) { + console.log(row.map((c, i) => pad(c, widths[i])).join(' ')) + } + process.exit(0) +} + // Validate configured model on startup — catches stale settings from prior installs // (e.g. grok-2 which no longer exists) and fresh installs with no settings. // Only resets the default when the configured model no longer exists in the registry; diff --git a/src/onboarding.ts b/src/onboarding.ts index f2ec1fa62..665746f98 100644 --- a/src/onboarding.ts +++ b/src/onboarding.ts @@ -49,18 +49,6 @@ const TOOL_KEYS: ToolKeyConfig[] = [ label: 'Jina AI', hint: 'clean web page extraction', }, - { - provider: 'slack_bot', - envVar: 'SLACK_BOT_TOKEN', - label: 'Slack Bot', - hint: 'remote questions in auto-mode', - }, - { - provider: 'discord_bot', - envVar: 'DISCORD_BOT_TOKEN', - label: 'Discord Bot', - hint: 'remote questions in auto-mode', - }, ] /** Known LLM provider IDs that, if authed, mean the user doesn't need onboarding */ @@ -207,6 +195,18 @@ export async function runOnboarding(authStorage: AuthStorage): Promise { p.log.warn(`Web search setup failed: ${err instanceof Error ? err.message : String(err)}`) } + // ── Remote Questions ───────────────────────────────────────────────────── + let remoteConfigured: string | null = null + try { + remoteConfigured = await runRemoteQuestionsStep(p, pc, authStorage) + } catch (err) { + if (isCancelError(p, err)) { + p.cancel('Setup cancelled.') + return + } + p.log.warn(`Remote questions setup failed: ${err instanceof Error ? err.message : String(err)}`) + } + // ── Tool API Keys ───────────────────────────────────────────────────────── let toolKeyCount = 0 try { @@ -240,6 +240,12 @@ export async function runOnboarding(authStorage: AuthStorage): Promise { summaryLines.push(`${pc.dim('↷')} Web search: not configured — use /search-provider inside GSD`) } + if (remoteConfigured) { + summaryLines.push(`${pc.green('✓')} Remote questions: ${remoteConfigured}`) + } else { + summaryLines.push(`${pc.dim('↷')} Remote questions: not configured — use /gsd remote inside GSD`) + } + if (toolKeyCount > 0) { summaryLines.push(`${pc.green('✓')} ${toolKeyCount} tool key${toolKeyCount > 1 ? 's' : ''} saved`) } else { @@ -541,6 +547,208 @@ async function runToolKeysStep( return savedCount } +// ─── Remote Questions Step ──────────────────────────────────────────────────── + +async function runRemoteQuestionsStep( + p: ClackModule, + pc: PicoModule, + authStorage: AuthStorage, +): Promise { + // Check existing config + const hasDiscord = authStorage.has('discord_bot') && !!(authStorage.get('discord_bot') as any)?.key + const hasSlack = authStorage.has('slack_bot') && !!(authStorage.get('slack_bot') as any)?.key + const existingChannel = hasDiscord ? 'Discord' : hasSlack ? 'Slack' : null + + type RemoteOption = { value: string; label: string; hint?: string } + const options: RemoteOption[] = [] + + if (existingChannel) { + options.push({ value: 'keep', label: `Keep current (${existingChannel})`, hint: 'already configured' }) + } + + options.push( + { value: 'discord', label: 'Discord', hint: 'receive questions in a Discord channel' }, + { value: 'slack', label: 'Slack', hint: 'receive questions in a Slack channel' }, + { value: 'skip', label: 'Skip for now', hint: 'use /gsd remote inside GSD later' }, + ) + + const choice = await p.select({ + message: 'Set up remote questions? (get notified when GSD needs input)', + options, + }) + + if (p.isCancel(choice) || choice === 'skip') return null + if (choice === 'keep') return existingChannel + + if (choice === 'discord') { + const token = await p.password({ + message: 'Paste your Discord bot token:', + mask: '●', + }) + if (p.isCancel(token) || !(token as string)?.trim()) return null + const trimmed = (token as string).trim() + + authStorage.set('discord_bot', { type: 'api_key', key: trimmed }) + process.env.DISCORD_BOT_TOKEN = trimmed + + const channelName = await runDiscordChannelStep(p, pc, trimmed) + return channelName ? `Discord #${channelName}` : 'Discord' + } + + if (choice === 'slack') { + const token = await p.password({ + message: `Paste your Slack bot token ${pc.dim('(xoxb-...)')}:`, + mask: '●', + }) + if (p.isCancel(token) || !(token as string)?.trim()) return null + const trimmed = (token as string).trim() + if (!trimmed.startsWith('xoxb-')) { + p.log.warn('Invalid token format — Slack bot tokens start with xoxb-.') + return null + } + + // Validate + const s = p.spinner() + s.start('Validating Slack token...') + try { + const res = await fetch('https://slack.com/api/auth.test', { + headers: { Authorization: `Bearer ${trimmed}` }, + signal: AbortSignal.timeout(15_000), + }) + const data = await res.json() as any + if (!data?.ok) { + s.stop('Slack token validation failed') + return null + } + s.stop(`Slack authenticated as ${pc.green(data.user ?? 'bot')}`) + } catch { + s.stop('Could not reach Slack API') + return null + } + + authStorage.set('slack_bot', { type: 'api_key', key: trimmed }) + process.env.SLACK_BOT_TOKEN = trimmed + + const channelId = await p.text({ + message: 'Paste the Slack channel ID (e.g. C0123456789):', + validate: (val) => { + if (!val || !/^[A-Z0-9]{9,12}$/.test(val.trim())) return 'Expected 9-12 uppercase alphanumeric characters' + }, + }) + if (p.isCancel(channelId) || !channelId) return null + + const { saveRemoteQuestionsConfig } = await import('./resources/extensions/remote-questions/remote-command.js') + saveRemoteQuestionsConfig('slack', (channelId as string).trim()) + p.log.success(`Slack channel: ${pc.green((channelId as string).trim())}`) + return 'Slack' + } + + return null +} + +async function runDiscordChannelStep(p: ClackModule, pc: PicoModule, token: string): Promise { + const headers = { Authorization: `Bot ${token}` } + + // Validate token + const s = p.spinner() + s.start('Validating Discord bot token...') + let auth: any + try { + const res = await fetch('https://discord.com/api/v10/users/@me', { headers, signal: AbortSignal.timeout(15_000) }) + auth = await res.json() + } catch { + s.stop('Could not reach Discord API') + return null + } + if (!auth?.id) { + s.stop('Discord token validation failed') + return null + } + s.stop(`Bot authenticated as ${pc.green(auth.username ?? 'unknown')}`) + + // Fetch guilds + let guilds: Array<{ id: string; name: string }> + try { + const res = await fetch('https://discord.com/api/v10/users/@me/guilds', { headers, signal: AbortSignal.timeout(15_000) }) + const data = await res.json() + guilds = Array.isArray(data) ? data : [] + } catch { + p.log.warn('Could not fetch Discord servers — configure channel later with /gsd remote discord') + return null + } + + if (guilds.length === 0) { + p.log.warn('Bot is not in any Discord servers — configure channel later with /gsd remote discord') + return null + } + + // Select guild + let guildId: string + let guildName: string + if (guilds.length === 1) { + guildId = guilds[0].id + guildName = guilds[0].name + p.log.info(`Server: ${pc.green(guildName)}`) + } else { + const choice = await p.select({ + message: 'Which Discord server?', + options: guilds.map(g => ({ value: g.id, label: g.name })), + }) + if (p.isCancel(choice)) return null + guildId = choice as string + guildName = guilds.find(g => g.id === guildId)?.name ?? guildId + } + + // Fetch channels + let channels: Array<{ id: string; name: string; type: number }> + try { + const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, { headers, signal: AbortSignal.timeout(15_000) }) + const data = await res.json() + channels = Array.isArray(data) ? data.filter((ch: any) => ch.type === 0 || ch.type === 5) : [] + } catch { + p.log.warn('Could not fetch channels — configure later with /gsd remote discord') + return null + } + + if (channels.length === 0) { + p.log.warn('No text channels found — configure later with /gsd remote discord') + return null + } + + // Select channel + const MANUAL_VALUE = '__manual__' + const channelChoice = await p.select({ + message: 'Which channel should GSD use for remote questions?', + options: [ + ...channels.map(ch => ({ value: ch.id, label: `#${ch.name}` })), + { value: MANUAL_VALUE, label: 'Enter channel ID manually' }, + ], + }) + if (p.isCancel(channelChoice)) return null + + let channelId: string + if (channelChoice === MANUAL_VALUE) { + const manualId = await p.text({ + message: 'Paste the Discord channel ID:', + placeholder: '1234567890123456789', + validate: (val) => { + if (!val || !/^\d{17,20}$/.test(val.trim())) return 'Expected 17-20 digit numeric ID' + }, + }) + if (p.isCancel(manualId) || !manualId) return null + channelId = (manualId as string).trim() + } else { + channelId = channelChoice as string + } + + // Save remote questions config + const { saveRemoteQuestionsConfig } = await import('./resources/extensions/remote-questions/remote-command.js') + saveRemoteQuestionsConfig('discord', channelId) + const channelName = channels.find(ch => ch.id === channelId)?.name + p.log.success(`Discord channel: ${pc.green(channelName ? `#${channelName}` : channelId)}`) + return channelName ?? null +} + // ─── Env hydration (migrated from wizard.ts) ───────────────────────────────── /** diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index fcfa53703..dafc5ac60 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -58,16 +58,70 @@ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info"); ctx.ui.notify("Validating token...", "info"); - const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${token}` } }); + const headers = { Authorization: `Bot ${token}` }; + const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers }); if (!auth?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error"); - const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); - if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info"); - if (!isValidChannelId("discord", channelId)) return void ctx.ui.notify("Invalid Discord channel ID format — expected 17-20 digit numeric ID.", "error"); + // Fetch guilds the bot is a member of + const guilds: Array<{ id: string; name: string }> | null = await fetchJson("https://discord.com/api/v10/users/@me/guilds", { headers }); + if (!Array.isArray(guilds) || guilds.length === 0) { + return void ctx.ui.notify("Bot is not in any Discord servers.", "error"); + } + + let guildId: string; + let guildName: string; + if (guilds.length === 1) { + guildId = guilds[0].id; + guildName = guilds[0].name; + } else { + const guildOptions = guilds.map((g) => g.name); + const selectedGuild = await ctx.ui.select("Select a Discord server", guildOptions); + if (!selectedGuild) return void ctx.ui.notify("Discord setup cancelled.", "info"); + const chosen = guilds.find((g) => g.name === selectedGuild); + if (!chosen) return void ctx.ui.notify("Discord setup cancelled.", "info"); + guildId = chosen.id; + guildName = chosen.name; + } + + // Fetch text and announcement channels in the selected guild + ctx.ui.notify(`Fetching channels for ${guildName}...`, "info"); + const allChannels: Array<{ id: string; name: string; type: number }> | null = await fetchJson( + `https://discord.com/api/v10/guilds/${guildId}/channels`, + { headers }, + ); + const textChannels = Array.isArray(allChannels) + ? allChannels.filter((ch) => ch.type === 0 || ch.type === 5) + : []; + + const MANUAL_OPTION = "Enter channel ID manually"; + let channelId: string; + + if (textChannels.length === 0) { + ctx.ui.notify("No text channels found — falling back to manual entry.", "warning"); + const manualId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); + if (!manualId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + if (!isValidChannelId("discord", manualId)) return void ctx.ui.notify("Invalid Discord channel ID format — expected 17-20 digit numeric ID.", "error"); + channelId = manualId; + } else { + const channelOptions = [...textChannels.map((ch) => `#${ch.name}`), MANUAL_OPTION]; + const selectedChannel = await ctx.ui.select("Select a channel", channelOptions); + if (!selectedChannel) return void ctx.ui.notify("Discord setup cancelled.", "info"); + + if (selectedChannel === MANUAL_OPTION) { + const manualId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); + if (!manualId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + if (!isValidChannelId("discord", manualId)) return void ctx.ui.notify("Invalid Discord channel ID format — expected 17-20 digit numeric ID.", "error"); + channelId = manualId; + } else { + const chosenChannel = textChannels.find((ch) => `#${ch.name}` === selectedChannel); + if (!chosenChannel) return void ctx.ui.notify("Discord setup cancelled.", "info"); + channelId = chosenChannel.id; + } + } const sendResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", - headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify({ content: "GSD remote questions connected." }), signal: AbortSignal.timeout(15_000), }); @@ -165,7 +219,7 @@ function removeProviderToken(provider: string): void { auth.set(provider, { type: "api_key", key: "" }); } -function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { +export function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { const prefsPath = getGlobalGSDPreferencesPath(); const block = [ "remote_questions:",