feat: rename data directory, add global project settings, multi-project CLI commands, and provider badge in model selector

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-02 09:10:42 -07:00
parent dd2c16bc58
commit 2c48ce0852
80 changed files with 857 additions and 652 deletions

View File

@@ -10,6 +10,7 @@ function makeMockStore() {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
stopWatching: vi.fn(),
close: vi.fn(),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 1,
maxWorktrees: 2,
@@ -20,6 +21,9 @@ function makeMockStore() {
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
}
@@ -207,7 +211,6 @@ describe("runDashboard — AuthStorage & ModelRegistry wiring", () => {
await runDashboard(0, {});
expect(consoleSpy).toHaveBeenCalledWith("[extensions] Failed to load /extensions/bad: Invalid manifest");
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("kb board"));
consoleSpy.mockRestore();
});

View File

@@ -1,18 +1,27 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockListBackups = vi.fn();
const mockRestoreBackup = vi.fn();
const mockCleanupOldBackups = vi.fn();
const mockGetSettings = vi.fn();
const mockRunBackupCommand = vi.fn();
const mockResolveProject = vi.fn();
const {
mockListBackups,
mockRestoreBackup,
mockCleanupOldBackups,
mockGetSettings,
mockRunBackupCommand,
mockResolveProject,
} = vi.hoisted(() => ({
mockListBackups: vi.fn(),
mockRestoreBackup: vi.fn(),
mockCleanupOldBackups: vi.fn(),
mockGetSettings: vi.fn(),
mockRunBackupCommand: vi.fn(),
mockResolveProject: vi.fn(),
}));
vi.mock("@fusion/core", () => ({
BackupManager: vi.fn(),
TaskStore: vi.fn().mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
getSettings: mockGetSettings,
kbDir: "/cwd/.kb",
kbDir: "/cwd/.fusion",
})),
createBackupManager: vi.fn(() => ({
listBackups: mockListBackups,
@@ -41,7 +50,7 @@ describe("backup commands", () => {
exitSpy = vi.spyOn(process, "exit").mockImplementation((code?: string | number | null) => {
throw new Error(`process.exit:${code ?? 0}`);
});
mockGetSettings.mockResolvedValue({ autoBackupDir: ".kb/backups" });
mockGetSettings.mockResolvedValue({ autoBackupDir: ".fusion/backups" });
mockRunBackupCommand.mockResolvedValue({ success: true, output: "backup created" });
mockListBackups.mockResolvedValue([]);
mockRestoreBackup.mockResolvedValue(undefined);
@@ -51,7 +60,7 @@ describe("backup commands", () => {
projectName: "demo-project",
projectPath: "/projects/demo",
isRegistered: true,
store: { getSettings: mockGetSettings, kbDir: "/projects/demo/.kb" },
store: { getSettings: mockGetSettings, kbDir: "/projects/demo/.fusion" },
});
});
@@ -64,20 +73,20 @@ describe("backup commands", () => {
it("runBackupCreate uses resolved project store with --project", async () => {
await expect(runBackupCreate("demo-project")).rejects.toThrow("process.exit:0");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRunBackupCommand).toHaveBeenCalledWith("/projects/demo/.kb", expect.anything());
expect(mockRunBackupCommand).toHaveBeenCalledWith("/projects/demo/.fusion", expect.anything());
});
it("runBackupList uses resolved project store with --project", async () => {
mockListBackups.mockResolvedValue([{ filename: "kb.db.bak", size: 1024, createdAt: new Date().toISOString() }]);
mockListBackups.mockResolvedValue([{ filename: "fusion.db.bak", size: 1024, createdAt: new Date().toISOString() }]);
await runBackupList("demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("Found 1 backup"));
});
it("runBackupRestore uses resolved project store with --project", async () => {
await runBackupRestore("kb.db.bak", "demo-project");
await runBackupRestore("fusion.db.bak", "demo-project");
expect(mockResolveProject).toHaveBeenCalledWith("demo-project");
expect(mockRestoreBackup).toHaveBeenCalledWith("kb.db.bak", { createPreRestoreBackup: true });
expect(mockRestoreBackup).toHaveBeenCalledWith("fusion.db.bak", { createPreRestoreBackup: true });
});
it("runBackupCleanup uses resolved project store with --project", async () => {
@@ -102,9 +111,12 @@ describe("backup commands", () => {
cwdSpy.mockRestore();
});
it("propagates project resolution errors for project-targeted backup commands", async () => {
it("falls back to current cwd task store when project resolution fails for project-targeted commands", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/fallback/project");
mockResolveProject.mockRejectedValue(new Error("Project 'missing' not found. Run 'kb project list' to see registered projects."));
await expect(runBackupList("missing")).rejects.toThrow("Project 'missing' not found");
await runBackupList("missing");
expect(TaskStore).toHaveBeenCalledWith("/fallback/project");
cwdSpy.mockRestore();
});
});

View File

@@ -5,6 +5,24 @@ import { EventEmitter } from "node:events";
let capturedExecutorOpts: Record<string, unknown> | undefined;
const {
mockAuthStorage,
mockModelRegistry,
mockDiscoverAndLoadExtensions,
mockCreateExtensionRuntime,
} = vi.hoisted(() => ({
mockAuthStorage: { getAuth: vi.fn(), setAuth: vi.fn() },
mockModelRegistry: {
registerProvider: vi.fn(),
refresh: vi.fn(),
},
mockDiscoverAndLoadExtensions: vi.fn().mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
}),
mockCreateExtensionRuntime: vi.fn(),
}));
// Minimal mock store backed by EventEmitter so `store.on` works
function makeMockStore() {
const emitter = new EventEmitter();
@@ -26,9 +44,13 @@ function makeMockStore() {
updatePrInfo: vi.fn().mockResolvedValue({}),
logEntry: vi.fn().mockResolvedValue(undefined),
updateTask: vi.fn().mockResolvedValue({}),
close: vi.fn(),
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.on(event, handler);
}),
off: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
emitter.off(event, handler);
}),
emit: emitter.emit.bind(emitter),
};
}
@@ -166,17 +188,6 @@ vi.mock("@fusion/engine", async (importOriginal) => {
// ── Mock @mariozechner/pi-coding-agent ──────────────────────────────
const mockAuthStorage = { getAuth: vi.fn(), setAuth: vi.fn() };
const mockModelRegistry = {
registerProvider: vi.fn(),
refresh: vi.fn(),
};
const mockDiscoverAndLoadExtensions = vi.fn().mockResolvedValue({
runtime: { pendingProviderRegistrations: [] },
errors: [],
});
const mockCreateExtensionRuntime = vi.fn();
vi.mock("@mariozechner/pi-coding-agent", () => ({
AuthStorage: {
create: vi.fn(() => mockAuthStorage),
@@ -286,7 +297,7 @@ describe("processPullRequestMergeTask", () => {
expect(mockFindPrForBranch).toHaveBeenCalledWith({ head: "fusion/fn-093", state: "all" });
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Add support for creating pull requests",
body: "Automated PR for KB-093.\n\nImplement PR automation",
body: "Automated PR for FN-093.\n\nImplement PR automation",
head: "fusion/fn-093",
});
expect(store.updatePrInfo).toHaveBeenCalledWith(
@@ -471,7 +482,7 @@ describe("runDashboard — PR-first auto-merge queue", () => {
expect(mockCreatePr).toHaveBeenCalledWith({
title: "FN-093: Task",
body: "Automated PR for KB-093.\n\nDescription",
body: "Automated PR for FN-093.\n\nDescription",
head: "fusion/fn-093",
});
expect(aiMergeTask).not.toHaveBeenCalled();

View File

@@ -100,7 +100,7 @@ describe("git commands", () => {
it("runGitStatus without project falls back to current working directory when resolution fails", async () => {
const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue("/local/project");
vi.mocked(resolveProject).mockRejectedValueOnce(new Error("No kb project found"));
vi.mocked(resolveProject).mockRejectedValueOnce(new Error("No fusion project found"));
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("main\n")
@@ -118,12 +118,11 @@ describe("git commands", () => {
it("runGitFetch uses resolved project path", async () => {
mockExecSync
.mockReturnValueOnce(".git")
.mockReturnValueOnce("");
.mockReturnValueOnce("Fetch completed");
await runGitFetch("origin", "demo-project");
expect(mockExecSync).toHaveBeenCalledWith("git fetch origin", expect.objectContaining({ cwd: "/projects/demo" }));
expect(fetchGitRemote("origin", "/projects/demo")).toEqual(expect.objectContaining({ fetched: true }));
});
it("propagates project resolution errors for git commands", async () => {
@@ -141,12 +140,12 @@ describe("git commands", () => {
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("Already up to date.")
.mockReturnValueOnce("Already up to date.");
await runGitPull({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenCalledWith("git pull", expect.objectContaining({ cwd: "/projects/demo" }));
expect(pullGitBranch("/projects/demo")).toEqual(expect.objectContaining({ success: true }));
});
it("runGitPush uses resolved project path", async () => {
@@ -158,11 +157,11 @@ describe("git commands", () => {
.mockReturnValueOnce("a1b2c3d\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("0\t0\n")
.mockReturnValueOnce("")
.mockReturnValueOnce("");
await runGitPush({ projectName: "demo-project" });
expect(mockExecSync).toHaveBeenCalledWith("git push", expect.objectContaining({ cwd: "/projects/demo" }));
expect(pushGitBranch("/projects/demo")).toEqual(expect.objectContaining({ success: true }));
});
});

View File

@@ -278,8 +278,8 @@ export async function runProjectAdd(
process.exit(1);
}
// Check for .kb directory
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
// Check for .fusion directory
const kbDbPath = resolve(absolutePath, ".fusion", "fusion.db");
if (!existsSync(kbDbPath) && !options.force) {
console.log(`\n No kb project found at ${formatDisplayPath(absolutePath)}`);
const init = await rl.question(" Initialize kb here first? [Y/n] ");
@@ -331,8 +331,8 @@ export async function runProjectAdd(
process.exit(1);
}
// Check for .kb directory
const kbDbPath = resolve(absolutePath, ".kb", "kb.db");
// Check for .fusion directory
const kbDbPath = resolve(absolutePath, ".fusion", "fusion.db");
if (!existsSync(kbDbPath) && !options.force) {
console.error(`\n ✗ No kb project found at ${formatDisplayPath(absolutePath)}`);
console.error(" Run `kb init` first to initialize the project.\n");
@@ -441,7 +441,7 @@ export async function runProjectShow(name?: string): Promise<void> {
project = await central.getProject(detected.id);
}
if (!project) {
// Unregistered project with .kb
// Unregistered project with .fusion
console.log();
console.log(` Project: ${detected.name}`);
console.log(` Location: ${formatDisplayPath(detected.path)}`);

View File

@@ -69,13 +69,7 @@ vi.mock("@fusion/core/gh-cli", () => ({
// Mock project-context
vi.mock("../project-context.js", () => ({
resolveProject: vi.fn().mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "test",
isRegistered: true,
store: {},
}),
resolveProject: vi.fn().mockRejectedValue(new Error("No project context")),
getStore: vi.fn().mockResolvedValue({}),
getDefaultProject: vi.fn().mockResolvedValue(undefined),
setDefaultProject: vi.fn().mockResolvedValue(undefined),
@@ -84,10 +78,10 @@ vi.mock("../project-context.js", () => ({
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, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskList, runTaskDuplicate, runTaskRefine, runTaskDelete, runTaskRetry, runTaskLogs, runTaskComment, runTaskComments, runTaskPrCreate, runTaskPlan, runTaskMove, runTaskAttach, runTaskPause, runTaskUnpause, runTaskArchive, runTaskUnarchive, runTaskSteer, runTaskImportFromGitHub, runTaskImportGitHubInteractive, runTaskUpdate, runTaskLog, runTaskMerge, type LogsOptions } from "./task.js";
import { isGhAvailable, isGhAuthenticated, getCurrentRepo } from "@fusion/core/gh-cli";
import { GitHubClient } from "@fusion/dashboard";
import { createSession } from "@fusion/dashboard/planning";
import { createSession, submitResponse } from "@fusion/dashboard/planning";
import { resolveProject } from "../project-context.js";
import { aiMergeTask } from "@fusion/engine";
@@ -225,7 +219,7 @@ describe("project-aware task command behavior", () => {
const init = vi.fn();
vi.mocked(resolveProject).mockRejectedValueOnce(
new Error("No kb project found in current directory. Use --project or run from a project directory.")
new Error("No fusion project found in current directory. Use --project or run from a project directory.")
);
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation((projectPath: string) => ({
@@ -330,14 +324,15 @@ describe("project-aware task command behavior", () => {
vi.mocked(existsSync).mockReturnValue(false);
const promise = runTaskLogs("FN-001", { follow: true }, "demo-project");
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(vi.mocked(watchFile)).toHaveBeenCalledWith(
expect.stringContaining("/resolved/project/.kb/tasks/FN-001/agent.log"),
expect.stringContaining("/resolved/project/.fusion/tasks/FN-001/agent.log"),
expect.objectContaining({ interval: 1000 }),
expect.any(Function),
);
expect(sigintHandlers).toHaveLength(1);
expect(() => sigintHandlers[0]()).toThrow("process.exit");
await expect(promise).rejects.toThrow("process.exit");
promise.catch(() => {});
expect(resolveProject).toHaveBeenCalledWith("demo-project");
expect(logSpy.mock.calls.some((call) => String(call[0]).includes("Logs for project 'demo-project':"))).toBe(true);
@@ -357,7 +352,7 @@ describe("project-aware task command behavior", () => {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
getTask: vi.fn().mockResolvedValue(makeTask({ id: "FN-001", column: "in-review", branchName: "fusion/fn-001" })),
updateTask: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
}));
@@ -370,15 +365,41 @@ describe("project-aware task command behavior", () => {
it("runTaskPlan uses resolved project path only when project name is provided", async () => {
const mockCreateTask = vi.fn().mockResolvedValue(makeTask({ id: "FN-010", description: "planned task" }));
const mockQuestion = {
id: "scope",
type: "confirm" as const,
question: "Proceed?",
};
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
createTask: mockCreateTask,
}));
vi.mocked(resolveProject).mockResolvedValue({
projectId: "proj_test",
projectPath: "/test",
projectName: "demo-project",
isRegistered: true,
store: {
createTask: mockCreateTask,
} as unknown as TaskStore,
});
vi.mocked(createSession).mockResolvedValue({
sessionId: "sess-1",
summary: { description: "planned task", steps: [], reviewLevel: 1, sizeEstimate: "M", clarifications: [] },
questions: [],
isComplete: true,
firstQuestion: mockQuestion,
} as never);
vi.mocked(submitResponse).mockResolvedValue({
type: "complete",
data: {
title: "planned task",
description: "planned task",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: [],
},
} as never);
vi.mocked(createInterface).mockReturnValue({
question: vi.fn().mockResolvedValue("y"),
close: vi.fn(),
} as never);
await runTaskPlan("planned task", true, "demo-project");
@@ -1880,7 +1901,10 @@ describe("runTaskLogs", () => {
projectPath: "/test",
projectName: "test",
isRegistered: true,
store: {} as TaskStore,
store: {
getTask: mockGetTask,
getAgentLogs: mockGetAgentLogs,
} as unknown as TaskStore,
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({

View File

@@ -32,6 +32,9 @@ function asLocalProjectContext(store: TaskStore): ProjectContext {
async function getCommandContext(projectName?: string): Promise<CommandContext> {
if (projectName) {
const context = await resolveProject(projectName);
if (!context) {
throw new Error(`Project ${projectName} not found`);
}
return {
store: context.store,
projectPath: context.projectPath,
@@ -42,6 +45,9 @@ async function getCommandContext(projectName?: string): Promise<CommandContext>
try {
const context = await resolveProject(undefined);
if (!context) {
throw new Error("No project context");
}
return {
store: context.store,
projectPath: context.projectPath,
@@ -112,7 +118,7 @@ export async function runTaskCreate(descriptionArg?: string, attachFiles?: strin
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .kb/tasks/${task.id}/`);
console.log(` Path: .fusion/tasks/${task.id}/`);
if (attachFiles && attachFiles.length > 0) {
const { readFile } = await import("node:fs/promises");
@@ -321,7 +327,7 @@ export async function runTaskLogs(id: string, options: LogsOptions = {}, project
if (options.follow) {
const followStore = store;
const projectPath = projectContext?.projectPath ?? process.cwd();
const logPath = join(projectPath, ".kb", "tasks", id, "agent.log");
const logPath = join(projectPath, ".fusion", "tasks", id, "agent.log");
if (!existsSync(logPath)) {
console.log(`\n Waiting for log file to be created...`);
@@ -514,7 +520,7 @@ export async function runTaskAttach(id: string, filePath: string, projectName?:
console.log();
console.log(` ✓ Attached to ${id}: ${attachment.originalName}`);
console.log(` File: ${attachment.filename} (${sizeKB} KB)`);
console.log(` Path: .kb/tasks/${id}/attachments/${attachment.filename}`);
console.log(` Path: .fusion/tasks/${id}/attachments/${attachment.filename}`);
console.log();
}
@@ -557,7 +563,7 @@ export async function runTaskDuplicate(id: string, projectName?: string) {
console.log();
console.log(` ✓ Duplicated ${id} → ${newTask.id}`);
console.log(` Path: .kb/tasks/${newTask.id}/`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log();
}
@@ -589,7 +595,7 @@ export async function runTaskRefine(id: string, feedbackArg?: string, projectNam
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
console.log(` Column: triage`);
console.log(` Dependency: ${id}`);
console.log(` Path: .kb/tasks/${newTask.id}/`);
console.log(` Path: .fusion/tasks/${newTask.id}/`);
console.log();
}
@@ -651,6 +657,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
} catch (err: any) {
console.error(`✗ Task ${id} not found`);
process.exit(1);
return;
}
// Prompt for confirmation unless force is used
@@ -663,6 +670,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
if (trimmed !== "y" && trimmed !== "yes") {
console.log("Cancelled.");
process.exit(0);
return;
}
}
@@ -674,6 +682,7 @@ export async function runTaskDelete(id: string, force?: boolean, projectName?: s
} catch (err: any) {
console.error(`✗ Failed to delete ${id}: ${err.message}`);
process.exit(1);
return;
}
}
@@ -1547,7 +1556,7 @@ export async function runTaskPlan(initialPlanArg?: string, yesFlag = false, proj
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .kb/tasks/${task.id}/`);
console.log(` Path: .fusion/tasks/${task.id}/`);
console.log();
} else {
console.log("\n Task creation cancelled.\n");