Implement markdown-first company package import export
This commit is contained in:
parent
2975aa950b
commit
271c2b9018
5 changed files with 1230 additions and 157 deletions
|
|
@ -1,11 +1,10 @@
|
||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import type {
|
import type {
|
||||||
Company,
|
Company,
|
||||||
CompanyPortabilityExportResult,
|
CompanyPortabilityExportResult,
|
||||||
CompanyPortabilityInclude,
|
CompanyPortabilityInclude,
|
||||||
CompanyPortabilityManifest,
|
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
CompanyPortabilityImportResult,
|
CompanyPortabilityImportResult,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
|
|
@ -84,37 +83,39 @@ function isGithubUrl(input: string): boolean {
|
||||||
return /^https?:\/\/github\.com\//i.test(input.trim());
|
return /^https?:\/\/github\.com\//i.test(input.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectPackageFiles(root: string, current: string, files: Record<string, string>): Promise<void> {
|
||||||
|
const entries = await readdir(current, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.name.startsWith(".git")) continue;
|
||||||
|
const absolutePath = path.join(current, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await collectPackageFiles(root, absolutePath, files);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
||||||
|
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
|
||||||
|
files[relativePath] = await readFile(absolutePath, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
|
||||||
manifest: CompanyPortabilityManifest;
|
rootPath: string;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}> {
|
}> {
|
||||||
const resolved = path.resolve(inputPath);
|
const resolved = path.resolve(inputPath);
|
||||||
const resolvedStat = await stat(resolved);
|
const resolvedStat = await stat(resolved);
|
||||||
const manifestPath = resolvedStat.isDirectory()
|
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
|
||||||
? path.join(resolved, "paperclip.manifest.json")
|
|
||||||
: resolved;
|
|
||||||
const manifestBaseDir = path.dirname(manifestPath);
|
|
||||||
const manifestRaw = await readFile(manifestPath, "utf8");
|
|
||||||
const manifest = JSON.parse(manifestRaw) as CompanyPortabilityManifest;
|
|
||||||
const files: Record<string, string> = {};
|
const files: Record<string, string> = {};
|
||||||
|
await collectPackageFiles(rootDir, rootDir, files);
|
||||||
if (manifest.company?.path) {
|
return {
|
||||||
const companyPath = manifest.company.path.replace(/\\/g, "/");
|
rootPath: path.basename(rootDir),
|
||||||
files[companyPath] = await readFile(path.join(manifestBaseDir, companyPath), "utf8");
|
files,
|
||||||
}
|
};
|
||||||
for (const agent of manifest.agents ?? []) {
|
|
||||||
const agentPath = agent.path.replace(/\\/g, "/");
|
|
||||||
files[agentPath] = await readFile(path.join(manifestBaseDir, agentPath), "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { manifest, files };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
|
async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
|
||||||
const root = path.resolve(outDir);
|
const root = path.resolve(outDir);
|
||||||
await mkdir(root, { recursive: true });
|
await mkdir(root, { recursive: true });
|
||||||
const manifestPath = path.join(root, "paperclip.manifest.json");
|
|
||||||
await writeFile(manifestPath, JSON.stringify(exported.manifest, null, 2), "utf8");
|
|
||||||
for (const [relativePath, content] of Object.entries(exported.files)) {
|
for (const [relativePath, content] of Object.entries(exported.files)) {
|
||||||
const normalized = relativePath.replace(/\\/g, "/");
|
const normalized = relativePath.replace(/\\/g, "/");
|
||||||
const filePath = path.join(root, normalized);
|
const filePath = path.join(root, normalized);
|
||||||
|
|
@ -257,7 +258,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
company
|
company
|
||||||
.command("export")
|
.command("export")
|
||||||
.description("Export a company into portable manifest + markdown files")
|
.description("Export a company into a portable markdown package")
|
||||||
.argument("<companyId>", "Company ID")
|
.argument("<companyId>", "Company ID")
|
||||||
.requiredOption("--out <path>", "Output directory")
|
.requiredOption("--out <path>", "Output directory")
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
||||||
|
|
@ -277,7 +278,8 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
{
|
{
|
||||||
ok: true,
|
ok: true,
|
||||||
out: path.resolve(opts.out!),
|
out: path.resolve(opts.out!),
|
||||||
filesWritten: Object.keys(exported.files).length + 1,
|
rootPath: exported.rootPath,
|
||||||
|
filesWritten: Object.keys(exported.files).length,
|
||||||
warningCount: exported.warnings.length,
|
warningCount: exported.warnings.length,
|
||||||
},
|
},
|
||||||
{ json: ctx.json },
|
{ json: ctx.json },
|
||||||
|
|
@ -296,7 +298,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
addCommonClientOptions(
|
addCommonClientOptions(
|
||||||
company
|
company
|
||||||
.command("import")
|
.command("import")
|
||||||
.description("Import a portable company package from local path, URL, or GitHub")
|
.description("Import a portable markdown company package from local path, URL, or GitHub")
|
||||||
.requiredOption("--from <pathOrUrl>", "Source path or URL")
|
.requiredOption("--from <pathOrUrl>", "Source path or URL")
|
||||||
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
.option("--include <values>", "Comma-separated include set: company,agents", "company,agents")
|
||||||
.option("--target <mode>", "Target mode: new | existing")
|
.option("--target <mode>", "Target mode: new | existing")
|
||||||
|
|
@ -343,7 +345,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
let sourcePayload:
|
let sourcePayload:
|
||||||
| { type: "inline"; manifest: CompanyPortabilityManifest; files: Record<string, string> }
|
| { type: "inline"; rootPath?: string | null; files: Record<string, string> }
|
||||||
| { type: "url"; url: string }
|
| { type: "url"; url: string }
|
||||||
| { type: "github"; url: string };
|
| { type: "github"; url: string };
|
||||||
|
|
||||||
|
|
@ -355,7 +357,7 @@ export function registerCompanyCommands(program: Command): void {
|
||||||
const inline = await resolveInlineSourceFromPath(from);
|
const inline = await resolveInlineSourceFromPath(from);
|
||||||
sourcePayload = {
|
sourcePayload = {
|
||||||
type: "inline",
|
type: "inline",
|
||||||
manifest: inline.manifest,
|
rootPath: inline.rootPath,
|
||||||
files: inline.files,
|
files: inline.files,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ export interface CompanyPortabilityManifest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CompanyPortabilityExportResult {
|
export interface CompanyPortabilityExportResult {
|
||||||
|
rootPath: string;
|
||||||
manifest: CompanyPortabilityManifest;
|
manifest: CompanyPortabilityManifest;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
|
@ -57,7 +58,7 @@ export interface CompanyPortabilityExportResult {
|
||||||
export type CompanyPortabilitySource =
|
export type CompanyPortabilitySource =
|
||||||
| {
|
| {
|
||||||
type: "inline";
|
type: "inline";
|
||||||
manifest: CompanyPortabilityManifest;
|
rootPath?: string | null;
|
||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,7 @@ export const portabilityManifestSchema = z.object({
|
||||||
export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
export const portabilitySourceSchema = z.discriminatedUnion("type", [
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal("inline"),
|
type: z.literal("inline"),
|
||||||
manifest: portabilityManifestSchema,
|
rootPath: z.string().min(1).optional().nullable(),
|
||||||
files: z.record(z.string()),
|
files: z.record(z.string()),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import type {
|
||||||
CompanyPortabilityPreviewAgentPlan,
|
CompanyPortabilityPreviewAgentPlan,
|
||||||
CompanyPortabilityPreviewResult,
|
CompanyPortabilityPreviewResult,
|
||||||
} from "@paperclipai/shared";
|
} from "@paperclipai/shared";
|
||||||
import { normalizeAgentUrlKey, portabilityManifestSchema } from "@paperclipai/shared";
|
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
import { notFound, unprocessable } from "../errors.js";
|
import { notFound, unprocessable } from "../errors.js";
|
||||||
import { accessService } from "./access.js";
|
import { accessService } from "./access.js";
|
||||||
import { agentService } from "./agents.js";
|
import { agentService } from "./agents.js";
|
||||||
|
|
@ -41,6 +41,10 @@ type MarkdownDoc = {
|
||||||
body: string;
|
body: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CompanyPackageIncludeEntry = {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
type ImportPlanInternal = {
|
type ImportPlanInternal = {
|
||||||
preview: CompanyPortabilityPreviewResult;
|
preview: CompanyPortabilityPreviewResult;
|
||||||
source: ResolvedSource;
|
source: ResolvedSource;
|
||||||
|
|
@ -146,6 +150,45 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePortablePath(input: string) {
|
||||||
|
const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const segment of normalized.split("/")) {
|
||||||
|
if (!segment || segment === ".") continue;
|
||||||
|
if (segment === "..") {
|
||||||
|
if (parts.length > 0) parts.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(segment);
|
||||||
|
}
|
||||||
|
return parts.join("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePortablePath(fromPath: string, targetPath: string) {
|
||||||
|
const baseDir = path.posix.dirname(fromPath.replace(/\\/g, "/"));
|
||||||
|
return normalizePortablePath(path.posix.join(baseDir, targetPath.replace(/\\/g, "/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFileMap(
|
||||||
|
files: Record<string, string>,
|
||||||
|
rootPath?: string | null,
|
||||||
|
): Record<string, string> {
|
||||||
|
const normalizedRoot = rootPath ? normalizePortablePath(rootPath) : null;
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [rawPath, content] of Object.entries(files)) {
|
||||||
|
let nextPath = normalizePortablePath(rawPath);
|
||||||
|
if (normalizedRoot && nextPath === normalizedRoot) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (normalizedRoot && nextPath.startsWith(`${normalizedRoot}/`)) {
|
||||||
|
nextPath = nextPath.slice(normalizedRoot.length + 1);
|
||||||
|
}
|
||||||
|
if (!nextPath) continue;
|
||||||
|
out[nextPath] = content;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function ensureMarkdownPath(pathValue: string) {
|
function ensureMarkdownPath(pathValue: string) {
|
||||||
const normalized = pathValue.replace(/\\/g, "/");
|
const normalized = pathValue.replace(/\\/g, "/");
|
||||||
if (!normalized.endsWith(".md")) {
|
if (!normalized.endsWith(".md")) {
|
||||||
|
|
@ -340,6 +383,129 @@ function renderCompanyAgentsSection(agentSummaries: Array<{ slug: string; name:
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseYamlScalar(rawValue: string): unknown {
|
||||||
|
const trimmed = rawValue.trim();
|
||||||
|
if (trimmed === "") return "";
|
||||||
|
if (trimmed === "null" || trimmed === "~") return null;
|
||||||
|
if (trimmed === "true") return true;
|
||||||
|
if (trimmed === "false") return false;
|
||||||
|
if (trimmed === "[]") return [];
|
||||||
|
if (trimmed === "{}") return {};
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
||||||
|
if (
|
||||||
|
trimmed.startsWith("\"") ||
|
||||||
|
trimmed.startsWith("[") ||
|
||||||
|
trimmed.startsWith("{")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
} catch {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareYamlLines(raw: string) {
|
||||||
|
return raw
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => ({
|
||||||
|
indent: line.match(/^ */)?.[0].length ?? 0,
|
||||||
|
content: line.trim(),
|
||||||
|
}))
|
||||||
|
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlBlock(
|
||||||
|
lines: Array<{ indent: number; content: string }>,
|
||||||
|
startIndex: number,
|
||||||
|
indentLevel: number,
|
||||||
|
): { value: unknown; nextIndex: number } {
|
||||||
|
let index = startIndex;
|
||||||
|
while (index < lines.length && lines[index]!.content.length === 0) {
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
if (index >= lines.length || lines[index]!.indent < indentLevel) {
|
||||||
|
return { value: {}, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
|
||||||
|
if (isArray) {
|
||||||
|
const values: unknown[] = [];
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index]!;
|
||||||
|
if (line.indent < indentLevel) break;
|
||||||
|
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
|
||||||
|
const remainder = line.content.slice(1).trim();
|
||||||
|
index += 1;
|
||||||
|
if (!remainder) {
|
||||||
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||||
|
values.push(nested.value);
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const inlineObjectSeparator = remainder.indexOf(":");
|
||||||
|
if (
|
||||||
|
inlineObjectSeparator > 0 &&
|
||||||
|
!remainder.startsWith("\"") &&
|
||||||
|
!remainder.startsWith("{") &&
|
||||||
|
!remainder.startsWith("[")
|
||||||
|
) {
|
||||||
|
const key = remainder.slice(0, inlineObjectSeparator).trim();
|
||||||
|
const rawValue = remainder.slice(inlineObjectSeparator + 1).trim();
|
||||||
|
const nextObject: Record<string, unknown> = {
|
||||||
|
[key]: parseYamlScalar(rawValue),
|
||||||
|
};
|
||||||
|
if (index < lines.length && lines[index]!.indent > indentLevel) {
|
||||||
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||||
|
if (isPlainRecord(nested.value)) {
|
||||||
|
Object.assign(nextObject, nested.value);
|
||||||
|
}
|
||||||
|
index = nested.nextIndex;
|
||||||
|
}
|
||||||
|
values.push(nextObject);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
values.push(parseYamlScalar(remainder));
|
||||||
|
}
|
||||||
|
return { value: values, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
const record: Record<string, unknown> = {};
|
||||||
|
while (index < lines.length) {
|
||||||
|
const line = lines[index]!;
|
||||||
|
if (line.indent < indentLevel) break;
|
||||||
|
if (line.indent !== indentLevel) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separatorIndex = line.content.indexOf(":");
|
||||||
|
if (separatorIndex <= 0) {
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = line.content.slice(0, separatorIndex).trim();
|
||||||
|
const remainder = line.content.slice(separatorIndex + 1).trim();
|
||||||
|
index += 1;
|
||||||
|
if (!remainder) {
|
||||||
|
const nested = parseYamlBlock(lines, index, indentLevel + 2);
|
||||||
|
record[key] = nested.value;
|
||||||
|
index = nested.nextIndex;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
record[key] = parseYamlScalar(remainder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: record, nextIndex: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYamlFrontmatter(raw: string): Record<string, unknown> {
|
||||||
|
const prepared = prepareYamlLines(raw);
|
||||||
|
if (prepared.length === 0) return {};
|
||||||
|
const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent);
|
||||||
|
return isPlainRecord(parsed.value) ? parsed.value : {};
|
||||||
|
}
|
||||||
|
|
||||||
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||||
const normalized = raw.replace(/\r\n/g, "\n");
|
const normalized = raw.replace(/\r\n/g, "\n");
|
||||||
if (!normalized.startsWith("---\n")) {
|
if (!normalized.startsWith("---\n")) {
|
||||||
|
|
@ -351,41 +517,10 @@ function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
|
||||||
}
|
}
|
||||||
const frontmatterRaw = normalized.slice(4, closing).trim();
|
const frontmatterRaw = normalized.slice(4, closing).trim();
|
||||||
const body = normalized.slice(closing + 5).trim();
|
const body = normalized.slice(closing + 5).trim();
|
||||||
const frontmatter: Record<string, unknown> = {};
|
return {
|
||||||
for (const line of frontmatterRaw.split("\n")) {
|
frontmatter: parseYamlFrontmatter(frontmatterRaw),
|
||||||
const idx = line.indexOf(":");
|
body,
|
||||||
if (idx <= 0) continue;
|
};
|
||||||
const key = line.slice(0, idx).trim();
|
|
||||||
const rawValue = line.slice(idx + 1).trim();
|
|
||||||
if (!key) continue;
|
|
||||||
if (rawValue === "null") {
|
|
||||||
frontmatter[key] = null;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (rawValue === "true" || rawValue === "false") {
|
|
||||||
frontmatter[key] = rawValue === "true";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (/^-?\d+(\.\d+)?$/.test(rawValue)) {
|
|
||||||
frontmatter[key] = Number(rawValue);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
frontmatter[key] = JSON.parse(rawValue);
|
|
||||||
continue;
|
|
||||||
} catch {
|
|
||||||
frontmatter[key] = rawValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { frontmatter, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchJson(url: string) {
|
|
||||||
const response = await fetch(url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchText(url: string) {
|
async function fetchText(url: string) {
|
||||||
|
|
@ -396,6 +531,15 @@ async function fetchText(url: string) {
|
||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchOptionalText(url: string) {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (response.status === 404) return null;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) {
|
function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecrets"]) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: CompanyPortabilityManifest["requiredSecrets"] = [];
|
const out: CompanyPortabilityManifest["requiredSecrets"] = [];
|
||||||
|
|
@ -408,7 +552,190 @@ function dedupeRequiredSecrets(values: CompanyPortabilityManifest["requiredSecre
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGitHubTreeUrl(rawUrl: string) {
|
function buildIncludes(paths: string[]): CompanyPackageIncludeEntry[] {
|
||||||
|
return paths.map((value) => ({ path: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCompanyApprovalDefault(frontmatter: Record<string, unknown>) {
|
||||||
|
const topLevel = frontmatter.requireBoardApprovalForNewAgents;
|
||||||
|
if (typeof topLevel === "boolean") return topLevel;
|
||||||
|
const defaults = frontmatter.defaults;
|
||||||
|
if (isPlainRecord(defaults) && typeof defaults.requireBoardApprovalForNewAgents === "boolean") {
|
||||||
|
return defaults.requireBoardApprovalForNewAgents;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readIncludeEntries(frontmatter: Record<string, unknown>): CompanyPackageIncludeEntry[] {
|
||||||
|
const includes = frontmatter.includes;
|
||||||
|
if (!Array.isArray(includes)) return [];
|
||||||
|
return includes.flatMap((entry) => {
|
||||||
|
if (typeof entry === "string") {
|
||||||
|
return [{ path: entry }];
|
||||||
|
}
|
||||||
|
if (isPlainRecord(entry)) {
|
||||||
|
const pathValue = asString(entry.path);
|
||||||
|
return pathValue ? [{ path: pathValue }] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAgentSecretRequirements(
|
||||||
|
frontmatter: Record<string, unknown>,
|
||||||
|
agentSlug: string,
|
||||||
|
): CompanyPortabilityManifest["requiredSecrets"] {
|
||||||
|
const requirements = frontmatter.requirements;
|
||||||
|
const secretsFromRequirements =
|
||||||
|
isPlainRecord(requirements) && Array.isArray(requirements.secrets)
|
||||||
|
? requirements.secrets
|
||||||
|
: [];
|
||||||
|
const legacyRequiredSecrets = Array.isArray(frontmatter.requiredSecrets)
|
||||||
|
? frontmatter.requiredSecrets
|
||||||
|
: [];
|
||||||
|
const combined = [...secretsFromRequirements, ...legacyRequiredSecrets];
|
||||||
|
|
||||||
|
return combined.flatMap((entry) => {
|
||||||
|
if (typeof entry === "string" && entry.trim()) {
|
||||||
|
return [{
|
||||||
|
key: entry.trim(),
|
||||||
|
description: `Set ${entry.trim()} for agent ${agentSlug}`,
|
||||||
|
agentSlug,
|
||||||
|
providerHint: null,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
if (isPlainRecord(entry)) {
|
||||||
|
const key = asString(entry.key);
|
||||||
|
if (!key) return [];
|
||||||
|
return [{
|
||||||
|
key,
|
||||||
|
description: asString(entry.description) ?? `Set ${key} for agent ${agentSlug}`,
|
||||||
|
agentSlug,
|
||||||
|
providerHint: asString(entry.providerHint),
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildManifestFromPackageFiles(
|
||||||
|
files: Record<string, string>,
|
||||||
|
opts?: { sourceLabel?: { companyId: string; companyName: string } | null },
|
||||||
|
): ResolvedSource {
|
||||||
|
const normalizedFiles = normalizeFileMap(files);
|
||||||
|
const companyPath =
|
||||||
|
normalizedFiles["COMPANY.md"]
|
||||||
|
?? undefined;
|
||||||
|
const resolvedCompanyPath = companyPath !== undefined
|
||||||
|
? "COMPANY.md"
|
||||||
|
: Object.keys(normalizedFiles).find((entry) => entry.endsWith("/COMPANY.md") || entry === "COMPANY.md");
|
||||||
|
if (!resolvedCompanyPath) {
|
||||||
|
throw unprocessable("Company package is missing COMPANY.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyDoc = parseFrontmatterMarkdown(normalizedFiles[resolvedCompanyPath]!);
|
||||||
|
const companyFrontmatter = companyDoc.frontmatter;
|
||||||
|
const companyName =
|
||||||
|
asString(companyFrontmatter.name)
|
||||||
|
?? opts?.sourceLabel?.companyName
|
||||||
|
?? "Imported Company";
|
||||||
|
const companySlug =
|
||||||
|
asString(companyFrontmatter.slug)
|
||||||
|
?? normalizeAgentUrlKey(companyName)
|
||||||
|
?? "company";
|
||||||
|
|
||||||
|
const includeEntries = readIncludeEntries(companyFrontmatter);
|
||||||
|
const referencedAgentPaths = includeEntries
|
||||||
|
.map((entry) => resolvePortablePath(resolvedCompanyPath, entry.path))
|
||||||
|
.filter((entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md");
|
||||||
|
const discoveredAgentPaths = Object.keys(normalizedFiles).filter(
|
||||||
|
(entry) => entry.endsWith("/AGENTS.md") || entry === "AGENTS.md",
|
||||||
|
);
|
||||||
|
const agentPaths = Array.from(new Set([...referencedAgentPaths, ...discoveredAgentPaths])).sort();
|
||||||
|
|
||||||
|
const manifest: CompanyPortabilityManifest = {
|
||||||
|
schemaVersion: 2,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
source: opts?.sourceLabel ?? null,
|
||||||
|
includes: {
|
||||||
|
company: true,
|
||||||
|
agents: true,
|
||||||
|
},
|
||||||
|
company: {
|
||||||
|
path: resolvedCompanyPath,
|
||||||
|
name: companyName,
|
||||||
|
description: asString(companyFrontmatter.description),
|
||||||
|
brandColor: asString(companyFrontmatter.brandColor),
|
||||||
|
requireBoardApprovalForNewAgents: readCompanyApprovalDefault(companyFrontmatter),
|
||||||
|
},
|
||||||
|
agents: [],
|
||||||
|
requiredSecrets: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
for (const agentPath of agentPaths) {
|
||||||
|
const markdownRaw = normalizedFiles[agentPath];
|
||||||
|
if (typeof markdownRaw !== "string") {
|
||||||
|
warnings.push(`Referenced agent file is missing from package: ${agentPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const agentDoc = parseFrontmatterMarkdown(markdownRaw);
|
||||||
|
const frontmatter = agentDoc.frontmatter;
|
||||||
|
const fallbackSlug = normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(agentPath))) ?? "agent";
|
||||||
|
const slug = asString(frontmatter.slug) ?? fallbackSlug;
|
||||||
|
const adapter = isPlainRecord(frontmatter.adapter) ? frontmatter.adapter : null;
|
||||||
|
const runtime = isPlainRecord(frontmatter.runtime) ? frontmatter.runtime : null;
|
||||||
|
const permissions = isPlainRecord(frontmatter.permissions) ? frontmatter.permissions : {};
|
||||||
|
const metadata = isPlainRecord(frontmatter.metadata) ? frontmatter.metadata : null;
|
||||||
|
const adapterConfig = isPlainRecord(adapter?.config)
|
||||||
|
? adapter.config
|
||||||
|
: isPlainRecord(frontmatter.adapterConfig)
|
||||||
|
? frontmatter.adapterConfig
|
||||||
|
: {};
|
||||||
|
const runtimeConfig = runtime ?? (isPlainRecord(frontmatter.runtimeConfig) ? frontmatter.runtimeConfig : {});
|
||||||
|
const title = asString(frontmatter.title);
|
||||||
|
const capabilities = asString(frontmatter.capabilities);
|
||||||
|
|
||||||
|
manifest.agents.push({
|
||||||
|
slug,
|
||||||
|
name: asString(frontmatter.name) ?? title ?? slug,
|
||||||
|
path: agentPath,
|
||||||
|
role: asString(frontmatter.role) ?? "agent",
|
||||||
|
title,
|
||||||
|
icon: asString(frontmatter.icon),
|
||||||
|
capabilities,
|
||||||
|
reportsToSlug: asString(frontmatter.reportsTo),
|
||||||
|
adapterType: asString(adapter?.type) ?? asString(frontmatter.adapterType) ?? "process",
|
||||||
|
adapterConfig,
|
||||||
|
runtimeConfig,
|
||||||
|
permissions,
|
||||||
|
budgetMonthlyCents:
|
||||||
|
typeof frontmatter.budgetMonthlyCents === "number" && Number.isFinite(frontmatter.budgetMonthlyCents)
|
||||||
|
? Math.max(0, Math.floor(frontmatter.budgetMonthlyCents))
|
||||||
|
: 0,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
manifest.requiredSecrets.push(...readAgentSecretRequirements(frontmatter, slug));
|
||||||
|
|
||||||
|
if (frontmatter.kind !== "agent") {
|
||||||
|
warnings.push(`Agent markdown ${agentPath} does not declare kind: agent in frontmatter.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest.requiredSecrets = dedupeRequiredSecrets(manifest.requiredSecrets);
|
||||||
|
return {
|
||||||
|
manifest,
|
||||||
|
files: normalizedFiles,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGitCommitRef(value: string) {
|
||||||
|
return /^[0-9a-f]{40}$/i.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGitHubSourceUrl(rawUrl: string) {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
if (url.hostname !== "github.com") {
|
if (url.hostname !== "github.com") {
|
||||||
throw unprocessable("GitHub source must use github.com URL");
|
throw unprocessable("GitHub source must use github.com URL");
|
||||||
|
|
@ -421,11 +748,21 @@ function parseGitHubTreeUrl(rawUrl: string) {
|
||||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||||
let ref = "main";
|
let ref = "main";
|
||||||
let basePath = "";
|
let basePath = "";
|
||||||
|
let companyPath = "COMPANY.md";
|
||||||
if (parts[2] === "tree") {
|
if (parts[2] === "tree") {
|
||||||
ref = parts[3] ?? "main";
|
ref = parts[3] ?? "main";
|
||||||
basePath = parts.slice(4).join("/");
|
basePath = parts.slice(4).join("/");
|
||||||
|
} else if (parts[2] === "blob") {
|
||||||
|
ref = parts[3] ?? "main";
|
||||||
|
const blobPath = parts.slice(4).join("/");
|
||||||
|
if (!blobPath) {
|
||||||
|
throw unprocessable("Invalid GitHub blob URL");
|
||||||
|
}
|
||||||
|
companyPath = blobPath;
|
||||||
|
basePath = path.posix.dirname(blobPath);
|
||||||
|
if (basePath === ".") basePath = "";
|
||||||
}
|
}
|
||||||
return { owner, repo, ref, basePath };
|
return { owner, repo, ref, basePath, companyPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
|
function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) {
|
||||||
|
|
@ -485,65 +822,80 @@ export function companyPortabilityService(db: Db) {
|
||||||
|
|
||||||
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
|
async function resolveSource(source: CompanyPortabilityPreview["source"]): Promise<ResolvedSource> {
|
||||||
if (source.type === "inline") {
|
if (source.type === "inline") {
|
||||||
return {
|
return buildManifestFromPackageFiles(
|
||||||
manifest: portabilityManifestSchema.parse(source.manifest),
|
normalizeFileMap(source.files, source.rootPath),
|
||||||
files: source.files,
|
);
|
||||||
warnings: [],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source.type === "url") {
|
if (source.type === "url") {
|
||||||
const manifestJson = await fetchJson(source.url);
|
const normalizedUrl = source.url.trim();
|
||||||
const manifest = portabilityManifestSchema.parse(manifestJson);
|
const companyUrl = normalizedUrl.endsWith(".md")
|
||||||
const base = new URL(".", source.url);
|
? normalizedUrl
|
||||||
const files: Record<string, string> = {};
|
: new URL("COMPANY.md", normalizedUrl.endsWith("/") ? normalizedUrl : `${normalizedUrl}/`).toString();
|
||||||
const warnings: string[] = [];
|
const companyMarkdown = await fetchText(companyUrl);
|
||||||
|
const files: Record<string, string> = {
|
||||||
|
"COMPANY.md": companyMarkdown,
|
||||||
|
};
|
||||||
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
||||||
|
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
||||||
|
|
||||||
if (manifest.company?.path) {
|
for (const includeEntry of includeEntries) {
|
||||||
const companyPath = ensureMarkdownPath(manifest.company.path);
|
const includePath = normalizePortablePath(includeEntry.path);
|
||||||
files[companyPath] = await fetchText(new URL(companyPath, base).toString());
|
if (!includePath.endsWith(".md")) continue;
|
||||||
|
const includeUrl = new URL(includeEntry.path, companyUrl).toString();
|
||||||
|
files[includePath] = await fetchText(includeUrl);
|
||||||
}
|
}
|
||||||
for (const agent of manifest.agents) {
|
return buildManifestFromPackageFiles(files);
|
||||||
const filePath = ensureMarkdownPath(agent.path);
|
|
||||||
files[filePath] = await fetchText(new URL(filePath, base).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return { manifest, files, warnings };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseGitHubTreeUrl(source.url);
|
const parsed = parseGitHubSourceUrl(source.url);
|
||||||
let ref = parsed.ref;
|
let ref = parsed.ref;
|
||||||
const manifestRelativePath = [parsed.basePath, "paperclip.manifest.json"].filter(Boolean).join("/");
|
|
||||||
let manifest: CompanyPortabilityManifest | null = null;
|
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
if (!isGitCommitRef(ref)) {
|
||||||
|
warnings.push("GitHub source is not pinned to a commit SHA; imports may drift if the ref changes.");
|
||||||
|
}
|
||||||
|
const companyRelativePath = parsed.companyPath === "COMPANY.md"
|
||||||
|
? [parsed.basePath, "COMPANY.md"].filter(Boolean).join("/")
|
||||||
|
: parsed.companyPath;
|
||||||
|
let companyMarkdown: string | null = null;
|
||||||
try {
|
try {
|
||||||
manifest = portabilityManifestSchema.parse(
|
companyMarkdown = await fetchOptionalText(
|
||||||
await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)),
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath),
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (ref === "main") {
|
if (ref === "main") {
|
||||||
ref = "master";
|
ref = "master";
|
||||||
warnings.push("GitHub ref main not found; falling back to master.");
|
warnings.push("GitHub ref main not found; falling back to master.");
|
||||||
manifest = portabilityManifestSchema.parse(
|
companyMarkdown = await fetchOptionalText(
|
||||||
await fetchJson(resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, manifestRelativePath)),
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, companyRelativePath),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!companyMarkdown) {
|
||||||
|
throw unprocessable("GitHub company package is missing COMPANY.md");
|
||||||
|
}
|
||||||
|
|
||||||
const files: Record<string, string> = {};
|
const companyPath = parsed.companyPath === "COMPANY.md"
|
||||||
if (manifest.company?.path) {
|
? "COMPANY.md"
|
||||||
files[manifest.company.path] = await fetchText(
|
: normalizePortablePath(path.posix.relative(parsed.basePath || ".", parsed.companyPath));
|
||||||
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, manifest.company.path].filter(Boolean).join("/")),
|
const files: Record<string, string> = {
|
||||||
|
[companyPath]: companyMarkdown,
|
||||||
|
};
|
||||||
|
const companyDoc = parseFrontmatterMarkdown(companyMarkdown);
|
||||||
|
const includeEntries = readIncludeEntries(companyDoc.frontmatter);
|
||||||
|
for (const includeEntry of includeEntries) {
|
||||||
|
const repoPath = [parsed.basePath, includeEntry.path].filter(Boolean).join("/");
|
||||||
|
if (!repoPath.endsWith(".md")) continue;
|
||||||
|
files[normalizePortablePath(includeEntry.path)] = await fetchText(
|
||||||
|
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, repoPath),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const agent of manifest.agents) {
|
|
||||||
files[agent.path] = await fetchText(
|
const resolved = buildManifestFromPackageFiles(files);
|
||||||
resolveRawGitHubUrl(parsed.owner, parsed.repo, ref, [parsed.basePath, agent.path].filter(Boolean).join("/")),
|
resolved.warnings.unshift(...warnings);
|
||||||
);
|
return resolved;
|
||||||
}
|
|
||||||
return { manifest, files, warnings };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function exportBundle(
|
async function exportBundle(
|
||||||
|
|
@ -557,20 +909,7 @@ export function companyPortabilityService(db: Db) {
|
||||||
const files: Record<string, string> = {};
|
const files: Record<string, string> = {};
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = [];
|
const requiredSecrets: CompanyPortabilityManifest["requiredSecrets"] = [];
|
||||||
const generatedAt = new Date().toISOString();
|
const rootPath = normalizeAgentUrlKey(company.name) ?? "company-package";
|
||||||
|
|
||||||
const manifest: CompanyPortabilityManifest = {
|
|
||||||
schemaVersion: 1,
|
|
||||||
generatedAt,
|
|
||||||
source: {
|
|
||||||
companyId: company.id,
|
|
||||||
companyName: company.name,
|
|
||||||
},
|
|
||||||
includes: include,
|
|
||||||
company: null,
|
|
||||||
agents: [],
|
|
||||||
requiredSecrets: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
|
||||||
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
const agentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
|
||||||
|
|
@ -589,29 +928,32 @@ export function companyPortabilityService(db: Db) {
|
||||||
idToSlug.set(agent.id, slug);
|
idToSlug.set(agent.id, slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.company) {
|
{
|
||||||
const companyPath = "COMPANY.md";
|
const companyPath = "COMPANY.md";
|
||||||
const companyAgentSummaries = agentRows.map((agent) => ({
|
const companyAgentSummaries = agentRows.map((agent) => ({
|
||||||
slug: idToSlug.get(agent.id) ?? "agent",
|
slug: idToSlug.get(agent.id) ?? "agent",
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
}));
|
}));
|
||||||
|
const includes = include.agents
|
||||||
|
? buildIncludes(
|
||||||
|
companyAgentSummaries.map((agent) => `agents/${agent.slug}/AGENTS.md`),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
files[companyPath] = buildMarkdown(
|
files[companyPath] = buildMarkdown(
|
||||||
{
|
{
|
||||||
|
schema: "company-packages/v0.1",
|
||||||
kind: "company",
|
kind: "company",
|
||||||
|
slug: rootPath,
|
||||||
name: company.name,
|
name: company.name,
|
||||||
description: company.description ?? null,
|
description: company.description ?? null,
|
||||||
brandColor: company.brandColor ?? null,
|
brandColor: company.brandColor ?? null,
|
||||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents,
|
defaults: {
|
||||||
|
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents,
|
||||||
|
},
|
||||||
|
includes,
|
||||||
},
|
},
|
||||||
renderCompanyAgentsSection(companyAgentSummaries),
|
renderCompanyAgentsSection(companyAgentSummaries),
|
||||||
);
|
);
|
||||||
manifest.company = {
|
|
||||||
path: companyPath,
|
|
||||||
name: company.name,
|
|
||||||
description: company.description ?? null,
|
|
||||||
brandColor: company.brandColor ?? null,
|
|
||||||
requireBoardApprovalForNewAgents: company.requireBoardApprovalForNewAgents,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (include.agents) {
|
if (include.agents) {
|
||||||
|
|
@ -647,46 +989,52 @@ export function companyPortabilityService(db: Db) {
|
||||||
|
|
||||||
files[agentPath] = buildMarkdown(
|
files[agentPath] = buildMarkdown(
|
||||||
{
|
{
|
||||||
|
schema: "company-packages/v0.1",
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
slug,
|
slug,
|
||||||
role: agent.role,
|
|
||||||
adapterType: agent.adapterType,
|
|
||||||
kind: "agent",
|
kind: "agent",
|
||||||
|
role: agent.role,
|
||||||
|
title: agent.title ?? null,
|
||||||
icon: agent.icon ?? null,
|
icon: agent.icon ?? null,
|
||||||
capabilities: agent.capabilities ?? null,
|
capabilities: agent.capabilities ?? null,
|
||||||
reportsTo: reportsToSlug,
|
reportsTo: reportsToSlug,
|
||||||
runtimeConfig: portableRuntimeConfig,
|
adapter: {
|
||||||
|
type: agent.adapterType,
|
||||||
|
config: portableAdapterConfig,
|
||||||
|
},
|
||||||
|
runtime: portableRuntimeConfig,
|
||||||
permissions: portablePermissions,
|
permissions: portablePermissions,
|
||||||
adapterConfig: portableAdapterConfig,
|
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
|
||||||
requiredSecrets: agentRequiredSecrets,
|
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
||||||
|
requirements: agentRequiredSecrets.length > 0
|
||||||
|
? {
|
||||||
|
secrets: agentRequiredSecrets.map((secret) => ({
|
||||||
|
key: secret.key,
|
||||||
|
description: secret.description,
|
||||||
|
providerHint: secret.providerHint,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: {},
|
||||||
},
|
},
|
||||||
instructions.body,
|
instructions.body,
|
||||||
);
|
);
|
||||||
|
|
||||||
manifest.agents.push({
|
|
||||||
slug,
|
|
||||||
name: agent.name,
|
|
||||||
path: agentPath,
|
|
||||||
role: agent.role,
|
|
||||||
title: agent.title ?? null,
|
|
||||||
icon: agent.icon ?? null,
|
|
||||||
capabilities: agent.capabilities ?? null,
|
|
||||||
reportsToSlug,
|
|
||||||
adapterType: agent.adapterType,
|
|
||||||
adapterConfig: portableAdapterConfig,
|
|
||||||
runtimeConfig: portableRuntimeConfig,
|
|
||||||
permissions: portablePermissions,
|
|
||||||
budgetMonthlyCents: agent.budgetMonthlyCents ?? 0,
|
|
||||||
metadata: (agent.metadata as Record<string, unknown> | null) ?? null,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets);
|
const resolved = buildManifestFromPackageFiles(files, {
|
||||||
|
sourceLabel: {
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolved.manifest.includes = include;
|
||||||
|
resolved.manifest.requiredSecrets = dedupeRequiredSecrets(requiredSecrets);
|
||||||
|
resolved.warnings.unshift(...warnings);
|
||||||
return {
|
return {
|
||||||
manifest,
|
rootPath,
|
||||||
|
manifest: resolved.manifest,
|
||||||
files,
|
files,
|
||||||
warnings,
|
warnings: resolved.warnings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -702,11 +1050,17 @@ export function companyPortabilityService(db: Db) {
|
||||||
errors.push("Manifest does not include company metadata.");
|
errors.push("Manifest does not include company metadata.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedSlugs = input.agents && input.agents !== "all"
|
const selectedSlugs = include.agents
|
||||||
? Array.from(new Set(input.agents))
|
? (
|
||||||
: manifest.agents.map((agent) => agent.slug);
|
input.agents && input.agents !== "all"
|
||||||
|
? Array.from(new Set(input.agents))
|
||||||
|
: manifest.agents.map((agent) => agent.slug)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
const selectedAgents = manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug));
|
const selectedAgents = include.agents
|
||||||
|
? manifest.agents.filter((agent) => selectedSlugs.includes(agent.slug))
|
||||||
|
: [];
|
||||||
const selectedMissing = selectedSlugs.filter((slug) => !manifest.agents.some((agent) => agent.slug === slug));
|
const selectedMissing = selectedSlugs.filter((slug) => !manifest.agents.some((agent) => agent.slug === slug));
|
||||||
for (const missing of selectedMissing) {
|
for (const missing of selectedMissing) {
|
||||||
errors.push(`Selected agent slug not found in manifest: ${missing}`);
|
errors.push(`Selected agent slug not found in manifest: ${missing}`);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,20 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type {
|
||||||
|
CompanyPortabilityCollisionStrategy,
|
||||||
|
CompanyPortabilityExportResult,
|
||||||
|
CompanyPortabilityPreviewRequest,
|
||||||
|
CompanyPortabilityPreviewResult,
|
||||||
|
CompanyPortabilitySource,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { useToast } from "../context/ToastContext";
|
||||||
import { companiesApi } from "../api/companies";
|
import { companiesApi } from "../api/companies";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Settings, Check } from "lucide-react";
|
import { Settings, Check, Download, Github, Link2, Upload } from "lucide-react";
|
||||||
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
|
|
@ -28,7 +36,9 @@ export function CompanySettings() {
|
||||||
setSelectedCompanyId
|
setSelectedCompanyId
|
||||||
} = useCompany();
|
} = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const { pushToast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const packageInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
// General settings local state
|
// General settings local state
|
||||||
const [companyName, setCompanyName] = useState("");
|
const [companyName, setCompanyName] = useState("");
|
||||||
|
|
@ -47,6 +57,18 @@ export function CompanySettings() {
|
||||||
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
||||||
const [snippetCopied, setSnippetCopied] = useState(false);
|
const [snippetCopied, setSnippetCopied] = useState(false);
|
||||||
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
||||||
|
const [packageIncludeCompany, setPackageIncludeCompany] = useState(true);
|
||||||
|
const [packageIncludeAgents, setPackageIncludeAgents] = useState(true);
|
||||||
|
const [importSourceMode, setImportSourceMode] = useState<"github" | "url" | "local">("github");
|
||||||
|
const [importUrl, setImportUrl] = useState("");
|
||||||
|
const [importTargetMode, setImportTargetMode] = useState<"existing" | "new">("existing");
|
||||||
|
const [newCompanyName, setNewCompanyName] = useState("");
|
||||||
|
const [collisionStrategy, setCollisionStrategy] = useState<CompanyPortabilityCollisionStrategy>("rename");
|
||||||
|
const [localPackage, setLocalPackage] = useState<{
|
||||||
|
rootPath: string | null;
|
||||||
|
files: Record<string, string>;
|
||||||
|
} | null>(null);
|
||||||
|
const [importPreview, setImportPreview] = useState<CompanyPortabilityPreviewResult | null>(null);
|
||||||
|
|
||||||
const generalDirty =
|
const generalDirty =
|
||||||
!!selectedCompany &&
|
!!selectedCompany &&
|
||||||
|
|
@ -54,6 +76,57 @@ export function CompanySettings() {
|
||||||
description !== (selectedCompany.description ?? "") ||
|
description !== (selectedCompany.description ?? "") ||
|
||||||
brandColor !== (selectedCompany.brandColor ?? ""));
|
brandColor !== (selectedCompany.brandColor ?? ""));
|
||||||
|
|
||||||
|
const packageInclude = useMemo(
|
||||||
|
() => ({
|
||||||
|
company: packageIncludeCompany,
|
||||||
|
agents: packageIncludeAgents
|
||||||
|
}),
|
||||||
|
[packageIncludeAgents, packageIncludeCompany]
|
||||||
|
);
|
||||||
|
|
||||||
|
const importSource = useMemo<CompanyPortabilitySource | null>(() => {
|
||||||
|
if (importSourceMode === "local") {
|
||||||
|
if (!localPackage || Object.keys(localPackage.files).length === 0) return null;
|
||||||
|
return {
|
||||||
|
type: "inline",
|
||||||
|
rootPath: localPackage.rootPath,
|
||||||
|
files: localPackage.files
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const trimmed = importUrl.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return importSourceMode === "github"
|
||||||
|
? { type: "github", url: trimmed }
|
||||||
|
: { type: "url", url: trimmed };
|
||||||
|
}, [importSourceMode, importUrl, localPackage]);
|
||||||
|
|
||||||
|
const importPayload = useMemo<CompanyPortabilityPreviewRequest | null>(() => {
|
||||||
|
if (!importSource) return null;
|
||||||
|
return {
|
||||||
|
source: importSource,
|
||||||
|
include: packageInclude,
|
||||||
|
target:
|
||||||
|
importTargetMode === "new"
|
||||||
|
? {
|
||||||
|
mode: "new_company",
|
||||||
|
newCompanyName: newCompanyName.trim() || null
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
mode: "existing_company",
|
||||||
|
companyId: selectedCompanyId!
|
||||||
|
},
|
||||||
|
agents: "all",
|
||||||
|
collisionStrategy
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
collisionStrategy,
|
||||||
|
importSource,
|
||||||
|
importTargetMode,
|
||||||
|
newCompanyName,
|
||||||
|
packageInclude,
|
||||||
|
selectedCompanyId
|
||||||
|
]);
|
||||||
|
|
||||||
const generalMutation = useMutation({
|
const generalMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -75,6 +148,102 @@ export function CompanySettings() {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
companiesApi.exportBundle(selectedCompanyId!, {
|
||||||
|
include: packageInclude
|
||||||
|
}),
|
||||||
|
onSuccess: async (exported) => {
|
||||||
|
await downloadCompanyPackage(exported);
|
||||||
|
pushToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Company package exported",
|
||||||
|
body: `${exported.rootPath}.tar downloaded with ${Object.keys(exported.files).length} file${Object.keys(exported.files).length === 1 ? "" : "s"}.`
|
||||||
|
});
|
||||||
|
if (exported.warnings.length > 0) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Export completed with warnings",
|
||||||
|
body: exported.warnings[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Export failed",
|
||||||
|
body: err instanceof Error ? err.message : "Failed to export company package"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewImportMutation = useMutation({
|
||||||
|
mutationFn: (payload: CompanyPortabilityPreviewRequest) =>
|
||||||
|
companiesApi.importPreview(payload),
|
||||||
|
onSuccess: (preview) => {
|
||||||
|
setImportPreview(preview);
|
||||||
|
if (preview.errors.length > 0) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Import preview found issues",
|
||||||
|
body: preview.errors[0]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Import preview ready",
|
||||||
|
body: `${preview.plan.agentPlans.length} agent action${preview.plan.agentPlans.length === 1 ? "" : "s"} planned.`
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setImportPreview(null);
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Import preview failed",
|
||||||
|
body: err instanceof Error ? err.message : "Failed to preview company package"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const importPackageMutation = useMutation({
|
||||||
|
mutationFn: (payload: CompanyPortabilityPreviewRequest) =>
|
||||||
|
companiesApi.importBundle(payload),
|
||||||
|
onSuccess: async (result) => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(result.company.id) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.org(result.company.id) })
|
||||||
|
]);
|
||||||
|
if (importTargetMode === "new") {
|
||||||
|
setSelectedCompanyId(result.company.id);
|
||||||
|
}
|
||||||
|
pushToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Company package imported",
|
||||||
|
body: `${result.agents.filter((agent) => agent.action !== "skipped").length} agent${result.agents.filter((agent) => agent.action !== "skipped").length === 1 ? "" : "s"} applied.`
|
||||||
|
});
|
||||||
|
if (result.warnings.length > 0) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Import completed with warnings",
|
||||||
|
body: result.warnings[0]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setImportPreview(null);
|
||||||
|
setLocalPackage(null);
|
||||||
|
setImportUrl("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Import failed",
|
||||||
|
body: err instanceof Error ? err.message : "Failed to import company package"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
||||||
|
|
@ -134,6 +303,21 @@ export function CompanySettings() {
|
||||||
setSnippetCopied(false);
|
setSnippetCopied(false);
|
||||||
setSnippetCopyDelightId(0);
|
setSnippetCopyDelightId(0);
|
||||||
}, [selectedCompanyId]);
|
}, [selectedCompanyId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setImportPreview(null);
|
||||||
|
}, [
|
||||||
|
collisionStrategy,
|
||||||
|
importSourceMode,
|
||||||
|
importTargetMode,
|
||||||
|
importUrl,
|
||||||
|
localPackage,
|
||||||
|
newCompanyName,
|
||||||
|
packageIncludeAgents,
|
||||||
|
packageIncludeCompany,
|
||||||
|
selectedCompanyId
|
||||||
|
]);
|
||||||
|
|
||||||
const archiveMutation = useMutation({
|
const archiveMutation = useMutation({
|
||||||
mutationFn: ({
|
mutationFn: ({
|
||||||
companyId,
|
companyId,
|
||||||
|
|
@ -178,6 +362,64 @@ export function CompanySettings() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleChooseLocalPackage(
|
||||||
|
event: ChangeEvent<HTMLInputElement>
|
||||||
|
) {
|
||||||
|
const selection = event.target.files;
|
||||||
|
if (!selection || selection.length === 0) {
|
||||||
|
setLocalPackage(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = await readLocalPackageSelection(selection);
|
||||||
|
setLocalPackage(parsed);
|
||||||
|
pushToast({
|
||||||
|
tone: "success",
|
||||||
|
title: "Local package loaded",
|
||||||
|
body: `${Object.keys(parsed.files).length} markdown file${Object.keys(parsed.files).length === 1 ? "" : "s"} ready for preview.`
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setLocalPackage(null);
|
||||||
|
pushToast({
|
||||||
|
tone: "error",
|
||||||
|
title: "Failed to read local package",
|
||||||
|
body: err instanceof Error ? err.message : "Could not read selected files"
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewImport() {
|
||||||
|
if (!importPayload) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Source required",
|
||||||
|
body:
|
||||||
|
importSourceMode === "local"
|
||||||
|
? "Choose a local folder with COMPANY.md before previewing."
|
||||||
|
: "Enter a company package URL before previewing."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewImportMutation.mutate(importPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApplyImport() {
|
||||||
|
if (!importPayload) {
|
||||||
|
pushToast({
|
||||||
|
tone: "warn",
|
||||||
|
title: "Source required",
|
||||||
|
body:
|
||||||
|
importSourceMode === "local"
|
||||||
|
? "Choose a local folder with COMPANY.md before importing."
|
||||||
|
: "Enter a company package URL before importing."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importPackageMutation.mutate(importPayload);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl space-y-6">
|
<div className="max-w-2xl space-y-6">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -379,6 +621,355 @@ export function CompanySettings() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Import / Export */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
Company Packages
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Export markdown package</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Download a markdown-first company package as a single tar file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => exportMutation.mutate()}
|
||||||
|
disabled={
|
||||||
|
exportMutation.isPending ||
|
||||||
|
(!packageIncludeCompany && !packageIncludeAgents)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{exportMutation.isPending ? "Exporting..." : "Export package"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={packageIncludeCompany}
|
||||||
|
onChange={(e) => setPackageIncludeCompany(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Include company metadata
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={packageIncludeAgents}
|
||||||
|
onChange={(e) => setPackageIncludeAgents(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Include agents
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{exportMutation.data && (
|
||||||
|
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Last export
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm">
|
||||||
|
{exportMutation.data.rootPath}.tar with{" "}
|
||||||
|
{Object.keys(exportMutation.data.files).length} file
|
||||||
|
{Object.keys(exportMutation.data.files).length === 1 ? "" : "s"}.
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||||
|
{Object.keys(exportMutation.data.files).map((filePath) => (
|
||||||
|
<span
|
||||||
|
key={filePath}
|
||||||
|
className="rounded-full border border-border px-2 py-0.5"
|
||||||
|
>
|
||||||
|
{filePath}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{exportMutation.data.warnings.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-1 text-xs text-amber-700">
|
||||||
|
{exportMutation.data.warnings.map((warning) => (
|
||||||
|
<div key={warning}>{warning}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-md border border-border px-4 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-sm font-medium">Import company package</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Preview a GitHub repo, direct COMPANY.md URL, or local folder before applying it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 md:grid-cols-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
importSourceMode === "github"
|
||||||
|
? "border-foreground bg-accent"
|
||||||
|
: "border-border hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setImportSourceMode("github")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Github className="h-4 w-4" />
|
||||||
|
GitHub repo
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
importSourceMode === "url"
|
||||||
|
? "border-foreground bg-accent"
|
||||||
|
: "border-border hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setImportSourceMode("url")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
Direct URL
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded-md border px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
importSourceMode === "local"
|
||||||
|
? "border-foreground bg-accent"
|
||||||
|
: "border-border hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setImportSourceMode("local")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="h-4 w-4" />
|
||||||
|
Local folder
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importSourceMode === "local" ? (
|
||||||
|
<div className="rounded-md border border-dashed border-border px-3 py-3">
|
||||||
|
<input
|
||||||
|
ref={packageInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
|
||||||
|
webkitdirectory=""
|
||||||
|
onChange={handleChooseLocalPackage}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => packageInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
Choose folder
|
||||||
|
</Button>
|
||||||
|
{localPackage && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{localPackage.rootPath ?? "package"} with{" "}
|
||||||
|
{Object.keys(localPackage.files).length} markdown file
|
||||||
|
{Object.keys(localPackage.files).length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!localPackage && (
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
|
Select a folder that contains COMPANY.md and any referenced
|
||||||
|
AGENTS.md files.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Field
|
||||||
|
label={importSourceMode === "github" ? "GitHub URL" : "Package URL"}
|
||||||
|
hint={
|
||||||
|
importSourceMode === "github"
|
||||||
|
? "Repo root, tree path, or blob URL to COMPANY.md. Unpinned refs warn but do not block."
|
||||||
|
: "Point directly at COMPANY.md or a directory that contains it."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={importUrl}
|
||||||
|
placeholder={
|
||||||
|
importSourceMode === "github"
|
||||||
|
? "https://github.com/owner/repo/tree/main/company"
|
||||||
|
: "https://example.com/company/COMPANY.md"
|
||||||
|
}
|
||||||
|
onChange={(e) => setImportUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Field
|
||||||
|
label="Target"
|
||||||
|
hint="Import into this company or create a new one from the package."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
value={importTargetMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setImportTargetMode(e.target.value as "existing" | "new")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="existing">
|
||||||
|
Existing company: {selectedCompany.name}
|
||||||
|
</option>
|
||||||
|
<option value="new">Create new company</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field
|
||||||
|
label="Collision strategy"
|
||||||
|
hint="Controls what happens when imported agent slugs already exist."
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
value={collisionStrategy}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCollisionStrategy(
|
||||||
|
e.target.value as CompanyPortabilityCollisionStrategy
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="rename">Rename imported agents</option>
|
||||||
|
<option value="skip">Skip existing agents</option>
|
||||||
|
<option value="replace">Replace existing agents</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importTargetMode === "new" && (
|
||||||
|
<Field
|
||||||
|
label="New company name"
|
||||||
|
hint="Optional override. Leave blank to use the package name."
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
||||||
|
type="text"
|
||||||
|
value={newCompanyName}
|
||||||
|
onChange={(e) => setNewCompanyName(e.target.value)}
|
||||||
|
placeholder="Imported Company"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handlePreviewImport}
|
||||||
|
disabled={
|
||||||
|
previewImportMutation.isPending ||
|
||||||
|
(!packageIncludeCompany && !packageIncludeAgents)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{previewImportMutation.isPending ? "Previewing..." : "Preview import"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApplyImport}
|
||||||
|
disabled={
|
||||||
|
importPackageMutation.isPending ||
|
||||||
|
previewImportMutation.isPending ||
|
||||||
|
!!(importPreview && importPreview.errors.length > 0) ||
|
||||||
|
(!packageIncludeCompany && !packageIncludeAgents)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{importPackageMutation.isPending ? "Importing..." : "Apply import"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importPreview && (
|
||||||
|
<div className="space-y-3 rounded-md border border-border bg-muted/20 p-3">
|
||||||
|
<div className="grid gap-2 md:grid-cols-2">
|
||||||
|
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Company action
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{importPreview.plan.companyAction}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-background/70 px-3 py-2">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Agent actions
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm font-medium">
|
||||||
|
{importPreview.plan.agentPlans.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{importPreview.plan.agentPlans.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{importPreview.plan.agentPlans.map((agentPlan) => (
|
||||||
|
<div
|
||||||
|
key={agentPlan.slug}
|
||||||
|
className="rounded-md border border-border bg-background/70 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-sm">
|
||||||
|
<span className="font-medium">
|
||||||
|
{agentPlan.slug} {"->"} {agentPlan.plannedName}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
{agentPlan.action}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{agentPlan.reason && (
|
||||||
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
|
{agentPlan.reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importPreview.requiredSecrets.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Required secrets
|
||||||
|
</div>
|
||||||
|
{importPreview.requiredSecrets.map((secret) => (
|
||||||
|
<div
|
||||||
|
key={`${secret.agentSlug ?? "company"}:${secret.key}`}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{secret.key}
|
||||||
|
{secret.agentSlug ? ` for ${secret.agentSlug}` : ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importPreview.warnings.length > 0 && (
|
||||||
|
<div className="space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700">
|
||||||
|
{importPreview.warnings.map((warning) => (
|
||||||
|
<div key={warning}>{warning}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{importPreview.errors.length > 0 && (
|
||||||
|
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||||
|
{importPreview.errors.map((error) => (
|
||||||
|
<div key={error}>{error}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{/* Danger Zone */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
||||||
|
|
@ -435,6 +1026,131 @@ export function CompanySettings() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readLocalPackageSelection(fileList: FileList): Promise<{
|
||||||
|
rootPath: string | null;
|
||||||
|
files: Record<string, string>;
|
||||||
|
}> {
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
let rootPath: string | null = null;
|
||||||
|
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
const relativePath =
|
||||||
|
(file as File & { webkitRelativePath?: string }).webkitRelativePath?.replace(
|
||||||
|
/\\/g,
|
||||||
|
"/"
|
||||||
|
) || file.name;
|
||||||
|
if (!relativePath.endsWith(".md")) continue;
|
||||||
|
const topLevel = relativePath.split("/")[0] ?? null;
|
||||||
|
if (!rootPath && topLevel) rootPath = topLevel;
|
||||||
|
files[relativePath] = await file.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(files).length === 0) {
|
||||||
|
throw new Error("No markdown files were found in the selected folder.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rootPath, files };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadCompanyPackage(
|
||||||
|
exported: CompanyPortabilityExportResult
|
||||||
|
): Promise<void> {
|
||||||
|
const tarBytes = createTarArchive(exported.files, exported.rootPath);
|
||||||
|
const tarBuffer = new ArrayBuffer(tarBytes.byteLength);
|
||||||
|
new Uint8Array(tarBuffer).set(tarBytes);
|
||||||
|
const blob = new Blob(
|
||||||
|
[tarBuffer],
|
||||||
|
{
|
||||||
|
type: "application/x-tar"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${exported.rootPath}.tar`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTarArchive(
|
||||||
|
files: Record<string, string>,
|
||||||
|
rootPath: string
|
||||||
|
): Uint8Array {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
|
||||||
|
for (const [relativePath, contents] of Object.entries(files)) {
|
||||||
|
const tarPath = `${rootPath}/${relativePath}`.replace(/\\/g, "/");
|
||||||
|
const body = encoder.encode(contents);
|
||||||
|
chunks.push(buildTarHeader(tarPath, body.length));
|
||||||
|
chunks.push(body);
|
||||||
|
const remainder = body.length % 512;
|
||||||
|
if (remainder > 0) {
|
||||||
|
chunks.push(new Uint8Array(512 - remainder));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(new Uint8Array(1024));
|
||||||
|
|
||||||
|
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||||
|
const archive = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
archive.set(chunk, offset);
|
||||||
|
offset += chunk.length;
|
||||||
|
}
|
||||||
|
return archive;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTarHeader(pathname: string, size: number): Uint8Array {
|
||||||
|
const header = new Uint8Array(512);
|
||||||
|
writeTarString(header, 0, 100, pathname);
|
||||||
|
writeTarOctal(header, 100, 8, 0o644);
|
||||||
|
writeTarOctal(header, 108, 8, 0);
|
||||||
|
writeTarOctal(header, 116, 8, 0);
|
||||||
|
writeTarOctal(header, 124, 12, size);
|
||||||
|
writeTarOctal(header, 136, 12, Math.floor(Date.now() / 1000));
|
||||||
|
for (let i = 148; i < 156; i += 1) {
|
||||||
|
header[i] = 32;
|
||||||
|
}
|
||||||
|
header[156] = "0".charCodeAt(0);
|
||||||
|
writeTarString(header, 257, 6, "ustar");
|
||||||
|
writeTarString(header, 263, 2, "00");
|
||||||
|
const checksum = header.reduce((sum, byte) => sum + byte, 0);
|
||||||
|
writeTarChecksum(header, checksum);
|
||||||
|
return header;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarString(
|
||||||
|
target: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
length: number,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
const encoded = new TextEncoder().encode(value);
|
||||||
|
target.set(encoded.slice(0, length), offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarOctal(
|
||||||
|
target: Uint8Array,
|
||||||
|
offset: number,
|
||||||
|
length: number,
|
||||||
|
value: number
|
||||||
|
) {
|
||||||
|
const stringValue = value.toString(8).padStart(length - 1, "0");
|
||||||
|
writeTarString(target, offset, length - 1, stringValue);
|
||||||
|
target[offset + length - 1] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeTarChecksum(target: Uint8Array, checksum: number) {
|
||||||
|
const stringValue = checksum.toString(8).padStart(6, "0");
|
||||||
|
writeTarString(target, 148, 6, stringValue);
|
||||||
|
target[154] = 0;
|
||||||
|
target[155] = 32;
|
||||||
|
}
|
||||||
|
|
||||||
function buildAgentSnippet(input: AgentSnippetInput) {
|
function buildAgentSnippet(input: AgentSnippetInput) {
|
||||||
const candidateUrls = buildCandidateOnboardingUrls(input);
|
const candidateUrls = buildCandidateOnboardingUrls(input);
|
||||||
const resolutionTestUrl = buildResolutionTestUrl(input);
|
const resolutionTestUrl = buildResolutionTestUrl(input);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue