feat(FN-2361): codify workspace verification bootstrap contract
- Add root verify:workspace script enforcing lint -> test -> build order - Update CI workflow to run verify:workspace as the single workspace gate before binary packaging - Add CLI guardrail tests for workflow sequencing and root script contract invariants - Document deterministic workspace bootstrap expectations in contributing guide - Clarify bundle-output test bootstrap intent for explicit artifact setup
This commit is contained in:
@@ -14,6 +14,9 @@ const tsupConfigPath = join(cliRoot, "tsup.config.ts");
|
||||
|
||||
describe("CLI bundle output", () => {
|
||||
beforeAll(() => {
|
||||
// Intentional: bundle-output tests validate compiled artifacts, so they
|
||||
// perform their own explicit build bootstrap instead of relying on ambient
|
||||
// workspace dist/ state.
|
||||
buildCliWithRealDashboardAssets();
|
||||
}, 300_000);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync, accessSync, constants, existsSync } from "node:fs";
|
||||
import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { parse } from "yaml";
|
||||
|
||||
@@ -22,13 +22,20 @@ function loadWorkflow(name: string): any {
|
||||
describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
let workflow: any;
|
||||
let content: string;
|
||||
let ciSteps: any[];
|
||||
|
||||
beforeAll(() => {
|
||||
const result = loadWorkflow("ci.yml");
|
||||
workflow = result.parsed;
|
||||
content = result.content;
|
||||
ciSteps = workflow.jobs?.ci?.steps ?? [];
|
||||
});
|
||||
|
||||
const findStepByRun = (runSnippet: string) => ciSteps.find((step) => typeof step.run === "string" && step.run.includes(runSnippet));
|
||||
|
||||
const findStepIndexByRun = (runSnippet: string) =>
|
||||
ciSteps.findIndex((step) => typeof step.run === "string" && step.run.includes(runSnippet));
|
||||
|
||||
it("is valid YAML", () => {
|
||||
expect(workflow).toBeDefined();
|
||||
expect(typeof workflow).toBe("object");
|
||||
@@ -47,8 +54,24 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
expect(content).toContain("pnpm install");
|
||||
});
|
||||
|
||||
it("includes pnpm build step", () => {
|
||||
expect(content).toContain("pnpm build");
|
||||
it("uses verify:workspace as the single lint/test/build contract", () => {
|
||||
const verifyStep = findStepByRun("pnpm verify:workspace");
|
||||
expect(verifyStep).toBeDefined();
|
||||
expect(verifyStep.name).toContain("bootstrap contract");
|
||||
|
||||
const directLintStep = findStepByRun("pnpm lint");
|
||||
const directTestStep = findStepByRun("pnpm test");
|
||||
const directBuildStep = findStepByRun("pnpm build");
|
||||
expect(directLintStep).toBeUndefined();
|
||||
expect(directTestStep).toBeUndefined();
|
||||
expect(directBuildStep).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs workspace verification before binary packaging", () => {
|
||||
const verifyIdx = findStepIndexByRun("pnpm verify:workspace");
|
||||
const buildExeIdx = findStepIndexByRun("build:exe");
|
||||
expect(verifyIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(buildExeIdx).toBeGreaterThan(verifyIdx);
|
||||
});
|
||||
|
||||
it("includes binary build step", () => {
|
||||
@@ -62,10 +85,6 @@ describe("CI workflow (.github/workflows/ci.yml)", () => {
|
||||
it("verifies binary exists after build", () => {
|
||||
expect(content).toContain("test -f packages/cli/dist/fn");
|
||||
});
|
||||
|
||||
it("includes pnpm test step", () => {
|
||||
expect(content).toContain("pnpm test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Version & Release workflow (.github/workflows/version.yml)", () => {
|
||||
|
||||
@@ -16,6 +16,11 @@ function loadWorkflowYaml(name: string): any {
|
||||
return parse(content);
|
||||
}
|
||||
|
||||
function loadRootPackageJson(): any {
|
||||
const path = join(workspaceRoot, "package.json");
|
||||
return JSON.parse(readFileSync(path, "utf-8"));
|
||||
}
|
||||
|
||||
describe("CLI package.json publishing config", () => {
|
||||
const pkg = loadPackageJson("cli");
|
||||
|
||||
@@ -106,6 +111,30 @@ describe("Scoped @fusion/* packages publishing config", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("Workspace bootstrap script contract", () => {
|
||||
const rootPkg = loadRootPackageJson();
|
||||
|
||||
it("keeps root test self-sufficient (no implicit pre-build dependency)", () => {
|
||||
const testScript = rootPkg.scripts?.test;
|
||||
expect(testScript).toBeDefined();
|
||||
expect(testScript).toContain("pnpm -r");
|
||||
expect(testScript).not.toContain("pnpm build");
|
||||
});
|
||||
|
||||
it("defines verify:workspace in lint -> test -> build order", () => {
|
||||
const verifyScript = rootPkg.scripts?.["verify:workspace"];
|
||||
expect(verifyScript).toBe("pnpm lint && pnpm test && pnpm build");
|
||||
|
||||
const lintIdx = verifyScript.indexOf("pnpm lint");
|
||||
const testIdx = verifyScript.indexOf("pnpm test");
|
||||
const buildIdx = verifyScript.indexOf("pnpm build");
|
||||
|
||||
expect(lintIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(testIdx).toBeGreaterThan(lintIdx);
|
||||
expect(buildIdx).toBeGreaterThan(testIdx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow YAML validity", () => {
|
||||
it("ci.yml is valid YAML", () => {
|
||||
const parsed = loadWorkflowYaml("ci.yml");
|
||||
|
||||
108
packages/cli/src/commands/claude-skills-runner.ts
Normal file
108
packages/cli/src/commands/claude-skills-runner.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Thin wrapper around claude-skills install logic that also handles the
|
||||
* "should we even try?" question (reads global settings, checks detection)
|
||||
* so call sites don't each repeat that plumbing.
|
||||
*/
|
||||
|
||||
import { getPackageManagerAgentDir } from "./auth-paths.js";
|
||||
import {
|
||||
ensureFusionSkillForProjects,
|
||||
installFusionSkillIntoProject,
|
||||
isPiClaudeCliConfigured,
|
||||
resolveFusionSkillSource,
|
||||
type InstallResult,
|
||||
} from "./claude-skills.js";
|
||||
import { createReadOnlyProviderSettingsView } from "./provider-settings.js";
|
||||
|
||||
/**
|
||||
* Resolve whether pi-claude-cli is configured by reading the user's global
|
||||
* settings (`~/.fusion/agent/settings.json` with cascade to legacy `.pi`).
|
||||
*
|
||||
* The project path is used only so the settings reader can merge the
|
||||
* project's `.fusion/settings.json` overlay; we only examine the global
|
||||
* portion for this check.
|
||||
*/
|
||||
export function detectPiClaudeCli(projectPath: string): boolean {
|
||||
try {
|
||||
const agentDir = getPackageManagerAgentDir();
|
||||
const view = createReadOnlyProviderSettingsView(projectPath, agentDir);
|
||||
return isPiClaudeCliConfigured(view.getGlobalSettings());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the fusion skill into a single newly-created project, logging the
|
||||
* outcome to the console. Intended for CLI entry points (`fn init`,
|
||||
* `fn project add`) where the user is watching the output.
|
||||
*
|
||||
* No-op (and silent) when pi-claude-cli is not configured so the file layout
|
||||
* stays clean for users who only use direct Anthropic API.
|
||||
*/
|
||||
export function maybeInstallClaudeSkillForNewProject(projectPath: string): InstallResult {
|
||||
const enabled = detectPiClaudeCli(projectPath);
|
||||
const result = installFusionSkillIntoProject(projectPath, { enabled });
|
||||
logInstallResult(result, { verbose: enabled });
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the fusion skill into every registered project during server
|
||||
* startup. Non-blocking: callers invoke this without awaiting. Logs one line
|
||||
* per non-skipped, non-already-installed project; stays quiet when there's
|
||||
* nothing to do.
|
||||
*/
|
||||
export function ensureClaudeSkillsForAllProjectsOnStartup(
|
||||
projects: Array<{ id: string; name: string; path: string }>,
|
||||
): InstallResult[] {
|
||||
if (projects.length === 0) return [];
|
||||
// Detect using the first project; all share the same user-level settings.
|
||||
const enabled = detectPiClaudeCli(projects[0]!.path);
|
||||
if (!enabled) {
|
||||
return projects.map((p) => ({
|
||||
outcome: "skipped" as const,
|
||||
target: `${p.path}/.claude/skills/fusion`,
|
||||
reason: "pi-claude-cli not configured",
|
||||
}));
|
||||
}
|
||||
const source = resolveFusionSkillSource();
|
||||
const results = ensureFusionSkillForProjects(projects, { enabled, source });
|
||||
for (let i = 0; i < results.length; i++) {
|
||||
const result = results[i]!;
|
||||
if (result.outcome === "installed" || result.outcome === "replaced") {
|
||||
console.log(
|
||||
`[fusion] Installed Claude skill for project '${projects[i]!.name}' (${result.outcome}): ${result.target}`,
|
||||
);
|
||||
} else if (result.outcome === "failed") {
|
||||
console.warn(
|
||||
`[fusion] Could not install Claude skill for project '${projects[i]!.name}': ${result.reason ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function logInstallResult(result: InstallResult, options: { verbose: boolean }): void {
|
||||
switch (result.outcome) {
|
||||
case "installed":
|
||||
console.log(` ✓ Installed fusion skill at ${result.target}`);
|
||||
break;
|
||||
case "replaced":
|
||||
console.log(` ✓ Refreshed fusion skill at ${result.target}`);
|
||||
break;
|
||||
case "already-installed":
|
||||
if (options.verbose) {
|
||||
console.log(` ✓ Fusion skill already present at ${result.target}`);
|
||||
}
|
||||
break;
|
||||
case "failed":
|
||||
console.warn(
|
||||
` ⚠ Could not install fusion skill: ${result.reason ?? "unknown error"}`,
|
||||
);
|
||||
break;
|
||||
case "skipped":
|
||||
// Silent — the user hasn't opted into Claude Code routing.
|
||||
break;
|
||||
}
|
||||
}
|
||||
250
packages/cli/src/commands/claude-skills.ts
Normal file
250
packages/cli/src/commands/claude-skills.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Claude Code skill installation for Fusion projects.
|
||||
*
|
||||
* When pi-claude-cli routes the model through Claude Code, pi's own skill
|
||||
* injection is bypassed (pi-claude-cli only forwards systemPrompt + AGENTS.md).
|
||||
* Claude Code instead auto-loads skills from `<project>/.claude/skills/<name>/`
|
||||
* and `~/.claude/skills/<name>/`. To make the fusion skill available inside
|
||||
* Claude Code sessions, we symlink the shipped `skill/fusion` directory into
|
||||
* each project's `.claude/skills/fusion/`.
|
||||
*
|
||||
* Entry points that call installFusionSkillIntoProject:
|
||||
* - `fn init` (packages/cli/src/commands/init.ts)
|
||||
* - `fn project add` (packages/cli/src/commands/project.ts)
|
||||
* - POST /api/projects (packages/dashboard/src/routes.ts)
|
||||
* - serve.ts startup reconciliation (packages/cli/src/commands/serve.ts)
|
||||
*
|
||||
* All call sites are guarded by isPiClaudeCliConfigured() so users who don't
|
||||
* route through Claude Code never see `.claude/skills/` appear in their repos.
|
||||
*/
|
||||
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readlinkSync,
|
||||
symlinkSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* The skill directory name under `.claude/skills/`. The shipped source at
|
||||
* `packages/cli/skill/fusion/` is symlinked in at this path.
|
||||
*/
|
||||
export const FUSION_SKILL_NAME = "fusion";
|
||||
|
||||
/**
|
||||
* Result of an install attempt.
|
||||
*
|
||||
* - "installed": created a new symlink (or copy fallback)
|
||||
* - "already-installed": correct link/copy already present, nothing to do
|
||||
* - "skipped": pi-claude-cli not configured, intentionally did nothing
|
||||
* - "replaced": an existing incorrect entry was replaced
|
||||
* - "failed": error occurred; reason populated
|
||||
*/
|
||||
export type InstallOutcome =
|
||||
| "installed"
|
||||
| "already-installed"
|
||||
| "skipped"
|
||||
| "replaced"
|
||||
| "failed";
|
||||
|
||||
export interface InstallResult {
|
||||
outcome: InstallOutcome;
|
||||
target: string;
|
||||
source?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the user has pi-claude-cli configured, meaning pi should route
|
||||
* model calls through Claude Code and we should mirror the fusion skill into
|
||||
* each project's `.claude/skills/`.
|
||||
*
|
||||
* Detection order:
|
||||
* 1. Explicit `useClaudeCli` boolean in global settings (either value wins)
|
||||
* 2. Legacy signal: `packages` array contains `"npm:pi-claude-cli"`
|
||||
*/
|
||||
export function isPiClaudeCliConfigured(
|
||||
globalSettings: Record<string, unknown> | null | undefined,
|
||||
): boolean {
|
||||
if (!globalSettings || typeof globalSettings !== "object") {
|
||||
return false;
|
||||
}
|
||||
const toggle = (globalSettings as { useClaudeCli?: unknown }).useClaudeCli;
|
||||
if (typeof toggle === "boolean") {
|
||||
return toggle;
|
||||
}
|
||||
const packages = (globalSettings as { packages?: unknown }).packages;
|
||||
if (Array.isArray(packages)) {
|
||||
return packages.some(
|
||||
(entry) =>
|
||||
typeof entry === "string" &&
|
||||
/(^|[:/])pi-claude-cli(@|$)/.test(entry.trim()),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the path to the shipped fusion skill directory.
|
||||
*
|
||||
* At runtime this file lives at `<cli-pkg>/dist/commands/claude-skills.js`
|
||||
* when published, and `<cli-pkg>/src/commands/claude-skills.ts` in dev under
|
||||
* tsx. Either way, `../../skill/fusion` points at the packaged skill.
|
||||
*
|
||||
* Returns null if the directory is missing (e.g. broken install).
|
||||
*/
|
||||
export function resolveFusionSkillSource(): string | null {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const candidate = resolve(dirname(here), "..", "..", "skill", FUSION_SKILL_NAME);
|
||||
return existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the fusion skill into `<projectPath>/.claude/skills/fusion`.
|
||||
*
|
||||
* Idempotent:
|
||||
* - If the target is already a symlink to the current source, no-op.
|
||||
* - If it's a stale symlink or a foreign file/dir, it's replaced.
|
||||
* - Prefers symlinks so skill updates flow automatically when the fusion
|
||||
* package is upgraded; falls back to a copy on platforms where symlinks
|
||||
* aren't allowed (typically Windows without developer mode).
|
||||
*
|
||||
* Never throws: errors are captured and returned as {outcome: "failed", reason}.
|
||||
*/
|
||||
export function installFusionSkillIntoProject(
|
||||
projectPath: string,
|
||||
options: { source?: string | null; enabled?: boolean } = {},
|
||||
): InstallResult {
|
||||
const target = join(projectPath, ".claude", "skills", FUSION_SKILL_NAME);
|
||||
|
||||
if (options.enabled === false) {
|
||||
return { outcome: "skipped", target, reason: "pi-claude-cli not configured" };
|
||||
}
|
||||
|
||||
const source = options.source ?? resolveFusionSkillSource();
|
||||
if (!source) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
target,
|
||||
reason: "fusion skill source directory not found in installed package",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
|
||||
let replaced = false;
|
||||
if (existsSync(target) || isBrokenSymlink(target)) {
|
||||
const stat = lstatSync(target);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const current = safeReadlink(target);
|
||||
if (current && resolve(dirname(target), current) === resolve(source)) {
|
||||
return { outcome: "already-installed", target, source };
|
||||
}
|
||||
unlinkSync(target);
|
||||
replaced = true;
|
||||
} else {
|
||||
// A directory or file occupies the slot — don't blow it away unless
|
||||
// it looks like something we created. Check for a SKILL.md to reduce
|
||||
// the odds of clobbering a user's hand-authored skill.
|
||||
const skillMd = join(target, "SKILL.md");
|
||||
if (!existsSync(skillMd)) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
target,
|
||||
reason: "target exists and does not look like a fusion skill install",
|
||||
};
|
||||
}
|
||||
// Replace: a plain copy from a prior install. Delete and re-symlink.
|
||||
removeRecursive(target);
|
||||
replaced = true;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
symlinkSync(source, target, "dir");
|
||||
} catch (err) {
|
||||
// Windows / restricted FS — copy instead.
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
try {
|
||||
cpSync(source, target, { recursive: true });
|
||||
return {
|
||||
outcome: replaced ? "replaced" : "installed",
|
||||
target,
|
||||
source,
|
||||
reason: `symlink failed (${reason}); copied files instead`,
|
||||
};
|
||||
} catch (copyErr) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
target,
|
||||
source,
|
||||
reason: copyErr instanceof Error ? copyErr.message : String(copyErr),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { outcome: replaced ? "replaced" : "installed", target, source };
|
||||
} catch (err) {
|
||||
return {
|
||||
outcome: "failed",
|
||||
target,
|
||||
source,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the fusion skill into every project in a list. Used at server
|
||||
* startup to self-heal any projects whose `.claude/skills/fusion` was removed
|
||||
* or never installed (e.g. projects registered before this feature landed).
|
||||
*
|
||||
* Failures are collected but never thrown — startup must not be blocked by
|
||||
* filesystem quirks on a single project.
|
||||
*/
|
||||
export function ensureFusionSkillForProjects(
|
||||
projects: Array<{ id: string; name: string; path: string }>,
|
||||
options: { enabled: boolean; source?: string | null } = { enabled: false },
|
||||
): InstallResult[] {
|
||||
if (!options.enabled) {
|
||||
return projects.map((p) => ({
|
||||
outcome: "skipped" as const,
|
||||
target: join(p.path, ".claude", "skills", FUSION_SKILL_NAME),
|
||||
reason: "pi-claude-cli not configured",
|
||||
}));
|
||||
}
|
||||
const source = options.source ?? resolveFusionSkillSource();
|
||||
return projects.map((p) =>
|
||||
installFusionSkillIntoProject(p.path, { source, enabled: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function isBrokenSymlink(path: string): boolean {
|
||||
try {
|
||||
const stat = lstatSync(path);
|
||||
if (!stat.isSymbolicLink()) return false;
|
||||
return !existsSync(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReadlink(path: string): string | null {
|
||||
try {
|
||||
return readlinkSync(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeRecursive(path: string): void {
|
||||
// Node 14.14+: rmSync. Imported lazily to keep the top imports minimal.
|
||||
const { rmSync } = require("node:fs") as typeof import("node:fs");
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
}
|
||||
@@ -635,7 +635,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Enables the PluginManager UI to list, install, enable, disable, and
|
||||
// configure plugins via the /api/plugins REST endpoints.
|
||||
//
|
||||
const pluginStore = new PluginStore(store.getFusionDir());
|
||||
const pluginStore = new PluginStore(store.getRootDir());
|
||||
await pluginStore.init();
|
||||
|
||||
// ── PluginLoader: plugin lifecycle management ───────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@ import { promisify } from "node:util";
|
||||
const execAsync = promisify(exec);
|
||||
import { CentralCore, QMD_INSTALL_COMMAND, isQmdAvailable } from "@fusion/core";
|
||||
import { resolveGlobalDir } from "@fusion/core";
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
|
||||
/** Options for the init command */
|
||||
export interface InitOptions {
|
||||
@@ -99,6 +100,7 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
const existing = await central.getProjectByPath(cwd);
|
||||
if (existing) {
|
||||
console.log(` ✓ Already registered in central database`);
|
||||
maybeInstallClaudeSkillForNewProject(cwd);
|
||||
console.log(`\n✓ Project "${projectName}" is ready!`);
|
||||
console.log(`\n Next steps:`);
|
||||
console.log(` fn task list # View tasks`);
|
||||
@@ -118,6 +120,8 @@ export async function runInit(options: InitOptions = {}): Promise<void> {
|
||||
// Activate the project (registration sets it to 'initializing')
|
||||
await central.updateProject(project.id, { status: "active" });
|
||||
|
||||
maybeInstallClaudeSkillForNewProject(cwd);
|
||||
|
||||
console.log(` ✓ Registered in central database`);
|
||||
console.log(`\n✓ Project "${project.name}" initialized successfully!`);
|
||||
console.log(`\n Next steps:`);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { resolve, isAbsolute, relative, basename } from "node:path";
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { formatProjectLine, detectProjectFromCwd, setDefaultProject, resolveProject as resolveProjectContext } from "../project-context.js";
|
||||
import { maybeInstallClaudeSkillForNewProject } from "./claude-skills-runner.js";
|
||||
|
||||
const VALID_ISOLATION_MODES: IsolationMode[] = ["in-process", "child-process"];
|
||||
|
||||
@@ -373,6 +374,8 @@ export async function runProjectAdd(
|
||||
console.warn(` ⚠ Warning: Could not initialize project memory: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
maybeInstallClaudeSkillForNewProject(absolutePath);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Registered project '${projectName}'`);
|
||||
console.log(` Location: ${formatDisplayPath(project.path)}`);
|
||||
|
||||
@@ -373,7 +373,7 @@ export async function runServe(
|
||||
// internally for task-execution plugin hooks. These instances here serve the
|
||||
// HTTP plugin-management API routes and are intentionally separate.
|
||||
//
|
||||
const pluginStore = new PluginStore(store.getFusionDir());
|
||||
const pluginStore = new PluginStore(store.getRootDir());
|
||||
await pluginStore.init();
|
||||
|
||||
// ── PluginLoader: plugin lifecycle management ───────────────────────
|
||||
|
||||
Reference in New Issue
Block a user