feat: opus 4.6 1M default, model selector UX, Discord onboarding (#290)

This commit is contained in:
Juan Francisco Lebrero 2026-03-14 11:43:56 -03:00 committed by GitHub
parent 8f8a9db7cb
commit 3a1b8f457d
6 changed files with 416 additions and 34 deletions

View file

@ -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<T>(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<T = unknown>(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<T = unknown>(text: string): T {
* Handles unclosed strings, objects, arrays, trailing commas, and truncated literals.
*/
export function parsePartialJson<T = unknown>(text: string): T {
return native.parsePartialJson(text) as T;
if (hasNativeJson) {
return native.parsePartialJson(text) as T;
}
return jsFallbackStreamingJson<T>(text);
}
/**
@ -30,5 +69,8 @@ export function parseStreamingJson<T = unknown>(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<T>(text);
}

View file

@ -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),

View file

@ -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));
}
}
}

View file

@ -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 <path> Load additional extension\n')
process.stdout.write(' --tools <a,b,c> 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;

View file

@ -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<void> {
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<void> {
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<string | null> {
// 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<string | null> {
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) ─────────────────────────────────
/**

View file

@ -58,16 +58,70 @@ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise<void> {
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:",