Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 73 additions & 6 deletions src/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -190,6 +200,9 @@ export async function runQuickstart(): Promise<void> {
let groupId = "";
let userId = "";
let tokenEnvName = "";
let generalChannelId = "";
let categoryName = "";
let pluginInstalled = false;

if (channel === "telegram") {
// ── Telegram flow ──────────────────────────────────
Expand Down Expand Up @@ -226,8 +239,22 @@ export async function runQuickstart(): Promise<void> {

} 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`);
Expand All @@ -246,7 +273,7 @@ export async function runQuickstart(): Promise<void> {
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);
Expand All @@ -273,6 +300,32 @@ export async function runQuickstart(): Promise<void> {
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 ────────────────────────────────────
Expand Down Expand Up @@ -317,6 +370,13 @@ export async function runQuickstart(): Promise<void> {
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}`,
Expand All @@ -327,6 +387,7 @@ export async function runQuickstart(): Promise<void> {
" mode: locked",
" allowed_users:",
` - ${qUid}`,
...(discordOptions.length > 0 ? [" options:", ...discordOptions] : []),
"",
...(projectRoots.length > 0
? ["project_roots:", ...projectRoots.map(p => ` - ${p}`), ""]
Expand All @@ -352,10 +413,16 @@ export async function runQuickstart(): Promise<void> {
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`);
Expand Down
173 changes: 173 additions & 0 deletions tests/quickstart.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading