feat(KB-503): add multi-project CLI support

- Add project context utilities for multi-project operations

- Add project subcommands (list, add, remove, set-default, detect)

- Update CLI argument parsing with --project flag support

- Add multi-project documentation and changeset
This commit is contained in:
gsxdsm
2026-03-31 23:28:43 -07:00
parent e642c3cce4
commit 1840f17ebf
34 changed files with 802 additions and 598 deletions

View File

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

View File

@@ -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...");

View File

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

View File

@@ -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);
}
}

View File

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

View File

@@ -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,6 +59,19 @@ 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";

View File

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