feat: add /plans command to manage planning artifacts

This commit is contained in:
Vignesh Natarajan 2026-01-24 16:35:32 -08:00
parent 1179c3a88f
commit e8287571e2
4 changed files with 215 additions and 0 deletions

View File

@ -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.",

View File

@ -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,

View File

@ -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<string[]> {
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<string | null> {
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 <name>\n" +
" /plans rename <old> <new>\n" +
" /plans trash <name>\n\n" +
"Notes:\n" +
"- Plans are stored under workspace/plans/.\n" +
"- Use /plan <goal> 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 <goal>" },
};
}
const lines = ["Plans:", ...names.map((n) => `- ${n}`), "", "Tip: /plans show <name>"];
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 <name>" } };
}
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 <old> <new>" } };
}
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 <name>" } };
}
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;
};

View File

@ -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();