feat(KB-198): add task delete CLI command with --force flag
- Add `kb task delete` CLI command with required --force flag for safety - Implement runTaskDelete function with task existence validation - Register kb_task_delete tool in Pi extension for chat agent access - Add comprehensive unit tests for runTaskDelete and extension registration - Wire delete command in CLI bin.ts with proper argument parsing
This commit is contained in:
@@ -87,6 +87,7 @@ describe("kb pi extension", () => {
|
||||
"kb_task_browse_github_issues",
|
||||
"kb_task_archive",
|
||||
"kb_task_unarchive",
|
||||
"kb_task_delete",
|
||||
"kb_task_plan",
|
||||
];
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ if (isBunBinary) {
|
||||
|
||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||
const { runDashboard } = await import("./commands/dashboard.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete } = await import("./commands/task.js");
|
||||
|
||||
const HELP = `
|
||||
kb — AI-orchestrated task board
|
||||
@@ -61,6 +61,7 @@ Usage:
|
||||
kb task refine <id> [opts] Create a refinement task from done/in-review
|
||||
kb task archive <id> Archive a done task
|
||||
kb task unarchive <id> Unarchive an archived task
|
||||
kb task delete <id> [--force] Delete a task (use --force to skip confirmation)
|
||||
kb task attach <id> <file> Attach a file to a task
|
||||
kb task pause <id> Pause a task (stops all automation)
|
||||
kb task unpause <id> Unpause a task (resumes automation)
|
||||
@@ -220,6 +221,13 @@ async function main() {
|
||||
await runTaskUnarchive(id);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task delete <id> [--force]"); process.exit(1); }
|
||||
const force = args.includes("--force");
|
||||
await runTaskDelete(id, force);
|
||||
break;
|
||||
}
|
||||
case "attach": {
|
||||
const id = args[2], file = args[3];
|
||||
if (!id || !file) {
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine } from "./task.js";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete } from "./task.js";
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -1026,3 +1026,152 @@ describe("runTaskRefine", () => {
|
||||
await expect(runTaskRefine("KB-999", "Some feedback")).rejects.toThrow("Task KB-999 not found");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Delete Tests ---
|
||||
|
||||
describe("runTaskDelete", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockGetTask: ReturnType<typeof vi.fn>;
|
||||
let mockDeleteTask: ReturnType<typeof vi.fn>;
|
||||
let mockRlQuestion: ReturnType<typeof vi.fn>;
|
||||
let mockRlClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockRlQuestion = vi.fn();
|
||||
mockRlClose = vi.fn();
|
||||
|
||||
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
question: mockRlQuestion,
|
||||
close: mockRlClose,
|
||||
});
|
||||
|
||||
mockGetTask = vi.fn().mockResolvedValue({
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
mockDeleteTask = vi.fn().mockResolvedValue({
|
||||
id: "KB-001",
|
||||
description: "Test task",
|
||||
column: "triage",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getTask: mockGetTask,
|
||||
deleteTask: mockDeleteTask,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("deletes task successfully with force=true (no prompt)", async () => {
|
||||
await runTaskDelete("KB-001", true);
|
||||
|
||||
expect(mockGetTask).toHaveBeenCalledOnce();
|
||||
expect(mockGetTask).toHaveBeenCalledWith("KB-001");
|
||||
expect(mockRlQuestion).not.toHaveBeenCalled();
|
||||
expect(mockDeleteTask).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteTask).toHaveBeenCalledWith("KB-001");
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Deleted"),
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
expect(successLine![0]).toContain("KB-001");
|
||||
});
|
||||
|
||||
it("deletes task after confirmation prompt with 'y'", async () => {
|
||||
mockRlQuestion.mockResolvedValue("y");
|
||||
|
||||
await runTaskDelete("KB-001", false);
|
||||
|
||||
expect(mockRlQuestion).toHaveBeenCalledOnce();
|
||||
expect(mockRlQuestion).toHaveBeenCalledWith("Are you sure you want to delete KB-001? [y/N] ");
|
||||
expect(mockRlClose).toHaveBeenCalled();
|
||||
expect(mockDeleteTask).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteTask).toHaveBeenCalledWith("KB-001");
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Deleted"),
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
});
|
||||
|
||||
it("deletes task after confirmation prompt with 'yes'", async () => {
|
||||
mockRlQuestion.mockResolvedValue("yes");
|
||||
|
||||
await runTaskDelete("KB-001", false);
|
||||
|
||||
expect(mockDeleteTask).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("cancels deletion on 'n' response", async () => {
|
||||
mockRlQuestion.mockResolvedValue("n");
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskDelete("KB-001", false);
|
||||
|
||||
expect(mockRlQuestion).toHaveBeenCalledOnce();
|
||||
expect(mockRlClose).toHaveBeenCalled();
|
||||
expect(mockDeleteTask).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("cancels deletion on empty response", async () => {
|
||||
mockRlQuestion.mockResolvedValue("");
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskDelete("KB-001", false);
|
||||
|
||||
expect(mockRlQuestion).toHaveBeenCalledOnce();
|
||||
expect(mockDeleteTask).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exits with error when task not found", async () => {
|
||||
mockGetTask.mockRejectedValueOnce(new Error("Task KB-999 not found"));
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskDelete("KB-999", true);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("✗ Task KB-999 not found");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
expect(mockDeleteTask).not.toHaveBeenCalled();
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exits with error when deleteTask fails", async () => {
|
||||
mockDeleteTask.mockRejectedValueOnce(new Error("Task has dependencies"));
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskDelete("KB-001", true);
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("✗ Failed to delete KB-001: Task has dependencies");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,13 +339,40 @@ export async function runTaskArchive(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskUnarchive(id: string) {
|
||||
export async function runTaskDelete(id: string, force?: boolean) {
|
||||
const store = await getStore();
|
||||
const task = await store.unarchiveTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`);
|
||||
console.log();
|
||||
// Check if task exists first
|
||||
let task;
|
||||
try {
|
||||
task = await store.getTask(id);
|
||||
} catch (err: any) {
|
||||
console.error(`✗ Task ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Prompt for confirmation unless force is used
|
||||
if (!force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await rl.question(`Are you sure you want to delete ${id}? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
if (trimmed !== "y" && trimmed !== "yes") {
|
||||
console.log("Cancelled.");
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await store.deleteTask(id);
|
||||
console.log();
|
||||
console.log(` ✓ Deleted ${id}`);
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`✗ Failed to delete ${id}: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskImportGitHubInteractive(
|
||||
|
||||
@@ -466,6 +466,35 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_delete ─────────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_task_delete",
|
||||
label: "KB: Delete Task",
|
||||
description:
|
||||
"Permanently delete a task from the kb board. " +
|
||||
"Tasks are deleted immediately and cannot be recovered.",
|
||||
promptSnippet: "Delete a kb task",
|
||||
promptGuidelines: [
|
||||
"Use for cleaning up test tasks or tasks created in error",
|
||||
"Tasks are permanently deleted and cannot be recovered",
|
||||
"Consider archiving instead of deleting for completed work you may need to reference later",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to delete (e.g. KB-001)" }),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const task = await store.deleteTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Deleted ${task.id}` }],
|
||||
details: { taskId: task.id },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_import_github ─────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user