import chalk from "chalk"; import { Command } from "commander"; import { findSeedsDir, readConfig } from "../config.ts"; import { generateId } from "../id.ts"; import { accent, muted, outputJson, printIssueOneLine, printSuccess } from "../output.ts"; import { isValidPriority, PRIORITY_ERROR, parsePriority } from "../priority.ts"; import { appendTemplate, issuesPath, readIssues, readTemplates, templatesPath, withLock, writeIssues, writeTemplates, } from "../store.ts"; import type { Issue, Template, TemplateStep } from "../types.ts"; import { VALID_TYPES } from "../types.ts"; function parseArgs(args: string[]) { const flags: Record = {}; const positional: string[] = []; let i = 0; while (i < args.length) { const arg = args[i]; if (!arg) { i++; continue; } if (arg.startsWith("--")) { const key = arg.slice(2); const eqIdx = key.indexOf("="); if (eqIdx !== -1) { flags[key.slice(0, eqIdx)] = key.slice(eqIdx + 1); i++; } else { const next = args[i + 1]; if (next !== undefined && !next.startsWith("--")) { flags[key] = next; i += 2; } else { flags[key] = true; i++; } } } else { positional.push(arg); i++; } } return { flags, positional }; } export async function run(args: string[], seedsDir?: string): Promise { const jsonMode = args.includes("--json"); const { flags, positional } = parseArgs(args); const subcmd = positional[0]; if (!subcmd) throw new Error("Usage: sd tpl "); const dir = seedsDir ?? (await findSeedsDir()); // sd tpl create --name "..." if (subcmd === "create") { const name = flags.name; if (!name || typeof name !== "string") throw new Error("--name is required"); let createdId = ""; await withLock(templatesPath(dir), async () => { const existing = await readTemplates(dir); const existingIds = new Set(existing.map((t) => t.id)); const id = generateId("tpl", existingIds); const template: Template = { id, name, steps: [] }; await appendTemplate(dir, template); createdId = id; }); if (jsonMode) { await outputJson({ success: true, command: "tpl create", id: createdId }); } else { printSuccess(`Created template ${createdId}: ${name}`); } return; } // sd tpl step add --title "..." [--type task] [--priority 2] if (subcmd === "step") { const stepSubcmd = positional[1]; if (stepSubcmd !== "add") throw new Error("Usage: sd tpl step add --title ..."); const templateId = positional[2]; if (!templateId) throw new Error("Template ID is required"); const title = flags.title; if (!title || typeof title !== "string") throw new Error("--title is required"); const typeVal = typeof flags.type === "string" ? flags.type : "task"; if (!(VALID_TYPES as readonly string[]).includes(typeVal)) { throw new Error(`Invalid --type value: ${typeVal}. Valid: ${VALID_TYPES.join("|")}`); } const priority = parsePriority(typeof flags.priority === "string" ? flags.priority : undefined); if (!isValidPriority(priority)) { throw new Error(PRIORITY_ERROR); } let stepCount = 0; await withLock(templatesPath(dir), async () => { const templates = await readTemplates(dir); const idx = templates.findIndex((t) => t.id === templateId); const tpl = templates[idx]; if (!tpl) throw new Error(`Template not found: ${templateId}`); const step: TemplateStep = { title, type: typeVal, priority }; const updated: Template = { ...tpl, steps: [...tpl.steps, step] }; templates[idx] = updated; stepCount = updated.steps.length; await writeTemplates(dir, templates); }); if (jsonMode) { await outputJson({ success: true, command: "tpl step add", id: templateId, stepCount }); } else { printSuccess(`Added step ${stepCount} to ${templateId}: "${title}"`); } return; } // sd tpl list if (subcmd === "list") { const templates = await readTemplates(dir); if (jsonMode) { await outputJson({ success: true, command: "tpl list", templates, count: templates.length }); } else { if (templates.length === 0) { console.log("No templates."); return; } for (const tpl of templates) { console.log(`${accent.bold(tpl.id)} ${tpl.name} ${muted(`(${tpl.steps.length} steps)`)}`); } } return; } // sd tpl show if (subcmd === "show") { const templateId = positional[1]; if (!templateId) throw new Error("Usage: sd tpl show "); const templates = await readTemplates(dir); const tpl = templates.find((t) => t.id === templateId); if (!tpl) throw new Error(`Template not found: ${templateId}`); if (jsonMode) { await outputJson({ success: true, command: "tpl show", template: tpl }); } else { console.log(`${accent.bold(tpl.id)} ${tpl.name}`); console.log(muted(`Steps (${tpl.steps.length}):`)); tpl.steps.forEach((step, i) => { console.log( ` ${i + 1}. ${step.title} ${muted(`[${step.type ?? "task"} P${step.priority ?? 2}]`)}`, ); }); } return; } // sd tpl pour --prefix "..." if (subcmd === "pour") { const templateId = positional[1]; if (!templateId) throw new Error("Usage: sd tpl pour --prefix ..."); if (typeof flags.prefix !== "string") throw new Error("--prefix is required"); const prefix = flags.prefix; const templates = await readTemplates(dir); const tpl = templates.find((t) => t.id === templateId); if (!tpl) throw new Error(`Template not found: ${templateId}`); if (tpl.steps.length === 0) throw new Error(`Template ${templateId} has no steps`); const config = await readConfig(dir); const createdIds: string[] = []; await withLock(issuesPath(dir), async () => { const existing = await readIssues(dir); const existingIds = new Set(existing.map((i) => i.id)); const now = new Date().toISOString(); // Create all issues first (collect IDs) const newIssues: Issue[] = []; for (const step of tpl.steps) { const id = generateId( config.project, new Set([...existingIds, ...newIssues.map((i) => i.id)]), ); const title = step.title.replace(/\{prefix\}/g, prefix); const issue: Issue = { id, title, status: "open", type: (step.type ?? "task") as Issue["type"], priority: step.priority ?? 2, createdAt: now, updatedAt: now, convoy: templateId, }; newIssues.push(issue); createdIds.push(id); } // Wire dependencies: step[i+1] blocked by step[i] for (let i = 1; i < newIssues.length; i++) { const prev = newIssues[i - 1]; const curr = newIssues[i]; if (!prev || !curr) continue; curr.blockedBy = [prev.id]; prev.blocks = [curr.id]; } // Append all new issues const allIssues = [...existing, ...newIssues]; await writeIssues(dir, allIssues); }); if (jsonMode) { await outputJson({ success: true, command: "tpl pour", ids: createdIds }); } else { printSuccess(`Poured template ${accent(templateId)} — created ${createdIds.length} issues`); for (const id of createdIds) console.log(` ${accent(id)}`); } return; } // sd tpl status if (subcmd === "status") { const templateId = positional[1]; if (!templateId) throw new Error("Usage: sd tpl status "); const issues = await readIssues(dir); const convoyIssues = issues.filter((i: Issue) => i.convoy === templateId); if (convoyIssues.length === 0) { if (jsonMode) { await outputJson({ success: true, command: "tpl status", templateId, total: 0, issues: [], }); } else { console.log(`No issues found for convoy ${templateId}`); } return; } const closedIds = new Set(issues.filter((i: Issue) => i.status === "closed").map((i) => i.id)); const completed = convoyIssues.filter((i) => i.status === "closed").length; const inProgress = convoyIssues.filter((i) => i.status === "in_progress").length; const blocked = convoyIssues.filter((i) => { if (i.status === "closed") return false; return (i.blockedBy ?? []).some((bid) => !closedIds.has(bid)); }).length; const status = { templateId, total: convoyIssues.length, completed, inProgress, blocked, issues: convoyIssues.map((i) => i.id), }; if (jsonMode) { await outputJson({ success: true, command: "tpl status", status }); } else { console.log(`${chalk.bold("Convoy:")} ${accent(templateId)}`); console.log(` ${muted("Total:")} ${status.total}`); console.log(` ${muted("Completed:")} ${completed}`); console.log(` ${muted("In progress:")} ${inProgress}`); console.log(` ${muted("Blocked:")} ${blocked}`); console.log(muted(" Issues:")); for (const issue of convoyIssues) { process.stdout.write(" "); printIssueOneLine(issue, closedIds); } } return; } throw new Error( `Unknown tpl subcommand: ${subcmd}. Use create, step, list, show, pour, or status.`, ); } export function register(program: Command): void { const tpl = new Command("tpl").description("Manage issue templates (molecules)"); tpl .command("create") .description("Create a new template") .requiredOption("--name ", "Template name") .option("--json", "Output as JSON") .action(async (opts: { name: string; json?: boolean }) => { const args: string[] = ["create", "--name", opts.name]; if (opts.json) args.push("--json"); await run(args); }); const step = new Command("step").description("Manage template steps"); step .command("add ") .description("Add a step to a template") .requiredOption("--title ", "Step title") .option("--type ", "Step type (task|bug|feature|epic)", "task") .option("--priority ", "Step priority 0-4", "2") .option("--json", "Output as JSON") .action( async ( id: string, opts: { title: string; type?: string; priority?: string; json?: boolean }, ) => { const args: string[] = ["step", "add", id, "--title", opts.title]; if (opts.type) args.push("--type", opts.type); if (opts.priority) args.push("--priority", opts.priority); if (opts.json) args.push("--json"); await run(args); }, ); tpl.addCommand(step); tpl .command("list") .description("List all templates") .option("--json", "Output as JSON") .action(async (opts: { json?: boolean }) => { await run(opts.json ? ["list", "--json"] : ["list"]); }); tpl .command("show ") .description("Show template with steps") .option("--json", "Output as JSON") .action(async (id: string, opts: { json?: boolean }) => { const args: string[] = ["show", id]; if (opts.json) args.push("--json"); await run(args); }); tpl .command("pour ") .description("Instantiate template into issues") .requiredOption("--prefix ", "Prefix for issue titles") .option("--json", "Output as JSON") .action(async (id: string, opts: { prefix: string; json?: boolean }) => { const args: string[] = ["pour", id, "--prefix", opts.prefix]; if (opts.json) args.push("--json"); await run(args); }); tpl .command("status ") .description("Show convoy status for a template") .option("--json", "Output as JSON") .action(async (id: string, opts: { json?: boolean }) => { const args: string[] = ["status", id]; if (opts.json) args.push("--json"); await run(args); }); program.addCommand(tpl); }