feat: interactive /plan questionnaire in CLI
This commit is contained in:
parent
ecf4b1a527
commit
07f176b562
@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
description: "Show current status.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "plan",
|
||||||
|
nativeName: "plan",
|
||||||
|
description: "Interactive project planning (/plan) with saved artifacts.",
|
||||||
|
textAlias: "/plan",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "plans",
|
key: "plans",
|
||||||
nativeName: "plans",
|
nativeName: "plans",
|
||||||
|
|||||||
@ -28,6 +28,7 @@ import {
|
|||||||
} from "./commands-session.js";
|
} from "./commands-session.js";
|
||||||
import { handlePluginCommand } from "./commands-plugin.js";
|
import { handlePluginCommand } from "./commands-plugin.js";
|
||||||
import { handlePlansCommand } from "./commands-plans.js";
|
import { handlePlansCommand } from "./commands-plans.js";
|
||||||
|
import { handlePlanCommand } from "./commands-plan.js";
|
||||||
import type {
|
import type {
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
CommandHandlerResult,
|
CommandHandlerResult,
|
||||||
@ -44,6 +45,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleRestartCommand,
|
handleRestartCommand,
|
||||||
handleTtsCommands,
|
handleTtsCommands,
|
||||||
handleHelpCommand,
|
handleHelpCommand,
|
||||||
|
handlePlanCommand,
|
||||||
handlePlansCommand,
|
handlePlansCommand,
|
||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
|
|||||||
164
src/auto-reply/reply/commands-plan.test.ts
Normal file
164
src/auto-reply/reply/commands-plan.test.ts
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
|
import type { MsgContext } from "../templating.js";
|
||||||
|
import { buildCommandContext, handleCommands } from "./commands.js";
|
||||||
|
import { parseInlineDirectives } from "./directive-handling.js";
|
||||||
|
|
||||||
|
// Mock clack prompts to simulate TUI interaction.
|
||||||
|
const clackHoisted = vi.hoisted(() => {
|
||||||
|
let selectCalls = 0;
|
||||||
|
const select = vi.fn(async ({ options, message }: any) => {
|
||||||
|
selectCalls += 1;
|
||||||
|
// First: choose a section (pick first).
|
||||||
|
if (String(message).includes("Choose a section")) {
|
||||||
|
if (selectCalls > 1) return "__review";
|
||||||
|
return options[0].value;
|
||||||
|
}
|
||||||
|
// Second: action selection etc.
|
||||||
|
return options[0].value;
|
||||||
|
});
|
||||||
|
const text = vi.fn(async ({ initialValue }: any) => initialValue ?? "");
|
||||||
|
const confirm = vi.fn(async () => true);
|
||||||
|
const isCancel = vi.fn((v: any) => v === Symbol.for("clack:cancel"));
|
||||||
|
return { select, text, confirm, isCancel };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@clack/prompts", async () => {
|
||||||
|
return {
|
||||||
|
confirm: clackHoisted.confirm,
|
||||||
|
isCancel: clackHoisted.isCancel,
|
||||||
|
select: clackHoisted.select,
|
||||||
|
text: clackHoisted.text,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const runEmbeddedPiAgent = vi.fn(async ({ prompt }: any) => {
|
||||||
|
if (String(prompt).includes("Generate a compact questionnaire")) {
|
||||||
|
return {
|
||||||
|
payloads: [
|
||||||
|
{
|
||||||
|
text: JSON.stringify({
|
||||||
|
goal: "demo",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "budget",
|
||||||
|
section: "Constraints",
|
||||||
|
prompt: "Budget?",
|
||||||
|
kind: "select",
|
||||||
|
required: true,
|
||||||
|
options: ["$", "$$"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "deadline",
|
||||||
|
section: "Timeline",
|
||||||
|
prompt: "Deadline?",
|
||||||
|
kind: "text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payloads: [{ text: JSON.stringify({ ok: true }) }],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { runEmbeddedPiAgent };
|
||||||
|
});
|
||||||
|
|
||||||
|
// llm-task extension dynamically imports embedded runner in src-first/dist-fallback form.
|
||||||
|
vi.mock("../../../src/agents/pi-embedded-runner.js", () => ({
|
||||||
|
runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent,
|
||||||
|
}));
|
||||||
|
vi.mock("../../../agents/pi-embedded-runner.js", () => ({
|
||||||
|
runEmbeddedPiAgent: hoisted.runEmbeddedPiAgent,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let testWorkspaceDir = os.tmpdir();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
testWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-plan-"));
|
||||||
|
await fs.writeFile(path.join(testWorkspaceDir, "AGENTS.md"), "# Agents\n", "utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await fs.rm(testWorkspaceDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
|
||||||
|
const ctx = {
|
||||||
|
Body: commandBody,
|
||||||
|
CommandBody: commandBody,
|
||||||
|
CommandSource: "cli",
|
||||||
|
CommandAuthorized: true,
|
||||||
|
Provider: "cli",
|
||||||
|
Surface: "cli",
|
||||||
|
...ctxOverrides,
|
||||||
|
} as MsgContext;
|
||||||
|
|
||||||
|
const command = buildCommandContext({
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
isGroup: false,
|
||||||
|
triggerBodyNormalized: commandBody.trim().toLowerCase(),
|
||||||
|
commandAuthorized: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
ctx,
|
||||||
|
cfg,
|
||||||
|
command,
|
||||||
|
directives: parseInlineDirectives(commandBody),
|
||||||
|
elevated: { enabled: true, allowed: true, failures: [] },
|
||||||
|
sessionKey: "agent:main:main",
|
||||||
|
workspaceDir: testWorkspaceDir,
|
||||||
|
defaultGroupActivation: () => "mention",
|
||||||
|
resolvedVerboseLevel: "off" as const,
|
||||||
|
resolvedReasoningLevel: "off" as const,
|
||||||
|
resolveDefaultThinkingLevel: async () => undefined,
|
||||||
|
provider: "cli",
|
||||||
|
model: "test-model",
|
||||||
|
contextTokens: 0,
|
||||||
|
isGroup: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("/plan TUI", () => {
|
||||||
|
it("creates a plan directory and writes plan.md + answers.json", async () => {
|
||||||
|
// Make TTY true for interactive mode.
|
||||||
|
(process.stdin as any).isTTY = true;
|
||||||
|
(process.stdout as any).isTTY = true;
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
commands: { text: true },
|
||||||
|
agents: { defaults: { model: { primary: "openai/mock-1" } } },
|
||||||
|
} as ClawdbotConfig;
|
||||||
|
|
||||||
|
const params = buildParams("/plan plan a trip", cfg);
|
||||||
|
const result = await handleCommands(params);
|
||||||
|
|
||||||
|
expect(result.shouldContinue).toBe(false);
|
||||||
|
expect(result.reply?.text).toContain("Plan saved");
|
||||||
|
|
||||||
|
const plansDir = path.join(testWorkspaceDir, "plans");
|
||||||
|
const entries = await fs.readdir(plansDir, { withFileTypes: true });
|
||||||
|
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||||
|
expect(dirs.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const createdDir = path.join(plansDir, dirs[0]);
|
||||||
|
const planMd = await fs.readFile(path.join(createdDir, "plan.md"), "utf-8");
|
||||||
|
const answers = JSON.parse(await fs.readFile(path.join(createdDir, "answers.json"), "utf-8"));
|
||||||
|
|
||||||
|
expect(planMd).toContain("# Plan");
|
||||||
|
expect(Object.keys(answers).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
350
src/auto-reply/reply/commands-plan.ts
Normal file
350
src/auto-reply/reply/commands-plan.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { confirm, isCancel, select, text } from "@clack/prompts";
|
||||||
|
|
||||||
|
import type { ClawdbotPluginApi } from "../../plugins/types.js";
|
||||||
|
import type { CommandHandler } from "./commands-types.js";
|
||||||
|
|
||||||
|
// We import the llm-task tool directly (Clawdbot-native) so standalone TUI runs can
|
||||||
|
// still use the agent's configured models via the embedded runner.
|
||||||
|
import { createLlmTaskTool } from "../../../extensions/llm-task/src/llm-task-tool.js";
|
||||||
|
|
||||||
|
type QuestionKind = "text" | "select" | "multiselect" | "confirm";
|
||||||
|
|
||||||
|
export type QuestionSpec = {
|
||||||
|
id: string;
|
||||||
|
section: string;
|
||||||
|
prompt: string;
|
||||||
|
kind: QuestionKind;
|
||||||
|
required?: boolean;
|
||||||
|
options?: string[];
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QuestionSet = {
|
||||||
|
title?: string;
|
||||||
|
goal: string;
|
||||||
|
questions: QuestionSpec[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUESTIONS_SCHEMA = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
title: { type: "string" },
|
||||||
|
goal: { type: "string" },
|
||||||
|
questions: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
section: { type: "string" },
|
||||||
|
prompt: { type: "string" },
|
||||||
|
kind: { type: "string", enum: ["text", "select", "multiselect", "confirm"] },
|
||||||
|
required: { type: "boolean" },
|
||||||
|
options: { type: "array", items: { type: "string" } },
|
||||||
|
placeholder: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["id", "section", "prompt", "kind"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["goal", "questions"],
|
||||||
|
additionalProperties: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowStamp() {
|
||||||
|
const d = new Date();
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(
|
||||||
|
d.getMinutes(),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeReadJson(filePath: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const text = await fs.readFile(filePath, "utf-8");
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.code === "ENOENT") return null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(filePath: string, value: any) {
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(value, null, 2), "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTextFile(filePath: string, textContent: string) {
|
||||||
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.writeFile(filePath, textContent, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLlmQuestionPrompt(goal: string) {
|
||||||
|
return (
|
||||||
|
`You are helping a user plan a project. Generate a compact questionnaire grouped into sections.\n` +
|
||||||
|
`Return JSON matching the provided schema.\n\n` +
|
||||||
|
`Requirements:\n` +
|
||||||
|
`- Ask only high-signal questions. Prefer multiple choice when the user can pick from common options.\n` +
|
||||||
|
`- Use sections like Goals, Constraints, Inputs, Outputs, Timeline, Risks (as appropriate).\n` +
|
||||||
|
`- Keep it to ~8-15 questions unless the goal clearly needs more.\n` +
|
||||||
|
`- Use stable ids (snake_case).\n\n` +
|
||||||
|
`GOAL:\n${goal}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlanMarkdown(goal: string, answers: Record<string, any>, questions: QuestionSpec[]) {
|
||||||
|
const byId = new Map(questions.map((q) => [q.id, q] as const));
|
||||||
|
const sections = new Map<string, Array<{ q: QuestionSpec; a: any }>>();
|
||||||
|
for (const [id, a] of Object.entries(answers)) {
|
||||||
|
const q = byId.get(id);
|
||||||
|
if (!q) continue;
|
||||||
|
const list = sections.get(q.section) ?? [];
|
||||||
|
list.push({ q, a });
|
||||||
|
sections.set(q.section, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`# Plan\n\n## Goal\n${goal}\n`);
|
||||||
|
for (const [section, items] of sections) {
|
||||||
|
lines.push(`## ${section}`);
|
||||||
|
for (const { q, a } of items) {
|
||||||
|
const rendered = Array.isArray(a)
|
||||||
|
? a.join(", ")
|
||||||
|
: typeof a === "boolean"
|
||||||
|
? a
|
||||||
|
? "yes"
|
||||||
|
: "no"
|
||||||
|
: String(a);
|
||||||
|
lines.push(`- **${q.prompt}**: ${rendered}`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n").trim() + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFakePluginApi(cfg: any, workspaceDir: string): ClawdbotPluginApi {
|
||||||
|
// Minimal stub for llm-task tool; it only relies on `config` and `pluginConfig`.
|
||||||
|
return {
|
||||||
|
id: "local",
|
||||||
|
name: "local",
|
||||||
|
source: "internal",
|
||||||
|
config: cfg,
|
||||||
|
pluginConfig: {},
|
||||||
|
runtime: {} as any,
|
||||||
|
logger: {
|
||||||
|
info() {},
|
||||||
|
warn() {},
|
||||||
|
error() {},
|
||||||
|
debug() {},
|
||||||
|
},
|
||||||
|
registerTool() {},
|
||||||
|
registerHook() {},
|
||||||
|
registerHttpHandler() {},
|
||||||
|
registerChannel() {},
|
||||||
|
registerGatewayMethod() {},
|
||||||
|
registerCli() {},
|
||||||
|
registerService() {},
|
||||||
|
registerProvider() {},
|
||||||
|
registerCommand() {},
|
||||||
|
resolvePath: (input: string) => path.resolve(workspaceDir, input),
|
||||||
|
on() {},
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handlePlanCommand: CommandHandler = async (params, allowTextCommands) => {
|
||||||
|
if (!allowTextCommands) return null;
|
||||||
|
|
||||||
|
if (!params.command.commandBodyNormalized.startsWith("/plan")) return null;
|
||||||
|
|
||||||
|
// Only implement interactive plan builder in the local CLI.
|
||||||
|
const isInteractiveCli =
|
||||||
|
params.ctx.CommandSource === "cli" &&
|
||||||
|
Boolean(process.stdin.isTTY) &&
|
||||||
|
Boolean(process.stdout.isTTY);
|
||||||
|
if (!isInteractiveCli) return null;
|
||||||
|
|
||||||
|
const raw =
|
||||||
|
params.ctx.BodyForCommands ??
|
||||||
|
params.ctx.CommandBody ??
|
||||||
|
params.ctx.RawBody ??
|
||||||
|
params.ctx.BodyStripped ??
|
||||||
|
params.ctx.Body ??
|
||||||
|
"/plan";
|
||||||
|
|
||||||
|
const goal = raw.replace(/^\s*\/plan\s*/i, "").trim();
|
||||||
|
if (!goal) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "Usage: /plan <goal>" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultName = `${nowStamp()}-${slugify(goal.split(/\s+/).slice(0, 6).join(" ")) || "plan"}`;
|
||||||
|
const chosenName = await text({
|
||||||
|
message: "Plan name (folder under workspace/plans/)",
|
||||||
|
initialValue: defaultName,
|
||||||
|
validate: (v) => (!v?.trim() ? "Name required" : undefined),
|
||||||
|
});
|
||||||
|
if (isCancel(chosenName)) {
|
||||||
|
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const planName = slugify(String(chosenName).trim()) || defaultName;
|
||||||
|
const plansDir = path.join(params.workspaceDir, "plans");
|
||||||
|
const planDir = path.join(plansDir, planName);
|
||||||
|
await fs.mkdir(planDir, { recursive: true });
|
||||||
|
|
||||||
|
const metaPath = path.join(planDir, "meta.json");
|
||||||
|
const answersPath = path.join(planDir, "answers.json");
|
||||||
|
const planMdPath = path.join(planDir, "plan.md");
|
||||||
|
|
||||||
|
const existingAnswers = (await safeReadJson(answersPath)) ?? {};
|
||||||
|
|
||||||
|
await writeJson(metaPath, {
|
||||||
|
name: planName,
|
||||||
|
goal,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ask the LLM to generate the question set.
|
||||||
|
const api = createFakePluginApi(params.cfg, params.workspaceDir);
|
||||||
|
const tool = createLlmTaskTool(api);
|
||||||
|
|
||||||
|
const llmResult = await tool.execute("llm-task", {
|
||||||
|
prompt: buildLlmQuestionPrompt(goal),
|
||||||
|
input: { goal, existingAnswers },
|
||||||
|
schema: QUESTIONS_SCHEMA,
|
||||||
|
});
|
||||||
|
|
||||||
|
const questionSet = (llmResult as any).details?.json as QuestionSet;
|
||||||
|
const questions = Array.isArray(questionSet?.questions) ? questionSet.questions : [];
|
||||||
|
if (questions.length === 0) {
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: { text: "No questions generated (unexpected)." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by section.
|
||||||
|
const sectionOrder: string[] = [];
|
||||||
|
const bySection = new Map<string, QuestionSpec[]>();
|
||||||
|
for (const q of questions) {
|
||||||
|
const section = q.section?.trim() || "General";
|
||||||
|
if (!bySection.has(section)) sectionOrder.push(section);
|
||||||
|
const list = bySection.get(section) ?? [];
|
||||||
|
list.push(q);
|
||||||
|
bySection.set(section, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers: Record<string, any> = { ...existingAnswers };
|
||||||
|
|
||||||
|
// Iterate sections with a review loop.
|
||||||
|
while (true) {
|
||||||
|
const section = await select({
|
||||||
|
message: "Choose a section",
|
||||||
|
options: [
|
||||||
|
...sectionOrder.map((s) => ({ label: s, value: s })),
|
||||||
|
{ label: "Review + finalize", value: "__review" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (isCancel(section)) {
|
||||||
|
return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === "__review") break;
|
||||||
|
|
||||||
|
const qs = bySection.get(String(section)) ?? [];
|
||||||
|
for (const q of qs) {
|
||||||
|
const existing = answers[q.id];
|
||||||
|
const required = Boolean(q.required);
|
||||||
|
|
||||||
|
if (q.kind === "confirm") {
|
||||||
|
const res = await confirm({ message: q.prompt, initialValue: Boolean(existing ?? false) });
|
||||||
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
answers[q.id] = Boolean(res);
|
||||||
|
} else if (q.kind === "select") {
|
||||||
|
const opts = (q.options ?? []).map((o) => ({ label: o, value: o }));
|
||||||
|
if (opts.length === 0) {
|
||||||
|
const res = await text({
|
||||||
|
message: q.prompt,
|
||||||
|
initialValue: existing ? String(existing) : "",
|
||||||
|
});
|
||||||
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
const v = String(res).trim();
|
||||||
|
if (required && !v)
|
||||||
|
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
||||||
|
answers[q.id] = v;
|
||||||
|
} else {
|
||||||
|
const res = await select({ message: q.prompt, options: opts });
|
||||||
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
answers[q.id] = res;
|
||||||
|
}
|
||||||
|
} else if (q.kind === "multiselect") {
|
||||||
|
// clack multiselect isn't currently imported here to keep deps minimal in this handler.
|
||||||
|
// Fallback to freeform comma-separated input.
|
||||||
|
const res = await text({
|
||||||
|
message: `${q.prompt} (comma-separated)`,
|
||||||
|
initialValue: Array.isArray(existing) ? existing.join(", ") : "",
|
||||||
|
placeholder: q.placeholder,
|
||||||
|
});
|
||||||
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
const arr = String(res)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (required && arr.length === 0) {
|
||||||
|
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
||||||
|
}
|
||||||
|
answers[q.id] = arr;
|
||||||
|
} else {
|
||||||
|
const res = await text({
|
||||||
|
message: q.prompt,
|
||||||
|
initialValue: existing ? String(existing) : "",
|
||||||
|
placeholder: q.placeholder,
|
||||||
|
});
|
||||||
|
if (isCancel(res)) return { shouldContinue: false, reply: { text: "Cancelled." } };
|
||||||
|
const v = String(res).trim();
|
||||||
|
if (required && !v) {
|
||||||
|
return { shouldContinue: false, reply: { text: `Missing required answer: ${q.id}` } };
|
||||||
|
}
|
||||||
|
answers[q.id] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeJson(answersPath, answers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const md = buildPlanMarkdown(goal, answers, questions);
|
||||||
|
await writeTextFile(planMdPath, md);
|
||||||
|
await writeJson(metaPath, {
|
||||||
|
name: planName,
|
||||||
|
goal,
|
||||||
|
createdAt: (await safeReadJson(metaPath))?.createdAt ?? new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldContinue: false,
|
||||||
|
reply: {
|
||||||
|
text:
|
||||||
|
`✅ Plan saved: ${planName}\n` +
|
||||||
|
`- ${path.relative(params.workspaceDir, planMdPath)}\n` +
|
||||||
|
`- ${path.relative(params.workspaceDir, answersPath)}\n\n` +
|
||||||
|
`Use /plans show ${planName} to view it.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user