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:
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user