Files
openwork/services/openwork-share/server/_lib/package-openwork-files.ts
Jan c8c0ec9de0 fix/share inline success rail (#837)
* feat: enhance share functionality with new config support and improved UI elements

- Updated test command in package.json to include new test file.
- Added support for 'config' tone in share-bundle-page.js.
- Refactored share-home-client.js to improve handling of pasted content and state management.
- Introduced share-home-state.js for managing preview items and summary cards.
- Added tests for new share-home-state functions.
- Enhanced package-openwork-files.js to recognize and process config files.
- Updated UI styles for share components, including new configurations and improved layout.
- Added new CSS classes for config items and share actions.

* remove drop files

* skills area

* refactor: update ShareHomeClient component and styles for improved layout and item display

- Enhanced the rendering of included items in ShareHomeClient with a new layout.
- Removed redundant code for included items display.
- Updated CSS styles for .included-section, .included-list, and .included-item to improve alignment and spacing.
- Adjusted grid layout for input methods and share cards for better responsiveness.

* feat: implement syntax highlighting and enhance item examples in ShareHomeClient

- Added syntax highlighting functionality for JSON and Markdown content in ShareHomeClient.
- Introduced example fields for preview items in share-home-state.js to provide better context.
- Updated the layout and styles for the carbon preview component to improve user experience.
- Refactored the included items display to use buttons for better interaction and accessibility.

* feat: add syntax highlighting and improve layout for ShareHomeClient

- Implemented syntax highlighting for JSON and Markdown content in ShareHomeClient.
- Updated example names in share-home-state.js for clarity.
- Enhanced the layout and styles of the carbon preview component for better user experience.
- Adjusted CSS for improved responsiveness and visual appeal of share components.

* feat: enhance ShareHomeClient with example selection and improved item display

- Added state management for active examples in ShareHomeClient to allow users to select and preview example contents.
- Updated the rendering of included items to visually indicate active and dimmed states for better user interaction.
- Enhanced CSS styles for included items to reflect active and dimmed states, improving overall user experience.

* refactor: remove deprecated ShareHomeClient and share-home-state components

- Deleted the ShareHomeClient and share-home-state files as part of a cleanup process.
- Removed associated tests and unused functions to streamline the codebase.
- Updated styles in globals.css to reflect the removal of these components.

* feat: implement ShareHomeClient with TypeScript support and enhanced functionality

- Introduced ShareHomeClient component with TypeScript for improved type safety and maintainability.
- Added state management for file uploads and previews, enhancing user interaction.
- Created share-home-state for managing preview items and feedback.
- Updated package.json to include new TypeScript dependencies and adjusted test commands.
- Added tsconfig.json for TypeScript configuration and improved project structure.
- Enhanced styles in globals.css for better UI presentation and responsiveness.
- Removed outdated test files to streamline the codebase.

* refactor: update ShareHomeClient layout and enhance carbon preview component

- Reorganized the layout of the ShareHomeClient component to improve user experience.
- Updated the carbon preview component with a new footer for better content display.
- Adjusted CSS styles for grid layout and carbon window to enhance responsiveness and visual appeal.
- Removed outdated paste metadata display for a cleaner interface.

* refactor: update ShareHomeClient paste state management and improve styles

- Introduced a constant for default paste state to streamline state management in ShareHomeClient.
- Updated paste state messages for clarity and consistency across different scenarios.
- Enhanced CSS styles in globals.css for better visual presentation, including adjustments to background colors and element padding.

* feat: add baseline example and improve paste handling in ShareHomeClient

- Introduced a baseline example for skill input to guide users in the ShareHomeClient component.
- Enhanced paste handling to display the baseline example when no input is provided.
- Updated CSS styles for the status bar and highlighted paste area to reflect the new baseline state and improve visual clarity.

* feat: enhance ShareHomeClient with updated file sharing prompts and improved styles

- Updated selection label to provide clearer feedback on file readiness for sharing.
- Improved drop zone instructions for better user guidance on file uploads.
- Adjusted CSS styles for drop zones and icons to enhance visual appeal and user interaction.
- Refined layout and spacing for a more polished user experience.

* feat: enhance ShareHomeClient with improved copy functionality and updated styles

- Introduced a timer for copy state management to provide user feedback on link copying.
- Updated the layout of the share link section for better visual clarity and interaction.
- Enhanced CSS styles for share link actions and buttons to improve user experience and responsiveness.

* refactor: reorganize ShareHomeClient layout and enhance CSS styles

- Updated the layout of the ShareHomeClient component for improved visual clarity and user interaction.
- Enhanced CSS styles in globals.css for better alignment and responsiveness of share actions and status indicators.
- Removed unnecessary grid row definitions to streamline the layout structure.

* feat: add settings configuration and update CSS styles for improved layout

- Introduced a new settings.local.json file to manage permissions for web fetching.
- Updated CSS styles in globals.css to enhance layout responsiveness and visual consistency, including adjustments to alignment and background colors for severity indicators.

* feat: implement OpenWork Share service with API endpoints and UI components

- Added new service for OpenWork Share, including API routes for health checks, bundle management, and package handling.
- Introduced UI components for displaying shareable bundles and navigation.
- Implemented responsive design elements and improved user interaction through enhanced layouts and styles.
- Updated TypeScript configuration and package dependencies to support new features.
- Enhanced error handling and response management for API interactions.

* clean

* feat: enhance OpenWork Share service with Playwright integration and updated styles

- Added Playwright configuration for end-to-end testing of the OpenWork Share service.
- Updated package.json to include new test scripts for Playwright.
- Enhanced layout component with new font imports for improved typography.
- Refined global CSS styles for better font handling and visual consistency across components.
- Introduced new utility classes for improved styling and responsiveness.

* feat: enhance ShareHomeClient with placeholder item handling and updated styles

- Introduced a new function to build placeholder items based on pasted content and selected entries.
- Updated the component's state management to improve handling of examples and file previews.
- Refined CSS styles for better alignment and visual consistency, including adjustments to the preview filename display and layout responsiveness.
- Added a new JSON file to track test results for improved testing feedback.

* refactor: simplify package status handling and update button state logic in ShareHomeClient

- Removed unnecessary parameters from getPackageStatus function to streamline its usage.
- Updated button disabled state logic to improve clarity and functionality during publishing.
- Enhanced overall code readability by reducing complexity in state management.

* feat: enhance ShareHomeClient with improved selection and preview handling

- Introduced new utility functions for generating selection labels and preview filenames to streamline display logic.
- Updated selection label logic for clearer user feedback on file readiness for sharing.
- Enhanced preview filename display to reflect selected entries and pasted content more accurately.
- Refined CSS styles for better layout and visual consistency in publish actions and results.

* playwright not needed

* fix: update routing types and remove package-lock.json

- Changed the import path for routing types in next-env.d.ts to align with the latest Next.js structure.
- Removed package-lock.json as it is no longer needed for dependency management.

* added ts tests

* removed claude and added it to gitignore

* docs: add openwork-share PR screenshots

---------

Co-authored-by: jcllobet <jcllobet@users.noreply.github.com>
2026-03-11 13:40:32 -07:00

641 lines
22 KiB
TypeScript

import { parse as parseJsonc } from "jsonc-parser";
import type { PreviewItem } from "../../components/share-home-types.ts";
import { humanizeType, maybeArray, maybeString, parseFrontmatter } from "./share-utils.ts";
import type { Frontmatter, NormalizedFile, PackageInput, PackageResult, PackageSummary } from "./types.ts";
const MAX_FILES = 200;
const SECRET_KEY_RE = /(token|secret|password|api[-_]?key|authorization|bearer|private[-_]?key|client[-_]?secret)/i;
const SAFE_SECRET_VALUE_RE = /^(\$\{|\{env:|env\.|process\.env\.|<|YOUR_|REPLACE_ME|example|changeme)/i;
const AGENT_FRONTMATTER_KEYS = new Set(["mode", "model", "tools", "permission", "temperature", "color", "prompt"]);
const OPENCODE_CONFIG_KEYS = new Set(["model", "autoupdate", "server", "provider", "plugin", "mcp", "agent", "permission"]);
function normalizePath(input: unknown): string {
return String(input ?? "")
.replaceAll("\\", "/")
.replace(/\/+/g, "/")
.replace(/^\.\//, "")
.trim();
}
function basename(path: string): string {
const normalized = normalizePath(path);
if (!normalized) return "";
return normalized.split("/").pop() ?? normalized;
}
function dirname(path: string): string {
const normalized = normalizePath(path);
if (!normalized || !normalized.includes("/")) return "";
return normalized.slice(0, normalized.lastIndexOf("/"));
}
function stem(path: string): string {
const name = basename(path);
return name.replace(/\.[^.]+$/, "");
}
function slugify(value: unknown): string {
return String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64);
}
function normalizeFile(input: { path?: string; webkitRelativePath?: string; name?: string; content?: string }, index: number): NormalizedFile {
const path = normalizePath(input.path || input.webkitRelativePath || input.name || `file-${index + 1}`);
const name = basename(path) || `file-${index + 1}`;
const content = String(input.content ?? "").replace(/^\uFEFF/, "");
return { path, name, content };
}
function isMarkdownFile(file: NormalizedFile): boolean {
return /\.md$/i.test(file.name);
}
function isJsonFile(file: NormalizedFile): boolean {
return /\.jsonc?$/i.test(file.name);
}
function isSkillFile(file: NormalizedFile): boolean {
const lowerPath = file.path.toLowerCase();
return /^(skills?|skill)\.md$/i.test(basename(lowerPath)) || /(^|\/)\.opencode\/skills\//.test(lowerPath) || /(^|\/)skills\//.test(lowerPath);
}
function isCommandFile(file: NormalizedFile): boolean {
return /(^|\/)\.opencode\/commands\//.test(file.path.toLowerCase()) || /(^|\/)commands\//.test(file.path.toLowerCase());
}
function isAgentFile(file: NormalizedFile, frontmatterData: Record<string, unknown>): boolean {
const lowerPath = file.path.toLowerCase();
if (/(^|\/)\.opencode\/agents\//.test(lowerPath) || /(^|\/)agents\//.test(lowerPath) || /(^|\/)agents?\.md$/i.test(lowerPath)) {
return true;
}
return Object.keys(frontmatterData).some((key) => AGENT_FRONTMATTER_KEYS.has(key));
}
function isOpenworkConfigFile(file: NormalizedFile): boolean {
return /(^|\/)(?:\.opencode\/)?openwork\.jsonc?$/i.test(file.path);
}
function isOpencodeConfigFile(file: NormalizedFile): boolean {
return /(^|\/)opencode\.jsonc?$/i.test(file.path);
}
function looksLikeNamedMcpFile(file: NormalizedFile): boolean {
return /(^|\/)mcp\//i.test(file.path) || /\.mcp\.jsonc?$/i.test(file.name) || /(^|[._-])mcp(?:[_-]?config)?\.jsonc?$/i.test(file.name);
}
function looksLikeNamedAgentJsonFile(file: NormalizedFile): boolean {
return /(^|\/)agents\//i.test(file.path) || /\.agent\.jsonc?$/i.test(file.name);
}
function resolveName(preferred: unknown, fallback: unknown): string {
const fromPreferred = slugify(preferred);
if (fromPreferred) return fromPreferred;
const fromFallback = slugify(fallback);
if (fromFallback) return fromFallback;
return `item-${Date.now()}`;
}
function buildPreviewItem(name: string, kind: PreviewItem["kind"], meta: string, tone: PreviewItem["tone"]): PreviewItem {
return { name, kind, meta, tone };
}
function countMatches(text: string, token: string): number {
if (!text) return 0;
return (text.match(new RegExp(`\\b${token}\\b`, "gi")) || []).length;
}
function collectHeadings(content: string): string {
return Array.from(content.matchAll(/^#{1,3}\s+(.+)$/gm))
.map((match) => match[1])
.join("\n");
}
function inferMarkdownKind(file: NormalizedFile, frontmatterData: Record<string, unknown>): "agent" | "skill" {
const lowerPath = file.path.toLowerCase();
const lowerContent = file.content.toLowerCase();
const headings = collectHeadings(file.content).toLowerCase();
let agentScore = 0;
let skillScore = 0;
if (/^agents?\.md$/i.test(basename(lowerPath))) {
agentScore += 4;
}
if (/^skills?\.md$/i.test(basename(lowerPath))) {
skillScore += 4;
}
agentScore += countMatches(lowerContent, "agent");
skillScore += countMatches(lowerContent, "skill");
agentScore += countMatches(headings, "agent") * 2;
skillScore += countMatches(headings, "skill") * 2;
if ("trigger" in frontmatterData) {
skillScore += 3;
}
return agentScore > skillScore ? "agent" : "skill";
}
interface SkillRecord {
name: string;
description: string;
trigger: string;
content: string;
preview: PreviewItem;
}
function buildSkillRecord(file: NormalizedFile, warnings: string[], frontmatter: Frontmatter): SkillRecord | null {
const { data } = frontmatter;
const parentName = basename(dirname(file.path));
const name = resolveName(data.name, parentName || stem(file.path));
const description = typeof data.description === "string" ? data.description.trim() : "";
const trigger = typeof data.trigger === "string" ? data.trigger.trim() : "";
const version = typeof data.version === "string" ? data.version.trim() : "";
if (!file.content.trim()) {
warnings.push(`Ignored empty skill file: ${file.path}`);
return null;
}
return {
name,
description,
trigger,
content: file.content,
preview: buildPreviewItem(name, "Skill", version ? `v${version}` : trigger ? `Trigger · ${trigger}` : "Skill", "skill"),
};
}
interface AgentRecord {
name: string;
config: Record<string, unknown>;
preview: PreviewItem;
}
function buildAgentRecord(file: NormalizedFile, warnings: string[], frontmatter: Frontmatter): AgentRecord | null {
const { data, body } = frontmatter;
const name = resolveName(data.name, stem(file.path));
if (!name) {
warnings.push(`Ignored agent without a valid name: ${file.path}`);
return null;
}
const config: Record<string, unknown> = { ...data };
delete config.name;
const promptBody = body.trim();
if (promptBody && typeof config.prompt !== "string") {
config.prompt = promptBody;
}
const version = typeof config.version === "string" ? config.version.trim() : "";
const model = typeof config.model === "string" ? config.model.trim() : "";
return {
name,
config,
preview: buildPreviewItem(name, "Agent", version ? `v${version}` : model || "Agent config", "agent"),
};
}
interface CommandRecord {
name: string;
description: string;
template: string;
agent: string;
model: string;
subtask: boolean;
preview: PreviewItem;
}
function buildCommandRecord(file: NormalizedFile, warnings: string[], frontmatter: Frontmatter): CommandRecord | null {
const { data, body } = frontmatter;
const name = resolveName(data.name, stem(file.path));
const template = body.trim();
if (!name || !template) {
warnings.push(`Ignored command without template: ${file.path}`);
return null;
}
return {
name,
description: typeof data.description === "string" ? data.description.trim() : "",
template,
agent: typeof data.agent === "string" ? data.agent.trim() : "",
model: typeof data.model === "string" ? data.model.trim() : "",
subtask: data.subtask === true,
preview: buildPreviewItem(name, "Command", data.agent ? `Agent · ${data.agent}` : "Command", "command"),
};
}
function parseJsonConfig(file: NormalizedFile): unknown {
try {
return parseJsonc(file.content);
} catch {
throw new Error(`Could not parse ${file.path} as JSON/JSONC`);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function cloneRecord<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
function mergeConfigSections(target: Record<string, unknown> | null, source: Record<string, unknown> | null): Record<string, unknown> {
if (!source) return target ?? {};
const output: Record<string, unknown> = isRecord(target) ? cloneRecord(target) : {};
for (const [key, value] of Object.entries(source)) {
if (isRecord(value) && isRecord(output[key])) {
output[key] = mergeConfigSections(output[key] as Record<string, unknown>, value);
continue;
}
output[key] = cloneRecord(value);
}
return output;
}
function buildConfigPreview(file: NormalizedFile, meta: string): PreviewItem {
return buildPreviewItem(basename(file.path) || stem(file.path), "Config", meta, "config");
}
function collectOpencodePreviewItems(parsed: Record<string, unknown>): PreviewItem[] {
const preview: PreviewItem[] = [];
if (isRecord(parsed?.mcp) && Object.keys(parsed.mcp as Record<string, unknown>).length) {
for (const [name, entry] of Object.entries(parsed.mcp as Record<string, unknown>)) {
const meta = isRecord(entry) && typeof entry.type === "string" ? `${humanizeType(entry.type)} MCP` : "MCP config";
preview.push(buildPreviewItem(name, "MCP", meta, "mcp"));
}
}
if (isRecord(parsed?.agent) && Object.keys(parsed.agent as Record<string, unknown>).length) {
for (const [name, entry] of Object.entries(parsed.agent as Record<string, unknown>)) {
const meta = isRecord(entry) && typeof entry.model === "string" ? entry.model : "Agent config";
preview.push(buildPreviewItem(name, "Agent", meta, "agent"));
}
}
return preview;
}
function looksLikeMcpShape(parsed: unknown): boolean {
if (!isRecord(parsed)) return false;
const schema = maybeString(parsed.$schema).toLowerCase();
if (schema.includes("modelcontextprotocol") || schema.includes("mcp")) {
return true;
}
if (maybeArray(parsed.packages).length || maybeArray(parsed.remotes).length) {
return true;
}
const hasTransport = typeof parsed.type === "string" && (typeof parsed.url === "string" || typeof parsed.command === "string");
const hasCommandRuntime = typeof parsed.command === "string" || maybeArray(parsed.args).length > 0;
return hasTransport || hasCommandRuntime;
}
function looksLikeOpencodeShape(parsed: unknown): boolean {
if (!isRecord(parsed)) return false;
const schema = maybeString(parsed.$schema).toLowerCase();
if (schema.includes("opencode") && schema.includes("config")) {
return true;
}
let matchedKeys = 0;
for (const key of Object.keys(parsed)) {
if (OPENCODE_CONFIG_KEYS.has(key)) matchedKeys += 1;
}
return matchedKeys >= 2;
}
function looksLikeOpenworkShape(parsed: unknown): boolean {
if (!isRecord(parsed)) return false;
const schema = maybeString(parsed.$schema).toLowerCase();
return schema.includes("openwork") && schema.includes("config");
}
function redactSecrets(value: unknown, prefix: string[] = [], hits: string[] = []): string[] {
if (!value || typeof value !== "object") return hits;
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
const nextPrefix = [...prefix, key];
if (SECRET_KEY_RE.test(key) && typeof child === "string" && child.trim() && !SAFE_SECRET_VALUE_RE.test(child.trim())) {
hits.push(nextPrefix.join("."));
(value as Record<string, unknown>)[key] = "********";
}
if (typeof child === "object") {
redactSecrets(child, nextPrefix, hits);
}
}
return hits;
}
interface ConfigSection {
opencode: Record<string, unknown> | null;
openwork: Record<string, unknown> | null;
config: Record<string, unknown>;
preview: PreviewItem[];
configCount: number;
}
function readConfigSection(file: NormalizedFile, warnings: string[]): ConfigSection | null {
const parsed = parseJsonConfig(file);
if (!isRecord(parsed)) {
throw new Error(`Expected ${file.path} to contain an object`);
}
let opencode: Record<string, unknown> | null = null;
let openwork: Record<string, unknown> | null = null;
const config: Record<string, unknown> = {};
const preview: PreviewItem[] = [];
let configCount = 0;
if (isOpenworkConfigFile(file)) {
openwork = cloneRecord(parsed);
preview.push(buildConfigPreview(file, "OpenWork config"));
configCount += 1;
}
if (isOpencodeConfigFile(file)) {
opencode = cloneRecord(parsed);
preview.push(buildConfigPreview(file, "OpenCode config"));
preview.push(...collectOpencodePreviewItems(parsed));
configCount += 1;
}
if (!opencode && !openwork) {
if (looksLikeNamedMcpFile(file)) {
const name = resolveName("", stem(file.path));
opencode = { mcp: { [name]: cloneRecord(parsed) } };
const meta = typeof parsed.type === "string" ? `${humanizeType(parsed.type)} MCP` : "MCP config";
preview.push(buildPreviewItem(name, "MCP", meta, "mcp"));
} else if (looksLikeOpencodeShape(parsed)) {
opencode = cloneRecord(parsed);
preview.push(buildConfigPreview(file, "OpenCode config"));
preview.push(...collectOpencodePreviewItems(parsed));
configCount += 1;
} else if (looksLikeOpenworkShape(parsed)) {
openwork = cloneRecord(parsed);
preview.push(buildConfigPreview(file, "OpenWork config"));
configCount += 1;
} else if (looksLikeMcpShape(parsed)) {
const name = resolveName(parsed.name, stem(file.path));
opencode = { mcp: { [name]: cloneRecord(parsed) } };
preview.push(buildPreviewItem(maybeString(parsed.title).trim() || name, "MCP", "MCP config", "mcp"));
} else if (looksLikeNamedAgentJsonFile(file)) {
const name = resolveName(parsed.name, stem(file.path));
opencode = { agent: { [name]: cloneRecord(parsed) } };
const meta = typeof parsed.model === "string" ? parsed.model : "Agent config";
preview.push(buildPreviewItem(name, "Agent", meta, "agent"));
} else {
const name = resolveName("", stem(file.path));
config[name] = cloneRecord(parsed);
preview.push(buildPreviewItem(basename(file.path) || name, "Config", "Config file", "config"));
configCount += 1;
}
}
const secretHits = [
...redactSecrets(opencode ?? {}, ["opencode"]),
...redactSecrets(openwork ?? {}, ["openwork"]),
...redactSecrets(config, ["config"]),
];
if (secretHits.length) {
warnings.push(`Redacted ${secretHits.length} potential secret${secretHits.length === 1 ? "" : "s"} in ${file.path}: ${secretHits.slice(0, 3).join(", ")}`);
}
if (!opencode && !openwork && !Object.keys(config).length) {
warnings.push(`Ignored unsupported config file: ${file.path}`);
return null;
}
return { opencode, openwork, config, preview, configCount };
}
function mergeNamedObjects(target: Record<string, unknown>, source: Record<string, unknown> | undefined): void {
for (const [name, value] of Object.entries(source ?? {})) {
target[name] = value;
}
}
function buildSummary(
skills: Record<string, unknown>[],
agents: Record<string, unknown>,
mcp: Record<string, unknown>,
commands: Record<string, unknown>[],
warnings: string[],
configCount: number,
): PackageSummary {
return {
skills: skills.length,
agents: Object.keys(agents).length,
mcpServers: Object.keys(mcp).length,
commands: commands.length,
configs: configCount,
warnings: warnings.length,
};
}
function buildBundleName(
inputName: unknown,
skills: { name: string }[],
agentCount: number,
mcpCount: number,
commandCount: number,
configCount: number,
): string {
const explicit = String(inputName ?? "").trim();
if (explicit) return explicit;
if (skills.length === 1 && !agentCount && !mcpCount && !commandCount && !configCount) return skills[0].name;
if (skills.length > 1 && !agentCount && !mcpCount && !commandCount && !configCount) return "Shared skills set";
if (skills.length === 1) return `${skills[0].name} worker package`;
return "Packaged worker";
}
function buildBundleDescription(bundleType: string, summary: PackageSummary): string {
if (bundleType === "skill") return "Single skill bundle generated from dropped markdown.";
if (bundleType === "skills-set") return `${summary.skills} skills packaged together.`;
const parts: string[] = [];
if (summary.skills) parts.push(`${summary.skills} skill${summary.skills === 1 ? "" : "s"}`);
if (summary.agents) parts.push(`${summary.agents} agent${summary.agents === 1 ? "" : "s"}`);
if (summary.mcpServers) parts.push(`${summary.mcpServers} MCP${summary.mcpServers === 1 ? "" : "s"}`);
if (summary.commands) parts.push(`${summary.commands} command${summary.commands === 1 ? "" : "s"}`);
if (summary.configs) parts.push(`${summary.configs} config${summary.configs === 1 ? "" : "s"}`);
return parts.length ? `${parts.join(", ")} bundled into a worker package.` : "Worker package generated from shareable files.";
}
export function packageOpenworkFiles(input: PackageInput): PackageResult {
const rawFiles = Array.isArray(input?.files) ? input.files : [];
if (!rawFiles.length) {
throw new Error("Drop one or more OpenWork files to package them.");
}
if (rawFiles.length > MAX_FILES) {
throw new Error(`Too many files. Package up to ${MAX_FILES} files at once.`);
}
const warnings: string[] = [];
const skills: SkillRecord[] = [];
const commands: CommandRecord[] = [];
const previewItems: PreviewItem[] = [];
const opencodeAgent: Record<string, unknown> = {};
const opencodeMcp: Record<string, unknown> = {};
let opencodeConfig: Record<string, unknown> | null = null;
const genericConfig: Record<string, unknown> = {};
let openwork: Record<string, unknown> | null = null;
let configCount = 0;
for (let index = 0; index < rawFiles.length; index += 1) {
const file = normalizeFile(rawFiles[index], index);
if (!file.content.trim()) {
warnings.push(`Ignored empty file: ${file.path}`);
continue;
}
if (isMarkdownFile(file)) {
const frontmatter = parseFrontmatter(file.content);
const markdownKind = isCommandFile(file)
? "command"
: isSkillFile(file)
? "skill"
: isAgentFile(file, frontmatter.data)
? "agent"
: inferMarkdownKind(file, frontmatter.data);
if (markdownKind === "skill") {
const record = buildSkillRecord(file, warnings, frontmatter);
if (record) {
skills.push(record);
previewItems.push(record.preview);
}
continue;
}
if (markdownKind === "agent") {
const record = buildAgentRecord(file, warnings, frontmatter);
if (record) {
opencodeAgent[record.name] = record.config;
previewItems.push(record.preview);
}
continue;
}
if (markdownKind === "command") {
const record = buildCommandRecord(file, warnings, frontmatter);
if (record) {
commands.push(record);
previewItems.push(record.preview);
}
continue;
}
warnings.push(`Ignored unsupported markdown file: ${file.path}`);
continue;
}
if (isJsonFile(file)) {
const record = readConfigSection(file, warnings);
if (!record) continue;
if (record.opencode) {
opencodeConfig = mergeConfigSections(opencodeConfig, record.opencode);
}
mergeNamedObjects(opencodeAgent, (record.opencode?.agent ?? {}) as Record<string, unknown>);
mergeNamedObjects(opencodeMcp, (record.opencode?.mcp ?? {}) as Record<string, unknown>);
if (record.openwork) openwork = record.openwork;
mergeNamedObjects(genericConfig, record.config ?? {});
configCount += record.configCount ?? 0;
previewItems.push(...record.preview);
continue;
}
warnings.push(`Ignored unsupported file type: ${file.path}`);
}
const cleanSkills = skills.map(({ preview: _preview, ...skill }) => skill);
const cleanCommands = commands.map(({ preview: _preview, ...command }) => command);
const summary = buildSummary(cleanSkills, opencodeAgent, opencodeMcp, cleanCommands, warnings, configCount);
if (!summary.skills && !summary.agents && !summary.mcpServers && !summary.commands && !summary.configs && !openwork) {
throw new Error("No shareable files found. Drop markdown or JSON/JSONC config files to package them.");
}
const hasWorkspaceConfig =
Boolean(openwork) ||
Boolean(opencodeConfig) ||
Object.keys(opencodeAgent).length ||
Object.keys(opencodeMcp).length ||
Object.keys(genericConfig).length ||
cleanCommands.length;
const name = buildBundleName(input?.bundleName, cleanSkills, summary.agents, summary.mcpServers, summary.commands, summary.configs);
let bundleType = "workspace-profile";
let bundle: Record<string, unknown>;
if (cleanSkills.length === 1 && !hasWorkspaceConfig) {
bundleType = "skill";
const skill = cleanSkills[0];
bundle = {
schemaVersion: 1,
type: "skill",
name: skill.name,
description: skill.description || undefined,
trigger: skill.trigger || undefined,
content: skill.content,
};
} else if (cleanSkills.length > 0 && !hasWorkspaceConfig) {
bundleType = "skills-set";
bundle = {
schemaVersion: 1,
type: "skills-set",
name,
description: buildBundleDescription("skills-set", summary),
skills: cleanSkills,
};
} else {
const workspace: Record<string, unknown> = {
workspaceId: "share-service-package",
exportedAt: Date.now(),
...(cleanSkills.length ? { skills: cleanSkills } : null),
...(cleanCommands.length ? { commands: cleanCommands } : null),
...(Object.keys(genericConfig).length ? { config: genericConfig } : null),
...(openwork ? { openwork } : null),
...((opencodeConfig || Object.keys(opencodeAgent).length || Object.keys(opencodeMcp).length)
? {
opencode: mergeConfigSections(opencodeConfig, {
...(Object.keys(opencodeAgent).length ? { agent: opencodeAgent } : null),
...(Object.keys(opencodeMcp).length ? { mcp: opencodeMcp } : null),
}),
}
: null),
};
bundle = {
schemaVersion: 1,
type: "workspace-profile",
name,
description: buildBundleDescription("workspace-profile", summary),
workspace,
};
}
return {
bundle,
bundleType,
name: bundle.name as string,
summary,
warnings,
items: previewItems.slice(0, 12),
};
}