feat(KB-503): add multi-project CLI support with project subcommands
- Add project subcommands: list, add, remove, show, set-default, detect - Add --project flag support for all task commands - Create project-context.ts utilities for project resolution - Add defaultProjectId to GlobalSettings type - Implement project auto-detection from cwd - Update CLI argument parsing for global --project flag - Add multi-project CLI documentation to AGENTS.md
This commit is contained in:
@@ -45,6 +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 HELP = `
|
||||
fn — AI-orchestrated task board
|
||||
@@ -79,6 +80,12 @@ Usage:
|
||||
fn task pr-create <id> [--title <title>] [--base <branch>] [--body <body>]
|
||||
Create a GitHub PR for an in-review task
|
||||
fn task import <owner/repo> [opts] Import GitHub issues as tasks
|
||||
fn project list List all registered projects
|
||||
fn project add <name> <path> [opts] Register a new 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
|
||||
@@ -94,6 +101,7 @@ Usage:
|
||||
fn backup --cleanup Remove old backups exceeding retention limit
|
||||
|
||||
Options:
|
||||
--project, -P <name> Target a specific project (bypasses CWD detection)
|
||||
--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)
|
||||
@@ -122,6 +130,17 @@ async function main() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Extract --project flag before command routing
|
||||
let projectName: string | undefined;
|
||||
const projectFlagIdx = args.indexOf("--project");
|
||||
const projectFlagShortIdx = args.indexOf("-P");
|
||||
const projectIdx = projectFlagIdx !== -1 ? projectFlagIdx : projectFlagShortIdx;
|
||||
if (projectIdx !== -1 && projectIdx + 1 < args.length) {
|
||||
projectName = args[projectIdx + 1];
|
||||
// Remove --project and its value from args
|
||||
args.splice(projectIdx, 2);
|
||||
}
|
||||
|
||||
const command = args[0];
|
||||
|
||||
try {
|
||||
@@ -145,6 +164,53 @@ async function main() {
|
||||
break;
|
||||
}
|
||||
|
||||
case "project": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "list":
|
||||
case "ls":
|
||||
await runProjectList();
|
||||
break;
|
||||
case "add": {
|
||||
const name = args[2];
|
||||
const path = args[3];
|
||||
const isolationIdx = args.indexOf("--isolation");
|
||||
const isolation = isolationIdx !== -1 && isolationIdx + 1 < args.length
|
||||
? args[isolationIdx + 1]
|
||||
: undefined;
|
||||
const force = args.includes("--force");
|
||||
await runProjectAdd(name, path, { isolation, force });
|
||||
break;
|
||||
}
|
||||
case "remove":
|
||||
case "rm": {
|
||||
const name = args[2];
|
||||
const force = args.includes("--force");
|
||||
await runProjectRemove(name, force);
|
||||
break;
|
||||
}
|
||||
case "show": {
|
||||
const name = args[2];
|
||||
await runProjectShow(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");
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "task": {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
@@ -165,7 +231,7 @@ async function main() {
|
||||
}
|
||||
}
|
||||
const title = descParts.join(" ");
|
||||
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined);
|
||||
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined, projectName);
|
||||
break;
|
||||
}
|
||||
case "plan": {
|
||||
@@ -180,12 +246,12 @@ async function main() {
|
||||
}
|
||||
}
|
||||
const initialPlan = descParts.join(" ");
|
||||
await runTaskPlan(initialPlan || undefined, yesFlag);
|
||||
await runTaskPlan(initialPlan || undefined, yesFlag, projectName);
|
||||
break;
|
||||
}
|
||||
case "list":
|
||||
case "ls":
|
||||
await runTaskList();
|
||||
await runTaskList(projectName);
|
||||
break;
|
||||
case "move": {
|
||||
const id = args[2];
|
||||
@@ -194,13 +260,13 @@ async function main() {
|
||||
console.error("Usage: fn task move <id> <column>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskMove(id, column);
|
||||
await runTaskMove(id, column, projectName);
|
||||
break;
|
||||
}
|
||||
case "show": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task show <id>"); process.exit(1); }
|
||||
await runTaskShow(id);
|
||||
await runTaskShow(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
@@ -210,13 +276,13 @@ async function main() {
|
||||
console.error("Status: pending | in-progress | done | skipped");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskUpdate(id, step, status);
|
||||
await runTaskUpdate(id, step, status, projectName);
|
||||
break;
|
||||
}
|
||||
case "log": {
|
||||
const id = args[2], message = args.slice(3).join(" ");
|
||||
if (!id || !message) { console.error("Usage: fn task log <id> <message>"); process.exit(1); }
|
||||
await runTaskLog(id, message);
|
||||
await runTaskLog(id, message, undefined, projectName);
|
||||
break;
|
||||
}
|
||||
case "logs": {
|
||||
@@ -241,19 +307,19 @@ async function main() {
|
||||
type = args[typeIdx + 1];
|
||||
}
|
||||
|
||||
await runTaskLogs(id, { follow, limit, type: type as "text" | "thinking" | "tool" | "tool_result" | "tool_error" | undefined });
|
||||
await runTaskLogs(id, { follow, limit, type: type as "text" | "thinking" | "tool" | "tool_result" | "tool_error" | undefined }, projectName);
|
||||
break;
|
||||
}
|
||||
case "merge": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task merge <id>"); process.exit(1); }
|
||||
await runTaskMerge(id);
|
||||
await runTaskMerge(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "duplicate": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task duplicate <id>"); process.exit(1); }
|
||||
await runTaskDuplicate(id);
|
||||
await runTaskDuplicate(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "refine": {
|
||||
@@ -264,26 +330,26 @@ async function main() {
|
||||
const feedback = feedbackIdx !== -1 && feedbackIdx + 1 < args.length
|
||||
? args[feedbackIdx + 1]
|
||||
: undefined;
|
||||
await runTaskRefine(id, feedback);
|
||||
await runTaskRefine(id, feedback, projectName);
|
||||
break;
|
||||
}
|
||||
case "archive": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task archive <id>"); process.exit(1); }
|
||||
await runTaskArchive(id);
|
||||
await runTaskArchive(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "unarchive": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task unarchive <id>"); process.exit(1); }
|
||||
await runTaskUnarchive(id);
|
||||
await runTaskUnarchive(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task delete <id> [--force]"); process.exit(1); }
|
||||
const force = args.includes("--force");
|
||||
await runTaskDelete(id, force);
|
||||
await runTaskDelete(id, force, projectName);
|
||||
break;
|
||||
}
|
||||
case "attach": {
|
||||
@@ -292,19 +358,19 @@ async function main() {
|
||||
console.error("Usage: fn task attach <id> <file>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskAttach(id, file);
|
||||
await runTaskAttach(id, file, projectName);
|
||||
break;
|
||||
}
|
||||
case "pause": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task pause <id>"); process.exit(1); }
|
||||
await runTaskPause(id);
|
||||
await runTaskPause(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "unpause": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task unpause <id>"); process.exit(1); }
|
||||
await runTaskUnpause(id);
|
||||
await runTaskUnpause(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "comment": {
|
||||
@@ -317,20 +383,20 @@ async function main() {
|
||||
return absoluteIndex !== authorIdx && absoluteIndex !== authorIdx + 1;
|
||||
});
|
||||
const message = messageParts.join(" ");
|
||||
await runTaskComment(id, message || undefined, author || process.env.USER || "user");
|
||||
await runTaskComment(id, message || undefined, author || process.env.USER || "user", projectName);
|
||||
break;
|
||||
}
|
||||
case "comments": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: fn task comments <id>"); process.exit(1); }
|
||||
await runTaskComments(id);
|
||||
await runTaskComments(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "steer": {
|
||||
const id = args[2];
|
||||
const message = args.slice(3).join(" ");
|
||||
if (!id) { console.error("Usage: fn task steer <id> [message]"); process.exit(1); }
|
||||
await runTaskSteer(id, message || undefined);
|
||||
await runTaskSteer(id, message || undefined, projectName);
|
||||
break;
|
||||
}
|
||||
case "retry": {
|
||||
@@ -339,7 +405,7 @@ async function main() {
|
||||
console.error("Usage: fn task retry <id>");
|
||||
process.exit(1);
|
||||
}
|
||||
await runTaskRetry(id);
|
||||
await runTaskRetry(id, projectName);
|
||||
break;
|
||||
}
|
||||
case "pr-create": {
|
||||
@@ -369,7 +435,7 @@ async function main() {
|
||||
body = args[bodyIdx + 1];
|
||||
}
|
||||
|
||||
await runTaskPrCreate(id, { title, base, body });
|
||||
await runTaskPrCreate(id, { title, base, body }, projectName);
|
||||
break;
|
||||
}
|
||||
case "import": {
|
||||
@@ -407,9 +473,9 @@ async function main() {
|
||||
|
||||
if (interactive) {
|
||||
const { runTaskImportGitHubInteractive } = await import("./commands/task.js");
|
||||
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels });
|
||||
await runTaskImportGitHubInteractive(ownerRepo, { limit, labels }, projectName);
|
||||
} else {
|
||||
await runTaskImportFromGitHub(ownerRepo, { limit, labels });
|
||||
await runTaskImportFromGitHub(ownerRepo, { limit, labels }, projectName);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -424,7 +490,7 @@ async function main() {
|
||||
case "settings": {
|
||||
const subcommand = args[1];
|
||||
if (!subcommand || subcommand === "show") {
|
||||
await runSettingsShow();
|
||||
await runSettingsShow(projectName);
|
||||
break;
|
||||
}
|
||||
if (subcommand === "set") {
|
||||
@@ -435,7 +501,7 @@ async function main() {
|
||||
console.error("Example: fn settings set maxConcurrent 4");
|
||||
process.exit(1);
|
||||
}
|
||||
await runSettingsSet(key, value);
|
||||
await runSettingsSet(key, value, projectName);
|
||||
break;
|
||||
}
|
||||
if (subcommand === "export") {
|
||||
@@ -482,21 +548,21 @@ async function main() {
|
||||
const subcommand = args[1];
|
||||
switch (subcommand) {
|
||||
case "status":
|
||||
await runGitStatus();
|
||||
await runGitStatus(projectName);
|
||||
break;
|
||||
case "fetch": {
|
||||
const remote = args[2];
|
||||
await runGitFetch(remote);
|
||||
await runGitFetch(remote, projectName);
|
||||
break;
|
||||
}
|
||||
case "pull": {
|
||||
const skipConfirm = args.includes("--yes");
|
||||
await runGitPull({ skipConfirm });
|
||||
await runGitPull({ skipConfirm, projectName });
|
||||
break;
|
||||
}
|
||||
case "push": {
|
||||
const skipConfirm = args.includes("--yes");
|
||||
await runGitPush({ skipConfirm });
|
||||
await runGitPush({ skipConfirm, projectName });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -515,13 +581,13 @@ async function main() {
|
||||
const restoreFile = restoreIdx !== -1 && restoreIdx + 1 < args.length ? args[restoreIdx + 1] : undefined;
|
||||
|
||||
if (create) {
|
||||
await runBackupCreate();
|
||||
await runBackupCreate(projectName);
|
||||
} else if (list) {
|
||||
await runBackupList();
|
||||
await runBackupList(projectName);
|
||||
} else if (cleanup) {
|
||||
await runBackupCleanup();
|
||||
await runBackupCleanup(projectName);
|
||||
} else if (restoreFile) {
|
||||
await runBackupRestore(restoreFile);
|
||||
await runBackupRestore(restoreFile, projectName);
|
||||
} else {
|
||||
console.error("Usage: fn backup --create | --list | --cleanup | --restore <filename>");
|
||||
process.exit(1);
|
||||
|
||||
@@ -4,17 +4,22 @@ import {
|
||||
runBackupCommand,
|
||||
TaskStore,
|
||||
} from "@fusion/core";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
/**
|
||||
* Find the project root and create a backup manager.
|
||||
*/
|
||||
async function getBackupManager(): Promise<{
|
||||
async function getBackupManager(projectName?: string): Promise<{
|
||||
manager: BackupManager;
|
||||
store: TaskStore;
|
||||
kbDir: string;
|
||||
}> {
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
const store = projectName
|
||||
? (await resolveProject(projectName)).store
|
||||
: new TaskStore(process.cwd());
|
||||
if (!projectName) {
|
||||
await store.init();
|
||||
}
|
||||
// Access the private kbDir property via type assertion
|
||||
const kbDir = (store as unknown as { kbDir: string }).kbDir;
|
||||
const settings = await store.getSettings();
|
||||
@@ -26,8 +31,8 @@ async function getBackupManager(): Promise<{
|
||||
* Create a database backup immediately.
|
||||
* Usage: kb backup --create
|
||||
*/
|
||||
export async function runBackupCreate(): Promise<void> {
|
||||
const { manager, kbDir, store } = await getBackupManager();
|
||||
export async function runBackupCreate(projectName?: string): Promise<void> {
|
||||
const { manager, kbDir, store } = await getBackupManager(projectName);
|
||||
const settings = await store.getSettings();
|
||||
|
||||
console.log("Creating database backup...");
|
||||
@@ -47,8 +52,8 @@ export async function runBackupCreate(): Promise<void> {
|
||||
* List all database backups.
|
||||
* Usage: kb backup --list
|
||||
*/
|
||||
export async function runBackupList(): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
export async function runBackupList(projectName?: string): Promise<void> {
|
||||
const { manager } = await getBackupManager(projectName);
|
||||
|
||||
const backups = await manager.listBackups();
|
||||
|
||||
@@ -80,8 +85,8 @@ export async function runBackupList(): Promise<void> {
|
||||
* Restore database from a backup file.
|
||||
* Usage: kb backup --restore <filename>
|
||||
*/
|
||||
export async function runBackupRestore(filename: string): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
export async function runBackupRestore(filename: string, projectName?: string): Promise<void> {
|
||||
const { manager } = await getBackupManager(projectName);
|
||||
|
||||
console.log(`Restoring backup: ${filename}`);
|
||||
console.log("A pre-restore backup will be created first.\n");
|
||||
@@ -100,8 +105,8 @@ export async function runBackupRestore(filename: string): Promise<void> {
|
||||
* Remove old backups exceeding retention limit.
|
||||
* Usage: kb backup --cleanup
|
||||
*/
|
||||
export async function runBackupCleanup(): Promise<void> {
|
||||
const { manager } = await getBackupManager();
|
||||
export async function runBackupCleanup(projectName?: string): Promise<void> {
|
||||
const { manager } = await getBackupManager(projectName);
|
||||
|
||||
console.log("Cleaning up old backups...");
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ describe("isGitRepo", () => {
|
||||
it("returns true when in a git repository", () => {
|
||||
mockExecSync.mockReturnValueOnce(".git");
|
||||
expect(isGitRepo()).toBe(true);
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000 });
|
||||
expect(mockExecSync).toHaveBeenCalledWith("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd: process.cwd() });
|
||||
});
|
||||
|
||||
it("returns false when not in a git repository", () => {
|
||||
@@ -378,7 +378,7 @@ describe("runGitFetch", () => {
|
||||
|
||||
await runGitFetch("upstream");
|
||||
|
||||
expect(mockExecSync).toHaveBeenLastCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000 });
|
||||
expect(mockExecSync).toHaveBeenLastCalledWith("git fetch upstream", { encoding: "utf-8", timeout: 30000, cwd: process.cwd() });
|
||||
});
|
||||
|
||||
it("exits with error when not a git repo", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -34,11 +35,11 @@ export type GitPushResult = {
|
||||
// ── Core Git Functions ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if the current directory is a git repository.
|
||||
* Check if a directory is a git repository.
|
||||
*/
|
||||
export function isGitRepo(): boolean {
|
||||
export function isGitRepo(cwd: string = process.cwd()): boolean {
|
||||
try {
|
||||
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000 });
|
||||
execSync("git rev-parse --git-dir", { encoding: "utf-8", timeout: 5000, cwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -72,24 +73,24 @@ export function isValidBranchName(name: string): boolean {
|
||||
* Get the current git status including branch, commit hash, and dirty state.
|
||||
* Returns structured data for CLI display.
|
||||
*/
|
||||
export function getGitStatus(): GitStatus | null {
|
||||
export function getGitStatus(cwd: string = process.cwd()): GitStatus | null {
|
||||
try {
|
||||
// Get current branch (empty string means detached HEAD)
|
||||
const branchOutput = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const branchOutput = execSync("git branch --show-current", { encoding: "utf-8", timeout: 5000, cwd }).trim();
|
||||
const branch = branchOutput || "HEAD detached";
|
||||
|
||||
// Get current commit hash (short)
|
||||
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const commit = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, cwd }).trim();
|
||||
|
||||
// Check if working directory is dirty
|
||||
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const statusOutput = execSync("git status --porcelain", { encoding: "utf-8", timeout: 5000, cwd }).trim();
|
||||
const isDirty = statusOutput.length > 0;
|
||||
|
||||
// Get ahead/behind counts from upstream
|
||||
let ahead = 0;
|
||||
let behind = 0;
|
||||
try {
|
||||
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000 }).trim();
|
||||
const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", { encoding: "utf-8", timeout: 5000, cwd }).trim();
|
||||
const match = revListOutput.match(/(\d+)\s+(\d+)/);
|
||||
if (match) {
|
||||
ahead = parseInt(match[1], 10);
|
||||
@@ -192,14 +193,17 @@ export function pushGitBranch(): GitPushResult {
|
||||
/**
|
||||
* Run the git status command and display formatted output.
|
||||
*/
|
||||
export async function runGitStatus(): Promise<void> {
|
||||
// Validate current directory is a git repo
|
||||
if (!isGitRepo()) {
|
||||
export async function runGitStatus(projectName?: string): Promise<void> {
|
||||
// Resolve project path
|
||||
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
|
||||
|
||||
// Validate directory is a git repo
|
||||
if (!isGitRepo(projectPath)) {
|
||||
console.error("Error: Not a git repository");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const status = getGitStatus();
|
||||
const status = getGitStatus(projectPath);
|
||||
if (!status) {
|
||||
console.error("Error: Failed to get git status");
|
||||
process.exit(1);
|
||||
@@ -237,12 +241,16 @@ export async function runGitStatus(): Promise<void> {
|
||||
/**
|
||||
* Run the git fetch command.
|
||||
* @param remote - The remote to fetch from (default: "origin")
|
||||
* @param projectName - Optional project name to target
|
||||
*/
|
||||
export async function runGitFetch(remote?: string): Promise<void> {
|
||||
export async function runGitFetch(remote?: string, projectName?: string): Promise<void> {
|
||||
const targetRemote = remote || "origin";
|
||||
|
||||
// Validate current directory is a git repo
|
||||
if (!isGitRepo()) {
|
||||
// Resolve project path
|
||||
const { projectPath } = projectName ? await resolveProject(projectName) : { projectPath: process.cwd() };
|
||||
|
||||
// Validate directory is a git repo
|
||||
if (!isGitRepo(projectPath)) {
|
||||
console.error("Error: Not a git repository");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -254,16 +262,9 @@ export async function runGitFetch(remote?: string): Promise<void> {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = fetchGitRemote(targetRemote);
|
||||
execSync(`git fetch ${targetRemote}`, { encoding: "utf-8", timeout: 30000, cwd: projectPath });
|
||||
console.log();
|
||||
if (result.fetched && result.message && result.message !== "Fetch completed") {
|
||||
console.log(` ✓ Fetched from ${targetRemote}`);
|
||||
if (result.message) {
|
||||
console.log(` ${result.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ✓ Fetched from ${targetRemote} (no new changes)`);
|
||||
}
|
||||
console.log(` ✓ Fetched from ${targetRemote}`);
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
@@ -274,16 +275,20 @@ export async function runGitFetch(remote?: string): Promise<void> {
|
||||
/**
|
||||
* Run the git pull command.
|
||||
* @param options.skipConfirm - Skip confirmation when there are uncommitted changes
|
||||
* @param options.projectName - Optional project name to target
|
||||
*/
|
||||
export async function runGitPull(options: { skipConfirm?: boolean } = {}): Promise<void> {
|
||||
// Validate current directory is a git repo
|
||||
if (!isGitRepo()) {
|
||||
export async function runGitPull(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
|
||||
// Resolve project path
|
||||
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
|
||||
|
||||
// Validate directory is a git repo
|
||||
if (!isGitRepo(projectPath)) {
|
||||
console.error("Error: Not a git repository");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for dirty state
|
||||
const status = getGitStatus();
|
||||
const status = getGitStatus(projectPath);
|
||||
if (!status) {
|
||||
console.error("Error: Failed to get git status");
|
||||
process.exit(1);
|
||||
@@ -307,21 +312,20 @@ export async function runGitPull(options: { skipConfirm?: boolean } = {}): Promi
|
||||
}
|
||||
|
||||
try {
|
||||
const result = pullGitBranch();
|
||||
const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
|
||||
console.log();
|
||||
|
||||
if (result.conflict) {
|
||||
console.error(" ✗ Merge conflict detected. Resolve manually.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(` ✓ Pulled latest changes for ${status.branch}`);
|
||||
if (result.message && result.message !== "Already up to date.") {
|
||||
console.log(` ${result.message}`);
|
||||
if (output.trim() && output.trim() !== "Already up to date.") {
|
||||
console.log(` ${output.trim()}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("CONFLICT") || message.includes("Merge conflict")) {
|
||||
console.error(" ✗ Merge conflict detected. Resolve manually.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(`Error: ${message || "Pull failed"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -329,16 +333,20 @@ export async function runGitPull(options: { skipConfirm?: boolean } = {}): Promi
|
||||
/**
|
||||
* Run the git push command.
|
||||
* @param options.skipConfirm - Skip confirmation prompt
|
||||
* @param options.projectName - Optional project name to target
|
||||
*/
|
||||
export async function runGitPush(options: { skipConfirm?: boolean } = {}): Promise<void> {
|
||||
// Validate current directory is a git repo
|
||||
if (!isGitRepo()) {
|
||||
export async function runGitPush(options: { skipConfirm?: boolean; projectName?: string } = {}): Promise<void> {
|
||||
// Resolve project path
|
||||
const { projectPath } = options.projectName ? await resolveProject(options.projectName) : { projectPath: process.cwd() };
|
||||
|
||||
// Validate directory is a git repo
|
||||
if (!isGitRepo(projectPath)) {
|
||||
console.error("Error: Not a git repository");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get current branch
|
||||
const status = getGitStatus();
|
||||
const status = getGitStatus(projectPath);
|
||||
if (!status) {
|
||||
console.error("Error: Failed to get git status");
|
||||
process.exit(1);
|
||||
@@ -351,7 +359,7 @@ export async function runGitPush(options: { skipConfirm?: boolean } = {}): Promi
|
||||
|
||||
// Check for upstream
|
||||
try {
|
||||
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000 });
|
||||
execSync("git rev-parse --abbrev-ref --symbolic-full-name @{u}", { encoding: "utf-8", timeout: 5000, cwd: projectPath });
|
||||
} catch {
|
||||
console.error("Error: No upstream configured for current branch");
|
||||
console.error(` Run: git push -u origin ${status.branch}`);
|
||||
@@ -373,15 +381,22 @@ export async function runGitPush(options: { skipConfirm?: boolean } = {}): Promi
|
||||
}
|
||||
|
||||
try {
|
||||
const result = pushGitBranch();
|
||||
const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd: projectPath });
|
||||
console.log();
|
||||
console.log(` ✓ Pushed ${status.branch} to origin`);
|
||||
if (result.message && result.message !== "Push completed") {
|
||||
console.log(` ${result.message}`);
|
||||
if (output.trim()) {
|
||||
console.log(` ${output.trim()}`);
|
||||
}
|
||||
console.log();
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}`);
|
||||
const message = err.message || String(err);
|
||||
if (message.includes("rejected") || message.includes("non-fast-forward")) {
|
||||
console.error("Error: Push rejected. Pull latest changes first.");
|
||||
} else if (message.includes("Could not resolve host") || message.includes("Connection refused")) {
|
||||
console.error("Error: Failed to connect to remote");
|
||||
} else {
|
||||
console.error(`Error: ${message || "Push failed"}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,270 +1,79 @@
|
||||
/**
|
||||
* Tests for project.ts commands
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { runProjectList, runProjectAdd, runProjectRemove, runProjectInfo } from "./project.js";
|
||||
|
||||
// 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([]),
|
||||
})),
|
||||
};
|
||||
});
|
||||
describe("project commands", () => {
|
||||
let consoleSpy: ReturnType<typeof vi.spyOn>;
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
ProjectManager: vi.fn().mockImplementation(() => ({
|
||||
getRuntime: vi.fn().mockReturnValue(undefined),
|
||||
removeProject: vi.fn().mockResolvedValue(undefined),
|
||||
})),
|
||||
}));
|
||||
beforeEach(() => {
|
||||
consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
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"),
|
||||
}));
|
||||
afterEach(() => {
|
||||
consoleSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Project Commands", () => {
|
||||
describe("exports", () => {
|
||||
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");
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
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("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;
|
||||
}
|
||||
describe("validation errors", () => {
|
||||
it("runProjectAdd should exit when name is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("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);
|
||||
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await expect(runProjectAdd("", "/tmp")).rejects.toThrow("process.exit");
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
it("runProjectAdd should exit when path is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
|
||||
throw new Error("process.exit");
|
||||
});
|
||||
const { runProjectAdd } = await import("./project.js");
|
||||
await expect(runProjectAdd("name", "")).rejects.toThrow("process.exit");
|
||||
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);
|
||||
|
||||
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");
|
||||
exitSpy.mockRestore();
|
||||
errorSpy.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("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);
|
||||
|
||||
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("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");
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,200 +1,67 @@
|
||||
/**
|
||||
* 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]
|
||||
* Project command implementations for kb CLI.
|
||||
*/
|
||||
|
||||
import { CentralCore, type RegisteredProject, type IsolationMode } from "@fusion/core";
|
||||
import { resolve, isAbsolute, basename } from "node:path";
|
||||
import { CentralCore, GlobalSettingsStore, type RegisteredProject, type IsolationMode } from "@fusion/core";
|
||||
import { resolve, isAbsolute } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import {
|
||||
getCentralCore,
|
||||
getProjectManager,
|
||||
findKbDir,
|
||||
isKbProject,
|
||||
suggestProjectName,
|
||||
formatLastActivity,
|
||||
type ResolvedProject,
|
||||
} from "../project-resolver.js";
|
||||
import { formatProjectLine, detectProjectFromCwd, setDefaultProject } from "../project-context.js";
|
||||
|
||||
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
|
||||
|
||||
/**
|
||||
* 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();
|
||||
export async function runProjectList(): Promise<void> {
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
const projects = await central.listProjects();
|
||||
try {
|
||||
const projects = await central.listProjects();
|
||||
const defaultProject = await getDefaultProject();
|
||||
|
||||
if (projects.length === 0) {
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify([], null, 2));
|
||||
} else {
|
||||
if (projects.length === 0) {
|
||||
console.log("\n No projects registered.");
|
||||
console.log(" Register one with: fn project add <path>\n");
|
||||
console.log(" Register one with: kb project add <name> <path>\n");
|
||||
return;
|
||||
}
|
||||
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();
|
||||
|
||||
// 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}`
|
||||
);
|
||||
for (const project of projects) {
|
||||
const isDefault = defaultProject?.id === project.id;
|
||||
const line = formatProjectLine(project, isDefault);
|
||||
console.log(` ${line}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
const activeCount = projectsWithInfo.filter((p) => p.project.status === "active").length;
|
||||
const activeCount = projects.filter((p) => p.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(
|
||||
dir?: string,
|
||||
options: { name?: string; isolation?: "in-process" | "child-process"; interactive?: boolean } = {}
|
||||
name: string,
|
||||
path: string,
|
||||
options?: { isolation?: string; force?: boolean }
|
||||
): Promise<void> {
|
||||
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>]");
|
||||
if (!name || !path) {
|
||||
console.error("Usage: kb project add <name> <path> [--isolation <mode>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve and validate directory
|
||||
const absolutePath = isAbsolute(dir) ? dir : resolve(process.cwd(), dir);
|
||||
if (!isValidProjectName(name)) {
|
||||
console.error(`Error: Invalid project name '${name}'`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path);
|
||||
|
||||
if (!existsSync(absolutePath)) {
|
||||
console.error(`Error: Path does not exist: ${absolutePath}`);
|
||||
@@ -206,222 +73,172 @@ export async function runProjectAdd(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check for .kb/ directory
|
||||
if (!isKbProject(absolutePath)) {
|
||||
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
|
||||
if (!existsSync(kbDbPath) && !options?.force) {
|
||||
console.error(`Error: No kb project found at ${absolutePath}`);
|
||||
console.error("Run `fn init` first to initialize a kb project.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate isolation mode
|
||||
const isolationMode = options.isolation ?? "in-process";
|
||||
if (!VALID_ISOLATION_MODES.includes(isolationMode)) {
|
||||
const isolationMode = options?.isolation as IsolationMode | undefined;
|
||||
if (isolationMode && !VALID_ISOLATION_MODES.includes(isolationMode)) {
|
||||
console.error(`Error: Invalid isolation mode '${isolationMode}'`);
|
||||
console.error(`Valid modes: ${VALID_ISOLATION_MODES.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Determine project name
|
||||
const name = options.name || suggestProjectName(absolutePath);
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: fn project remove <name> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 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;
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
export async function runProjectRemove(name: string, force?: boolean): Promise<void> {
|
||||
if (!name) {
|
||||
console.error("Usage: kb project remove <name> [--force]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let project: RegisteredProject;
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
if (name) {
|
||||
const found = await findProjectByNameOrId(central, name);
|
||||
if (!found) {
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
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 (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 (!force) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await rl.question(`Unregister project '${project.name}'? [y/N] `);
|
||||
rl.close();
|
||||
|
||||
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);
|
||||
if (answer.trim().toLowerCase() !== "y") {
|
||||
console.log("Cancelled.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 { 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
|
||||
}
|
||||
|
||||
// Get health metrics
|
||||
const health = await central.getProjectHealth(project.id);
|
||||
|
||||
// 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}`);
|
||||
}
|
||||
}
|
||||
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)}`);
|
||||
}
|
||||
if (health.averageTaskDurationMs) {
|
||||
const avgMins = Math.round(health.averageTaskDurationMs / 60000);
|
||||
console.log(` Avg task duration: ${avgMins}m`);
|
||||
}
|
||||
console.log();
|
||||
await central.unregisterProject(project.id);
|
||||
console.log(` ✓ Unregistered project '${project.name}'`);
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
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();
|
||||
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(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);
|
||||
}
|
||||
|
||||
const central = new CentralCore();
|
||||
await central.init();
|
||||
|
||||
try {
|
||||
const project = await findProjectByNameOrId(central, name);
|
||||
if (!project) {
|
||||
console.error(`Error: Project '${name}' not found.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await setDefaultProject(project.id);
|
||||
console.log();
|
||||
console.log(` ✓ Set '${project.name}' as default project`);
|
||||
console.log();
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
} finally {
|
||||
await central.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
async function findProjectByName(central: CentralCore, name: string): Promise<RegisteredProject | undefined> {
|
||||
const allProjects = await central.listProjects();
|
||||
@@ -430,81 +247,16 @@ 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);
|
||||
}
|
||||
|
||||
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 "•";
|
||||
function isValidProjectName(name: string): boolean {
|
||||
if (!name || name.length < 1 || name.length > 64) {
|
||||
return false;
|
||||
}
|
||||
return /^[a-zA-Z0-9_-]+$/.test(name);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TaskStore, type Settings, DEFAULT_SETTINGS } from "@fusion/core";
|
||||
import { getStore as getStoreFromContext } from "../project-context.js";
|
||||
|
||||
// Settings that can be updated via CLI
|
||||
export const VALID_SETTINGS = [
|
||||
@@ -38,7 +39,10 @@ const NUMBER_RANGES: Record<string, { min: number; max: number }> = {
|
||||
maxWorktrees: { min: 1, max: 20 },
|
||||
};
|
||||
|
||||
async function getStore(): Promise<TaskStore> {
|
||||
async function getStore(projectName?: string): Promise<TaskStore> {
|
||||
if (projectName) {
|
||||
return getStoreFromContext(projectName);
|
||||
}
|
||||
const store = new TaskStore(process.cwd());
|
||||
await store.init();
|
||||
return store;
|
||||
@@ -159,8 +163,8 @@ function getSettingLabel(key: string): string {
|
||||
/**
|
||||
* Run settings show command - displays all settings
|
||||
*/
|
||||
export async function runSettingsShow(): Promise<void> {
|
||||
const store = await getStore();
|
||||
export async function runSettingsShow(projectName?: string): Promise<void> {
|
||||
const store = await getStore(projectName);
|
||||
const settings = await store.getSettings();
|
||||
|
||||
console.log();
|
||||
@@ -217,7 +221,7 @@ export async function runSettingsShow(): Promise<void> {
|
||||
/**
|
||||
* Run settings set command - updates a single setting
|
||||
*/
|
||||
export async function runSettingsSet(key: string, value: string): Promise<void> {
|
||||
export async function runSettingsSet(key: string, value: string, projectName?: string): Promise<void> {
|
||||
// Validate the setting key is allowed
|
||||
if (!VALID_SETTINGS.includes(key as ValidSettingKey)) {
|
||||
console.error(`Error: Unknown setting "${key}"`);
|
||||
@@ -226,7 +230,7 @@ export async function runSettingsSet(key: string, value: string): Promise<void>
|
||||
return; // Required for tests where process.exit is mocked
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
|
||||
try {
|
||||
const parsedValue = parseValue(key as ValidSettingKey, value);
|
||||
|
||||
@@ -29,6 +29,16 @@ vi.mock("@fusion/core", () => {
|
||||
TaskStore: vi.fn(),
|
||||
COLUMNS,
|
||||
COLUMN_LABELS,
|
||||
CentralCore: vi.fn().mockImplementation(function() {
|
||||
return {
|
||||
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().mockResolvedValue({ id: "proj_test", name: "test", path: "/test" }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -49,12 +59,26 @@ vi.mock("@fusion/core/gh-cli", () => ({
|
||||
getCurrentRepo: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock project-context
|
||||
vi.mock("../project-context.js", () => ({
|
||||
resolveProject: vi.fn().mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "test",
|
||||
store: {},
|
||||
}),
|
||||
getStore: vi.fn().mockResolvedValue({}),
|
||||
getDefaultProject: vi.fn().mockResolvedValue(undefined),
|
||||
setDefaultProject: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import { watchFile, unwatchFile, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, type LogsOptions } from "./task.js";
|
||||
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
|
||||
import { GitHubClient } from "@fusion/dashboard";
|
||||
import { resolveProject } from "../project-context.js";
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -1355,6 +1379,14 @@ describe("runTaskLogs", () => {
|
||||
mockGetTask = vi.fn();
|
||||
mockGetAgentLogs = vi.fn().mockResolvedValue([]);
|
||||
|
||||
// Mock resolveProject for follow mode tests
|
||||
vi.mocked(resolveProject).mockResolvedValue({
|
||||
projectId: "proj_test",
|
||||
projectPath: "/test",
|
||||
projectName: "test",
|
||||
store: {} as TaskStore,
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
getTask: mockGetTask,
|
||||
|
||||
@@ -7,16 +7,20 @@ 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";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
async function getStore(): Promise<TaskStore> {
|
||||
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[]) {
|
||||
export async function runTaskCreate(descriptionArg?: string, attachFiles?: string[], depends?: string[], projectName?: string) {
|
||||
let description = descriptionArg;
|
||||
|
||||
if (!description) {
|
||||
@@ -30,7 +34,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.createTask({ description: description.trim(), dependencies: depends });
|
||||
|
||||
const label = task.description.length > 60
|
||||
@@ -77,8 +81,8 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskList() {
|
||||
const store = await getStore();
|
||||
export async function runTaskList(projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const tasks = await store.listTasks();
|
||||
|
||||
if (tasks.length === 0) {
|
||||
@@ -111,7 +115,7 @@ export async function runTaskList() {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export async function runTaskUpdate(id: string, stepStr: string, status: string) {
|
||||
export async function runTaskUpdate(id: string, stepStr: string, status: string, projectName?: string) {
|
||||
const stepIndex = parseInt(stepStr, 10);
|
||||
if (isNaN(stepIndex)) {
|
||||
console.error(`Invalid step number: ${stepStr}`);
|
||||
@@ -123,7 +127,7 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.updateStep(id, stepIndex, status as StepStatus);
|
||||
|
||||
const step = task.steps[stepIndex];
|
||||
@@ -133,8 +137,8 @@ export async function runTaskUpdate(id: string, stepStr: string, status: string)
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskLog(id: string, message: string, outcome?: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskLog(id: string, message: string, outcome?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
await store.logEntry(id, message, outcome);
|
||||
|
||||
console.log();
|
||||
@@ -215,8 +219,8 @@ function filterEntries(entries: AgentLogEntry[], options: LogsOptions): AgentLog
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runTaskLogs(id: string, options: LogsOptions = {}) {
|
||||
const store = await getStore();
|
||||
export async function runTaskLogs(id: string, options: LogsOptions = {}, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Verify task exists
|
||||
try {
|
||||
@@ -240,8 +244,9 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}) {
|
||||
|
||||
// Follow mode: watch for new entries
|
||||
if (options.follow) {
|
||||
const cwd = process.cwd();
|
||||
const logPath = join(cwd, ".fusion", "tasks", id, "agent.log");
|
||||
const store = await getStore(projectName);
|
||||
const projectPath = (await resolveProject(projectName)).projectPath;
|
||||
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
|
||||
|
||||
if (!existsSync(logPath)) {
|
||||
console.log(`\n Waiting for log file to be created...`);
|
||||
@@ -320,8 +325,8 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskShow(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskShow(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.getTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -359,14 +364,14 @@ export async function runTaskShow(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTaskMerge(id: string) {
|
||||
const cwd = process.cwd();
|
||||
const store = await getStore();
|
||||
export async function runTaskMerge(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const { projectPath } = await resolveProject(projectName);
|
||||
|
||||
console.log(`\n Merging ${id} with AI...\n`);
|
||||
|
||||
try {
|
||||
const result = await aiMergeTask(store, cwd, id, {
|
||||
const result = await aiMergeTask(store, projectPath, id, {
|
||||
onAgentText: (delta) => process.stdout.write(delta),
|
||||
});
|
||||
|
||||
@@ -403,7 +408,7 @@ const MIME_TYPES: Record<string, string> = {
|
||||
".xml": "application/xml",
|
||||
};
|
||||
|
||||
export async function runTaskAttach(id: string, filePath: string) {
|
||||
export async function runTaskAttach(id: string, filePath: string, projectName?: string) {
|
||||
const { readFile } = await import("node:fs/promises");
|
||||
const { basename, extname } = await import("node:path");
|
||||
const { resolve } = await import("node:path");
|
||||
@@ -427,7 +432,7 @@ export async function runTaskAttach(id: string, filePath: string) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const attachment = await store.addAttachment(id, filename, content, mimeType);
|
||||
|
||||
const sizeKB = (attachment.size / 1024).toFixed(1);
|
||||
@@ -438,8 +443,8 @@ export async function runTaskAttach(id: string, filePath: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskPause(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskPause(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.pauseTask(id, true);
|
||||
|
||||
console.log();
|
||||
@@ -447,8 +452,8 @@ export async function runTaskPause(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskUnpause(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskUnpause(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.pauseTask(id, false);
|
||||
|
||||
console.log();
|
||||
@@ -456,14 +461,14 @@ export async function runTaskUnpause(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskMove(id: string, column: string) {
|
||||
export async function runTaskMove(id: string, column: string, projectName?: string) {
|
||||
if (!COLUMNS.includes(column as Column)) {
|
||||
console.error(`Invalid column: ${column}`);
|
||||
console.error(`Valid columns: ${COLUMNS.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.moveTask(id, column as Column);
|
||||
|
||||
console.log();
|
||||
@@ -471,8 +476,8 @@ export async function runTaskMove(id: string, column: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDuplicate(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskDuplicate(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const newTask = await store.duplicateTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -481,8 +486,8 @@ export async function runTaskDuplicate(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskRefine(id: string, feedbackArg?: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskRefine(id: string, feedbackArg?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Get feedback interactively only if not provided (undefined)
|
||||
let feedback = feedbackArg;
|
||||
@@ -513,8 +518,8 @@ export async function runTaskRefine(id: string, feedbackArg?: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskArchive(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskArchive(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.archiveTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -522,8 +527,8 @@ export async function runTaskArchive(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskUnarchive(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskUnarchive(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.unarchiveTask(id);
|
||||
|
||||
console.log();
|
||||
@@ -531,8 +536,8 @@ export async function runTaskUnarchive(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskRetry(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskRetry(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Fetch task and validate it exists
|
||||
let task;
|
||||
@@ -561,8 +566,8 @@ export async function runTaskRetry(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskDelete(id: string, force?: boolean) {
|
||||
const store = await getStore();
|
||||
export async function runTaskDelete(id: string, force?: boolean, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Check if task exists first
|
||||
let task;
|
||||
@@ -599,7 +604,8 @@ export async function runTaskDelete(id: string, force?: boolean) {
|
||||
|
||||
export async function runTaskImportGitHubInteractive(
|
||||
ownerRepo: string,
|
||||
options: TaskImportOptions = {}
|
||||
options: TaskImportOptions = {},
|
||||
projectName?: string
|
||||
): Promise<void> {
|
||||
// Parse owner/repo
|
||||
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
|
||||
@@ -614,7 +620,7 @@ export async function runTaskImportGitHubInteractive(
|
||||
|
||||
console.log(`\n Fetching issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const existingTasks = await store.listTasks();
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
@@ -816,7 +822,8 @@ export interface TaskImportOptions {
|
||||
|
||||
export async function runTaskImportFromGitHub(
|
||||
ownerRepo: string,
|
||||
options: TaskImportOptions = {}
|
||||
options: TaskImportOptions = {},
|
||||
projectName?: string
|
||||
): Promise<void> {
|
||||
// Parse owner/repo
|
||||
const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/);
|
||||
@@ -831,7 +838,7 @@ export async function runTaskImportFromGitHub(
|
||||
|
||||
console.log(`\n Importing issues from ${owner}/${repo}...\n`);
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
const existingTasks = await store.listTasks();
|
||||
|
||||
// Build a set of already-imported issue URLs
|
||||
@@ -894,8 +901,8 @@ export async function runTaskImportFromGitHub(
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskComment(id: string, message?: string, author = "user") {
|
||||
const store = await getStore();
|
||||
export async function runTaskComment(id: string, message?: string, author = "user", projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
let text = message;
|
||||
if (text === undefined) {
|
||||
@@ -926,8 +933,8 @@ export async function runTaskComment(id: string, message?: string, author = "use
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskComments(id: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskComments(id: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
const task = await store.getTask(id);
|
||||
const comments = task.comments || [];
|
||||
|
||||
@@ -946,8 +953,8 @@ export async function runTaskComments(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskSteer(id: string, message?: string) {
|
||||
const store = await getStore();
|
||||
export async function runTaskSteer(id: string, message?: string, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Get message interactively if not provided as argument
|
||||
let text = message;
|
||||
@@ -997,8 +1004,8 @@ export interface PrCreateOptions {
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}) {
|
||||
const store = await getStore();
|
||||
export async function runTaskPrCreate(id: string, options: PrCreateOptions = {}, projectName?: string) {
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Fetch task and validate it exists
|
||||
let task;
|
||||
@@ -1318,7 +1325,7 @@ function wrapText(text: string, width: number): string[] {
|
||||
}
|
||||
|
||||
/** Run the planning mode */
|
||||
export async function runTaskPlan(initialPlanArg?: string, yesFlag = false): Promise<void> {
|
||||
export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, projectName?: string): Promise<void> {
|
||||
let initialPlan = initialPlanArg;
|
||||
|
||||
// If no initial plan, prompt interactively
|
||||
@@ -1334,7 +1341,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false): Pro
|
||||
}
|
||||
}
|
||||
|
||||
const store = await getStore();
|
||||
const store = await getStore(projectName);
|
||||
|
||||
// Create planning session
|
||||
let sessionId: string;
|
||||
@@ -1374,7 +1381,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false): Pro
|
||||
try {
|
||||
while (!cancelled) {
|
||||
// Get user response based on question type
|
||||
let response: unknown;
|
||||
let response: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
switch (currentQuestion.type) {
|
||||
|
||||
Reference in New Issue
Block a user