Files
fusion/packages/cli/src/commands/agent-import.ts
gsxdsm 97172fdcf2 fix(FN-7952): require PostgreSQL in CLI and desktop (#2110)
## Summary

CLI commands, daemon/dashboard startup, packaged desktop startup, and
live-data maintenance scripts now share the mandatory PostgreSQL
lifecycle. Operators no longer risk a command silently reading or
writing a disconnected SQLite shadow when PostgreSQL setup fails.

## Design decisions

- Every startup owner retains and awaits its PostgreSQL shutdown
callback, including partial-startup failure paths.
- CLI project context and lock-retry flows resolve through asynchronous
project stores.
- Maintenance scripts use the shared backend helper; explicit database
migration/inspection remains the only CLI surface allowed to read legacy
SQLite sources.

## Validation

- CLI and Desktop typechecks pass on the stacked branch.
- `pnpm test:gate` passes all 478 gate tests.
- This PR changes 54 files.

## Stack

- Depends on #2109, which depends on #2108.
- Bundled plugins and docs/release follow in later PRs.

Related: #2105


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the authoritative store for structured project and
task metadata.
* Projects can be recognized and initialized using
`.fusion/project.json`, without creating a legacy SQLite database.
  * CLI commands now retry transient PostgreSQL contention errors.

* **Bug Fixes**
* Improved cleanup when commands complete, fail, or run in the
background, preventing lingering resources.
  * Improved desktop, server, and session shutdown reliability.

* **Documentation**
* Updated storage and standalone binary guidance to reflect PostgreSQL
and legacy SQLite compatibility.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 23:18:55 -07:00

418 lines
13 KiB
TypeScript

/**
* CLI command for importing agents from Agent Companies packages.
*
* Usage:
* fn agent import <source> [--dry-run] [--skip-existing] [--project <name>]
*
* @module agent-import
*/
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import {
AgentStore,
parseCompanyDirectory,
parseCompanyArchive,
parseSingleAgentManifest,
prepareAgentCompaniesImport,
AgentCompaniesParseError,
} from "@fusion/core";
import type { AgentCreateInput } from "@fusion/core";
import type { SkillManifest } from "@fusion/core";
import { stringify as stringifyYaml } from "yaml";
import { resolveAgentStoreBase } from "../project-context.js";
export interface SkillImportResult {
imported: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
}
const UNSUPPORTED_FORMAT_MESSAGE =
"Unsupported format. Provide an Agent Companies directory, .tar.gz/.tgz/.zip archive, or AGENTS.md file.";
/**
* Convert a string to a safe path segment (slug).
* - Lowercase
* - Replace spaces with hyphens
* - Remove characters that are not alphanumeric, hyphens, or underscores
* - Collapse multiple hyphens/underscores to single
* - Trim leading/trailing hyphens/underscores
* - Fallback to "unnamed" if empty after sanitization
*/
function slugifyPathSegment(input: string): string {
if (!input || typeof input !== "string") {
return "unnamed";
}
const slug = input
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9\-_]/g, "")
.replace(/-+/g, "-")
.replace(/_+/g, "_")
.replace(/^-+|-+$/g, "")
.replace(/^_+|_+$/g, "");
if (!slug) {
return "unnamed";
}
return slug.slice(0, 64);
}
/**
* Generate SKILL.md content from a SkillManifest.
* Uses the same format as the dashboard/skills adapter.
*/
function toSkillMarkdown(skill: SkillManifest): string {
const frontmatter: Record<string, unknown> = {
name: skill.name,
schema: "agentcompanies/v1",
kind: "skill",
};
// Copy optional fields when present and valid
if (typeof skill.description === "string" && skill.description.length > 0) {
frontmatter.description = skill.description;
}
if (typeof skill.slug === "string" && skill.slug.length > 0) {
frontmatter.slug = skill.slug;
}
if (typeof skill.version === "string" && skill.version.length > 0) {
frontmatter.version = skill.version;
}
if (typeof skill.license === "string" && skill.license.length > 0) {
frontmatter.license = skill.license;
}
if (Array.isArray(skill.authors) && skill.authors.length > 0) {
frontmatter.authors = skill.authors.filter((a): a is string => typeof a === "string" && a.length > 0);
}
if (Array.isArray(skill.tags) && skill.tags.length > 0) {
frontmatter.tags = skill.tags.filter((t): t is string => typeof t === "string" && t.length > 0);
}
const body = skill.instructionBody && skill.instructionBody.trim().length > 0
? skill.instructionBody
: `# ${skill.name}`;
const yaml = stringifyYaml(frontmatter, { lineWidth: 0 }).trimEnd();
return `---\n${yaml}\n---\n${body}`;
}
/**
* Import skills from a package to the project skills directory.
* Skills are written to: {projectPath}/skills/imported/{companySlug}/{skillSlug}/SKILL.md
*/
async function importSkillsToProject(
projectPath: string,
skills: SkillManifest[],
companySlug: string | undefined,
dryRun: boolean,
): Promise<SkillImportResult> {
const result: SkillImportResult = {
imported: [],
skipped: [],
errors: [],
};
const companyDir = slugifyPathSegment(companySlug ?? "unknown-company");
const baseSkillsDir = resolve(projectPath, "skills", "imported", companyDir);
for (const skill of skills) {
// Skip skills without a name
if (!skill.name || typeof skill.name !== "string" || skill.name.trim().length === 0) {
result.errors.push({ name: "(unnamed)", error: "Skill is missing required 'name' field" });
continue;
}
const skillSlug = slugifyPathSegment(skill.name);
const skillDir = resolve(baseSkillsDir, skillSlug);
const skillPath = resolve(skillDir, "SKILL.md");
// Check if skill already exists
if (existsSync(skillPath)) {
result.skipped.push(skill.name);
continue;
}
if (dryRun) {
// In dry-run mode, just report what would be imported
result.imported.push(skill.name);
continue;
}
try {
mkdirSync(skillDir, { recursive: true });
writeFileSync(skillPath, toSkillMarkdown(skill), "utf-8");
result.imported.push(skill.name);
} catch (err) {
result.errors.push({ name: skill.name, error: (err as Error).message });
}
}
return result;
}
/**
* Print a summary of the import result.
*/
function printSummary(
companyName: string | undefined,
agentCount: number,
teamCount: number,
created: string[],
skipped: string[],
errors: Array<{ name: string; error: string }>,
dryRun: boolean,
skillResult?: SkillImportResult,
): void {
const prefix = dryRun ? "[DRY RUN] " : "";
console.log();
console.log(` ${prefix}Company: ${companyName ?? "Unknown"}`);
console.log(` ${prefix}Agents: ${agentCount}`);
console.log(` ${prefix}Teams: ${teamCount}`);
console.log(` ${prefix}Created: ${created.length}`);
for (const name of created) {
console.log(` ✓ ${name}`);
}
if (skipped.length > 0) {
console.log(` ${prefix}Skipped: ${skipped.length}`);
for (const name of skipped) {
console.log(` ○ ${name}`);
}
}
if (errors.length > 0) {
console.log(` ${prefix}Errors: ${errors.length}`);
for (const err of errors) {
console.log(` ✗ ${err.name}: ${err.error}`);
}
}
if (skillResult) {
console.log(` ${prefix}Skills: ${skillResult.imported.length} imported, ${skillResult.skipped.length} skipped, ${skillResult.errors.length} errors`);
for (const name of skillResult.imported) {
console.log(` ✓ ${name}`);
}
for (const name of skillResult.skipped) {
console.log(` ○ ${name}`);
}
for (const err of skillResult.errors) {
console.log(` ✗ ${err.name}: ${err.error}`);
}
}
console.log();
}
function isArchivePath(path: string): boolean {
return path.endsWith(".tar.gz") || path.endsWith(".tgz") || path.endsWith(".zip");
}
/**
* Run the agent import command.
*
* @param source - Path to an Agent Companies directory/archive/manifest source
* @param options - Command options
*/
export async function runAgentImport(
source: string,
options?: {
dryRun?: boolean;
skipExisting?: boolean;
project?: string;
},
): Promise<void> {
const dryRun = options?.dryRun ?? false;
const skipExisting = options?.skipExisting ?? false;
const sourcePath = resolve(source);
if (!existsSync(sourcePath)) {
console.error(`Path not found: ${sourcePath}`);
process.exit(1);
}
// FNXC:PostgresCutover 2026-07-04: construct AgentStore in backend mode by
// borrowing the asyncLayer from the resolved project store (SQLite runtime
// removed under VAL-REMOVAL-005), mirroring extension.ts getAgentStore.
const base = await resolveAgentStoreBase(options?.project);
const projectPath = base.rootDir;
const agentStore = new AgentStore({ rootDir: projectPath + "/.fusion", asyncLayer: base.asyncLayer });
let cleanupPromise: Promise<void> | undefined;
let exitRequested = false;
const cleanup = (): Promise<void> => {
cleanupPromise ??= (async () => {
const failures: unknown[] = [];
try {
agentStore.close();
} catch (error) {
failures.push(error);
}
try {
await base.cleanup();
} catch (error) {
failures.push(error);
}
if (failures.length === 1) throw failures[0];
if (failures.length > 1) throw new AggregateError(failures, "Failed to close agent import resources");
})();
return cleanupPromise;
};
const exitWithCleanup = async (code: number): Promise<never> => {
exitRequested = true;
/* FNXC:PostgresCliLifecycle 2026-07-14-22:55: Import parse failures retain their established exit code even when teardown fails, while cleanup still attempts both AgentStore and borrowed PostgreSQL owners. */
await cleanup().catch((error) => console.error(`Cleanup failed: ${error instanceof Error ? error.message : String(error)}`));
return process.exit(code);
};
try {
await agentStore.init();
const existingAgents = await agentStore.listAgents();
const existingNames = new Set(existingAgents.map((a) => a.name));
const conversionOptions = {
...(skipExisting ? { skipExisting: [...existingNames] } : {}),
existingAgents,
};
let companyName: string | undefined;
let companySlug: string | undefined;
let agentCount = 0;
let teamCount = 0;
let importItems: Array<{
manifestKey: string;
input: AgentCreateInput;
reportsTo?: {
raw: string;
resolvedAgentId?: string;
deferredManifestKey?: string;
};
}> = [];
let result: {
created: string[];
skipped: string[];
errors: Array<{ name: string; error: string }>;
} = {
created: [],
skipped: [],
errors: [],
};
let skills: SkillManifest[] = [];
let isPackageImport = false;
try {
const sourceStats = statSync(sourcePath);
if (sourceStats.isDirectory()) {
const pkg = parseCompanyDirectory(sourcePath);
companyName = pkg.company?.name;
companySlug = pkg.company?.slug;
agentCount = pkg.agents.length;
teamCount = pkg.teams.length;
skills = pkg.skills ?? [];
isPackageImport = true;
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else if (isArchivePath(sourcePath)) {
const pkg = await parseCompanyArchive(sourcePath);
companyName = pkg.company?.name;
companySlug = pkg.company?.slug;
agentCount = pkg.agents.length;
teamCount = pkg.teams.length;
skills = pkg.skills ?? [];
isPackageImport = true;
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else if (sourcePath.endsWith(".md")) {
const content = readFileSync(sourcePath, "utf-8");
const { manifest } = parseSingleAgentManifest(content);
const pkg = {
company: undefined,
agents: [manifest],
teams: [],
projects: [],
tasks: [],
};
agentCount = pkg.agents.length;
teamCount = 0;
skills = [];
isPackageImport = false;
({ items: importItems, result } = prepareAgentCompaniesImport(pkg, conversionOptions));
} else {
throw new Error(UNSUPPORTED_FORMAT_MESSAGE);
}
} catch (err) {
if (err instanceof AgentCompaniesParseError) {
console.error(`Parse error: ${err.message}`);
return await exitWithCleanup(1);
}
if (err instanceof Error && err.message === UNSUPPORTED_FORMAT_MESSAGE) {
console.error(err.message);
return await exitWithCleanup(1);
}
console.error(`Error reading source: ${(err as Error).message}`);
return await exitWithCleanup(1);
}
if (result.created.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
console.log();
console.log(" No agents found in manifest");
console.log();
return;
}
// Dry run: just preview (includes skill preview for package imports)
if (dryRun) {
const skillResult = isPackageImport
? await importSkillsToProject(projectPath, skills, companySlug, true)
: undefined;
printSummary(companyName, agentCount, teamCount, result.created, result.skipped, result.errors, true, skillResult);
return;
}
// Create agents
const created: string[] = [];
const errors: Array<{ name: string; error: string }> = [...result.errors];
const createdAgentIdsByManifestKey = new Map<string, string>();
for (const item of importItems) {
try {
// Double-check for duplicates if not using skipExisting
if (!skipExisting && existingNames.has(item.input.name)) {
errors.push({ name: item.input.name, error: "Agent with this name already exists" });
continue;
}
const input: AgentCreateInput = {
...item.input,
...(item.input.metadata ? { metadata: { ...item.input.metadata } } : {}),
};
if (item.reportsTo?.deferredManifestKey) {
const resolvedReportsTo = createdAgentIdsByManifestKey.get(item.reportsTo.deferredManifestKey);
if (!resolvedReportsTo) {
errors.push({
name: item.input.name,
error: `Could not resolve reportsTo reference "${item.reportsTo.raw}" because the manager was not created`,
});
continue;
}
input.reportsTo = resolvedReportsTo;
} else if (item.reportsTo?.resolvedAgentId) {
input.reportsTo = item.reportsTo.resolvedAgentId;
}
const agent = await agentStore.createAgent(input);
created.push(input.name);
createdAgentIdsByManifestKey.set(item.manifestKey, agent.id);
} catch (err) {
errors.push({ name: item.input.name, error: (err as Error).message });
}
}
// Import skills for package imports (directory/archive)
const skillResult = isPackageImport && skills.length > 0
? await importSkillsToProject(projectPath, skills, companySlug, false)
: undefined;
printSummary(companyName, agentCount, teamCount, created, result.skipped, errors, false, skillResult);
} finally {
if (exitRequested) await cleanup().catch(() => undefined);
else await cleanup();
}
}