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:
gsxdsm
2026-03-30 13:27:59 -07:00
parent 9e35c00a9c
commit 4ee7d0663b
6 changed files with 226 additions and 7 deletions

View File

@@ -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",
];

View File

@@ -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) {

View 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();
});
});

View File

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

View File

@@ -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({