feat: add /plans command to manage planning artifacts
This commit is contained in:
parent
1179c3a88f
commit
e8287571e2
@ -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.",
|
||||
|
||||
@ -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,
|
||||
|
||||
168
src/auto-reply/reply/commands-plans.ts
Normal file
168
src/auto-reply/reply/commands-plans.ts
Normal 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;
|
||||
};
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user