From e8287571e26df677f1c98ee2069165cfc218f0c5 Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sat, 24 Jan 2026 16:35:32 -0800 Subject: [PATCH] feat: add /plans command to manage planning artifacts --- src/auto-reply/commands-registry.data.ts | 7 + src/auto-reply/reply/commands-core.ts | 2 + src/auto-reply/reply/commands-plans.ts | 168 +++++++++++++++++++++++ src/auto-reply/reply/commands.test.ts | 38 +++++ 4 files changed, 215 insertions(+) create mode 100644 src/auto-reply/reply/commands-plans.ts diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 87d06b9d0..b8a9691a3 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -157,6 +157,13 @@ function buildChatCommands(): ChatCommandDefinition[] { description: "Show current status.", textAlias: "/status", }), + defineChatCommand({ + key: "plans", + nativeName: "plans", + description: "List/show/rename saved /plan artifacts.", + textAlias: "/plans", + acceptsArgs: true, + }), defineChatCommand({ key: "allowlist", description: "List/add/remove allowlist entries.", diff --git a/src/auto-reply/reply/commands-core.ts b/src/auto-reply/reply/commands-core.ts index a54f90b2b..4f734ab30 100644 --- a/src/auto-reply/reply/commands-core.ts +++ b/src/auto-reply/reply/commands-core.ts @@ -27,6 +27,7 @@ import { handleUsageCommand, } from "./commands-session.js"; import { handlePluginCommand } from "./commands-plugin.js"; +import { handlePlansCommand } from "./commands-plans.js"; import type { CommandHandler, CommandHandlerResult, @@ -43,6 +44,7 @@ const HANDLERS: CommandHandler[] = [ handleRestartCommand, handleTtsCommands, handleHelpCommand, + handlePlansCommand, handleCommandsListCommand, handleStatusCommand, handleAllowlistCommand, diff --git a/src/auto-reply/reply/commands-plans.ts b/src/auto-reply/reply/commands-plans.ts new file mode 100644 index 000000000..e91b8473d --- /dev/null +++ b/src/auto-reply/reply/commands-plans.ts @@ -0,0 +1,168 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import type { CommandHandler } from "./commands-types.js"; + +type PlansArgs = { + action: "list" | "show" | "rename" | "trash" | "help"; + a?: string; + b?: string; +}; + +function parsePlansArgs(raw: string): PlansArgs { + const trimmed = raw.trim(); + const parts = trimmed.split(/\s+/).filter(Boolean); + const cmd = parts[0]?.toLowerCase(); + if (cmd !== "/plans") return { action: "help" }; + const sub = parts[1]?.toLowerCase(); + if (!sub || sub === "list") return { action: "list" }; + if (sub === "help") return { action: "help" }; + if (sub === "show") return { action: "show", a: parts[2] }; + if (sub === "rename") return { action: "rename", a: parts[2], b: parts[3] }; + if (sub === "trash" || sub === "delete" || sub === "rm") return { action: "trash", a: parts[2] }; + // Default: treat unknown as help. + return { action: "help" }; +} + +async function listPlanDirs(plansDir: string): Promise { + try { + const entries = await fs.readdir(plansDir, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.startsWith(".")) + .sort(); + } catch (err: any) { + if (err?.code === "ENOENT") return []; + throw err; + } +} + +async function readFirstExisting(paths: string[]): Promise { + for (const p of paths) { + try { + const text = await fs.readFile(p, "utf-8"); + return text; + } catch (err: any) { + if (err?.code === "ENOENT") continue; + throw err; + } + } + return null; +} + +export const handlePlansCommand: CommandHandler = async (params, allowTextCommands) => { + if (!allowTextCommands) return null; + + // Match on normalized command body + if (!params.command.commandBodyNormalized.startsWith("/plans")) return null; + + const raw = + params.ctx.BodyForCommands ?? + params.ctx.CommandBody ?? + params.ctx.RawBody ?? + params.ctx.BodyStripped ?? + params.ctx.Body ?? + "/plans"; + + const args = parsePlansArgs(raw); + + const plansDir = path.join(params.workspaceDir, "plans"); + + if (args.action === "help") { + return { + shouldContinue: false, + reply: { + text: + "Usage:\n" + + " /plans list\n" + + " /plans show \n" + + " /plans rename \n" + + " /plans trash \n\n" + + "Notes:\n" + + "- Plans are stored under workspace/plans/.\n" + + "- Use /plan to start a new plan; /plans helps manage saved plan folders.", + }, + }; + } + + if (args.action === "list") { + const names = await listPlanDirs(plansDir); + if (names.length === 0) { + return { + shouldContinue: false, + reply: { text: "No plans found yet. Create one with: /plan " }, + }; + } + + const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show "]; + return { shouldContinue: false, reply: { text: lines.join("\n") } }; + } + + if (args.action === "show") { + const name = String(args.a ?? "").trim(); + if (!name) { + return { shouldContinue: false, reply: { text: "Usage: /plans show " } }; + } + + const planDir = path.join(plansDir, name); + const planMd = await readFirstExisting([ + path.join(planDir, "plan.md"), + path.join(planDir, "README.md"), + ]); + + if (!planMd) { + return { + shouldContinue: false, + reply: { text: `No plan.md found for '${name}'. Looked in: ${planDir}` }, + }; + } + + const trimmed = planMd.trim(); + const preview = trimmed.length > 1800 ? `${trimmed.slice(0, 1800)}\n\n…(truncated)` : trimmed; + return { + shouldContinue: false, + reply: { text: `Plan: ${name}\n\n${preview}` }, + }; + } + + if (args.action === "rename") { + const from = String(args.a ?? "").trim(); + const to = String(args.b ?? "").trim(); + if (!from || !to) { + return { shouldContinue: false, reply: { text: "Usage: /plans rename " } }; + } + const fromDir = path.join(plansDir, from); + const toDir = path.join(plansDir, to); + + await fs.mkdir(plansDir, { recursive: true }); + await fs.rename(fromDir, toDir); + + return { + shouldContinue: false, + reply: { text: `Renamed plan '${from}' → '${to}'.` }, + }; + } + + if (args.action === "trash") { + const name = String(args.a ?? "").trim(); + if (!name) { + return { shouldContinue: false, reply: { text: "Usage: /plans trash " } }; + } + + const fromDir = path.join(plansDir, name); + const trashDir = path.join(plansDir, ".trash"); + await fs.mkdir(trashDir, { recursive: true }); + + const stamped = `${name}__${new Date().toISOString().replace(/[:.]/g, "-")}`; + const toDir = path.join(trashDir, stamped); + await fs.rename(fromDir, toDir); + + return { + shouldContinue: false, + reply: { text: `Moved plan '${name}' to trash.` }, + }; + } + + return null; +}; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index d27b8e2a8..ed8bba7ba 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -117,6 +117,44 @@ describe("handleCommands gating", () => { }); }); +describe("/plans", () => { + it("lists plan directories", async () => { + const cfg = { + commands: { text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + + const plansDir = path.join(testWorkspaceDir, "plans"); + await fs.mkdir(path.join(plansDir, "2026-01-24-trip"), { recursive: true }); + await fs.writeFile( + path.join(plansDir, "2026-01-24-trip", "plan.md"), + "# Trip Plan\n\nDo stuff\n", + "utf-8", + ); + + const params = buildParams("/plans list", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("2026-01-24-trip"); + }); + + it("shows plan.md", async () => { + const cfg = { + commands: { text: true }, + whatsapp: { allowFrom: ["*"] }, + } as ClawdbotConfig; + + const plansDir = path.join(testWorkspaceDir, "plans"); + await fs.mkdir(path.join(plansDir, "demo"), { recursive: true }); + await fs.writeFile(path.join(plansDir, "demo", "plan.md"), "Hello plan", "utf-8"); + + const params = buildParams("/plans show demo", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("Hello plan"); + }); +}); + describe("handleCommands bash alias", () => { it("routes !poll through the /bash handler", async () => { resetBashChatCommandForTests();