mirror of
https://github.com/different-ai/openwork
synced 2026-04-26 09:35:17 +02:00
* 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>
641 lines
22 KiB
TypeScript
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),
|
|
};
|
|
}
|