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.",
|
description: "Show current status.",
|
||||||
textAlias: "/status",
|
textAlias: "/status",
|
||||||
}),
|
}),
|
||||||
|
defineChatCommand({
|
||||||
|
key: "plans",
|
||||||
|
nativeName: "plans",
|
||||||
|
description: "List/show/rename saved /plan artifacts.",
|
||||||
|
textAlias: "/plans",
|
||||||
|
acceptsArgs: true,
|
||||||
|
}),
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "allowlist",
|
key: "allowlist",
|
||||||
description: "List/add/remove allowlist entries.",
|
description: "List/add/remove allowlist entries.",
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import {
|
|||||||
handleUsageCommand,
|
handleUsageCommand,
|
||||||
} 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 type {
|
import type {
|
||||||
CommandHandler,
|
CommandHandler,
|
||||||
CommandHandlerResult,
|
CommandHandlerResult,
|
||||||
@ -43,6 +44,7 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleRestartCommand,
|
handleRestartCommand,
|
||||||
handleTtsCommands,
|
handleTtsCommands,
|
||||||
handleHelpCommand,
|
handleHelpCommand,
|
||||||
|
handlePlansCommand,
|
||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleAllowlistCommand,
|
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", () => {
|
describe("handleCommands bash alias", () => {
|
||||||
it("routes !poll through the /bash handler", async () => {
|
it("routes !poll through the /bash handler", async () => {
|
||||||
resetBashChatCommandForTests();
|
resetBashChatCommandForTests();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user