import path from "node:path"; import type { Platform } from "../platform/types.js"; import { addAgentToConfig, getGlobalReviewAgentsConfigPath, getGlobalReviewAgentsDir, getRootReviewAgentsConfigPath, getRootReviewAgentsDir, getWorkspaceReviewAgentsConfigPath, getWorkspaceReviewAgentsDir, loadMergedReviewAgents, resolveReviewAgentContext, writeAgentFile, } from "../review/agent-loader.js"; import { selectModelFromList, THINKING_LEVELS } from "./model.js"; import type { ConfiguredReviewAgent, ThinkingLevel } from "../types.js"; import creatingAgentsSkill from "../../skills/creating-supi-agents/SKILL.md" with { type: "text" }; // ── List View ────────────────────────────────────────────────── interface AgentDashboardContext { workspaceRelativeDir: string | null; } function formatAgentSourceScope( scope: ConfiguredReviewAgent["scope"], context: AgentDashboardContext, ): string { switch (scope) { case "global": return "global"; case "workspace": return "workspace"; case "root": case undefined: return context.workspaceRelativeDir ? "root" : "project"; } } function buildAgentDashboard( agents: ConfiguredReviewAgent[], context: AgentDashboardContext, ): string { const nameCol = 18; const modelCol = 24; const thinkingCol = 12; const sourceCol = 10; const lines: string[] = ["\n Review Agents\n"]; if (context.workspaceRelativeDir) { lines.push(` Workspace: ${context.workspaceRelativeDir}`); lines.push(" Effective precedence: workspace → root → global"); lines.push(""); } lines.push( ` ${"name".padEnd(nameCol)} ${"model".padEnd(modelCol)} ${"thinking".padEnd(thinkingCol)} ${"source".padEnd(sourceCol)} focus`, ); for (const agent of agents) { const name = agent.name.padEnd(nameCol); const model = (agent.model ?? "—").padEnd(modelCol); const thinking = (agent.thinkingLevel ?? "—").padEnd(thinkingCol); const source = formatAgentSourceScope(agent.scope, context).padEnd(sourceCol); const focus = agent.focus ? agent.focus.length > 40 ? agent.focus.slice(0, 37) + "..." : agent.focus : "—"; lines.push(` ${name} ${model} ${thinking} ${source} ${focus}`); } const globalCount = agents.filter((agent) => agent.scope === "global").length; const rootCount = agents.filter((agent) => agent.scope === "root" || !agent.scope).length; const workspaceCount = agents.filter((agent) => agent.scope === "workspace").length; lines.push(""); if (context.workspaceRelativeDir) { lines.push(` ${agents.length} agent(s) (${workspaceCount} workspace, ${rootCount} root, ${globalCount} global)`); } else { lines.push(` ${agents.length} agent(s) (${rootCount} project, ${globalCount} global)`); } lines.push(""); return lines.join("\n"); } // ── Create Flow ──────────────────────────────────────────────── const KEBAB_CASE_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; type AgentCreateScope = "global" | "project"; type AgentCreateStorageScope = "global" | "root" | "workspace"; interface AgentCreateDestination { agentsDir: string; configPath: string; scope: AgentCreateStorageScope; } function matchesCreateScope( agent: ConfiguredReviewAgent, scope: AgentCreateStorageScope, ): boolean { switch (scope) { case "global": return agent.scope === "global"; case "workspace": return agent.scope === "workspace"; case "root": return agent.scope === "root" || agent.scope === undefined; } } function resolveAgentCreateDestination( platform: Platform, cwd: string, scope: AgentCreateScope, ): AgentCreateDestination { if (scope === "global") { return { agentsDir: getGlobalReviewAgentsDir(platform.paths), configPath: getGlobalReviewAgentsConfigPath(platform.paths), scope: "global", }; } const context = resolveReviewAgentContext(cwd); if (context.workspaceRelativeDir) { return { agentsDir: getWorkspaceReviewAgentsDir(platform.paths, context.repoRoot, context.workspaceRelativeDir), configPath: getWorkspaceReviewAgentsConfigPath(platform.paths, context.repoRoot, context.workspaceRelativeDir), scope: "workspace", }; } return { agentsDir: getRootReviewAgentsDir(platform.paths, context.repoRoot), configPath: getRootReviewAgentsConfigPath(platform.paths, context.repoRoot), scope: "root", }; } export async function runAgentCreateFlow(platform: Platform, ctx: any): Promise { if (!ctx.hasUI) { ctx.ui.notify("Agent creation requires interactive mode", "warning"); return; } // Step 1: Scope selection const scopeChoice = await ctx.ui.select("Where should this agent live?", [ "This project", "Global", ], { helpText: "Select scope · Esc to cancel" }); if (!scopeChoice) return; const scope: AgentCreateScope = scopeChoice === "Global" ? "global" : "project"; // Step 2: Agent name const nameInput = await ctx.ui.input("Agent name (kebab-case)", { helpText: "e.g. performance, api-design, accessibility", }); if (!nameInput) return; const agentName = nameInput.trim().toLowerCase(); if (!KEBAB_CASE_RE.test(agentName)) { ctx.ui.notify(`Invalid agent name "${agentName}" — must be kebab-case (e.g. "my-agent")`, "error"); return; } const destination = resolveAgentCreateDestination(platform, ctx.cwd, scope); // Check for name collision in target scope try { const existing = await loadMergedReviewAgents(platform.paths, ctx.cwd); const collision = existing.agents.find( (agent) => agent.name === agentName && matchesCreateScope(agent, destination.scope), ); if (collision) { ctx.ui.notify(`Agent "${agentName}" already exists in ${destination.scope} scope`, "error"); return; } } catch { // If loading fails (e.g., no project dir), we can continue } // Step 3: Model selection const modelInput = await selectModelFromList(ctx); // null means "inherit default" — that's fine // Step 4: Thinking level const thinkingChoice = await ctx.ui.select( "Thinking level", THINKING_LEVELS.map((t) => t.label), { helpText: "Select thinking level · Esc to cancel" }, ); if (!thinkingChoice) return; const thinkingLevel: ThinkingLevel | null = THINKING_LEVELS.find((t) => t.label === thinkingChoice)?.value ?? null; // Step 5: Prompt source const promptChoice = await ctx.ui.select("Agent prompt", [ "Send a prompt", "Create from zero", ], { helpText: "Choose how to provide the agent prompt" }); if (!promptChoice) return; if (promptChoice === "Create from zero") { // Load the creating-supi-agents skill via steer loadSkillAndSteer(platform, ctx, scope, agentName, modelInput, thinkingLevel); return; } // "Send a prompt" path const promptBody = await ctx.ui.input("Paste your agent prompt", { helpText: "The instructions the agent follows when reviewing code", }); if (!promptBody?.trim()) { ctx.ui.notify("Agent creation cancelled — empty prompt", "warning"); return; } // Step 6: Save const fileName = writeAgentFile(destination.agentsDir, agentName, { name: agentName, description: `${agentName} review agent`, focus: null, }, promptBody.trim()); await addAgentToConfig(destination.configPath, { name: agentName, enabled: true, data: fileName, model: modelInput, thinkingLevel, }); ctx.ui.notify(`Agent "${agentName}" created (${scope})`, "info"); } // ── Skill Integration (steer pattern) ────────────────────────── function loadSkillAndSteer( platform: Platform, ctx: any, scope: AgentCreateScope, agentName: string, model: string | null, thinkingLevel: ThinkingLevel | null, ): void { const destination = resolveAgentCreateDestination(platform, ctx.cwd, scope); const prompt = buildSkillSteerPrompt( agentName, scope, destination.agentsDir, destination.configPath, model, thinkingLevel, ); platform.sendMessage( { customType: "supi-agents-create", content: [{ type: "text", text: prompt }], display: "none", }, { deliverAs: "steer", triggerTurn: true }, ); } function buildSkillSteerPrompt( agentName: string, scope: "global" | "project", agentsDir: string, configPath: string, model: string | null, thinkingLevel: ThinkingLevel | null, ): string { return `You are guiding the user through creating a new AI review agent for supipowers' multi-agent code review pipeline. ## Agent Details (already collected) - **Name**: ${agentName} - **Scope**: ${scope} - **Model**: ${model ?? "inherit default"} - **Thinking Level**: ${thinkingLevel ?? "inherit default"} - **Target directory**: ${agentsDir} - **Config path**: ${configPath} ## Skill Instructions ${creatingAgentsSkill} ## Save Instructions Once the user approves the agent design, save it: 1. Write the markdown file to: ${path.join(agentsDir, `${agentName}.md`)} - The file MUST have YAML frontmatter (name, description, focus) and a prompt body ending with {output_instructions} 2. Update config at: ${configPath} - Add entry: { name: "${agentName}", enabled: true, data: "${agentName}.md", model: ${model ? `"${model}"` : "null"}, thinkingLevel: ${thinkingLevel ? `"${thinkingLevel}"` : "null"} } Use the \`writeAgentFile\` and \`addAgentToConfig\` functions from \`src/review/agent-loader.ts\` if you have tool access, or write the files directly. Start by asking the user about the goal/focus of their "${agentName}" agent.`; } // ── Command Entry Point ──────────────────────────────────────── export function handleAgents(platform: Platform, ctx: any, args?: string): void { if (args?.trim() === "create") { runAgentCreateFlow(platform, ctx).catch((err: Error) => { ctx.ui.notify(`Agent creation error: ${err.message}`, "error"); }); return; } // Solo invocation: show dashboard showAgentsDashboard(platform, ctx).catch((err: Error) => { ctx.ui.notify(`Error loading agents: ${err.message}`, "error"); }); } async function showAgentsDashboard(platform: Platform, ctx: any): Promise { const result = await loadMergedReviewAgents(platform.paths, ctx.cwd); const dashboardContext = resolveReviewAgentContext(ctx.cwd); const dashboard = buildAgentDashboard(result.agents, { workspaceRelativeDir: dashboardContext.workspaceRelativeDir, }); ctx.ui.notify(dashboard, "info"); } // ── Registration ─────────────────────────────────────────────── const SUBCOMMANDS = [ { name: "create", description: "Create a new review agent" }, ] as const; export function registerAgentsCommand(platform: Platform): void { platform.registerCommand("supi:agents", { description: "List and manage review agents", getArgumentCompletions(prefix: string) { const lower = prefix.toLowerCase(); const matches = SUBCOMMANDS .filter((s) => s.name.startsWith(lower)) .map((s) => ({ value: `${s.name} `, label: s.name, description: s.description })); return matches.length > 0 ? matches : null; }, async handler(args: string | undefined, ctx: any) { handleAgents(platform, ctx, args); }, }); }