feat: opus 4.6 1M default, model selector UX, Discord onboarding (#290)
This commit is contained in:
parent
8f8a9db7cb
commit
3a1b8f457d
6 changed files with 416 additions and 34 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
47
src/cli.ts
47
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 <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;
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue