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:
gsxdsm
2026-04-01 07:02:15 -07:00
parent 1ee99b8729
commit d2d32acce8
81 changed files with 4130 additions and 2320 deletions

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