From 25a543844735af714bcc1f814125165fd4239297 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 31 Mar 2026 23:41:36 -0700 Subject: [PATCH] feat(KB-619): add multi-project CLI support - Add project-resolver module with CentralCore integration for project resolution - Add project subcommands: list, add, remove, info with proper CLI integration - Add --project flag support across all task and settings commands - Refactor getStore to use project resolution with options pattern - Update bin.ts command routing to extract and propagate project context - Add project context propagation via FN_PROJECT environment variable --- .changeset/add-cli-multi-project-commands.md | 17 + packages/cli/src/bin.ts | 58 +- packages/cli/src/commands/project.test.ts | 301 ++++-- packages/cli/src/commands/project.ts | 578 ++++++++---- packages/cli/src/commands/settings.test.ts | 77 +- packages/cli/src/commands/settings.ts | 11 +- packages/cli/src/commands/task.ts | 69 +- packages/cli/src/project-resolver.test.ts | 415 +++++++++ packages/cli/src/project-resolver.ts | 915 +++++++++++++++++++ packages/core/src/types.ts | 5 +- 10 files changed, 2136 insertions(+), 310 deletions(-) create mode 100644 .changeset/add-cli-multi-project-commands.md create mode 100644 packages/cli/src/project-resolver.test.ts create mode 100644 packages/cli/src/project-resolver.ts diff --git a/.changeset/add-cli-multi-project-commands.md b/.changeset/add-cli-multi-project-commands.md new file mode 100644 index 000000000..cdb2b7566 --- /dev/null +++ b/.changeset/add-cli-multi-project-commands.md @@ -0,0 +1,17 @@ +--- +"@gsxdsm/fusion": minor +--- + +Add CLI multi-project commands and --project flag support. + +New commands: +- `fn project list [--json]` — List all registered projects +- `fn project add [dir] [--name ] [--isolation ]` — Register a project +- `fn project remove [--force]` — Unregister a project +- `fn project info [name]` — Show project details + +All task and settings commands now support `--project ` flag: +- `fn task list --project myapp` +- `fn settings --project myapp` + +Projects are auto-detected from cwd by walking up to find `.kb/`. diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts index 01389215b..10ee4831a 100644 --- a/packages/cli/src/bin.ts +++ b/packages/cli/src/bin.ts @@ -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, runProjectInfo } = await import("./commands/project.js"); const HELP = ` fn — AI-orchestrated task board @@ -80,16 +80,10 @@ Usage: fn task pr-create [--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 List all registered projects - fn project add <name> <path> [opts] Register a new project + fn project list [--json] List all registered projects + fn project add [dir] [--name <name>] [--isolation <mode>] Register a project fn project remove <name> [--force] Unregister a project - fn project show <name> Show project details - fn project set-default <name> Set default project - fn project detect Detect project from current directory - fn settings Show current Fusion configuration - fn settings set <key> <value> Update a configuration setting - fn settings export [opts] Export settings to a JSON file - fn settings import <file> [opts] Import settings from a JSON file + fn project info [name] Show project details fn git status Show current branch, commit, dirty state, ahead/behind fn git push Push current branch @@ -101,7 +95,7 @@ Usage: fn backup --cleanup Remove old backups exceeding retention limit Options: - --project, -P <name> Target a specific project (bypasses CWD detection) + --project <name> Target a specific project (for task/settings commands) --port, -p <port> Dashboard port (default: 4040) --interactive Interactive mode (port selection for dashboard, issue selection for import) --paused Start with engine paused (automation disabled) @@ -123,7 +117,7 @@ Requires configured API keys — run "pi" first to set up authentication. `.trim(); async function main() { - const args = process.argv.slice(2); + let args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(HELP); @@ -140,6 +134,10 @@ async function main() { // Remove --project and its value from args args.splice(projectIdx, 2); } + // Store for subcommands to access via resolveProject + if (projectName) { + process.env.FN_PROJECT = projectName; + } const command = args[0]; @@ -168,44 +166,42 @@ async function main() { const subcommand = args[1]; switch (subcommand) { case "list": - case "ls": - await runProjectList(); + case "ls": { + const json = args.includes("--json"); + await runProjectList({ json }); break; + } case "add": { - const name = args[2]; - const path = args[3]; + const dir = args[2]; + const nameIdx = args.indexOf("--name"); + const name = nameIdx !== -1 && nameIdx + 1 < args.length ? args[nameIdx + 1] : undefined; 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 }); + await runProjectAdd(dir, { name, isolation }); break; } case "remove": case "rm": { const name = args[2]; + if (!name) { + console.error("Usage: fn project remove <name> [--force]"); + process.exit(1); + } const force = args.includes("--force"); - await runProjectRemove(name, force); + await runProjectRemove(name, { force }); break; } + case "info": case "show": { const name = args[2]; - await runProjectShow(name); + await runProjectInfo(name); break; } - case "set-default": - case "default": { - const name = args[2]; - await runProjectSetDefault(name); - break; - } - case "detect": - await runProjectDetect(); - break; default: console.error(`Unknown subcommand: project ${subcommand || ""}`); - console.log("Try: fn project list | add | remove | show | set-default | detect"); + console.error("Try: fn project list | add [dir] | remove <name> | info [name]"); process.exit(1); } break; diff --git a/packages/cli/src/commands/project.test.ts b/packages/cli/src/commands/project.test.ts index 4eeb82169..d6ff9638b 100644 --- a/packages/cli/src/commands/project.test.ts +++ b/packages/cli/src/commands/project.test.ts @@ -1,79 +1,270 @@ -/** - * Tests for project.ts commands - */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } from "./project.js"; -describe("project commands", () => { - let consoleSpy: ReturnType<typeof vi.spyOn>; - let consoleErrorSpy: ReturnType<typeof vi.spyOn>; +// Mock dependencies +vi.mock("@fusion/core", async () => { + const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core"); + return { + ...actual, + CentralCore: vi.fn(), + GlobalSettingsStore: vi.fn(), + TaskStore: vi.fn().mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + listTasks: vi.fn().mockResolvedValue([]), + })), + }; +}); - beforeEach(() => { - consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); - consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - }); +vi.mock("@fusion/engine", () => ({ + ProjectManager: vi.fn().mockImplementation(() => ({ + getRuntime: vi.fn().mockReturnValue(undefined), + removeProject: vi.fn().mockResolvedValue(undefined), + })), +})); - afterEach(() => { - consoleSpy.mockRestore(); - consoleErrorSpy.mockRestore(); - vi.resetModules(); - }); +vi.mock("../project-resolver.js", () => ({ + getCentralCore: vi.fn(), + getProjectManager: vi.fn(), + findKbDir: vi.fn().mockReturnValue(null), + isKbProject: vi.fn().mockReturnValue(true), + suggestProjectName: vi.fn().mockReturnValue("test-project"), + formatLastActivity: vi.fn().mockReturnValue("just now"), +})); +describe("Project Commands", () => { describe("exports", () => { - it("should export all project command functions", async () => { - const project = await import("./project.js"); - expect(typeof project.runProjectList).toBe("function"); - expect(typeof project.runProjectAdd).toBe("function"); - expect(typeof project.runProjectRemove).toBe("function"); - expect(typeof project.runProjectShow).toBe("function"); - expect(typeof project.runProjectSetDefault).toBe("function"); - expect(typeof project.runProjectDetect).toBe("function"); + it("exports runProjectList as a function", () => { + expect(typeof runProjectList).toBe("function"); + }); + + it("exports runProjectAdd as a function", () => { + expect(typeof runProjectAdd).toBe("function"); + }); + + it("exports runProjectRemove as a function", () => { + expect(typeof runProjectRemove).toBe("function"); + }); + + it("exports runProjectInfo as a function", () => { + expect(typeof runProjectInfo).toBe("function"); }); }); - describe("validation errors", () => { - it("runProjectAdd should exit when name is empty", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - const { runProjectAdd } = await import("./project.js"); - await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit"); - exitSpy.mockRestore(); + describe("runProjectList", () => { + it("should handle empty project list", async () => { + const { getCentralCore } = await import("../project-resolver.js"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([]), + getProjectHealth: vi.fn().mockResolvedValue(undefined), + } as unknown as import("@fusion/core").CentralCore); + + const { getProjectManager } = await import("../project-resolver.js"); + vi.mocked(getProjectManager).mockResolvedValue({ + getRuntime: vi.fn().mockReturnValue(undefined), + } as unknown as import("@fusion/engine").ProjectManager); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runProjectList(); + + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("No projects registered")); + consoleSpy.mockRestore(); }); - it("runProjectAdd should exit when path is empty", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); + it("should output JSON when --json flag is set", async () => { + const mockProject = { + id: "proj_123", + name: "test-project", + path: "/path/to/project", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + const { getCentralCore } = await import("../project-resolver.js"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([mockProject]), + getProjectHealth: vi.fn().mockResolvedValue({ + lastActivityAt: "2024-01-01T00:00:00.000Z", + inFlightAgentCount: 0, + }), + } as unknown as import("@fusion/core").CentralCore); + + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runProjectList({ json: true }); + + // Check that JSON was output + const jsonCall = consoleSpy.mock.calls.find(call => { + try { + JSON.parse(call[0] as string); + return true; + } catch { + return false; + } }); - const { runProjectAdd } = await import("./project.js"); - await expect(runProjectAdd("name", "")).rejects.toThrow("process.exit"); + expect(jsonCall).toBeDefined(); + + const output = JSON.parse(jsonCall![0] as string); + expect(output).toBeInstanceOf(Array); + expect(output[0]).toHaveProperty("id", "proj_123"); + expect(output[0]).toHaveProperty("name", "test-project"); + + consoleSpy.mockRestore(); + }); + }); + + describe("runProjectAdd", () => { + it("should exit if no directory provided in non-interactive mode", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runProjectAdd(undefined, { interactive: false }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Usage:")); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + errorSpy.mockRestore(); }); - it("runProjectRemove should exit when name is empty", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - const { runProjectRemove } = await import("./project.js"); - await expect(runProjectRemove("")).rejects.toThrow("process.exit"); + it("should validate isolation mode", async () => { + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runProjectAdd("/tmp", { isolation: "invalid-mode" as any, interactive: false }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("Invalid isolation mode")); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + }); + + describe("runProjectRemove", () => { + it("should exit if project not found", async () => { + const { getCentralCore } = await import("../project-resolver.js"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([]), + getProject: vi.fn().mockResolvedValue(undefined), + getProjectByPath: vi.fn().mockResolvedValue(undefined), + unregisterProject: vi.fn(), + } as unknown as import("@fusion/core").CentralCore); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runProjectRemove("nonexistent", { force: true, interactive: false }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found")); + expect(exitSpy).toHaveBeenCalledWith(1); + + exitSpy.mockRestore(); + errorSpy.mockRestore(); }); - it("runProjectShow should exit when name is empty", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - const { runProjectShow } = await import("./project.js"); - await expect(runProjectShow("")).rejects.toThrow("process.exit"); - exitSpy.mockRestore(); + it("should skip confirmation with --force flag", async () => { + const mockProject = { + id: "proj_123", + name: "test-project", + path: "/path/to/project", + }; + + const { getCentralCore } = await import("../project-resolver.js"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([mockProject]), + getProject: vi.fn().mockResolvedValue(mockProject), + getProjectByPath: vi.fn().mockResolvedValue(mockProject), + unregisterProject: vi.fn().mockResolvedValue(undefined), + } as unknown as import("@fusion/core").CentralCore); + + const { getProjectManager } = await import("../project-resolver.js"); + vi.mocked(getProjectManager).mockResolvedValue({ + getRuntime: vi.fn().mockReturnValue(undefined), + removeProject: vi.fn().mockResolvedValue(undefined), + } as unknown as import("@fusion/engine").ProjectManager); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runProjectRemove("test-project", { force: true, interactive: false }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Unregistered")); + + logSpy.mockRestore(); + }); + }); + + describe("runProjectInfo", () => { + it("should auto-detect project from cwd when no name provided", async () => { + const mockProject = { + id: "proj_123", + name: "detected-project", + path: "/current/dir", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + const { getCentralCore, findKbDir } = await import("../project-resolver.js"); + vi.mocked(findKbDir).mockReturnValue("/current/dir"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([mockProject]), + getProject: vi.fn().mockResolvedValue(mockProject), + getProjectByPath: vi.fn().mockResolvedValue(mockProject), + getProjectHealth: vi.fn().mockResolvedValue({ + activeTaskCount: 5, + inFlightAgentCount: 2, + totalTasksCompleted: 100, + totalTasksFailed: 5, + lastActivityAt: "2024-01-01T00:00:00.000Z", + }), + } as unknown as import("@fusion/core").CentralCore); + + const { getProjectManager } = await import("../project-resolver.js"); + vi.mocked(getProjectManager).mockResolvedValue({ + getRuntime: vi.fn().mockReturnValue({ getStatus: () => "active" }), + removeProject: vi.fn(), + } as unknown as import("@fusion/engine").ProjectManager); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + await runProjectInfo(undefined, { interactive: false }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("detected-project")); + + logSpy.mockRestore(); }); - it("runProjectSetDefault should exit when name is empty", async () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { - throw new Error("process.exit"); - }); - const { runProjectSetDefault } = await import("./project.js"); - await expect(runProjectSetDefault("")).rejects.toThrow("process.exit"); + it("should exit if project not found by name", async () => { + const { getCentralCore } = await import("../project-resolver.js"); + vi.mocked(getCentralCore).mockResolvedValue({ + listProjects: vi.fn().mockResolvedValue([]), + getProject: vi.fn().mockResolvedValue(undefined), + } as unknown as import("@fusion/core").CentralCore); + + const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await runProjectInfo("nonexistent", { interactive: false }); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("not found")); + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + errorSpy.mockRestore(); }); }); }); + +// Helper function for creating mock CentralCore +describe("Project command helpers", () => { + it("should export all required functions", () => { + expect(runProjectList).toBeDefined(); + expect(runProjectAdd).toBeDefined(); + expect(runProjectRemove).toBeDefined(); + expect(runProjectInfo).toBeDefined(); + }); +}); diff --git a/packages/cli/src/commands/project.ts b/packages/cli/src/commands/project.ts index 1be329315..20ac1a73c 100644 --- a/packages/cli/src/commands/project.ts +++ b/packages/cli/src/commands/project.ts @@ -1,67 +1,200 @@ /** - * Project command implementations for kb CLI. + * Project subcommand implementations for kb CLI. + * + * Implements: + * - fn project list [--json] + * - fn project add [dir] [--name <name>] [--isolation <mode>] + * - fn project remove <name> [--force] + * - fn project info [name] */ -import { CentralCore, GlobalSettingsStore, type RegisteredProject, type IsolationMode } from "@fusion/core"; -import { resolve, isAbsolute } from "node:path"; +import { CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core"; +import { resolve, isAbsolute, 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 { + getCentralCore, + getProjectManager, + findKbDir, + isKbProject, + suggestProjectName, + formatLastActivity, + type ResolvedProject, +} from "../project-resolver.js"; const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"]; -export async function runProjectList(): Promise<void> { - const central = new CentralCore(); - await central.init(); +/** + * Run the `fn project list` command. + * + * Shows all registered projects with: + * - Name, directory, status + * - In-flight task count + * - Last activity timestamp + * - Optional JSON output with --json flag + */ +export async function runProjectList(options: { json?: boolean } = {}): Promise<void> { + const central = await getCentralCore(); + const pm = await getProjectManager(); - try { - const projects = await central.listProjects(); - const defaultProject = await getDefaultProject(); + const projects = await central.listProjects(); - if (projects.length === 0) { + if (projects.length === 0) { + 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; + console.log(" Register one with: fn project add <path>\n"); } + return; + } + // Get detailed info for each project + const projectsWithInfo = await Promise.all( + projects.map(async (project) => { + const runtime = pm.getRuntime(project.id); + const runtimeStatus = runtime?.getStatus() ?? "not_started"; + + // Get task counts from store + let taskCounts: Record<string, number> = {}; + let totalTasks = 0; + try { + const { TaskStore } = await import("@fusion/core"); + const store = new TaskStore(project.path); + await store.init(); + const tasks = await store.listTasks(); + totalTasks = tasks.length; + for (const task of tasks) { + taskCounts[task.column] = (taskCounts[task.column] || 0) + 1; + } + } catch { + // Ignore errors reading tasks + } + + const health = await central.getProjectHealth(project.id); + + return { + project, + runtimeStatus, + taskCounts, + totalTasks, + lastActivity: health?.lastActivityAt, + activeAgents: health?.inFlightAgentCount ?? 0, + }; + }) + ); + + // Sort by name alphabetically + projectsWithInfo.sort((a, b) => a.project.name.localeCompare(b.project.name)); + + if (options.json) { + // JSON output + const jsonOutput = projectsWithInfo.map((p) => ({ + id: p.project.id, + name: p.project.name, + path: p.project.path, + status: p.project.status, + isolationMode: p.project.isolationMode, + runtimeStatus: p.runtimeStatus, + totalTasks: p.totalTasks, + taskCounts: p.taskCounts, + activeAgents: p.activeAgents, + lastActivity: p.lastActivity, + createdAt: p.project.createdAt, + updatedAt: p.project.updatedAt, + })); + console.log(JSON.stringify(jsonOutput, null, 2)); + } else { + // 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}`); + // Calculate column widths + const nameWidth = Math.max(...projectsWithInfo.map((p) => p.project.name.length), 4); + const pathWidth = Math.max(...projectsWithInfo.map((p) => p.project.path.length), 4); + + // Header + console.log( + ` ${"Name".padEnd(nameWidth)} ${"Path".padEnd(pathWidth)} ${"Status".padEnd(10)} ${"Tasks".padEnd(6)} ${"Agents".padEnd(6)} Last Activity` + ); + console.log( + ` ${"-".repeat(nameWidth)} ${"-".repeat(pathWidth)} ${"-".repeat(10)} ${"-".repeat(6)} ${"-".repeat(6)} -------------` + ); + + for (const p of projectsWithInfo) { + const statusIcon = getStatusIcon(p.project.status); + const lastActivity = formatLastActivity(p.lastActivity); + console.log( + ` ${p.project.name.padEnd(nameWidth)} ${p.project.path.padEnd(pathWidth)} ${statusIcon} ${p.project.status.padEnd(8)} ${String(p.totalTasks).padEnd(6)} ${String(p.activeAgents).padEnd(6)} ${lastActivity}` + ); } console.log(); - const activeCount = projects.filter((p) => p.status === "active").length; + const activeCount = projectsWithInfo.filter((p) => p.project.status === "active").length; console.log(` ${projects.length} project${projects.length === 1 ? "" : "s"} registered, ${activeCount} active`); - if (defaultProject) { - console.log(` * indicates default project (${defaultProject.name})`); - } console.log(); - } finally { - await central.close(); } } +/** + * Run the `fn project add` command. + * + * Registers a new project with optional interactive prompts. + */ export async function runProjectAdd( - name: string, - path: string, - options?: { isolation?: string; force?: boolean } + dir?: string, + options: { name?: string; isolation?: "in-process" | "child-process"; interactive?: boolean } = {} ): Promise<void> { - if (!name || !path) { - console.error("Usage: kb project add <name> <path> [--isolation <mode>]"); + const central = await getCentralCore(); + const interactive = options.interactive ?? true; + + // Interactive wizard if no directory provided + if (!dir && interactive) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + // Ask for directory + const cwd = process.cwd(); + const dirInput = await rl.question(` Project directory [${cwd}]: `); + dir = dirInput.trim() || cwd; + + // Check if directory has .kb/ + const kbPath = resolve(dir, ".kb"); + if (!existsSync(kbPath)) { + console.log(`\n No .kb/ directory found in ${dir}`); + const shouldInit = await promptConfirm("Initialize kb here first?", true); + + if (shouldInit) { + const { TaskStore } = await import("@fusion/core"); + const store = new TaskStore(dir); + await store.init(); + console.log(` ✓ Initialized kb at ${dir}`); + } else { + console.log(" Cannot register project without .kb/ directory."); + rl.close(); + process.exit(1); + } + } + + // Ask for name + const suggestedName = options.name || suggestProjectName(dir); + const nameInput = await rl.question(` Project name [${suggestedName}]: `); + options.name = nameInput.trim() || suggestedName; + + // Ask for isolation mode + const isolationInput = await rl.question(` Isolation mode [in-process]: `); + options.isolation = (isolationInput.trim() as IsolationMode) || "in-process"; + + rl.close(); + } + + if (!dir) { + console.error("Usage: fn project add [dir] [--name <name>] [--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); + // Resolve and validate directory + const absolutePath = isAbsolute(dir) ? dir : resolve(process.cwd(), dir); if (!existsSync(absolutePath)) { console.error(`Error: Path does not exist: ${absolutePath}`); @@ -73,172 +206,222 @@ export async function runProjectAdd( process.exit(1); } - const kbDbPath = resolve(absolutePath, ".kb", "kb.db"); - if (!existsSync(kbDbPath) && !options?.force) { + // Check for .kb/ directory + if (!isKbProject(absolutePath)) { console.error(`Error: No kb project found at ${absolutePath}`); + console.error("Run `fn init` first to initialize a kb project."); process.exit(1); } - const isolationMode = options?.isolation as IsolationMode | undefined; - if (isolationMode && !VALID_ISOLATION_MODES.includes(isolationMode)) { + // Validate isolation mode + const isolationMode = options.isolation ?? "in-process"; + if (!VALID_ISOLATION_MODES.includes(isolationMode)) { console.error(`Error: Invalid isolation mode '${isolationMode}'`); + console.error(`Valid modes: ${VALID_ISOLATION_MODES.join(", ")}`); process.exit(1); } - const central = new CentralCore(); - await central.init(); + // Determine project name + const name = options.name || suggestProjectName(absolutePath); - try { - const existing = await findProjectByName(central, name); - if (existing) { - console.error(`Error: Project '${name}' already registered.`); - process.exit(1); - } - - const project = await central.registerProject({ - name, - path: absolutePath, - isolationMode: isolationMode ?? "in-process", - }); - - console.log(); - console.log(` ✓ Registered project '${name}'`); - console.log(` ID: ${project.id}`); - console.log(); - } finally { - await central.close(); + // Check for duplicate name + const existing = await findProjectByName(central, name); + if (existing) { + console.error(`Error: Project '${name}' already registered.`); + process.exit(1); } + + // Check for duplicate path + const existingByPath = await central.getProjectByPath(absolutePath); + if (existingByPath) { + console.error(`Error: Project already registered at path: ${absolutePath}`); + console.error(`Existing project: ${existingByPath.name}`); + process.exit(1); + } + + // Register the project + const project = await central.registerProject({ + name, + path: absolutePath, + isolationMode, + }); + + console.log(); + console.log(` ✓ Registered project '${name}'`); + console.log(` ID: ${project.id}`); + console.log(` Path: ${project.path}`); + console.log(` Isolation: ${project.isolationMode}`); + console.log(); } -export async function runProjectRemove(name: string, force?: boolean): Promise<void> { +/** + * Run the `fn project remove` command. + * + * Unregisters a project from the central registry. + */ +export async function runProjectRemove( + name: string, + options: { force?: boolean; interactive?: boolean } = {} +): Promise<void> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + const interactive = options.interactive ?? true; + if (!name) { - console.error("Usage: kb project remove <name> [--force]"); + console.error("Usage: fn project remove <name> [--force]"); process.exit(1); } - const central = new CentralCore(); - await central.init(); + const project = await findProjectByNameOrId(central, name); + if (!project) { + console.error(`Error: Project '${name}' not found.`); + process.exit(1); + } - try { - const project = await findProjectByNameOrId(central, name); - if (!project) { + // Check if runtime is active + const runtime = pm.getRuntime(project.id); + if (runtime) { + console.log(` Stopping runtime for '${project.name}'...`); + await pm.removeProject(project.id); + } + + // Confirmation prompt + if (!options.force && interactive) { + const confirmed = await promptConfirm( + `Unregister "${project.name}"? Project data will be preserved, only the registry entry will be removed.`, + false + ); + if (!confirmed) { + console.log("Cancelled."); + return; + } + } + + await central.unregisterProject(project.id); + + console.log(); + console.log(` ✓ Unregistered project '${project.name}'`); + console.log(` Project data at ${project.path} is preserved.`); + console.log(); +} + +/** + * Run the `fn project info` command. + * + * Shows detailed information about a specific project. + */ +export async function runProjectInfo(name?: string, options: { interactive?: boolean } = {}): Promise<void> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + const interactive = options.interactive ?? true; + + let project: RegisteredProject; + + if (name) { + const found = await findProjectByNameOrId(central, name); + if (!found) { console.error(`Error: Project '${name}' not found.`); process.exit(1); } + project = found; + } else { + // Auto-detect from cwd + const cwd = process.cwd(); + const kbDir = findKbDir(cwd); - if (!force) { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `); - rl.close(); + if (kbDir) { + const found = await central.getProjectByPath(kbDir); + if (found) { + project = found; + } else { + console.error(`Error: Found kb project at ${kbDir} but it's not registered.`); + console.error("Run `fn project add .` to register it."); + process.exit(1); + } + } else { + // List projects and ask user to select + const projects = await central.listProjects(); + if (projects.length === 0) { + console.error("Error: No projects registered."); + process.exit(1); + } - if (answer.trim().toLowerCase() !== "y") { - console.log("Cancelled."); - return; + if (projects.length === 1) { + project = projects[0]; + } else if (interactive) { + project = await promptProjectSelection(projects, "Select a project:"); + } else { + console.error("Error: Multiple projects registered. Please specify a project name."); + console.error("Run `fn project list` to see available projects."); + process.exit(1); } } - - await central.unregisterProject(project.id); - console.log(` ✓ Unregistered project '${project.name}'`); - } finally { - await central.close(); - } -} - -export async function runProjectShow(name: string): Promise<void> { - if (!name) { - console.error("Usage: kb project show <name>"); - process.exit(1); } - const central = new CentralCore(); - await central.init(); + // Get runtime status + const runtime = pm.getRuntime(project.id); + const runtimeStatus = runtime?.getStatus() ?? "not_started"; + // Get task counts + let taskCounts: Record<string, number> = {}; + let totalTasks = 0; try { - const project = await findProjectByNameOrId(central, name); - if (!project) { - console.error(`Error: Project '${name}' not found.`); - process.exit(1); + const { TaskStore } = await import("@fusion/core"); + const store = new TaskStore(project.path); + await store.init(); + const tasks = await store.listTasks(); + totalTasks = tasks.length; + for (const task of tasks) { + taskCounts[task.column] = (taskCounts[task.column] || 0) + 1; } - - const defaultProject = await getDefaultProject(); - const isDefault = defaultProject?.id === project.id; - - console.log(); - console.log(` Project: ${project.name}${isDefault ? " (default)" : ""}`); - console.log(` ID: ${project.id}`); - console.log(` Path: ${project.path}`); - console.log(` Status: ${project.status}`); - console.log(); - } finally { - await central.close(); - } -} - -export async function runProjectSetDefault(name: string): Promise<void> { - if (!name) { - console.error("Usage: kb project set-default <name>"); - process.exit(1); + } catch { + // Ignore errors reading tasks } - const central = new CentralCore(); - await central.init(); + // Get health metrics + const health = await central.getProjectHealth(project.id); - try { - const project = await findProjectByNameOrId(central, name); - if (!project) { - console.error(`Error: Project '${name}' not found.`); - process.exit(1); + // Display info + console.log(); + console.log(` Project: ${project.name}`); + console.log(` ID: ${project.id}`); + console.log(` Path: ${project.path}`); + console.log(` Status: ${project.status}`); + console.log(` Isolation Mode: ${project.isolationMode}`); + console.log(` Runtime: ${runtimeStatus}`); + console.log(` Created: ${new Date(project.createdAt).toLocaleString()}`); + console.log(` Updated: ${new Date(project.updatedAt).toLocaleString()}`); + console.log(); + + console.log(` Tasks (${totalTasks} total):`); + const columns = ["triage", "todo", "in-progress", "in-review", "done", "archived"]; + for (const col of columns) { + const count = taskCounts[col] || 0; + if (count > 0 || col !== "archived") { + const icon = getColumnIcon(col); + console.log(` ${icon} ${col}: ${count}`); } - - await setDefaultProject(project.id); - console.log(); - console.log(` ✓ Set '${project.name}' as default project`); - console.log(); - } finally { - await central.close(); } -} + console.log(); -export async function runProjectDetect(): Promise<void> { - const central = new CentralCore(); - await central.init(); - - try { - const project = await detectProjectFromCwd(process.cwd(), central); - - if (project) { - console.log(); - console.log(` Detected: ${project.name} (${project.path})`); - console.log(); - } else { - console.log(); - console.log(" No kb project detected from current directory."); - console.log(); + if (health) { + console.log(" Activity:"); + console.log(` Active tasks: ${health.activeTaskCount}`); + console.log(` In-flight agents: ${health.inFlightAgentCount}`); + console.log(` Total completed: ${health.totalTasksCompleted}`); + console.log(` Total failed: ${health.totalTasksFailed}`); + if (health.lastActivityAt) { + console.log(` Last activity: ${formatLastActivity(health.lastActivityAt)}`); } - } finally { - await central.close(); + if (health.averageTaskDurationMs) { + const avgMins = Math.round(health.averageTaskDurationMs / 60000); + console.log(` Avg task duration: ${avgMins}m`); + } + console.log(); } } -// Helpers - -async function getDefaultProject(): Promise<RegisteredProject | undefined> { - const globalStore = new GlobalSettingsStore(); - await globalStore.init(); - - const settings = await globalStore.getSettings(); - if (!settings.defaultProjectId) { - return undefined; - } - - const central = new CentralCore(); - await central.init(); - try { - return await central.getProject(settings.defaultProjectId); - } finally { - await central.close(); - } -} +// Helper functions async function findProjectByName(central: CentralCore, name: string): Promise<RegisteredProject | undefined> { const allProjects = await central.listProjects(); @@ -247,16 +430,81 @@ async function findProjectByName(central: CentralCore, name: string): Promise<Re } async function findProjectByNameOrId(central: CentralCore, nameOrId: string): Promise<RegisteredProject | undefined> { + // First try exact ID match const byId = await central.getProject(nameOrId); if (byId) { return byId; } + + // Then try case-insensitive name match return findProjectByName(central, nameOrId); } -function isValidProjectName(name: string): boolean { - if (!name || name.length < 1 || name.length > 64) { - return false; +async function promptProjectSelection( + projects: RegisteredProject[], + message: string +): Promise<RegisteredProject> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + console.log(`\n ${message}`); + for (let i = 0; i < projects.length; i++) { + console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`); + } + + while (true) { + const answer = await rl.question("\n Enter number: "); + const num = parseInt(answer.trim(), 10); + + if (!isNaN(num) && num >= 1 && num <= projects.length) { + rl.close(); + return projects[num - 1]; + } + + console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`); + } +} + +async function promptConfirm(message: string, defaultYes = false): Promise<boolean> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prompt = defaultYes ? "[Y/n]" : "[y/N]"; + const answer = await rl.question(` ${message} ${prompt}: `); + rl.close(); + + const trimmed = answer.trim().toLowerCase(); + if (trimmed === "" && defaultYes) return true; + return trimmed === "y" || trimmed === "yes"; +} + +function getStatusIcon(status: string): string { + switch (status) { + case "active": + return "●"; + case "paused": + return "⏸"; + case "errored": + return "✗"; + case "initializing": + return "◌"; + default: + return "○"; + } +} + +function getColumnIcon(column: string): string { + switch (column) { + case "triage": + return "●"; + case "todo": + return "○"; + case "in-progress": + return "▸"; + case "in-review": + return "◆"; + case "done": + return "✓"; + case "archived": + return "▪"; + default: + return "•"; } - return /^[a-zA-Z0-9_-]+$/.test(name); } diff --git a/packages/cli/src/commands/settings.test.ts b/packages/cli/src/commands/settings.test.ts index 3d2f07b49..7d26878ac 100644 --- a/packages/cli/src/commands/settings.test.ts +++ b/packages/cli/src/commands/settings.test.ts @@ -15,13 +15,74 @@ vi.mock("@fusion/core", () => { githubTokenConfigured: false, }; + // Mock CentralCore for project-resolver + const mockCentralCore = vi.fn().mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + listProjects: vi.fn().mockResolvedValue([]), + getProject: vi.fn().mockResolvedValue(undefined), + getProjectByPath: vi.fn().mockResolvedValue(undefined), + registerProject: vi.fn(), + unregisterProject: vi.fn(), + getProjectHealth: vi.fn().mockResolvedValue(undefined), + isInitialized: vi.fn().mockReturnValue(true), + })); + return { TaskStore: vi.fn(), + CentralCore: mockCentralCore, DEFAULT_SETTINGS, }; }); +// Mock project-resolver to return a simple getStore that returns a mock store +vi.mock("../project-resolver.js", async () => { + // Create a mock store with the methods tests expect + const createMockStore = () => ({ + init: vi.fn().mockResolvedValue(undefined), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + autoResolveConflicts: true, + smartConflictResolution: true, + requirePlanApproval: false, + ntfyEnabled: false, + taskPrefix: undefined, + ntfyTopic: undefined, + worktreeNaming: "random", + githubTokenConfigured: false, + defaultProvider: undefined, + defaultModelId: undefined, + defaultThinkingLevel: undefined, + }), + updateSettings: vi.fn().mockResolvedValue(undefined), + }); + + return { + getStore: vi.fn().mockImplementation(createMockStore), + resolveProject: vi.fn().mockRejectedValue(new Error("Not implemented in mock")), + ProjectResolutionError: class ProjectResolutionError extends Error { + code: string; + context?: Record<string, unknown>; + constructor(message: string, code: string, context?: Record<string, unknown>) { + super(message); + this.name = "ProjectResolutionError"; + this.code = code; + this.context = context; + } + }, + getCentralCore: vi.fn(), + getProjectManager: vi.fn(), + findKbDir: vi.fn().mockReturnValue(null), + isKbProject: vi.fn().mockReturnValue(true), + suggestProjectName: vi.fn().mockReturnValue("test-project"), + formatLastActivity: vi.fn().mockReturnValue("just now"), + resetProjectResolution: vi.fn(), + }; +}); + import { TaskStore, DEFAULT_SETTINGS } from "@fusion/core"; +import { getStore } from "../project-resolver.js"; import { runSettingsShow, runSettingsSet, @@ -223,10 +284,10 @@ describe("runSettingsShow", () => { taskPrefix: "CUSTOM", }); - (TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ + (getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ init: vi.fn(), getSettings: vi.fn().mockResolvedValue(mockSettings), - })); + }); await runSettingsShow(); @@ -254,10 +315,10 @@ describe("runSettingsShow", () => { githubTokenConfigured: true, }); - (TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ + (getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ init: vi.fn(), getSettings: vi.fn().mockResolvedValue(mockSettings), - })); + }); await runSettingsShow(); @@ -272,10 +333,10 @@ describe("runSettingsShow", () => { githubTokenConfigured: false, }); - (TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ + (getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ init: vi.fn(), getSettings: vi.fn().mockResolvedValue(mockSettings), - })); + }); await runSettingsShow(); @@ -302,7 +363,7 @@ describe("runSettingsSet", () => { maxWorktrees: 4, }); - (TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({ + (getStore as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ init: vi.fn(), updateSettings: mockUpdateSettings, getSettings: vi.fn().mockResolvedValue({ @@ -310,7 +371,7 @@ describe("runSettingsSet", () => { maxWorktrees: 4, taskPrefix: "TEST", }), - })); + }); }); afterEach(() => { diff --git a/packages/cli/src/commands/settings.ts b/packages/cli/src/commands/settings.ts index dc340e99e..e5864796e 100644 --- a/packages/cli/src/commands/settings.ts +++ b/packages/cli/src/commands/settings.ts @@ -1,5 +1,5 @@ import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core"; -import { getStore as getStoreFromContext } from "../project-context.js"; +import { getStore } from "../project-resolver.js"; // Settings that can be updated via CLI export const VALID_SETTINGS = [ @@ -39,15 +39,6 @@ const NUMBER_RANGES: Record<string, { min: number; max: number }> = { maxWorktrees: { min: 1, max: 20 }, }; -async function getStore(projectName?: string): Promise<TaskStore> { - if (projectName) { - return getStoreFromContext(projectName); - } - const store = new TaskStore(process.cwd()); - await store.init(); - return store; -} - /** * Parse and validate a setting value based on its key's expected type */ diff --git a/packages/cli/src/commands/task.ts b/packages/cli/src/commands/task.ts index 6e72ebe20..7e45fbdba 100644 --- a/packages/cli/src/commands/task.ts +++ b/packages/cli/src/commands/task.ts @@ -7,19 +7,10 @@ import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node import { join } from "node:path"; import { GitHubClient } from "@fusion/dashboard"; import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli"; -import { resolveProject, getStore as getStoreFromContext } from "../project-context.js"; +import { getStore, resolveProject } from "../project-resolver.js"; const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"]; -async function getStore(projectName?: string): Promise<TaskStore> { - if (projectName) { - return getStoreFromContext(projectName); - } - const store = new TaskStore(process.cwd()); - await store.init(); - return store; -} - export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) { let description = descriptionArg; @@ -34,7 +25,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin process.exit(1); } - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.createTask({ description: description.trim(), dependencies: depends }); const label = task.description.length > 60 @@ -82,7 +73,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin } export async function runTaskList(projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const tasks = await store.listTasks(); if (tasks.length === 0) { @@ -127,7 +118,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string, process.exit(1); } - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.updateStep(id, stepIndex, status as StepStatus); const step = task.steps[stepIndex]; @@ -138,7 +129,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string, } export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); await store.logEntry(id, message, outcome); console.log(); @@ -220,7 +211,7 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog } export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Verify task exists try { @@ -244,8 +235,8 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project // Follow mode: watch for new entries if (options.follow) { - const store = await getStore(projectName); - const projectPath = (await resolveProject(projectName)).projectPath; + const store = await getStore({ project: projectName }); + const projectPath = (await resolveProject({ project: projectName })).directory; const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log"); if (!existsSync(logPath)) { @@ -326,7 +317,7 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project } export async function runTaskShow(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.getTask(id); console.log(); @@ -365,13 +356,13 @@ export async function runTaskShow(id: string, projectName?: string) { } export async function runTaskMerge(id: string, projectName?: string) { - const store = await getStore(projectName); - const { projectPath } = await resolveProject(projectName); + const store = await getStore({ project: projectName }); + const { directory } = await resolveProject({ project: projectName }); console.log(`\n Merging ${id} with AI...\n`); try { - const result = await aiMergeTask(store, projectPath, id, { + const result = await aiMergeTask(store, directory, id, { onAgentText: (delta) => process.stdout.write(delta), }); @@ -432,7 +423,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?: process.exit(1); } - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const attachment = await store.addAttachment(id, filename, content, mimeType); const sizeKB = (attachment.size / 1024).toFixed(1); @@ -444,7 +435,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?: } export async function runTaskPause(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.pauseTask(id, true); console.log(); @@ -453,7 +444,7 @@ export async function runTaskPause(id: string, projectName?: string) { } export async function runTaskUnpause(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.pauseTask(id, false); console.log(); @@ -468,7 +459,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri process.exit(1); } - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.moveTask(id, column as Column); console.log(); @@ -477,7 +468,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri } export async function runTaskDuplicate(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const newTask = await store.duplicateTask(id); console.log(); @@ -487,7 +478,7 @@ export async function runTaskDuplicate(id: string, projectName?: string) { } export async function runTaskRefine(id: string, feedbackArg?: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Get feedback interactively only if not provided (undefined) let feedback = feedbackArg; @@ -519,7 +510,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam } export async function runTaskArchive(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.archiveTask(id); console.log(); @@ -528,7 +519,7 @@ export async function runTaskArchive(id: string, projectName?: string) { } export async function runTaskUnarchive(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.unarchiveTask(id); console.log(); @@ -537,7 +528,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) { } export async function runTaskRetry(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Fetch task and validate it exists let task; @@ -567,7 +558,7 @@ export async function runTaskRetry(id: string, projectName?: string) { } export async function runTaskDelete(id: string, force?: boolean, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Check if task exists first let task; @@ -620,7 +611,7 @@ export async function runTaskImportGitHubInteractive( console.log(`\n Fetching issues from ${owner}/${repo}...\n`); - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const existingTasks = await store.listTasks(); // Build a set of already-imported issue URLs @@ -838,7 +829,7 @@ export async function runTaskImportFromGitHub( console.log(`\n Importing issues from ${owner}/${repo}...\n`); - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const existingTasks = await store.listTasks(); // Build a set of already-imported issue URLs @@ -902,7 +893,7 @@ export async function runTaskImportFromGitHub( } export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); let text = message; if (text === undefined) { @@ -934,7 +925,7 @@ export async function runTaskComment(id: string, message?: string, author = "use } export async function runTaskComments(id: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); const task = await store.getTask(id); const comments = task.comments || []; @@ -954,7 +945,7 @@ export async function runTaskComments(id: string, projectName?: string) { } export async function runTaskSteer(id: string, message?: string, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Get message interactively if not provided as argument let text = message; @@ -1005,7 +996,7 @@ export interface PrCreateOptions { } export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) { - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Fetch task and validate it exists let task; @@ -1341,7 +1332,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj } } - const store = await getStore(projectName); + const store = await getStore({ project: projectName }); // Create planning session let sessionId: string; @@ -1381,7 +1372,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj try { while (!cancelled) { // Get user response based on question type - let response: unknown; + let response: Record<string, unknown>; try { switch (currentQuestion.type) { diff --git a/packages/cli/src/project-resolver.test.ts b/packages/cli/src/project-resolver.test.ts new file mode 100644 index 000000000..3b6f7533f --- /dev/null +++ b/packages/cli/src/project-resolver.test.ts @@ -0,0 +1,415 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + getCentralCore, + getProjectManager, + findKbDir, + resolveProject, + ProjectResolutionError, + isKbProject, + suggestProjectName, + resolveAbsolutePath, + formatLastActivity, + resetProjectResolution, +} from "./project-resolver.js"; +import { existsSync, statSync } from "node:fs"; +import { resolve, dirname, normalize } from "node:path"; + +// Mock fs and path modules +vi.mock("node:fs", () => ({ + existsSync: vi.fn(), + statSync: vi.fn(), +})); + +vi.mock("node:path", async () => { + const actual = await vi.importActual<typeof import("node:path")>("node:path"); + return { + ...actual, + resolve: vi.fn((...args: string[]) => args.join("/").replace(/\/+/g, "/")), + normalize: vi.fn((p: string) => p), + dirname: vi.fn((p: string) => p.split("/").slice(0, -1).join("/") || "/"), + }; +}); + +// Mock @fusion/core +vi.mock("@fusion/core", () => { + const mockInit = vi.fn().mockResolvedValue(undefined); + const mockClose = vi.fn().mockResolvedValue(undefined); + const mockListProjects = vi.fn(); + const mockGetProject = vi.fn(); + const mockGetProjectByPath = vi.fn(); + const mockRegisterProject = vi.fn(); + const mockUnregisterProject = vi.fn(); + const mockGetProjectHealth = vi.fn(); + + const CentralCoreMock = vi.fn().mockImplementation(() => ({ + init: mockInit, + close: mockClose, + listProjects: mockListProjects, + getProject: mockGetProject, + getProjectByPath: mockGetProjectByPath, + registerProject: mockRegisterProject, + unregisterProject: mockUnregisterProject, + getProjectHealth: mockGetProjectHealth, + isInitialized: vi.fn().mockReturnValue(true), + })); + + // Store references on the mock constructor for tests to access + (CentralCoreMock as any).mockFunctions = { + mockInit, + mockClose, + mockListProjects, + mockGetProject, + mockGetProjectByPath, + mockRegisterProject, + mockUnregisterProject, + mockGetProjectHealth, + }; + + return { + CentralCore: CentralCoreMock, + TaskStore: vi.fn().mockImplementation(() => ({ + init: vi.fn().mockResolvedValue(undefined), + listTasks: vi.fn().mockResolvedValue([]), + })), + }; +}); + +// Import CentralCore to access mock functions +import { CentralCore } from "@fusion/core"; +const getMockFunctions = () => (CentralCore as any).mockFunctions; + +vi.mock("@fusion/engine", () => ({ + ProjectManager: vi.fn().mockImplementation(() => ({ + getRuntime: vi.fn().mockReturnValue(undefined), + removeProject: vi.fn().mockResolvedValue(undefined), + stopAll: vi.fn().mockResolvedValue(undefined), + })), +})); + +describe("Project Resolver", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetProjectResolution(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe("findKbDir", () => { + it("should find .kb directory in current path", () => { + vi.mocked(existsSync) + .mockReturnValueOnce(true) // First call for /project/.kb + .mockReturnValue(false); + + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + const result = findKbDir("/project"); + expect(result).toBe("/project"); + }); + + it("should walk up parent directories to find .kb", async () => { + // First call: /a/b/c - no .kb + // Second call: /a/b - has .kb + vi.mocked(existsSync) + .mockReturnValueOnce(false) // /a/b/c/.kb - not found + .mockReturnValueOnce(true) // /a/b/.kb - found + .mockReturnValue(false); + + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + // Mock dirname to return parent directory + const { dirname } = await import("node:path"); + vi.mocked(dirname) + .mockReturnValueOnce("/a/b") + .mockReturnValueOnce("/a") + .mockReturnValue("/a"); // Stop at root + + const result = findKbDir("/a/b/c"); + expect(result).toBe("/a/b"); + }); + + it("should return null if no .kb found", async () => { + vi.mocked(existsSync).mockReturnValue(false); + + const { dirname } = await import("node:path"); + vi.mocked(dirname).mockReturnValue("/"); + + const result = findKbDir("/some/path"); + expect(result).toBeNull(); + }); + + it("should return null if .kb is not a directory", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any); + + const result = findKbDir("/project"); + expect(result).toBeNull(); + }); + }); + + describe("isKbProject", () => { + it("should return true if .kb directory exists", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + expect(isKbProject("/project")).toBe(true); + }); + + it("should return false if .kb directory does not exist", () => { + vi.mocked(existsSync).mockReturnValue(false); + + expect(isKbProject("/project")).toBe(false); + }); + }); + + describe("suggestProjectName", () => { + it("should return last path segment as project name", () => { + expect(suggestProjectName("/path/to/my-project")).toBe("my-project"); + }); + + it("should handle paths without separators", () => { + expect(suggestProjectName("project")).toBe("project"); + }); + + it("should return 'unnamed' for empty path", () => { + expect(suggestProjectName("")).toBe("unnamed"); + }); + }); + + describe("resolveAbsolutePath", () => { + it("should resolve and validate existing directory", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + const result = resolveAbsolutePath("/existing/path"); + expect(result).toBeDefined(); + }); + + it("should throw ProjectResolutionError for non-existent path", () => { + vi.mocked(existsSync).mockReturnValue(false); + + expect(() => resolveAbsolutePath("/nonexistent")).toThrow(ProjectResolutionError); + }); + + it("should throw ProjectResolutionError for non-directory path", () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => false } as any); + + expect(() => resolveAbsolutePath("/file.txt")).toThrow(ProjectResolutionError); + }); + }); + + describe("resolveProject", () => { + it("should resolve by explicit --project flag", async () => { + const mockProject = { + id: "proj_123", + name: "my-project", + path: "/path/to/project", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + getMockFunctions().mockListProjects.mockResolvedValue([mockProject]); + vi.mocked(existsSync).mockReturnValue(true); + + const result = await resolveProject({ project: "my-project", interactive: false }); + + expect(result.name).toBe("my-project"); + expect(result.projectId).toBe("proj_123"); + }); + + it("should throw NOT_FOUND if --project project not found", async () => { + getMockFunctions().mockListProjects.mockResolvedValue([]); + + await expect(resolveProject({ project: "nonexistent", interactive: false })).rejects.toThrow( + ProjectResolutionError + ); + }); + + it("should auto-detect from cwd with matching registered project", async () => { + const mockProject = { + id: "proj_123", + name: "detected-project", + path: "/detected/path", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + getMockFunctions().mockListProjects.mockResolvedValue([mockProject]); + getMockFunctions().mockGetProjectByPath.mockResolvedValue(mockProject); + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + const result = await resolveProject({ cwd: "/detected/path", interactive: false }); + + expect(result.name).toBe("detected-project"); + }); + + it("should throw NOT_REGISTERED if .kb exists but project not registered", async () => { + getMockFunctions().mockListProjects.mockResolvedValue([]); + getMockFunctions().mockGetProjectByPath.mockResolvedValue(undefined); + vi.mocked(existsSync) + .mockReturnValueOnce(true) // .kb exists + .mockReturnValue(true); + vi.mocked(statSync).mockReturnValue({ isDirectory: () => true } as any); + + await expect(resolveProject({ cwd: "/unregistered", interactive: false })).rejects.toThrow( + ProjectResolutionError + ); + }); + + it("should use default project when no .kb found and only one project", async () => { + const mockProject = { + id: "proj_123", + name: "only-project", + path: "/only/path", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + getMockFunctions().mockListProjects.mockResolvedValue([mockProject]); + vi.mocked(existsSync).mockReturnValue(false); // No .kb found + + const result = await resolveProject({ interactive: false }); + + expect(result.name).toBe("only-project"); + }); + + it("should throw NO_PROJECTS when no projects registered and no .kb found", async () => { + getMockFunctions().mockListProjects.mockResolvedValue([]); + vi.mocked(existsSync).mockReturnValue(false); + + await expect(resolveProject({ interactive: false })).rejects.toThrow( + ProjectResolutionError + ); + }); + + it("should throw MULTIPLE_MATCHES when multiple projects and no match", async () => { + const projects = [ + { id: "proj_1", name: "project1", path: "/path1", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" }, + { id: "proj_2", name: "project2", path: "/path2", status: "active", isolationMode: "in-process", createdAt: "", updatedAt: "" }, + ]; + + getMockFunctions().mockListProjects.mockResolvedValue(projects); + vi.mocked(existsSync).mockReturnValue(false); + + await expect(resolveProject({ interactive: false })).rejects.toThrow( + ProjectResolutionError + ); + }); + + it("should throw PATH_MISMATCH if registered project directory moved", async () => { + const mockProject = { + id: "proj_123", + name: "moved-project", + path: "/old/path", + status: "active", + isolationMode: "in-process", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + }; + + getMockFunctions().mockListProjects.mockResolvedValue([mockProject]); + vi.mocked(existsSync).mockReturnValue(false); // Path doesn't exist + + await expect(resolveProject({ project: "moved-project", interactive: false })).rejects.toThrow( + ProjectResolutionError + ); + }); + }); + + describe("ProjectResolutionError", () => { + it("should create error with code and context", () => { + const error = new ProjectResolutionError("Test error", "NOT_FOUND", { id: "123" }); + + expect(error.message).toBe("Test error"); + expect(error.code).toBe("NOT_FOUND"); + expect(error.context).toEqual({ id: "123" }); + expect(error.name).toBe("ProjectResolutionError"); + }); + + it("should include all error codes", () => { + const codes = [ + "NOT_FOUND", + "NOT_REGISTERED", + "MULTIPLE_MATCHES", + "NO_PROJECTS", + "PATH_MISMATCH", + "NOT_INITIALIZED", + "CANCELLED", + ]; + + for (const code of codes) { + const error = new ProjectResolutionError("test", code as any); + expect(error.code).toBe(code); + } + }); + }); + + describe("formatLastActivity", () => { + it("should format 'just now' for recent timestamps", () => { + const now = new Date().toISOString(); + expect(formatLastActivity(now)).toBe("just now"); + }); + + it("should format minutes ago", () => { + const fiveMinutesAgo = new Date(Date.now() - 5 * 60000).toISOString(); + expect(formatLastActivity(fiveMinutesAgo)).toBe("5m ago"); + }); + + it("should format hours ago", () => { + const twoHoursAgo = new Date(Date.now() - 2 * 3600000).toISOString(); + expect(formatLastActivity(twoHoursAgo)).toBe("2h ago"); + }); + + it("should format days ago", () => { + const threeDaysAgo = new Date(Date.now() - 3 * 86400000).toISOString(); + expect(formatLastActivity(threeDaysAgo)).toBe("3d ago"); + }); + + it("should return 'never' for undefined timestamp", () => { + expect(formatLastActivity(undefined)).toBe("never"); + }); + }); + + describe("resetProjectResolution", () => { + it("should reset singleton instances", async () => { + // First call to initialize + await getCentralCore(); + + // Reset + resetProjectResolution(); + + // After reset, getCentralCore should create a new instance + const core = await getCentralCore(); + expect(getMockFunctions().mockInit).toHaveBeenCalled(); + }); + }); +}); + +describe("getCentralCore singleton", () => { + it("should return same instance on multiple calls", async () => { + const core1 = await getCentralCore(); + const core2 = await getCentralCore(); + + // Both should be the same object + expect(core1).toBe(core2); + }); +}); + +describe("getProjectManager singleton", () => { + it("should return same instance on multiple calls", async () => { + const pm1 = await getProjectManager(); + const pm2 = await getProjectManager(); + + // Both should be the same object + expect(pm1).toBe(pm2); + }); +}); diff --git a/packages/cli/src/project-resolver.ts b/packages/cli/src/project-resolver.ts new file mode 100644 index 000000000..74d82b51a --- /dev/null +++ b/packages/cli/src/project-resolver.ts @@ -0,0 +1,915 @@ +/** + * Project Resolution Module + * + * Handles determination of which project to use for CLI commands based on: + * - Explicit `--project <name>` flag + * - Current working directory auto-detection (walking up to find `.kb/`) + * - Default project when only one is registered + * - Interactive prompts when ambiguous + */ + +import { existsSync, statSync } from "node:fs"; +import { dirname, resolve, normalize } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { CentralCore, type RegisteredProject, type TaskStore } from "@fusion/core"; +import { ProjectManager } from "@fusion/engine"; + +// Singleton instances for reuse across commands +let centralCoreInstance: CentralCore | null = null; +let projectManagerInstance: ProjectManager | null = null; + +/** + * Error thrown when project resolution fails with actionable context. + */ +export class ProjectResolutionError extends Error { + constructor( + message: string, + public readonly code: + | "NOT_FOUND" + | "NOT_REGISTERED" + | "MULTIPLE_MATCHES" + | "NO_PROJECTS" + | "PATH_MISMATCH" + | "NOT_INITIALIZED" + | "CANCELLED", + public readonly context?: Record<string, unknown> + ) { + super(message); + this.name = "ProjectResolutionError"; + } +} + +/** + * Resolved project with all necessary references for command execution. + */ +export interface ResolvedProject { + /** Project ID from CentralCore */ + projectId: string; + /** Project display name */ + name: string; + /** Absolute path to project directory */ + directory: string; + /** Project status */ + status: string; + /** Isolation mode */ + isolationMode: string; + /** Reference to ProjectRuntime (if started) */ + runtime?: import("@fusion/engine").ProjectRuntime; + /** Initialized TaskStore for the project */ + store: TaskStore; +} + +/** + * Options for project resolution. + */ +export interface ResolveOptions { + /** Explicit project name from --project flag */ + project?: string; + /** Starting directory for cwd-based resolution (defaults to process.cwd()) */ + cwd?: string; + /** Allow interactive prompts (set to false for non-interactive environments) */ + interactive?: boolean; +} + +/** + * Initialize and return CentralCore singleton. + * Reuses the same instance across multiple calls for efficiency. + */ +export async function getCentralCore(): Promise<CentralCore> { + if (!centralCoreInstance) { + centralCoreInstance = new CentralCore(); + await centralCoreInstance.init(); + } + return centralCoreInstance; +} + +/** + * Initialize and return ProjectManager singleton. + * Creates the instance on first call, reuses thereafter. + */ +export async function getProjectManager(): Promise<ProjectManager> { + if (!projectManagerInstance) { + const central = await getCentralCore(); + projectManagerInstance = new ProjectManager(central); + } + return projectManagerInstance; +} + +/** + * Walk up from the given path to find a `.kb/` directory. + * + * @param startPath - Directory to start searching from + * @returns Absolute path to the directory containing `.kb/`, or null if not found + */ +export function findKbDir(startPath: string): string | null { + let current = resolve(startPath); + + // Safety limit to prevent infinite loops + for (let i = 0; i < 100; i++) { + const kbPath = resolve(current, ".kb"); + if (existsSync(kbPath) && statSync(kbPath).isDirectory()) { + return current; + } + + const parent = dirname(current); + if (parent === current) { + // Reached root + break; + } + current = parent; + } + + return null; +} + +/** + * Prompt the user to select from a list of projects. + */ +async function promptProjectSelection( + projects: RegisteredProject[], + message = "Select a project:" +): Promise<RegisteredProject> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + console.log(`\n ${message}`); + for (let i = 0; i < projects.length; i++) { + console.log(` ${i + 1}. ${projects[i].name} (${projects[i].path})`); + } + + while (true) { + const answer = await rl.question("\n Enter number: "); + const num = parseInt(answer.trim(), 10); + + if (!isNaN(num) && num >= 1 && num <= projects.length) { + rl.close(); + return projects[num - 1]; + } + + console.log(` Invalid selection. Please enter a number between 1 and ${projects.length}`); + } +} + +/** + * Prompt for yes/no confirmation. + */ +async function promptConfirm(message: string, defaultYes = false): Promise<boolean> { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const prompt = defaultYes ? "[Y/n]" : "[y/N]"; + const answer = await rl.question(` ${message} ${prompt}: `); + rl.close(); + + const trimmed = answer.trim().toLowerCase(); + if (trimmed === "" && defaultYes) return true; + return trimmed === "y" || trimmed === "yes"; +} + +/** + * Resolve which project to use based on options and cwd. + * + * Resolution order: + * 1. If --project <name> flag given, look up by name in registry + * 2. Walk up from cwd to find .kb/ directory + * 3. If found, match path against registered projects + * 4. If not registered but has .kb/, prompt to register or error + * 5. If no .kb/ found and exactly one project registered, use it + * 6. If multiple projects and no match, error with list + * + * @param options - Resolution options + * @returns Resolved project with store and runtime references + * @throws ProjectResolutionError with specific error codes + */ +export async function resolveProject(options: ResolveOptions = {}): Promise<ResolvedProject> { + const central = await getCentralCore(); + const interactive = options.interactive ?? true; + + // 1. Check explicit --project flag + if (options.project) { + const projects = await central.listProjects(); + const match = projects.find((p) => p.name === options.project); + + if (!match) { + // Suggest similar names if available + const similar = projects + .filter((p) => p.name.toLowerCase().includes(options.project!.toLowerCase())) + .map((p) => p.name); + + let suggestion = ""; + if (similar.length > 0) { + suggestion = ` Did you mean: ${similar.join(", ")}?`; + } + + throw new ProjectResolutionError( + `Project "${options.project}" not found.${suggestion}`, + "NOT_FOUND", + { searchedName: options.project, availableProjects: projects.map((p) => p.name) } + ); + } + + // Check if path still exists + if (!existsSync(match.path)) { + throw new ProjectResolutionError( + `Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` + + "Run `fn project remove " + match.name + "` to clean up the registry entry.", + "PATH_MISMATCH", + { projectId: match.id, path: match.path } + ); + } + + return createResolvedProject(match); + } + + // 2. Walk up from cwd to find .kb/ + const cwd = options.cwd ? resolve(options.cwd) : process.cwd(); + const kbDir = findKbDir(cwd); + + if (kbDir) { + // 3. Match path against registered projects + const allProjects = await central.listProjects(); + const normalizedKbDir = normalize(kbDir); + + const match = allProjects.find((p) => normalize(p.path) === normalizedKbDir); + + if (match) { + // Check if path still exists + if (!existsSync(match.path)) { + throw new ProjectResolutionError( + `Project "${match.name}" is registered but the directory no longer exists: ${match.path}\n\n` + + "Run `fn project remove " + match.name + "` to clean up the registry entry.", + "PATH_MISMATCH", + { projectId: match.id, path: match.path } + ); + } + + return createResolvedProject(match); + } + + // 4. Has .kb/ but not registered + if (interactive) { + console.log(`\n Found kb project at ${kbDir} but it's not registered.`); + const shouldRegister = await promptConfirm("Register this project now?", true); + + if (shouldRegister) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const defaultName = kbDir.split("/").pop() || "unnamed"; + const name = await rl.question(` Project name [${defaultName}]: `); + rl.close(); + + const finalName = name.trim() || defaultName; + + try { + const newProject = await central.registerProject({ + name: finalName, + path: kbDir, + isolationMode: "in-process", + }); + + console.log(`\n ✓ Registered project "${newProject.name}"`); + return createResolvedProject(newProject); + } catch (err: any) { + throw new ProjectResolutionError( + `Failed to register project: ${err.message}`, + "NOT_REGISTERED", + { directory: kbDir, error: err.message } + ); + } + } else { + throw new ProjectResolutionError( + "Project not registered. Run `fn project add <path>` to register.", + "NOT_REGISTERED", + { directory: kbDir } + ); + } + } else { + throw new ProjectResolutionError( + `Found kb project at ${kbDir} but it's not registered.\n\n` + + "Run `fn project add " + kbDir + "` to register it, or use --project <name>.", + "NOT_REGISTERED", + { directory: kbDir } + ); + } + } + + // 5. No .kb/ found - check registered projects + const allProjects = await central.listProjects(); + + if (allProjects.length === 0) { + // 6a. No projects at all + throw new ProjectResolutionError( + "No projects registered.\n\n" + + "To get started:\n" + + " 1. Navigate to your project directory\n" + + " 2. Run `fn init` to initialize kb\n" + + " 3. Run `fn project add .` to register it\n" + + "\nOr: `fn project add <path>` to register from anywhere.", + "NO_PROJECTS" + ); + } + + if (allProjects.length === 1) { + // 6b. Exactly one project - use as default + const project = allProjects[0]; + + // Check if path still exists + if (!existsSync(project.path)) { + throw new ProjectResolutionError( + `The only registered project "${project.name}" has a missing directory: ${project.path}\n\n` + + "Run `fn project remove " + project.name + "` to clean up, then register a valid project.", + "PATH_MISMATCH", + { projectId: project.id, path: project.path } + ); + } + + return createResolvedProject(project); + } + + // 6c. Multiple projects - need explicit selection + if (interactive) { + const selected = await promptProjectSelection( + allProjects, + "Multiple projects registered. Please select one:" + ); + return createResolvedProject(selected); + } else { + const projectList = allProjects.map((p) => ` - ${p.name}: ${p.path}`).join("\n"); + throw new ProjectResolutionError( + `Multiple projects registered. Use --project <name> to specify one.\n\nAvailable projects:\n${projectList}`, + "MULTIPLE_MATCHES", + { availableProjects: allProjects.map((p) => ({ name: p.name, path: p.path })) } + ); + } +} + +/** + * Create a ResolvedProject from a RegisteredProject. + * Initializes the TaskStore for the project. + */ +async function createResolvedProject(project: RegisteredProject): Promise<ResolvedProject> { + // Initialize TaskStore for this project + const store = new (await import("@fusion/core")).TaskStore(project.path); + await store.init(); + + // Try to get runtime from ProjectManager if available + let runtime: import("@fusion/engine").ProjectRuntime | undefined; + try { + const pm = await getProjectManager(); + runtime = pm.getRuntime(project.id); + } catch { + // ProjectManager not initialized or runtime not started - that's ok + runtime = undefined; + } + + return { + projectId: project.id, + name: project.name, + directory: project.path, + status: project.status, + isolationMode: project.isolationMode, + runtime, + store, + }; +} + +/** + * Clean up singleton instances. + * Call this on CLI exit to close database connections. + */ +export async function cleanupProjectResolution(): Promise<void> { + if (projectManagerInstance) { + // ProjectManager doesn't have a close method, but we should stop all runtimes + try { + await projectManagerInstance.stopAll(); + } catch { + // Ignore errors during cleanup + } + projectManagerInstance = null; + } + + if (centralCoreInstance) { + await centralCoreInstance.close(); + centralCoreInstance = null; + } +} + +/** + * Get the resolved project without needing to use it immediately. + * Useful for commands that just need to verify the project exists. + */ +export async function getResolvedProject(options: ResolveOptions = {}): Promise<ResolvedProject> { + return resolveProject(options); +} + +/** + * Format project resolution error for CLI display. + */ +export function formatResolutionError(error: ProjectResolutionError): string { + let output = `\n ✗ ${error.message}\n`; + + if (error.code === "NO_PROJECTS") { + // Message already includes detailed instructions + } else if (error.code === "MULTIPLE_MATCHES" && error.context?.availableProjects) { + // List already included in message + } else if (error.code === "NOT_FOUND" && error.context?.availableProjects) { + output += `\n Available projects:\n`; + for (const name of error.context.availableProjects as string[]) { + output += ` - ${name}\n`; + } + } + + return output; +} + +/** + * Check if a project is registered at the given path. + * Returns the project if found, undefined otherwise. + */ +export async function findProjectByPath( + path: string, + central?: CentralCore +): Promise<RegisteredProject | undefined> { + const core = central ?? (await getCentralCore()); + const normalizedPath = normalize(resolve(path)); + const projects = await core.listProjects(); + + return projects.find((p) => normalize(p.path) === normalizedPath); +} + +/** + * Check if a project name is already registered. + */ +export async function isProjectNameTaken( + name: string, + central?: CentralCore +): Promise<boolean> { + const core = central ?? (await getCentralCore()); + const projects = await core.listProjects(); + + return projects.some((p) => p.name.toLowerCase() === name.toLowerCase()); +} + +/** + * Validate that a path contains an initialized kb project (.kb/ directory exists). + */ +export function isKbProject(path: string): boolean { + const kbPath = resolve(path, ".kb"); + return existsSync(kbPath) && statSync(kbPath).isDirectory(); +} + +/** + * Get suggested project name from directory path. + */ +export function suggestProjectName(path: string): string { + const parts = path.replace(/\\/g, "/").split("/").filter(Boolean); + return parts[parts.length - 1] || "unnamed"; +} + +/** + * Resolve absolute path and validate it exists. + */ +export function resolveAbsolutePath(inputPath: string): string { + const resolved = resolve(inputPath); + if (!existsSync(resolved)) { + throw new ProjectResolutionError( + `Path does not exist: ${inputPath}`, + "NOT_FOUND", + { path: inputPath } + ); + } + if (!statSync(resolved).isDirectory()) { + throw new ProjectResolutionError( + `Path is not a directory: ${inputPath}`, + "NOT_FOUND", + { path: inputPath } + ); + } + return resolved; +} + +/** + * Get a quick summary of all registered projects. + * Used for CLI hints and error messages. + */ +export async function getProjectSummary(): Promise< + Array<{ name: string; path: string; status: string }> +> { + const central = await getCentralCore(); + const projects = await central.listProjects(); + + return projects.map((p) => ({ + name: p.name, + path: p.path, + status: p.status, + })); +} + +/** + * Reset the singleton instances. Used primarily for testing. + */ +export function resetProjectResolution(): void { + centralCoreInstance = null; + projectManagerInstance = null; +} + +/** + * Check if CentralCore is initialized. + */ +export function isCentralCoreInitialized(): boolean { + return centralCoreInstance?.isInitialized() ?? false; +} + +/** + * Get all registered projects from CentralCore. + */ +export async function listRegisteredProjects(): Promise<RegisteredProject[]> { + const central = await getCentralCore(); + return central.listProjects(); +} + +/** + * Get a single project by name. + */ +export async function getProjectByName(name: string): Promise<RegisteredProject | undefined> { + const central = await getCentralCore(); + const projects = await central.listProjects(); + return projects.find((p) => p.name === name); +} + +/** + * Register a new project with interactive prompts for missing info. + */ +export async function registerProjectInteractive( + dir: string, + options: { + name?: string; + isolation?: "in-process" | "child-process"; + interactive?: boolean; + } = {} +): Promise<ResolvedProject> { + const central = await getCentralCore(); + const interactive = options.interactive ?? true; + + // Validate directory + const absPath = resolveAbsolutePath(dir); + + // Check for .kb/ directory + if (!isKbProject(absPath)) { + if (interactive) { + console.log(`\n No .kb/ directory found in ${absPath}`); + const shouldInit = await promptConfirm("Initialize kb here first?", true); + + if (shouldInit) { + // Initialize the project (create .kb/) + const { TaskStore } = await import("@fusion/core"); + const store = new TaskStore(absPath); + await store.init(); + console.log(` ✓ Initialized kb at ${absPath}`); + } else { + throw new ProjectResolutionError( + "Cannot register project without .kb/ directory. Run `fn init` first.", + "NOT_INITIALIZED", + { directory: absPath } + ); + } + } else { + throw new ProjectResolutionError( + `No .kb/ directory found in ${absPath}. Run \`fn init\` first.`, + "NOT_INITIALIZED", + { directory: absPath } + ); + } + } + + // Determine project name + let name = options.name; + if (!name && interactive) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const suggested = suggestProjectName(absPath); + const input = await rl.question(` Project name [${suggested}]: `); + rl.close(); + name = input.trim() || suggested; + } + name = name || suggestProjectName(absPath); + + // Check for duplicate name + const isTaken = await isProjectNameTaken(name, central); + if (isTaken) { + throw new ProjectResolutionError( + `A project named "${name}" is already registered. Choose a different name.`, + "NOT_REGISTERED", + { name } + ); + } + + // Register the project + const project = await central.registerProject({ + name, + path: absPath, + isolationMode: options.isolation ?? "in-process", + }); + + return createResolvedProject(project); +} + +/** + * Unregister a project by name. + */ +export async function unregisterProject( + name: string, + options: { force?: boolean; interactive?: boolean } = {} +): Promise<void> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + const interactive = options.interactive ?? true; + + // Find the project + const project = await getProjectByName(name); + if (!project) { + throw new ProjectResolutionError( + `Project "${name}" not found.`, + "NOT_FOUND", + { name } + ); + } + + // Check if runtime is active + const runtime = pm.getRuntime(project.id); + if (runtime) { + // Stop the runtime first + await pm.removeProject(project.id); + } + + // Confirmation prompt + if (!options.force && interactive) { + const confirmed = await promptConfirm( + `Unregister "${project.name}" from the registry? The project data will be preserved.`, + false + ); + if (!confirmed) { + throw new ProjectResolutionError("Cancelled by user.", "CANCELLED"); + } + } + + // Unregister from CentralCore + await central.unregisterProject(project.id); +} + +/** + * Get detailed project info including runtime metrics and task counts. + */ +export async function getProjectInfo(name?: string): Promise<{ + project: ResolvedProject; + health: import("@fusion/core").ProjectHealth | undefined; + taskCounts: Record<string, number>; +}> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + + let project: ResolvedProject; + + if (name) { + const registered = await getProjectByName(name); + if (!registered) { + throw new ProjectResolutionError(`Project "${name}" not found.`, "NOT_FOUND", { name }); + } + project = await createResolvedProject(registered); + } else { + project = await resolveProject(); + } + + // Get health metrics + const health = await central.getProjectHealth(project.projectId); + + // Get task counts by column + const tasks = await project.store.listTasks(); + const taskCounts: Record<string, number> = {}; + for (const task of tasks) { + taskCounts[task.column] = (taskCounts[task.column] || 0) + 1; + } + + // Ensure runtime is tracked in ProjectManager + const runtime = pm.getRuntime(project.projectId); + if (runtime) { + project.runtime = runtime; + } + + return { project, health, taskCounts }; +} + +/** + * Start a project runtime if not already running. + */ +export async function startProjectRuntime(projectId: string): Promise<import("@fusion/engine").ProjectRuntime> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + + // Check if already running + const existing = pm.getRuntime(projectId); + if (existing) { + return existing; + } + + // Get project config from registry + const project = await central.getProject(projectId); + if (!project) { + throw new ProjectResolutionError(`Project "${projectId}" not found.`, "NOT_FOUND", { + projectId, + }); + } + + // Add and start the runtime + const runtime = await pm.addProject({ + projectId: project.id, + workingDirectory: project.path, + isolationMode: project.isolationMode, + maxConcurrent: project.settings?.maxConcurrent ?? 2, + maxWorktrees: project.settings?.maxWorktrees ?? 4, + }); + + return runtime; +} + +/** + * Stop a project runtime if running. + */ +export async function stopProjectRuntime(projectId: string): Promise<void> { + const pm = await getProjectManager(); + await pm.removeProject(projectId); +} + +/** + * Get the current runtime status for a project. + */ +export async function getProjectRuntimeStatus( + projectId: string +): Promise<import("@fusion/engine").RuntimeStatus | "not_started"> { + const pm = await getProjectManager(); + const runtime = pm.getRuntime(projectId); + + if (!runtime) { + return "not_started"; + } + + return runtime.getStatus(); +} + +/** + * Get the last activity timestamp for a project. + */ +export async function getProjectLastActivity(projectId: string): Promise<string | undefined> { + const central = await getCentralCore(); + const health = await central.getProjectHealth(projectId); + return health?.lastActivityAt; +} + +/** + * Get all projects with their runtime status. + */ +export async function getProjectsWithStatus(): Promise< + Array<{ + project: RegisteredProject; + runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; + taskCount: number; + }> +> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + + const projects = await central.listProjects(); + + const results = await Promise.all( + projects.map(async (project) => { + const runtime = pm.getRuntime(project.id); + const runtimeStatus = runtime?.getStatus() ?? "not_started"; + + // Get task count from store + let taskCount = 0; + try { + const store = new (await import("@fusion/core")).TaskStore(project.path); + await store.init(); + const tasks = await store.listTasks(); + taskCount = tasks.length; + } catch { + // If we can't read tasks, just report 0 + } + + return { project, runtimeStatus, taskCount } as { + project: RegisteredProject; + runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; + taskCount: number; + }; + }) + ); + + return results; +} + +/** + * Format a timestamp for display (relative or absolute). + */ +export function formatLastActivity(timestamp?: string): 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 the count of tasks by column for a project. + */ +export async function getProjectTaskCounts( + projectId: string, + store?: TaskStore +): Promise<Record<string, number>> { + const taskStore = + store ?? + (await (async () => { + const central = await getCentralCore(); + const project = await central.getProject(projectId); + if (!project) return undefined; + const s = new (await import("@fusion/core")).TaskStore(project.path); + await s.init(); + return s; + })()); + + if (!taskStore) return {}; + + const tasks = await taskStore.listTasks(); + const counts: Record<string, number> = {}; + + for (const task of tasks) { + counts[task.column] = (counts[task.column] || 0) + 1; + } + + return counts; +} + +/** + * Get summary information for a project. + */ +export async function getProjectSummaryInfo( + project: ResolvedProject +): Promise<{ + taskCounts: Record<string, number>; + lastActivity: string | undefined; + runtimeStatus: import("@fusion/engine").RuntimeStatus | "not_started"; +}> { + const central = await getCentralCore(); + const pm = await getProjectManager(); + + const [taskCounts, lastActivity, runtime] = await Promise.all([ + getProjectTaskCounts(project.projectId, project.store), + getProjectLastActivity(project.projectId), + Promise.resolve(pm.getRuntime(project.projectId)), + ]); + + return { + taskCounts, + lastActivity, + runtimeStatus: runtime?.getStatus() ?? "not_started", + }; +} + +// Cleanup on process exit (skip in test environment) +if (process.env.NODE_ENV !== "test" && process.env.VITEST === undefined) { + process.on("exit", () => { + // Note: cleanupProjectResolution is async, but process.exit doesn't await + // This is a best-effort cleanup - the OS will clean up resources anyway + void cleanupProjectResolution(); + }); + + process.on("SIGINT", () => { + void cleanupProjectResolution().then(() => process.exit(0)); + }); + + process.on("SIGTERM", () => { + void cleanupProjectResolution().then(() => process.exit(0)); + }); +} + +// Add getStore export for backward compatibility with existing code +export async function getStore(options?: { project?: string; cwd?: string }): Promise<TaskStore> { + const resolved = await resolveProject({ + project: options?.project, + cwd: options?.cwd, + interactive: true, + }); + return resolved.store; +} + +// Export getStore as default for backward compatibility +export { getStore as default }; + +// Re-export types from @fusion/core +export type { RegisteredProject } from "@fusion/core"; + +// Export internal helpers for tests +export { promptConfirm, promptProjectSelection, createResolvedProject }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 815c74473..88af3aa92 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -529,8 +529,9 @@ export interface GlobalSettings { /** ntfy.sh topic name for push notifications. When set along with ntfyEnabled, * notifications are sent to https://ntfy.sh/{topic} when tasks complete or fail. */ ntfyTopic?: string; - /** Default project ID for the current user. Used to automatically select - * the default project when opening the dashboard without a specific project. */ + /** Default project ID to use when no explicit project is specified and + * no project can be auto-detected from the current directory. + * Used for multi-project CLI workflows. */ defaultProjectId?: string; }