mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-14 17:17:58 +00:00
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
* feat: Add deployment skill directory * chore: Address deployment skill review feedback * fix: Include deployment skill file metadata * test: Add deployment skills e2e smoke test
983 lines
30 KiB
TypeScript
983 lines
30 KiB
TypeScript
import fs from 'fs';
|
|
import path from 'path';
|
|
import crypto from 'crypto';
|
|
import yaml from 'js-yaml';
|
|
import { Types } from 'mongoose';
|
|
import {
|
|
logger,
|
|
partitionIssues,
|
|
validateSkillName,
|
|
validateSkillBody,
|
|
validateRelativePath,
|
|
inferSkillFileCategory,
|
|
validateSkillFrontmatter,
|
|
validateSkillDescription,
|
|
deriveStructuredFrontmatterFields,
|
|
} from '@librechat/data-schemas';
|
|
import type { ValidationIssue } from '@librechat/data-schemas';
|
|
import type { CodeEnvRef } from 'librechat-data-provider';
|
|
import { parseFrontmatter, guessMimeType } from './import';
|
|
|
|
export const DEPLOYMENT_SKILLS_DIR_ENV = 'DEPLOYMENT_SKILLS_DIR';
|
|
export const DEFAULT_DEPLOYMENT_SKILLS_DIR = 'skill';
|
|
export const DEPLOYMENT_SKILL_SOURCE = 'deployment';
|
|
export const DEPLOYMENT_SKILL_FILE_SOURCE = 'deployment';
|
|
|
|
const SKILL_MD = 'SKILL.md';
|
|
const DEPLOYMENT_AUTHOR_ID = new Types.ObjectId('de9100000000000000000000');
|
|
const MAX_CACHED_TEXT_BYTES = 512 * 1024;
|
|
|
|
type SkillId = Types.ObjectId | string;
|
|
|
|
export type DeploymentSkillFile = {
|
|
_id: Types.ObjectId;
|
|
skillId: Types.ObjectId;
|
|
relativePath: string;
|
|
file_id: string;
|
|
filename: string;
|
|
filepath: string;
|
|
source: typeof DEPLOYMENT_SKILL_FILE_SOURCE;
|
|
mimeType: string;
|
|
bytes: number;
|
|
category: 'script' | 'reference' | 'asset' | 'other';
|
|
isExecutable: boolean;
|
|
author: Types.ObjectId;
|
|
content?: string;
|
|
isBinary?: boolean;
|
|
codeEnvRef?: CodeEnvRef;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
export type DeploymentSkill = {
|
|
_id: Types.ObjectId;
|
|
name: string;
|
|
displayTitle?: string;
|
|
description: string;
|
|
body: string;
|
|
frontmatter: Record<string, unknown>;
|
|
category: string;
|
|
disableModelInvocation?: boolean;
|
|
userInvocable?: boolean;
|
|
allowedTools?: string[];
|
|
author: Types.ObjectId;
|
|
authorName: string;
|
|
version: number;
|
|
source: typeof DEPLOYMENT_SKILL_SOURCE;
|
|
sourceMetadata: { deployment: true; directory: string };
|
|
fileCount: number;
|
|
alwaysApply: boolean;
|
|
isPublic: true;
|
|
deployment: true;
|
|
files: DeploymentSkillFile[];
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
};
|
|
|
|
type SkillLookupOptions = {
|
|
preferUserInvocable?: boolean;
|
|
preferModelInvocable?: boolean;
|
|
};
|
|
|
|
type SkillSummaryRow = {
|
|
_id: Types.ObjectId;
|
|
name: string;
|
|
displayTitle?: string;
|
|
description: string;
|
|
category?: string;
|
|
disableModelInvocation?: boolean;
|
|
userInvocable?: boolean;
|
|
allowedTools?: string[];
|
|
author: Types.ObjectId;
|
|
authorName?: string;
|
|
version?: number;
|
|
source?: string;
|
|
sourceMetadata?: Record<string, unknown>;
|
|
fileCount?: number;
|
|
alwaysApply?: boolean;
|
|
isPublic?: boolean;
|
|
tenantId?: string;
|
|
deployment?: boolean;
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
};
|
|
|
|
type SkillDetailRow = SkillSummaryRow & {
|
|
body: string;
|
|
frontmatter?: Record<string, unknown>;
|
|
version: number;
|
|
fileCount: number;
|
|
};
|
|
|
|
type AlwaysApplySkillRow = {
|
|
_id: Types.ObjectId;
|
|
name: string;
|
|
body: string;
|
|
author: Types.ObjectId | string;
|
|
allowedTools?: string[];
|
|
deployment?: boolean;
|
|
updatedAt?: Date;
|
|
};
|
|
|
|
type ListSkillsByAccessParams = {
|
|
accessibleIds: Types.ObjectId[];
|
|
category?: string;
|
|
search?: string;
|
|
limit: number;
|
|
cursor?: string | null;
|
|
};
|
|
|
|
type ListSkillsByAccessResult = {
|
|
skills: SkillSummaryRow[];
|
|
has_more?: boolean;
|
|
after?: string | null;
|
|
};
|
|
|
|
type ListAlwaysApplyParams = {
|
|
accessibleIds: Types.ObjectId[];
|
|
limit: number;
|
|
cursor?: string | null;
|
|
};
|
|
|
|
type ListAlwaysApplyResult = {
|
|
skills: AlwaysApplySkillRow[];
|
|
has_more?: boolean;
|
|
after?: string | null;
|
|
};
|
|
|
|
type SkillFileRow = Omit<DeploymentSkillFile, 'codeEnvRef' | 'content' | 'isBinary'> & {
|
|
storageKey?: string;
|
|
storageRegion?: string;
|
|
tenantId?: string;
|
|
};
|
|
|
|
type SkillFileContentRow = SkillFileRow & {
|
|
codeEnvRef?: CodeEnvRef;
|
|
content?: string;
|
|
isBinary?: boolean;
|
|
};
|
|
|
|
export type DeploymentSkillBaseMethods = {
|
|
getSkillById?: (id: SkillId) => Promise<SkillDetailRow | null>;
|
|
getSkillByName?: (
|
|
name: string,
|
|
accessibleIds: Types.ObjectId[],
|
|
options?: SkillLookupOptions,
|
|
) => Promise<SkillDetailRow | null>;
|
|
listSkillsByAccess?: (params: ListSkillsByAccessParams) => Promise<ListSkillsByAccessResult>;
|
|
listAlwaysApplySkills?: (params: ListAlwaysApplyParams) => Promise<ListAlwaysApplyResult>;
|
|
listSkillFiles?: (skillId: SkillId) => Promise<SkillFileRow[]>;
|
|
getSkillFileByPath?: (
|
|
skillId: SkillId,
|
|
relativePath: string,
|
|
) => Promise<SkillFileContentRow | null>;
|
|
updateSkillFileContent?: (
|
|
skillId: SkillId,
|
|
relativePath: string,
|
|
update: { content?: string; isBinary?: boolean },
|
|
) => Promise<void>;
|
|
updateSkillFileCodeEnvIds?: (
|
|
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
|
) => Promise<{ matchedCount: number; modifiedCount: number } | void>;
|
|
};
|
|
|
|
type Cursor = { updatedAt: Date; _id: Types.ObjectId };
|
|
|
|
type LoadDeploymentSkillsOptions = {
|
|
projectRoot?: string;
|
|
env?: NodeJS.ProcessEnv;
|
|
};
|
|
|
|
type DirectoryResolution = {
|
|
directory: string;
|
|
explicitlyConfigured: boolean;
|
|
};
|
|
|
|
type LoadedSkillDirectory = {
|
|
directory: string;
|
|
relativeDirectory: string;
|
|
};
|
|
|
|
export class DeploymentSkillRegistry {
|
|
private readonly skillsById = new Map<string, DeploymentSkill>();
|
|
private readonly skillsByName = new Map<string, DeploymentSkill>();
|
|
private readonly filesByPath = new Map<string, DeploymentSkillFile>();
|
|
|
|
constructor(
|
|
private readonly directory: string | null,
|
|
skills: DeploymentSkill[],
|
|
) {
|
|
for (const skill of skills) {
|
|
this.skillsById.set(skill._id.toString(), skill);
|
|
this.skillsByName.set(skill.name, skill);
|
|
for (const file of skill.files) {
|
|
this.filesByPath.set(file.filepath, file);
|
|
}
|
|
}
|
|
}
|
|
|
|
getDirectory(): string | null {
|
|
return this.directory;
|
|
}
|
|
|
|
list(): DeploymentSkill[] {
|
|
return Array.from(this.skillsById.values());
|
|
}
|
|
|
|
ids(): Types.ObjectId[] {
|
|
return this.list().map((skill) => skill._id);
|
|
}
|
|
|
|
hasId(id: SkillId | undefined): boolean {
|
|
return id != null && this.skillsById.has(id.toString());
|
|
}
|
|
|
|
getById(id: SkillId): DeploymentSkill | null {
|
|
return this.skillsById.get(id.toString()) ?? null;
|
|
}
|
|
|
|
getByName(
|
|
name: string,
|
|
accessibleIds: Types.ObjectId[],
|
|
_options?: SkillLookupOptions,
|
|
): DeploymentSkill | null {
|
|
const skill = this.skillsByName.get(name);
|
|
if (!skill) {
|
|
return null;
|
|
}
|
|
if (!hasAccessibleId(accessibleIds, skill._id)) {
|
|
return null;
|
|
}
|
|
return skill;
|
|
}
|
|
|
|
listByAccess(params: ListSkillsByAccessParams): DeploymentSkill[] {
|
|
const accessibleSet = new Set(params.accessibleIds.map((id) => id.toString()));
|
|
const cursor = decodeCursor(params.cursor);
|
|
const search = params.search?.toLowerCase();
|
|
return this.list()
|
|
.filter((skill) => accessibleSet.has(skill._id.toString()))
|
|
.filter((skill) => !params.category || skill.category === params.category)
|
|
.filter((skill) => {
|
|
if (!search) {
|
|
return true;
|
|
}
|
|
return (
|
|
skill.name.toLowerCase().includes(search) ||
|
|
skill.description.toLowerCase().includes(search) ||
|
|
(skill.displayTitle?.toLowerCase().includes(search) ?? false)
|
|
);
|
|
})
|
|
.filter((skill) => isAfterCursor(skill, cursor))
|
|
.sort(compareBySkillCursor);
|
|
}
|
|
|
|
listAlwaysApply(params: ListAlwaysApplyParams): DeploymentSkill[] {
|
|
const accessibleSet = new Set(params.accessibleIds.map((id) => id.toString()));
|
|
const cursor = decodeCursor(params.cursor);
|
|
return this.list()
|
|
.filter((skill) => skill.alwaysApply === true)
|
|
.filter((skill) => accessibleSet.has(skill._id.toString()))
|
|
.filter((skill) => isAfterCursor(skill, cursor))
|
|
.sort(compareBySkillCursor);
|
|
}
|
|
|
|
listFiles(skillId: SkillId): DeploymentSkillFile[] | null {
|
|
const skill = this.getById(skillId);
|
|
return skill ? [...skill.files] : null;
|
|
}
|
|
|
|
getFileByPath(skillId: SkillId, relativePath: string): DeploymentSkillFile | null {
|
|
const skill = this.getById(skillId);
|
|
if (!skill) {
|
|
return null;
|
|
}
|
|
return skill.files.find((file) => file.relativePath === relativePath) ?? null;
|
|
}
|
|
|
|
hasFilePath(filepath: string): boolean {
|
|
return this.filesByPath.has(filepath);
|
|
}
|
|
|
|
updateFileCodeEnvRefs(
|
|
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
|
): Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> {
|
|
const dbUpdates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> = [];
|
|
for (const update of updates) {
|
|
const file = this.getFileByPath(update.skillId, update.relativePath);
|
|
if (!file) {
|
|
dbUpdates.push(update);
|
|
continue;
|
|
}
|
|
file.codeEnvRef = update.codeEnvRef;
|
|
}
|
|
return dbUpdates;
|
|
}
|
|
}
|
|
|
|
let registry = new DeploymentSkillRegistry(null, []);
|
|
|
|
export function getDeploymentSkillRegistry(): DeploymentSkillRegistry {
|
|
return registry;
|
|
}
|
|
|
|
export function getDeploymentSkillIds(): Types.ObjectId[] {
|
|
return registry.ids();
|
|
}
|
|
|
|
export function mergeDeploymentSkillIds(ids: Array<SkillId>): Types.ObjectId[] {
|
|
const seen = new Set<string>();
|
|
const merged: Types.ObjectId[] = [];
|
|
for (const id of [...ids, ...registry.ids()]) {
|
|
const oid = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
|
const key = oid.toString();
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
merged.push(oid);
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
export function isDeploymentSkillId(id: SkillId | undefined): boolean {
|
|
return registry.hasId(id);
|
|
}
|
|
|
|
export function isDeploymentSkillFileSource(source: unknown): boolean {
|
|
return source === DEPLOYMENT_SKILL_FILE_SOURCE;
|
|
}
|
|
|
|
export function getDeploymentSkillById(id: SkillId): DeploymentSkill | null {
|
|
return registry.getById(id);
|
|
}
|
|
|
|
export function isDeploymentSkillFilePath(filepath: string): boolean {
|
|
return registry.hasFilePath(filepath);
|
|
}
|
|
|
|
export async function getDeploymentSkillDownloadStream(
|
|
filepath: string,
|
|
): Promise<NodeJS.ReadableStream> {
|
|
if (!registry.hasFilePath(filepath)) {
|
|
throw new Error('Deployment skill file is not registered');
|
|
}
|
|
return fs.createReadStream(filepath);
|
|
}
|
|
|
|
export function updateDeploymentSkillFileCodeEnvRefs(
|
|
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
|
): Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> {
|
|
return registry.updateFileCodeEnvRefs(updates);
|
|
}
|
|
|
|
export function resolveDeploymentSkillDirectory(
|
|
options: LoadDeploymentSkillsOptions = {},
|
|
): DirectoryResolution {
|
|
const env = options.env ?? process.env;
|
|
const projectRoot = options.projectRoot ?? process.cwd();
|
|
const configured = env[DEPLOYMENT_SKILLS_DIR_ENV]?.trim();
|
|
const rawDirectory =
|
|
configured && configured.length > 0 ? configured : DEFAULT_DEPLOYMENT_SKILLS_DIR;
|
|
return {
|
|
directory: path.isAbsolute(rawDirectory)
|
|
? rawDirectory
|
|
: path.resolve(projectRoot, rawDirectory),
|
|
explicitlyConfigured: configured != null && configured.length > 0,
|
|
};
|
|
}
|
|
|
|
export async function initializeDeploymentSkills(
|
|
options: LoadDeploymentSkillsOptions = {},
|
|
): Promise<DeploymentSkillRegistry> {
|
|
const resolved = resolveDeploymentSkillDirectory(options);
|
|
registry = await loadDeploymentSkillsFromDirectory(resolved.directory, {
|
|
projectRoot: options.projectRoot ?? process.cwd(),
|
|
explicitlyConfigured: resolved.explicitlyConfigured,
|
|
});
|
|
const count = registry.list().length;
|
|
if (count > 0) {
|
|
logger.info(
|
|
`[deploymentSkills] Loaded ${count} deployment skill(s) from ${registry.getDirectory()}`,
|
|
);
|
|
} else {
|
|
logger.debug(`[deploymentSkills] No deployment skills loaded from ${resolved.directory}`);
|
|
}
|
|
return registry;
|
|
}
|
|
|
|
export async function loadDeploymentSkillsFromDirectory(
|
|
directory: string,
|
|
options: { projectRoot?: string; explicitlyConfigured?: boolean } = {},
|
|
): Promise<DeploymentSkillRegistry> {
|
|
let rootStat: fs.Stats;
|
|
try {
|
|
rootStat = await fs.promises.stat(directory);
|
|
} catch (error) {
|
|
if (
|
|
(error as NodeJS.ErrnoException).code === 'ENOENT' &&
|
|
options.explicitlyConfigured !== true
|
|
) {
|
|
return new DeploymentSkillRegistry(directory, []);
|
|
}
|
|
throw new Error(`Deployment skills directory not found: ${directory}`);
|
|
}
|
|
if (!rootStat.isDirectory()) {
|
|
throw new Error(`Deployment skills path must be a directory: ${directory}`);
|
|
}
|
|
|
|
const skillDirectories = await findSkillDirectories(directory, options.projectRoot ?? directory);
|
|
const skills = await Promise.all(
|
|
skillDirectories.map((skillDirectory) => loadDeploymentSkill(skillDirectory, directory)),
|
|
);
|
|
validateUniqueNames(skills);
|
|
return new DeploymentSkillRegistry(directory, skills.sort(compareBySkillCursor));
|
|
}
|
|
|
|
export function createDeploymentSkillMethods<T extends DeploymentSkillBaseMethods>(
|
|
base: T,
|
|
): T & Required<Pick<DeploymentSkillBaseMethods, 'getSkillById'>> {
|
|
const methods = {
|
|
...base,
|
|
getSkillById: async (id: SkillId): Promise<SkillDetailRow | null> => {
|
|
const deployment = registry.getById(id);
|
|
if (deployment) {
|
|
return toSkillDetailRow(deployment);
|
|
}
|
|
return base.getSkillById ? base.getSkillById(id) : null;
|
|
},
|
|
getSkillByName: async (
|
|
name: string,
|
|
accessibleIds: Types.ObjectId[],
|
|
options?: SkillLookupOptions,
|
|
): Promise<SkillDetailRow | null> => {
|
|
const dbSkill = base.getSkillByName
|
|
? await base.getSkillByName(name, stripDeploymentIds(accessibleIds), options)
|
|
: null;
|
|
const deployment = registry.getByName(name, accessibleIds, options);
|
|
return pickPreferredSkill(
|
|
[dbSkill, deployment ? toSkillDetailRow(deployment) : null],
|
|
options,
|
|
);
|
|
},
|
|
listSkillsByAccess: async (
|
|
params: ListSkillsByAccessParams,
|
|
): Promise<ListSkillsByAccessResult> => {
|
|
const dbResult = base.listSkillsByAccess
|
|
? await base.listSkillsByAccess({
|
|
...params,
|
|
accessibleIds: stripDeploymentIds(params.accessibleIds),
|
|
})
|
|
: { skills: [], has_more: false, after: null };
|
|
return mergeSkillPage({
|
|
dbResult,
|
|
deploymentRows: registry.listByAccess(params).map(toSkillSummaryRow),
|
|
limit: params.limit,
|
|
});
|
|
},
|
|
listAlwaysApplySkills: async (
|
|
params: ListAlwaysApplyParams,
|
|
): Promise<ListAlwaysApplyResult> => {
|
|
const dbResult = base.listAlwaysApplySkills
|
|
? await base.listAlwaysApplySkills({
|
|
...params,
|
|
accessibleIds: stripDeploymentIds(params.accessibleIds),
|
|
})
|
|
: { skills: [], has_more: false, after: null };
|
|
return mergeAlwaysApplyPage({
|
|
dbResult,
|
|
deploymentRows: registry.listAlwaysApply(params).map(toAlwaysApplyRow),
|
|
limit: params.limit,
|
|
});
|
|
},
|
|
listSkillFiles: async (skillId: SkillId): Promise<SkillFileRow[]> => {
|
|
const deploymentFiles = registry.listFiles(skillId);
|
|
if (deploymentFiles) {
|
|
return deploymentFiles.map(toSkillFileRow);
|
|
}
|
|
return base.listSkillFiles ? base.listSkillFiles(skillId) : [];
|
|
},
|
|
getSkillFileByPath: async (
|
|
skillId: SkillId,
|
|
relativePath: string,
|
|
): Promise<SkillFileContentRow | null> => {
|
|
const deploymentFile = registry.getFileByPath(skillId, relativePath);
|
|
if (deploymentFile) {
|
|
return toSkillFileContentRow(deploymentFile);
|
|
}
|
|
return base.getSkillFileByPath ? base.getSkillFileByPath(skillId, relativePath) : null;
|
|
},
|
|
updateSkillFileContent: async (
|
|
skillId: SkillId,
|
|
relativePath: string,
|
|
update: { content?: string; isBinary?: boolean },
|
|
): Promise<void> => {
|
|
const deploymentFile = registry.getFileByPath(skillId, relativePath);
|
|
if (deploymentFile) {
|
|
if (update.content !== undefined) {
|
|
deploymentFile.content = update.content;
|
|
}
|
|
if (update.isBinary !== undefined) {
|
|
deploymentFile.isBinary = update.isBinary;
|
|
}
|
|
return;
|
|
}
|
|
if (base.updateSkillFileContent) {
|
|
await base.updateSkillFileContent(skillId, relativePath, update);
|
|
}
|
|
},
|
|
updateSkillFileCodeEnvIds: async (
|
|
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
|
): Promise<{ matchedCount: number; modifiedCount: number } | void> => {
|
|
const dbUpdates = registry.updateFileCodeEnvRefs(updates);
|
|
if (dbUpdates.length === 0) {
|
|
return { matchedCount: updates.length, modifiedCount: updates.length };
|
|
}
|
|
return base.updateSkillFileCodeEnvIds
|
|
? base.updateSkillFileCodeEnvIds(dbUpdates)
|
|
: { matchedCount: 0, modifiedCount: 0 };
|
|
},
|
|
};
|
|
return methods as T & Required<Pick<DeploymentSkillBaseMethods, 'getSkillById'>>;
|
|
}
|
|
|
|
async function findSkillDirectories(
|
|
directory: string,
|
|
projectRoot: string,
|
|
): Promise<LoadedSkillDirectory[]> {
|
|
const directories: LoadedSkillDirectory[] = [];
|
|
if (await fileExists(path.join(directory, SKILL_MD))) {
|
|
directories.push({
|
|
directory,
|
|
relativeDirectory: relativeToRoot(projectRoot, directory),
|
|
});
|
|
}
|
|
|
|
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
const childDirectories = entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => path.join(directory, entry.name));
|
|
const childSkillChecks = await Promise.all(
|
|
childDirectories.map(async (child) =>
|
|
(await fileExists(path.join(child, SKILL_MD)))
|
|
? {
|
|
directory: child,
|
|
relativeDirectory: relativeToRoot(projectRoot, child),
|
|
}
|
|
: null,
|
|
),
|
|
);
|
|
for (const child of childSkillChecks) {
|
|
if (child) {
|
|
directories.push(child);
|
|
}
|
|
}
|
|
return directories;
|
|
}
|
|
|
|
async function loadDeploymentSkill(
|
|
skillDirectory: LoadedSkillDirectory,
|
|
rootDirectory: string,
|
|
): Promise<DeploymentSkill> {
|
|
const skillMdPath = path.join(skillDirectory.directory, SKILL_MD);
|
|
const [content, stat] = await Promise.all([
|
|
fs.promises.readFile(skillMdPath, 'utf8'),
|
|
fs.promises.stat(skillMdPath),
|
|
]);
|
|
const parsed = parseFrontmatter(content);
|
|
const structured = parseStructuredFrontmatter(content);
|
|
if ('error' in structured) {
|
|
throw new Error(`${skillDirectory.relativeDirectory}/${SKILL_MD}: ${structured.error}`);
|
|
}
|
|
const frontmatter = structured.frontmatter ?? {};
|
|
const description =
|
|
typeof frontmatter.description === 'string' ? frontmatter.description : parsed.description;
|
|
const name = typeof frontmatter.name === 'string' ? frontmatter.name : parsed.name;
|
|
const issues: ValidationIssue[] = [
|
|
...validateSkillName(name),
|
|
...validateSkillDescription(description),
|
|
...validateSkillBody(content),
|
|
...validateSkillFrontmatter(frontmatter),
|
|
];
|
|
if (parsed.invalidBooleans.length > 0) {
|
|
issues.push(
|
|
...parsed.invalidBooleans.map((key) => ({
|
|
field: `frontmatter.${key}`,
|
|
code: 'INVALID_TYPE',
|
|
message: `"${key}" must be a boolean (true or false)`,
|
|
})),
|
|
);
|
|
}
|
|
const { errors, warnings } = partitionIssues(issues);
|
|
if (errors.length > 0) {
|
|
throw new Error(
|
|
`${skillDirectory.relativeDirectory}/${SKILL_MD}: ${errors
|
|
.map((issue) => `${issue.field}: ${issue.message}`)
|
|
.join('; ')}`,
|
|
);
|
|
}
|
|
if (warnings.length > 0) {
|
|
logger.warn(
|
|
`[deploymentSkills] ${skillDirectory.relativeDirectory}/${SKILL_MD}: ${warnings
|
|
.map((issue) => `${issue.field}: ${issue.message}`)
|
|
.join('; ')}`,
|
|
);
|
|
}
|
|
|
|
const derived = deriveStructuredFrontmatterFields(frontmatter);
|
|
const skillId = stableObjectId(`deployment-skill:${name}`);
|
|
const files = await loadDeploymentSkillFiles({
|
|
skillId,
|
|
skillName: name,
|
|
skillDirectory: skillDirectory.directory,
|
|
rootDirectory,
|
|
});
|
|
return {
|
|
_id: skillId,
|
|
name,
|
|
description,
|
|
body: content,
|
|
frontmatter,
|
|
category: '',
|
|
author: DEPLOYMENT_AUTHOR_ID,
|
|
authorName: 'Deployment',
|
|
version: 1,
|
|
source: DEPLOYMENT_SKILL_SOURCE,
|
|
sourceMetadata: {
|
|
deployment: true,
|
|
directory: skillDirectory.relativeDirectory,
|
|
},
|
|
fileCount: files.length,
|
|
alwaysApply: parsed.alwaysApply ?? false,
|
|
isPublic: true,
|
|
deployment: true,
|
|
files,
|
|
createdAt: stat.birthtime,
|
|
updatedAt: stat.mtime,
|
|
...derived,
|
|
};
|
|
}
|
|
|
|
async function loadDeploymentSkillFiles({
|
|
skillId,
|
|
skillName,
|
|
skillDirectory,
|
|
rootDirectory,
|
|
}: {
|
|
skillId: Types.ObjectId;
|
|
skillName: string;
|
|
skillDirectory: string;
|
|
rootDirectory: string;
|
|
}): Promise<DeploymentSkillFile[]> {
|
|
const files = await collectSkillFiles(skillDirectory, skillDirectory);
|
|
const rows = await Promise.all(
|
|
files.map(async (filePath) => {
|
|
const relativePath = normalizePath(path.relative(skillDirectory, filePath));
|
|
const issues = validateRelativePath(relativePath);
|
|
if (issues.length > 0) {
|
|
throw new Error(
|
|
`${relativeToRoot(rootDirectory, filePath)}: ${issues
|
|
.map((issue) => `${issue.field}: ${issue.message}`)
|
|
.join('; ')}`,
|
|
);
|
|
}
|
|
const stat = await fs.promises.stat(filePath);
|
|
const mimeType = guessMimeType(relativePath);
|
|
const cache = await readCachedFileContent(filePath, stat.size);
|
|
return {
|
|
_id: stableObjectId(`deployment-skill-file:${skillName}:${relativePath}`),
|
|
skillId,
|
|
relativePath,
|
|
file_id: stableObjectId(`deployment-skill-file-id:${skillName}:${relativePath}`).toString(),
|
|
filename: path.basename(relativePath),
|
|
filepath: filePath,
|
|
source: DEPLOYMENT_SKILL_FILE_SOURCE,
|
|
mimeType,
|
|
bytes: stat.size,
|
|
category: inferSkillFileCategory(relativePath),
|
|
isExecutable: false,
|
|
author: DEPLOYMENT_AUTHOR_ID,
|
|
createdAt: stat.birthtime,
|
|
updatedAt: stat.mtime,
|
|
...cache,
|
|
} satisfies DeploymentSkillFile;
|
|
}),
|
|
);
|
|
return rows.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
}
|
|
|
|
async function collectSkillFiles(root: string, directory: string): Promise<string[]> {
|
|
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
|
const results = await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const filePath = path.join(directory, entry.name);
|
|
const relativePath = normalizePath(path.relative(root, filePath));
|
|
if (relativePath === SKILL_MD) {
|
|
return [];
|
|
}
|
|
if (entry.isSymbolicLink()) {
|
|
throw new Error(`${relativePath}: symlinks are not allowed in deployment skills`);
|
|
}
|
|
if (entry.isDirectory()) {
|
|
return collectSkillFiles(root, filePath);
|
|
}
|
|
if (!entry.isFile()) {
|
|
return [];
|
|
}
|
|
return [filePath];
|
|
}),
|
|
);
|
|
return results.flat();
|
|
}
|
|
|
|
async function readCachedFileContent(
|
|
filePath: string,
|
|
bytes: number,
|
|
): Promise<Pick<DeploymentSkillFile, 'content' | 'isBinary'>> {
|
|
const file = await fs.promises.open(filePath, 'r');
|
|
try {
|
|
const probe = Buffer.alloc(Math.min(bytes, 8192));
|
|
if (probe.length > 0) {
|
|
await file.read(probe, 0, probe.length, 0);
|
|
}
|
|
if (probe.includes(0)) {
|
|
return { isBinary: true };
|
|
}
|
|
} finally {
|
|
await file.close();
|
|
}
|
|
if (bytes > MAX_CACHED_TEXT_BYTES) {
|
|
return { isBinary: false };
|
|
}
|
|
return {
|
|
isBinary: false,
|
|
content: await fs.promises.readFile(filePath, 'utf8'),
|
|
};
|
|
}
|
|
|
|
function parseStructuredFrontmatter(
|
|
content: string,
|
|
): { frontmatter?: Record<string, unknown>; error?: undefined } | { error: string } {
|
|
const trimmed = content.trim();
|
|
if (!trimmed.startsWith('---')) {
|
|
return { frontmatter: {} };
|
|
}
|
|
const after = trimmed.slice(3);
|
|
const closingIdx = after.indexOf('\n---');
|
|
if (closingIdx === -1) {
|
|
return { error: `Invalid ${SKILL_MD} frontmatter: missing closing "---".` };
|
|
}
|
|
try {
|
|
const parsed = yaml.load(after.slice(0, closingIdx));
|
|
if (parsed == null) {
|
|
return { frontmatter: {} };
|
|
}
|
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { error: `${SKILL_MD} frontmatter must be a YAML mapping.` };
|
|
}
|
|
return { frontmatter: parsed as Record<string, unknown> };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return { error: `Invalid ${SKILL_MD} frontmatter: ${message}` };
|
|
}
|
|
}
|
|
|
|
function validateUniqueNames(skills: DeploymentSkill[]): void {
|
|
const seen = new Set<string>();
|
|
for (const skill of skills) {
|
|
if (seen.has(skill.name)) {
|
|
throw new Error(`Duplicate deployment skill name "${skill.name}"`);
|
|
}
|
|
seen.add(skill.name);
|
|
}
|
|
}
|
|
|
|
function mergeSkillPage({
|
|
dbResult,
|
|
deploymentRows,
|
|
limit,
|
|
}: {
|
|
dbResult: ListSkillsByAccessResult;
|
|
deploymentRows: SkillSummaryRow[];
|
|
limit: number;
|
|
}): ListSkillsByAccessResult {
|
|
const boundedLimit = Math.min(Math.max(1, limit || 20), 100);
|
|
const merged = [...deploymentRows, ...dbResult.skills].sort(compareBySkillCursor);
|
|
const sliced = merged.slice(0, boundedLimit);
|
|
const hasMore = merged.length > boundedLimit || dbResult.has_more === true;
|
|
return {
|
|
skills: sliced,
|
|
has_more: hasMore,
|
|
after: hasMore && sliced.length > 0 ? encodeCursor(sliced[sliced.length - 1]) : null,
|
|
};
|
|
}
|
|
|
|
function mergeAlwaysApplyPage({
|
|
dbResult,
|
|
deploymentRows,
|
|
limit,
|
|
}: {
|
|
dbResult: ListAlwaysApplyResult;
|
|
deploymentRows: AlwaysApplySkillRow[];
|
|
limit: number;
|
|
}): ListAlwaysApplyResult {
|
|
const boundedLimit = Math.min(Math.max(1, limit || 20), 100);
|
|
const merged = [...deploymentRows, ...dbResult.skills].sort(compareBySkillCursor);
|
|
const sliced = merged.slice(0, boundedLimit);
|
|
const hasMore = merged.length > boundedLimit || dbResult.has_more === true;
|
|
return {
|
|
skills: sliced,
|
|
has_more: hasMore,
|
|
after: hasMore && sliced.length > 0 ? encodeCursor(sliced[sliced.length - 1]) : null,
|
|
};
|
|
}
|
|
|
|
function pickPreferredSkill(
|
|
rows: Array<SkillDetailRow | null>,
|
|
options?: SkillLookupOptions,
|
|
): SkillDetailRow | null {
|
|
const candidates = rows.filter((row): row is SkillDetailRow => row != null);
|
|
if (candidates.length === 0) {
|
|
return null;
|
|
}
|
|
const preferred = candidates.filter((row) => matchesLookupPreference(row, options));
|
|
return (preferred.length > 0 ? preferred : candidates).sort(compareBySkillCursor)[0];
|
|
}
|
|
|
|
function matchesLookupPreference(
|
|
row: Pick<SkillSummaryRow, 'disableModelInvocation' | 'userInvocable'>,
|
|
options?: SkillLookupOptions,
|
|
): boolean {
|
|
if (options?.preferUserInvocable === true && row.userInvocable === false) {
|
|
return false;
|
|
}
|
|
if (options?.preferModelInvocable === true && row.disableModelInvocation === true) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function toSkillSummaryRow(skill: DeploymentSkill): SkillSummaryRow {
|
|
const { body: _body, frontmatter: _frontmatter, files: _files, ...row } = skill;
|
|
return row;
|
|
}
|
|
|
|
function toSkillDetailRow(skill: DeploymentSkill): SkillDetailRow {
|
|
const { files: _files, ...row } = skill;
|
|
return row;
|
|
}
|
|
|
|
function toAlwaysApplyRow(skill: DeploymentSkill): AlwaysApplySkillRow {
|
|
return {
|
|
_id: skill._id,
|
|
name: skill.name,
|
|
body: skill.body,
|
|
author: skill.author,
|
|
deployment: true,
|
|
updatedAt: skill.updatedAt,
|
|
...(skill.allowedTools !== undefined ? { allowedTools: skill.allowedTools } : {}),
|
|
};
|
|
}
|
|
|
|
function toSkillFileRow(file: DeploymentSkillFile): SkillFileRow {
|
|
const { codeEnvRef: _codeEnvRef, content: _content, isBinary: _isBinary, ...row } = file;
|
|
return row;
|
|
}
|
|
|
|
function toSkillFileContentRow(file: DeploymentSkillFile): SkillFileContentRow {
|
|
return {
|
|
...toSkillFileRow(file),
|
|
codeEnvRef: file.codeEnvRef,
|
|
content: file.content,
|
|
isBinary: file.isBinary,
|
|
};
|
|
}
|
|
|
|
function stripDeploymentIds(ids: Types.ObjectId[]): Types.ObjectId[] {
|
|
return ids.filter((id) => !registry.hasId(id));
|
|
}
|
|
|
|
function hasAccessibleId(ids: Types.ObjectId[], skillId: Types.ObjectId): boolean {
|
|
const key = skillId.toString();
|
|
return ids.some((id) => id.toString() === key);
|
|
}
|
|
|
|
function compareBySkillCursor(
|
|
a: Pick<SkillSummaryRow, '_id' | 'updatedAt'>,
|
|
b: Pick<SkillSummaryRow, '_id' | 'updatedAt'>,
|
|
): number {
|
|
const aTime = (a.updatedAt ?? new Date(0)).getTime();
|
|
const bTime = (b.updatedAt ?? new Date(0)).getTime();
|
|
if (aTime !== bTime) {
|
|
return bTime - aTime;
|
|
}
|
|
return a._id.toString().localeCompare(b._id.toString());
|
|
}
|
|
|
|
function isAfterCursor(row: Pick<SkillSummaryRow, '_id' | 'updatedAt'>, cursor: Cursor | null) {
|
|
if (!cursor) {
|
|
return true;
|
|
}
|
|
const rowTime = (row.updatedAt ?? new Date(0)).getTime();
|
|
const cursorTime = cursor.updatedAt.getTime();
|
|
if (rowTime < cursorTime) {
|
|
return true;
|
|
}
|
|
if (rowTime > cursorTime) {
|
|
return false;
|
|
}
|
|
return row._id.toString() > cursor._id.toString();
|
|
}
|
|
|
|
function decodeCursor(cursor: string | null | undefined): Cursor | null {
|
|
if (!cursor || cursor === 'undefined' || cursor === 'null') {
|
|
return null;
|
|
}
|
|
try {
|
|
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as {
|
|
updatedAt?: string;
|
|
_id?: string;
|
|
};
|
|
if (!decoded.updatedAt || !decoded._id || !Types.ObjectId.isValid(decoded._id)) {
|
|
return null;
|
|
}
|
|
const updatedAt = new Date(decoded.updatedAt);
|
|
if (Number.isNaN(updatedAt.getTime())) {
|
|
return null;
|
|
}
|
|
return { updatedAt, _id: new Types.ObjectId(decoded._id) };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function encodeCursor(row: Pick<SkillSummaryRow, '_id' | 'updatedAt'>): string {
|
|
return Buffer.from(
|
|
JSON.stringify({
|
|
updatedAt: (row.updatedAt ?? new Date(0)).toISOString(),
|
|
_id: row._id.toString(),
|
|
}),
|
|
).toString('base64');
|
|
}
|
|
|
|
function stableObjectId(seed: string): Types.ObjectId {
|
|
return new Types.ObjectId(crypto.createHash('sha1').update(seed).digest('hex').slice(0, 24));
|
|
}
|
|
|
|
async function fileExists(filePath: string): Promise<boolean> {
|
|
try {
|
|
const stat = await fs.promises.stat(filePath);
|
|
return stat.isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function normalizePath(value: string): string {
|
|
return value.split(path.sep).join('/');
|
|
}
|
|
|
|
function relativeToRoot(root: string, target: string): string {
|
|
const relative = normalizePath(path.relative(root, target));
|
|
return relative.length > 0 ? relative : '.';
|
|
}
|