feat(KB-619): enhance project commands with JSON output and task counts

- Add --json flag to 'kb project list' for machine-readable output
- Add --json flag to 'kb project show' for structured data
- Show task counts per column in project details
- Update CLI help text for better documentation
- Add changeset for multi-project CLI commands feature
- Add comprehensive tests for project commands
This commit is contained in:
gsxdsm
2026-04-01 22:40:03 -07:00
parent 04b194982d
commit 59a236adbd
4 changed files with 608 additions and 83 deletions

View File

@@ -45,7 +45,7 @@ const { runSettingsExport } = await import("./commands/settings-export.js");
const { runSettingsImport } = await import("./commands/settings-import.js");
const { runGitStatus, runGitFetch, runGitPull, runGitPush } = await import("./commands/git.js");
const { runBackupCreate, runBackupList, runBackupRestore, runBackupCleanup } = await import("./commands/backup.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const { runProjectList, runProjectAdd, runProjectRemove, runProjectShow, runProjectInfo, runProjectSetDefault, runProjectDetect } = await import("./commands/project.js");
const HELP = `
fn — AI-orchestrated task board
@@ -80,11 +80,12 @@ Usage:
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
Create a GitHub PR for an in-review task
fn task import <owner/repo> [opts] Import GitHub issues as tasks
fn project list | ls List all registered projects
fn project add <name> <path> [opts] Register a new project
fn project list | ls [--json] List all registered projects
fn project add [name] [path] [opts] Register a new project
fn project remove | rm <name> [--force]
Unregister a project
fn project show <name> Show project details
fn project show <name> Show project details with health
fn project info [name] Show project details (alias for show)
fn project set-default | default <name>
Set default project
fn project detect Detect project from current directory
@@ -184,24 +185,33 @@ async function main() {
switch (subcommand) {
case "list":
case "ls":
await runProjectList();
{
const json = args.includes("--json");
await runProjectList({ json });
}
break;
case "add": {
const name = args[2];
const path = args[3];
const isolationIdx = args.indexOf("--isolation");
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
? args[isolationIdx + 1]
? args[isolationIdx + 1] as "in-process" | "child-process"
: undefined;
const force = args.includes("--force");
await runProjectAdd(name, path, { isolation, force });
const interactive = args.includes("--interactive");
await runProjectAdd(name, path, { isolation, force, interactive });
break;
}
case "info": {
const name = args[2];
await runProjectInfo(name);
break;
}
case "remove":
case "rm": {
const name = args[2];
const force = args.includes("--force");
await runProjectRemove(name, force);
await runProjectRemove(name, { force });
break;
}
case "show": {
@@ -220,7 +230,7 @@ async function main() {
break;
default:
console.error(`Unknown subcommand: project ${subcommand || ""}`);
console.log("Try: fn project list | add | remove | show | set-default | detect");
console.log("Try: fn project list | add | remove | show | info | set-default | detect");
process.exit(1);
}
break;

View File

@@ -8,6 +8,7 @@ const mockRegisterProject = vi.fn();
const mockUnregisterProject = vi.fn();
const mockGetProject = vi.fn();
const mockGetProjectByPath = vi.fn();
const mockGetProjectHealth = vi.fn();
const mockInit = vi.fn();
const mockClose = vi.fn();
const mockQuestion = vi.fn();
@@ -17,7 +18,10 @@ const mockDetectProjectFromCwd = vi.fn();
const mockFormatProjectLine = vi.fn();
const mockGetSettings = vi.fn();
const mockGlobalInit = vi.fn();
const mockTaskStoreInit = vi.fn();
const mockTaskStoreListTasks = vi.fn();
// Mock @fusion/core
vi.mock("@fusion/core", () => ({
CentralCore: vi.fn().mockImplementation(() => ({
init: mockInit.mockResolvedValue(undefined),
@@ -27,11 +31,25 @@ vi.mock("@fusion/core", () => ({
unregisterProject: mockUnregisterProject,
getProject: mockGetProject,
getProjectByPath: mockGetProjectByPath,
getProjectHealth: mockGetProjectHealth,
})),
GlobalSettingsStore: vi.fn().mockImplementation(() => ({
init: mockGlobalInit.mockResolvedValue(undefined),
getSettings: mockGetSettings,
})),
TaskStore: vi.fn().mockImplementation(() => ({
init: mockTaskStoreInit,
listTasks: mockTaskStoreListTasks,
})),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "To Do",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
}));
vi.mock("node:readline/promises", () => ({
@@ -45,6 +63,7 @@ vi.mock("../project-context.js", () => ({
formatProjectLine: mockFormatProjectLine,
detectProjectFromCwd: mockDetectProjectFromCwd,
setDefaultProject: mockSetDefaultProject,
resolveProject: vi.fn(),
}));
describe("project commands", () => {
@@ -63,6 +82,9 @@ describe("project commands", () => {
mockGetSettings.mockResolvedValue({});
mockFormatProjectLine.mockImplementation((project, isDefault) => `${isDefault ? "* " : " "}${project.name}`);
mockQuestion.mockResolvedValue("y");
mockGetProjectHealth.mockResolvedValue(undefined);
mockTaskStoreInit.mockResolvedValue(undefined);
mockTaskStoreListTasks.mockResolvedValue([]);
});
afterEach(() => {
@@ -77,6 +99,7 @@ describe("project commands", () => {
expect(typeof project.runProjectAdd).toBe("function");
expect(typeof project.runProjectRemove).toBe("function");
expect(typeof project.runProjectShow).toBe("function");
expect(typeof project.runProjectInfo).toBe("function");
expect(typeof project.runProjectSetDefault).toBe("function");
expect(typeof project.runProjectDetect).toBe("function");
});
@@ -96,8 +119,28 @@ describe("project commands", () => {
const { runProjectList } = await import("./project.js");
await runProjectList();
expect(mockFormatProjectLine).toHaveBeenCalledTimes(2);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("2 projects registered, 1 active"));
// Check that projects are displayed in output
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("app-one");
expect(output).toContain("app-two");
});
it("runProjectList with --json flag outputs JSON", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
]);
mockGetSettings.mockResolvedValue({});
const { runProjectList } = await import("./project.js");
await runProjectList({ json: true });
// Should output JSON
const jsonOutput = consoleSpy.mock.calls.map((call) => String(call[0])).join("");
expect(() => JSON.parse(jsonOutput)).not.toThrow();
const parsed = JSON.parse(jsonOutput);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed[0].name).toBe("app-one");
});
it("runProjectAdd registers project and prints sanitized path output", async () => {
@@ -118,12 +161,23 @@ describe("project commands", () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
const { runProjectRemove } = await import("./project.js");
await runProjectRemove("proj-1", false);
await runProjectRemove("proj-1", { force: false });
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered project 'demo'"));
});
it("runProjectRemove with --force skips confirmation", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
const { runProjectRemove } = await import("./project.js");
await runProjectRemove("proj-1", { force: true });
expect(mockUnregisterProject).toHaveBeenCalledWith("proj-1");
// Question should not be called when force is true
expect(mockQuestion).not.toHaveBeenCalled();
});
it("runProjectShow prints detailed project metadata without absolute path leakage", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
@@ -135,6 +189,7 @@ describe("project commands", () => {
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({ defaultProjectId: "proj-1" });
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectShow } = await import("./project.js");
await runProjectShow("proj-1");
@@ -146,6 +201,26 @@ describe("project commands", () => {
expect(output).not.toContain("/tmp/demo");
});
it("runProjectInfo is alias for runProjectShow", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectInfo } = await import("./project.js");
await runProjectInfo("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Project: demo");
});
it("runProjectSetDefault sets default project", async () => {
mockGetProject.mockResolvedValue({ id: "proj-1", name: "demo", path: "/tmp/demo", status: "active", isolationMode: "in-process" });
@@ -168,12 +243,100 @@ describe("project commands", () => {
expect(output).not.toContain("/tmp/demo");
});
it("validation exits on missing required args", async () => {
const { runProjectAdd, runProjectRemove, runProjectShow, runProjectSetDefault } = await import("./project.js");
it("runProjectList shows task counts for projects", async () => {
mockListProjects.mockResolvedValue([
{ id: "proj-1", name: "app-one", path: "/tmp/app-one", status: "active", isolationMode: "in-process" },
]);
mockGetSettings.mockResolvedValue({});
// Mock task store to return some tasks - return 3 tasks
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "in-progress" },
{ id: "FN-003", column: "done" },
]);
const { runProjectList } = await import("./project.js");
await runProjectList();
// Verify TaskStore.listTasks was called
expect(mockTaskStoreListTasks).toHaveBeenCalled();
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("3"); // Total task count
});
it("runProjectShow shows task counts in output", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockTaskStoreListTasks.mockResolvedValue([
{ id: "FN-001", column: "todo" },
{ id: "FN-002", column: "todo" },
{ id: "FN-003", column: "in-progress" },
]);
const { runProjectShow } = await import("./project.js");
await runProjectShow("proj-1");
// Verify TaskStore.listTasks was called
expect(mockTaskStoreListTasks).toHaveBeenCalled();
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Total: 3");
expect(output).toContain("To Do: 2");
expect(output).toContain("In Progress: 1");
});
it("runProjectShow shows health info when available", async () => {
mockGetProject.mockResolvedValue({
id: "proj-1",
name: "demo",
path: "/tmp/demo",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
});
mockGetSettings.mockResolvedValue({});
mockGetProjectHealth.mockResolvedValue({
projectId: "proj-1",
status: "active",
activeTaskCount: 2,
inFlightAgentCount: 1,
totalTasksCompleted: 10,
totalTasksFailed: 1,
lastActivityAt: new Date().toISOString(),
});
mockTaskStoreListTasks.mockResolvedValue([]);
const { runProjectShow } = await import("./project.js");
await runProjectShow("proj-1");
const output = consoleSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(output).toContain("Health:");
expect(output).toContain("Active Tasks: 2");
expect(output).toContain("In-Flight Agents: 1");
expect(output).toContain("Completed: 10");
});
it("validation exits on missing required args for runProjectAdd", async () => {
const { runProjectAdd } = await import("./project.js");
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit:1");
});
it("validation exits on missing required args for runProjectRemove", async () => {
const { runProjectRemove } = await import("./project.js");
await expect(runProjectRemove("")).rejects.toThrow("process.exit:1");
await expect(runProjectShow("")).rejects.toThrow("process.exit:1");
});
it("validation exits on missing required args for runProjectSetDefault", async () => {
const { runProjectSetDefault } = await import("./project.js");
await expect(runProjectSetDefault("")).rejects.toThrow("process.exit:1");
});
});

View File

@@ -1,15 +1,86 @@
/**
* Project command implementations for kb CLI.
*
* Provides commands for managing the project registry:
* - list: List all registered projects
* - add: Register a new project
* - remove: Unregister a project
* - show: Show project details
* - set-default: Set default project
* - detect: Detect project from current directory
*/
import { CentralCore, GlobalSettingsStore, type RegisteredProject, type IsolationMode } from "@fusion/core";
import {
CentralCore,
GlobalSettingsStore,
TaskStore,
type RegisteredProject,
type IsolationMode,
type ProjectHealth,
COLUMNS,
COLUMN_LABELS,
type Column,
} from "@fusion/core";
import { resolve, isAbsolute, relative, basename } from "node:path";
import { existsSync, statSync } from "node:fs";
import { createInterface } from "node:readline/promises";
import { formatProjectLine, detectProjectFromCwd, setDefaultProject } from "../project-context.js";
import { formatProjectLine, detectProjectFromCwd, setDefaultProject, resolveProject as resolveProjectContext } from "../project-context.js";
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
/**
* Options for project list command.
*/
export interface ProjectListOptions {
/** Output as JSON instead of table */
json?: boolean;
}
/**
* Options for project add command.
*/
export interface ProjectAddOptions {
/** Isolation mode for the project */
isolation?: IsolationMode;
/** Skip confirmation prompts */
force?: boolean;
/** Interactive mode */
interactive?: boolean;
}
/**
* Options for project remove command.
*/
export interface ProjectRemoveOptions {
/** Skip confirmation prompts */
force?: boolean;
}
/**
* Project info data structure for JSON output.
*/
export interface ProjectInfoData {
id: string;
name: string;
path: string;
status: string;
isolationMode: string;
createdAt: string;
updatedAt: string;
lastActivityAt?: string;
health?: {
activeTaskCount: number;
inFlightAgentCount: number;
totalTasksCompleted: number;
totalTasksFailed: number;
};
taskCounts: Record<string, number>;
defaultProject: boolean;
}
/**
* Format a path for display, showing relative path when possible.
*/
function formatDisplayPath(projectPath: string): string {
const rel = relative(process.cwd(), projectPath);
if (rel && !rel.startsWith("..") && rel !== "") {
@@ -18,7 +89,62 @@ function formatDisplayPath(projectPath: string): string {
return basename(projectPath) || ".";
}
export async function runProjectList(): Promise<void> {
/**
* Format a timestamp for display (relative or absolute).
*/
function formatLastActivity(timestamp?: string | null): string {
if (!timestamp) return "never";
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Get task counts by column for a project.
*/
async function getTaskCounts(projectPath: string): Promise<Record<string, number>> {
try {
const store = new TaskStore(projectPath);
await store.init();
const tasks = await store.listTasks();
const counts: Record<string, number> = {};
for (const col of COLUMNS) {
counts[col] = 0;
}
for (const task of tasks) {
counts[task.column] = (counts[task.column] || 0) + 1;
}
return counts;
} catch {
// Return empty counts if we can't read the project
return {};
}
}
/**
* Get project health from CentralCore.
*/
async function getProjectHealth(central: CentralCore, projectId: string): Promise<ProjectHealth | undefined> {
return central.getProjectHealth(projectId);
}
/**
* List all registered projects.
*
* @param options - Options including json output flag
*/
export async function runProjectList(options: ProjectListOptions = {}): Promise<void> {
const central = new CentralCore();
await central.init();
@@ -27,19 +153,72 @@ export async function runProjectList(): Promise<void> {
const defaultProject = await getDefaultProject();
if (projects.length === 0) {
console.log("\n No projects registered.");
console.log(" Register one with: kb project add <name> <path>\n");
if (options.json) {
console.log(JSON.stringify([], null, 2));
} else {
console.log("\n No projects registered.");
console.log(" Register one with: kb project add <name> <path>\n");
}
return;
}
// Gather task counts and health for each project
const projectData: ProjectInfoData[] = await Promise.all(
projects.map(async (project) => {
const [taskCounts, health] = await Promise.all([
getTaskCounts(project.path),
getProjectHealth(central, project.id),
]);
return {
id: project.id,
name: project.name,
path: project.path,
status: project.status,
isolationMode: project.isolationMode,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
lastActivityAt: health?.lastActivityAt ?? project.lastActivityAt,
health: health
? {
activeTaskCount: health.activeTaskCount,
inFlightAgentCount: health.inFlightAgentCount,
totalTasksCompleted: health.totalTasksCompleted,
totalTasksFailed: health.totalTasksFailed,
}
: undefined,
taskCounts,
defaultProject: defaultProject?.id === project.id,
};
})
);
if (options.json) {
console.log(JSON.stringify(projectData, null, 2));
return;
}
// Table output
console.log();
console.log(" Registered Projects:");
console.log();
for (const project of projects) {
const isDefault = defaultProject?.id === project.id;
const line = formatProjectLine(project, isDefault);
console.log(` ${line}`);
// Header
console.log(" Name Status Isolation Tasks Last Activity");
console.log(" " + "─".repeat(72));
for (const project of projectData) {
const totalTasks = Object.values(project.taskCounts).reduce((a, b) => a + b, 0);
const statusDot = project.status === "active" ? "●" : project.status === "paused" ? "○" : "○";
const defaultMarker = project.defaultProject ? " *" : " ";
const name = project.name.padEnd(16);
const status = `${statusDot} ${project.status}`.padEnd(12);
const isolation = project.isolationMode.padEnd(12);
const tasks = String(totalTasks).padStart(5);
const lastActivity = formatLastActivity(project.lastActivityAt);
console.log(` ${defaultMarker}${name} ${status} ${isolation} ${tasks} ${lastActivity}`);
}
console.log();
@@ -54,63 +233,136 @@ export async function runProjectList(): Promise<void> {
}
}
/**
* Add a new project to the registry.
*
* @param name - Project name (optional in interactive mode)
* @param path - Project path (optional in interactive mode)
* @param options - Additional options
*/
export async function runProjectAdd(
name: string,
path: string,
options?: { isolation?: string; force?: boolean }
name?: string,
path?: string,
options: ProjectAddOptions = {}
): Promise<void> {
if (!name || !path) {
console.error("Usage: kb project add <name> <path> [--isolation <mode>]");
process.exit(1);
}
if (!isValidProjectName(name)) {
console.error(`Error: Invalid project name '${name}'`);
process.exit(1);
}
const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path);
if (!existsSync(absolutePath)) {
console.error(`Error: Path does not exist: ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
if (!statSync(absolutePath).isDirectory()) {
console.error(`Error: Path is not a directory: ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
if (!existsSync(kbDbPath) && !options?.force) {
console.error(`Error: No kb project found at ${formatDisplayPath(absolutePath)}`);
process.exit(1);
}
const isolationMode = options?.isolation as IsolationMode | undefined;
if (isolationMode && !VALID_ISOLATION_MODES.includes(isolationMode)) {
console.error(`Error: Invalid isolation mode '${isolationMode}'`);
process.exit(1);
}
const central = new CentralCore();
await central.init();
try {
const existing = await findProjectByName(central, name);
if (existing) {
console.error(`Error: Project '${name}' already registered.`);
let projectName = name;
let projectPath = path;
// Interactive mode if name or path not provided
if (!projectName || !projectPath || options.interactive) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
// Get path if not provided
if (!projectPath) {
const defaultPath = process.cwd();
const pathInput = await rl.question(` Project path [${defaultPath}]: `);
projectPath = pathInput.trim() || defaultPath;
}
// Validate path
const absolutePath = isAbsolute(projectPath) ? projectPath : resolve(process.cwd(), projectPath);
if (!existsSync(absolutePath)) {
console.error(`\n ✗ Path does not exist: ${projectPath}`);
rl.close();
process.exit(1);
}
if (!statSync(absolutePath).isDirectory()) {
console.error(`\n ✗ Path is not a directory: ${projectPath}`);
rl.close();
process.exit(1);
}
// Check for .kb directory
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
if (!existsSync(kbDbPath) && !options.force) {
console.log(`\n No kb project found at ${formatDisplayPath(absolutePath)}`);
const init = await rl.question(" Initialize kb here first? [Y/n] ");
rl.close();
if (init.trim().toLowerCase() !== "n") {
// Initialize the project
const store = new TaskStore(absolutePath);
await store.init();
console.log(` ✓ Initialized kb at ${absolutePath}`);
} else {
console.log("\n Cancelled. Run `kb init` to initialize a project first.\n");
process.exit(1);
}
}
// Get name if not provided
if (!projectName) {
const suggested = basename(absolutePath);
projectName = await rl.question(` Project name [${suggested}]: `);
projectName = projectName.trim() || suggested;
}
rl.close();
}
// Validate name
if (!projectName) {
console.error("\n ✗ Project name is required\n");
process.exit(1);
}
if (!isValidProjectName(projectName)) {
console.error(`\n ✗ Invalid project name '${projectName}'`);
console.error(" Name must be 1-64 characters and contain only: a-z, A-Z, 0-9, _, -\n");
process.exit(1);
}
// Validate path
const absolutePath = isAbsolute(projectPath!) ? projectPath! : resolve(process.cwd(), projectPath!);
if (!existsSync(absolutePath)) {
console.error(`\n ✗ Path does not exist: ${projectPath}\n`);
process.exit(1);
}
if (!statSync(absolutePath).isDirectory()) {
console.error(`\n ✗ Path is not a directory: ${projectPath}\n`);
process.exit(1);
}
// Check for .kb directory
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
if (!existsSync(kbDbPath) && !options.force) {
console.error(`\n ✗ No kb project found at ${formatDisplayPath(absolutePath)}`);
console.error(" Run `kb init` first to initialize the project.\n");
process.exit(1);
}
// Validate isolation mode
const isolationMode = options.isolation ?? "in-process";
if (!VALID_ISOLATION_MODES.includes(isolationMode)) {
console.error(`\n ✗ Invalid isolation mode '${isolationMode}'`);
console.error(` Valid options: ${VALID_ISOLATION_MODES.join(", ")}\n`);
process.exit(1);
}
// Check for duplicate name
const existing = await findProjectByName(central, projectName);
if (existing) {
console.error(`\n ✗ Project '${projectName}' already registered.\n`);
process.exit(1);
}
// Register the project
const project = await central.registerProject({
name,
name: projectName,
path: absolutePath,
isolationMode: isolationMode ?? "in-process",
isolationMode,
});
console.log();
console.log(` ✓ Registered project '${name}'`);
console.log(` ✓ Registered project '${projectName}'`);
console.log(` Location: ${formatDisplayPath(project.path)}`);
console.log(` ID: ${project.id}`);
console.log(` Isolation: ${project.isolationMode}`);
@@ -120,7 +372,13 @@ export async function runProjectAdd(
}
}
export async function runProjectRemove(name: string, force?: boolean): Promise<void> {
/**
* Remove a project from the registry.
*
* @param name - Project name
* @param options - Options including force flag
*/
export async function runProjectRemove(name: string, options: ProjectRemoveOptions = {}): Promise<void> {
if (!name) {
console.error("Usage: kb project remove <name> [--force]");
process.exit(1);
@@ -136,7 +394,7 @@ export async function runProjectRemove(name: string, force?: boolean): Promise<v
process.exit(1);
}
if (!force) {
if (!options.force) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `);
rl.close();
@@ -148,31 +406,76 @@ export async function runProjectRemove(name: string, force?: boolean): Promise<v
}
await central.unregisterProject(project.id);
console.log();
console.log(` ✓ Unregistered project '${project.name}'`);
console.log(` Location: ${formatDisplayPath(project.path)}`);
console.log();
console.log(" Note: Project data is preserved. You can re-register with:");
console.log(` kb project add ${project.name} ${project.path}`);
console.log();
} finally {
await central.close();
}
}
export async function runProjectShow(name: string): Promise<void> {
if (!name) {
console.error("Usage: kb project show <name>");
process.exit(1);
}
/**
* Show detailed information about a project.
*
* @param name - Project name (optional, uses detection if not provided)
*/
export async function runProjectShow(name?: string): Promise<void> {
const central = new CentralCore();
await central.init();
try {
const project = await findProjectByNameOrId(central, name);
let project: RegisteredProject | undefined;
if (name) {
project = await findProjectByNameOrId(central, name);
} else {
// Auto-detect from cwd
const detected = await detectProjectFromCwd(process.cwd(), central);
if (detected) {
// detected might be a full project or just path info
if ("id" in detected && detected.id) {
project = await central.getProject(detected.id);
}
if (!project) {
// Unregistered project with .kb
console.log();
console.log(` Project: ${detected.name}`);
console.log(` Location: ${formatDisplayPath(detected.path)}`);
console.log(` Status: (not registered)`);
console.log();
const counts = await getTaskCounts(detected.path);
const total = Object.values(counts).reduce((a, b) => a + b, 0);
if (total > 0) {
console.log(` Tasks: ${total} total`);
for (const [col, count] of Object.entries(counts)) {
if (count > 0) {
console.log(` ${COLUMN_LABELS[col as Column]}: ${count}`);
}
}
}
console.log();
console.log(" Run 'kb project add' to register this project.");
console.log();
return;
}
}
}
if (!project) {
console.error(`Error: Project '${name}' not found.`);
console.error(`Error: Project '${name || "current directory"}' not found.`);
process.exit(1);
}
const defaultProject = await getDefaultProject();
const isDefault = defaultProject?.id === project.id;
const [health, taskCounts] = await Promise.all([
getProjectHealth(central, project.id),
getTaskCounts(project.path),
]);
console.log();
console.log(` Project: ${project.name}${isDefault ? " (default)" : ""}`);
@@ -182,12 +485,46 @@ export async function runProjectShow(name: string): Promise<void> {
console.log(` Isolation: ${project.isolationMode}`);
console.log(` Created: ${project.createdAt ?? "unknown"}`);
console.log(` Updated: ${project.updatedAt ?? "unknown"}`);
if (health) {
console.log();
console.log(` Health:`);
console.log(` Active Tasks: ${health.activeTaskCount}`);
console.log(` In-Flight Agents: ${health.inFlightAgentCount}`);
console.log(` Completed: ${health.totalTasksCompleted}`);
console.log(` Failed: ${health.totalTasksFailed}`);
if (health.lastActivityAt) {
console.log(` Last Activity: ${formatLastActivity(health.lastActivityAt)}`);
}
}
console.log();
console.log(` Tasks:`);
const total = Object.values(taskCounts).reduce((a, b) => a + b, 0);
console.log(` Total: ${total}`);
for (const col of COLUMNS) {
const count = taskCounts[col] || 0;
if (count > 0) {
console.log(` ${COLUMN_LABELS[col]}: ${count}`);
}
}
console.log();
} finally {
await central.close();
}
}
/**
* Alias for runProjectShow - shows project details.
*/
export const runProjectInfo = runProjectShow;
/**
* Set the default project.
*
* @param name - Project name
*/
export async function runProjectSetDefault(name: string): Promise<void> {
if (!name) {
console.error("Usage: kb project set-default <name>");
@@ -213,6 +550,9 @@ export async function runProjectSetDefault(name: string): Promise<void> {
}
}
/**
* Detect project from current directory.
*/
export async function runProjectDetect(): Promise<void> {
const central = new CentralCore();
await central.init();
@@ -224,6 +564,15 @@ export async function runProjectDetect(): Promise<void> {
console.log();
console.log(` Detected: ${project.name}`);
console.log(` Location: ${formatDisplayPath(project.path)}`);
if ("id" in project && project.id) {
console.log(` ID: ${project.id}`);
const health = await getProjectHealth(central, project.id);
if (health) {
console.log(` Status: ${health.status}`);
}
} else {
console.log(` Status: (not registered)`);
}
console.log();
} else {
console.log();
@@ -235,7 +584,7 @@ export async function runProjectDetect(): Promise<void> {
}
}
// Helpers
// ── Helpers ───────────────────────────────────────────────────────────────
async function getDefaultProject(): Promise<RegisteredProject | undefined> {
const globalStore = new GlobalSettingsStore();