diff --git a/src/quickstart.ts b/src/quickstart.ts index c1037362..062dc4f1 100644 --- a/src/quickstart.ts +++ b/src/quickstart.ts @@ -54,6 +54,16 @@ async function listDiscordGuilds(token: string): Promise<{ id: string; name: str } catch { return []; } } +export async function listDiscordChannels(token: string, guildId: string): Promise<{ id: string; name: string; type: number }[]> { + try { + const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/channels`, { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) return []; + return (await res.json()) as { id: string; name: string; type: number }[]; + } catch { return []; } +} + // ── Telegram group + user detection ────────────────────── const DETECT_TIMEOUT = 3 * 60_000; @@ -190,6 +200,9 @@ export async function runQuickstart(): Promise { let groupId = ""; let userId = ""; let tokenEnvName = ""; + let generalChannelId = ""; + let categoryName = ""; + let pluginInstalled = false; if (channel === "telegram") { // ── Telegram flow ────────────────────────────────── @@ -226,8 +239,22 @@ export async function runQuickstart(): Promise { } else { // ── Discord flow ─────────────────────────────────── + const totalSteps = 5; + + // Plugin check + try { + execSync("npm list -g @suzuke/agend-plugin-discord --depth=0", { stdio: "pipe" }); + pluginInstalled = true; + } catch { /* not installed */ } + if (!pluginInstalled) { + console.log(` ${yellow("⚠")} Discord plugin not found. Install with:`); + console.log(` ${bold("npm install -g @suzuke/agend-plugin-discord")}\n`); + const cont = await rl.question(" Continue setup anyway? [Y/n] "); + if (cont.toLowerCase() === "n") { rl.close(); return; } + console.log(); + } - console.log(bold("Step 3/4: Discord Bot")); + console.log(bold(`Step 3/${totalSteps}: Discord Bot`)); console.log(` 1. Go to Discord Developer Portal: ${dim("https://discord.com/developers/applications")}`); console.log(` 2. New Application → Bot → Reset Token → Copy`); console.log(` 3. Enable ${bold("Message Content Intent")} under Bot → Privileged Gateway Intents\n`); @@ -246,7 +273,7 @@ export async function runQuickstart(): Promise { break; } - console.log(bold("Step 4/4: Guild & User ID")); + console.log(bold(`Step 4/${totalSteps}: Guild & User ID`)); // Auto-detect guilds const guilds = await listDiscordGuilds(token); @@ -273,6 +300,32 @@ export async function runQuickstart(): Promise { console.log(` Discord Settings → Advanced → ${bold("Developer Mode")} ON → Right-click yourself → Copy User ID\n`); userId = (await rl.question(" Paste your User ID: ")).trim(); console.log(` ${green("✓")} User: ${userId}\n`); + + // ── General channel selection ────────────────────── + console.log(bold(`Step 5/${totalSteps}: General Channel`)); + + const channels = await listDiscordChannels(token, groupId); + const textChannels = channels.filter(c => c.type === 0).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20); + + if (textChannels.length > 0) { + console.log(" Text channels in your server:"); + for (let i = 0; i < textChannels.length; i++) { + console.log(` ${i + 1}. ${textChannels[i].name} ${dim(`(${textChannels[i].id})`)}`); + } + const cChoice = await rl.question(` Choose general channel [1]: `); + const parsed = parseInt(cChoice || "1", 10); + const cIdx = isNaN(parsed) ? 0 : Math.max(0, Math.min(textChannels.length - 1, parsed - 1)); + generalChannelId = textChannels[cIdx].id; + console.log(` ${green("✓")} General channel: ${textChannels[cIdx].name} (${generalChannelId})`); + } else { + generalChannelId = (await rl.question(" Paste general channel ID: ")).trim(); + if (generalChannelId) { + console.log(` ${green("✓")} General channel: ${generalChannelId}`); + } + } + + categoryName = (await rl.question(`\n Category name for agent channels [AgEnD Agents]: `)).trim() || "AgEnD Agents"; + console.log(` ${green("✓")} Category: ${categoryName}\n`); } // ── Project roots ──────────────────────────────────── @@ -317,6 +370,13 @@ export async function runQuickstart(): Promise { const qGid = groupId.length >= 16 ? `"${groupId}"` : groupId; const qUid = userId.length >= 16 ? `"${userId}"` : userId; + // Build Discord options block + const discordOptions: string[] = []; + if (channel === "discord") { + if (generalChannelId) discordOptions.push(` general_channel_id: "${generalChannelId}"`); + if (categoryName && categoryName !== "AgEnD Agents") discordOptions.push(` category_name: "${categoryName}"`); + } + const fleetYaml = [ "channel:", ` type: ${channel}`, @@ -327,6 +387,7 @@ export async function runQuickstart(): Promise { " mode: locked", " allowed_users:", ` - ${qUid}`, + ...(discordOptions.length > 0 ? [" options:", ...discordOptions] : []), "", ...(projectRoots.length > 0 ? ["project_roots:", ...projectRoots.map(p => ` - ${p}`), ""] @@ -352,10 +413,16 @@ export async function runQuickstart(): Promise { console.log(`\n${bold("═══ Setup Complete ═══")}\n`); if (channel === "discord") { console.log(" Next steps:"); - console.log(` 1. ${bold("npm install -g @suzuke/agend-plugin-discord")}`); - console.log(` 2. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); - console.log(` 3. ${bold("agend fleet start")}`); - console.log(` 4. Talk to ${botUsername} in your Discord server\n`); + if (!pluginInstalled) { + console.log(` 1. ${bold("npm install -g @suzuke/agend-plugin-discord")}`); + console.log(` 2. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); + console.log(` 3. ${bold("agend fleet start")}`); + console.log(` 4. Talk to ${botUsername} in your Discord server\n`); + } else { + console.log(` 1. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); + console.log(` 2. ${bold("agend fleet start")}`); + console.log(` 3. Talk to ${botUsername} in your Discord server\n`); + } } else { console.log(" Next steps:"); console.log(` 1. ${dim("(Optional)")} Edit ~/.agend/fleet.yaml to customize`); diff --git a/tests/quickstart.test.ts b/tests/quickstart.test.ts new file mode 100644 index 00000000..332ffa08 --- /dev/null +++ b/tests/quickstart.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { listDiscordChannels } from "../src/quickstart.js"; + +describe("Quickstart – Discord UX", () => { + beforeEach(() => { vi.restoreAllMocks(); }); + + describe("listDiscordChannels", () => { + it("returns text channels on success", async () => { + const channels = [ + { id: "111", name: "general", type: 0 }, + { id: "222", name: "voice", type: 2 }, + { id: "333", name: "dev", type: 0 }, + ]; + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(channels), + }); + + const result = await listDiscordChannels("fake-token", "guild-123"); + expect(result).toEqual(channels); + expect(global.fetch).toHaveBeenCalledWith( + "https://discord.com/api/v10/guilds/guild-123/channels", + { headers: { Authorization: "Bot fake-token" } }, + ); + }); + + it("returns empty array on API error", async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 403 }); + const result = await listDiscordChannels("bad-token", "guild-123"); + expect(result).toEqual([]); + }); + + it("returns empty array on network failure", async () => { + global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")); + const result = await listDiscordChannels("token", "guild-123"); + expect(result).toEqual([]); + }); + }); + + describe("Discord channel filtering and selection logic", () => { + it("filters only GuildText channels (type 0)", () => { + const channels = [ + { id: "1", name: "general", type: 0 }, + { id: "2", name: "voice", type: 2 }, + { id: "3", name: "category", type: 4 }, + { id: "4", name: "dev", type: 0 }, + { id: "5", name: "forum", type: 15 }, + ]; + const textChannels = channels.filter(c => c.type === 0); + expect(textChannels).toHaveLength(2); + expect(textChannels.map(c => c.name)).toEqual(["general", "dev"]); + }); + + it("sorts channels by name and limits to 20", () => { + const channels = Array.from({ length: 25 }, (_, i) => ({ + id: String(i), name: `ch-${String(i).padStart(2, "0")}`, type: 0, + })); + const result = channels.filter(c => c.type === 0).sort((a, b) => a.name.localeCompare(b.name)).slice(0, 20); + expect(result).toHaveLength(20); + expect(result[0].name).toBe("ch-00"); + expect(result[19].name).toBe("ch-19"); + }); + + it("NaN input defaults to index 0", () => { + const textChannels = [ + { id: "100", name: "general", type: 0 }, + { id: "200", name: "dev", type: 0 }, + ]; + const cChoice = "abc"; + const parsed = parseInt(cChoice || "1", 10); + const cIdx = isNaN(parsed) ? 0 : Math.max(0, Math.min(textChannels.length - 1, parsed - 1)); + expect(cIdx).toBe(0); + expect(textChannels[cIdx].id).toBe("100"); + }); + + it("empty input defaults to first channel", () => { + const textChannels = [ + { id: "100", name: "general", type: 0 }, + { id: "200", name: "dev", type: 0 }, + ]; + const cChoice = ""; + const parsed = parseInt(cChoice || "1", 10); + const cIdx = isNaN(parsed) ? 0 : Math.max(0, Math.min(textChannels.length - 1, parsed - 1)); + expect(cIdx).toBe(0); + }); + + it("out-of-range input clamps to valid range", () => { + const textChannels = [ + { id: "100", name: "general", type: 0 }, + { id: "200", name: "dev", type: 0 }, + ]; + const cChoice = "99"; + const parsed = parseInt(cChoice || "1", 10); + const cIdx = isNaN(parsed) ? 0 : Math.max(0, Math.min(textChannels.length - 1, parsed - 1)); + expect(cIdx).toBe(1); // clamped to last + }); + }); + + describe("Discord fleet.yaml options output", () => { + function buildDiscordOptions(generalChannelId: string, categoryName: string): string[] { + const discordOptions: string[] = []; + if (generalChannelId) discordOptions.push(` general_channel_id: "${generalChannelId}"`); + if (categoryName && categoryName !== "AgEnD Agents") discordOptions.push(` category_name: "${categoryName}"`); + return discordOptions; + } + + it("includes general_channel_id in options", () => { + const opts = buildDiscordOptions("345678901234567890", "AgEnD Agents"); + expect(opts).toEqual([' general_channel_id: "345678901234567890"']); + }); + + it("includes category_name when non-default", () => { + const opts = buildDiscordOptions("345678901234567890", "My Agents"); + expect(opts).toEqual([ + ' general_channel_id: "345678901234567890"', + ' category_name: "My Agents"', + ]); + }); + + it("omits category_name when default", () => { + const opts = buildDiscordOptions("123", "AgEnD Agents"); + expect(opts).toHaveLength(1); + expect(opts[0]).not.toContain("category_name"); + }); + + it("empty generalChannelId produces no options", () => { + const opts = buildDiscordOptions("", "AgEnD Agents"); + expect(opts).toEqual([]); + }); + + it("full fleet.yaml structure with options block", () => { + const discordOptions = buildDiscordOptions("345678901234567890", "Custom Category"); + const fleetYaml = [ + "channel:", + " type: discord", + " mode: topic", + " bot_token_env: AGEND_DISCORD_TOKEN", + ' group_id: "999888777666555444"', + " access:", + " mode: locked", + " allowed_users:", + ' - "111222333444555666"', + ...(discordOptions.length > 0 ? [" options:", ...discordOptions] : []), + "", + "defaults:", + " backend: claude-code", + "", + ].join("\n"); + + expect(fleetYaml).toContain("type: discord"); + expect(fleetYaml).toContain("options:"); + expect(fleetYaml).toContain('general_channel_id: "345678901234567890"'); + expect(fleetYaml).toContain('category_name: "Custom Category"'); + expect(fleetYaml).toContain("AGEND_DISCORD_TOKEN"); + }); + }); + + describe("Plugin check", () => { + it("npm list -g detects globally installed plugin", () => { + const { execSync } = require("node:child_process"); + let pluginInstalled = false; + try { + // This tests the actual command pattern used in quickstart + execSync("npm list -g @suzuke/agend-plugin-discord --depth=0", { stdio: "pipe" }); + pluginInstalled = true; + } catch { + pluginInstalled = false; + } + // Plugin may or may not be installed — just verify the check doesn't crash + expect(typeof pluginInstalled).toBe("boolean"); + }); + }); +});