feat(coding): add model tools and device packages
This commit is contained in:
224
electron/coding-packages/device-package-format.ts
Normal file
224
electron/coding-packages/device-package-format.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
DevicePackageKind,
|
||||
DevicePackageSkillEntry,
|
||||
} from '../../shared/device-packages';
|
||||
|
||||
export type {
|
||||
DevicePackageIndexV1,
|
||||
DevicePackageKind,
|
||||
DevicePackageRecordV1,
|
||||
DevicePackageSkillEntry,
|
||||
InstallPreviewV1,
|
||||
} from '../../shared/device-packages';
|
||||
|
||||
export type DevicePackageSource =
|
||||
| Readonly<{ kind: 'npm'; spec: string }>
|
||||
| Readonly<{ kind: 'git'; spec: string }>
|
||||
| Readonly<{ kind: 'file'; absolutePath: string }>;
|
||||
|
||||
export interface InspectedDevicePackage {
|
||||
packageId: string;
|
||||
displayName: string;
|
||||
resolvedVersion: string | null;
|
||||
skillEntries: DevicePackageSkillEntry[];
|
||||
extensionEntries: string[];
|
||||
ignoredLifecycleScripts: string[];
|
||||
}
|
||||
|
||||
const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/u;
|
||||
const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u;
|
||||
const LIFECYCLE_SCRIPT_NAMES = Object.freeze([
|
||||
'preinstall',
|
||||
'install',
|
||||
'postinstall',
|
||||
'prepare',
|
||||
] as const);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function nonempty(value: unknown, maximum: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const normalized = value.trim();
|
||||
return normalized && normalized.length <= maximum ? normalized : null;
|
||||
}
|
||||
|
||||
function containsControlCharacter(value: string): boolean {
|
||||
return Array.from(value).some((character) => {
|
||||
const codePoint = character.codePointAt(0) ?? 0;
|
||||
return codePoint <= 0x1f || codePoint === 0x7f;
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDevicePackageSource(value: string): DevicePackageSource {
|
||||
const source = nonempty(value, 2048);
|
||||
if (!source || containsControlCharacter(source)) {
|
||||
throw new Error('Device package source is invalid');
|
||||
}
|
||||
if (source.startsWith('npm:')) {
|
||||
const spec = nonempty(source.slice(4), 1024);
|
||||
if (!spec || /\s/u.test(spec)) throw new Error('npm package source is invalid');
|
||||
return { kind: 'npm', spec: `npm:${spec}` };
|
||||
}
|
||||
if (source.startsWith('git:') || /^https?:\/\//iu.test(source)
|
||||
|| /^ssh:\/\//iu.test(source) || /^git@[^:]+:/u.test(source)) {
|
||||
if (/\s/u.test(source)) throw new Error('Git package source is invalid');
|
||||
return { kind: 'git', spec: source };
|
||||
}
|
||||
if (!path.isAbsolute(source)) throw new Error('Local package source must be an absolute path');
|
||||
return { kind: 'file', absolutePath: path.resolve(source) };
|
||||
}
|
||||
|
||||
export function safeDevicePackageId(value: string): string {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
.replace(/^@/u, '')
|
||||
.replaceAll('/', '.')
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/^[^a-z0-9]+|[^a-z0-9._-]+$/gu, '')
|
||||
.slice(0, 128);
|
||||
if (!PACKAGE_ID_PATTERN.test(normalized)) throw new Error('Device package id is invalid');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function safeStorageSegment(value: string): string {
|
||||
const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 128);
|
||||
if (!normalized || normalized === '.' || normalized === '..') {
|
||||
throw new Error('Device package version is invalid');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function relativeContainedPath(root: string, candidate: string): string {
|
||||
const resolvedRoot = path.resolve(root);
|
||||
const resolvedCandidate = path.resolve(candidate);
|
||||
const relative = path.relative(resolvedRoot, resolvedCandidate);
|
||||
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error('Device package entry escapes its package root');
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const value = JSON.parse(await readFile(filePath, 'utf8')) as unknown;
|
||||
return isRecord(value) ? value : null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function existsFile(filePath: string): Promise<boolean> {
|
||||
return await stat(filePath).then((value) => value.isFile()).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function skillId(skillPath: string): Promise<string> {
|
||||
const source = await readFile(skillPath, 'utf8');
|
||||
const frontmatter = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(source)?.[1] ?? '';
|
||||
const declared = /^name:\s*['"]?([^'"\r\n]+?)['"]?\s*$/imu.exec(frontmatter)?.[1]?.trim();
|
||||
const fallback = path.basename(path.dirname(skillPath));
|
||||
const id = declared || fallback;
|
||||
if (!SKILL_ID_PATTERN.test(id)) throw new Error(`Skill id is invalid: ${id}`);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function collectSkillFiles(root: string): Promise<string[]> {
|
||||
const result: string[] = [];
|
||||
const visit = async (directory: string): Promise<void> => {
|
||||
if (result.length > 100) throw new Error('Device package contains too many Skills');
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
const candidate = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) await visit(candidate);
|
||||
else if (entry.isFile() && entry.name === 'SKILL.md') result.push(candidate);
|
||||
}
|
||||
};
|
||||
await visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function manifestEntries(value: unknown, field: string): string[] {
|
||||
if (value === undefined) return [];
|
||||
const values = typeof value === 'string' ? [value] : value;
|
||||
if (!Array.isArray(values) || values.length > 100
|
||||
|| values.some((entry) => typeof entry !== 'string' || !entry.trim())) {
|
||||
throw new Error(`${field} is invalid`);
|
||||
}
|
||||
return values.map((entry) => (entry as string).trim());
|
||||
}
|
||||
|
||||
async function expandSkillManifestEntry(packageRoot: string, entry: string): Promise<string[]> {
|
||||
const candidate = path.resolve(packageRoot, entry);
|
||||
relativeContainedPath(packageRoot, candidate);
|
||||
const metadata = await stat(candidate).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
if (!metadata) throw new Error(`Skill entry does not exist: ${entry}`);
|
||||
if (metadata.isFile()) {
|
||||
if (path.basename(candidate) !== 'SKILL.md') throw new Error(`Skill entry is not SKILL.md: ${entry}`);
|
||||
return [candidate];
|
||||
}
|
||||
if (!metadata.isDirectory()) throw new Error(`Skill entry is invalid: ${entry}`);
|
||||
return await collectSkillFiles(candidate);
|
||||
}
|
||||
|
||||
export async function inspectDevicePackage(
|
||||
packageRoot: string,
|
||||
fallbackName = path.basename(path.resolve(packageRoot)),
|
||||
): Promise<InspectedDevicePackage> {
|
||||
const root = path.resolve(packageRoot);
|
||||
const packageJson = await readJson(path.join(root, 'package.json'));
|
||||
const codexManifest = await readJson(path.join(root, '.codex-plugin', 'plugin.json'));
|
||||
const pi = isRecord(packageJson?.pi) ? packageJson.pi : null;
|
||||
const declaredExtensions = manifestEntries(pi?.extensions, 'pi.extensions');
|
||||
const declaredSkills = manifestEntries(pi?.skills, 'pi.skills');
|
||||
const extensionFiles: string[] = [];
|
||||
for (const entry of declaredExtensions) {
|
||||
const candidate = path.resolve(root, entry);
|
||||
relativeContainedPath(root, candidate);
|
||||
if (!await existsFile(candidate) || !/\.(?:js|ts)$/iu.test(candidate)) {
|
||||
throw new Error(`Extension entry is invalid: ${entry}`);
|
||||
}
|
||||
extensionFiles.push(candidate);
|
||||
}
|
||||
const skillFiles: string[] = [];
|
||||
for (const entry of declaredSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
||||
const codexSkills = manifestEntries(codexManifest?.skills, 'plugin.skills');
|
||||
for (const entry of codexSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry));
|
||||
if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md'));
|
||||
|
||||
const uniqueSkills = new Map<string, DevicePackageSkillEntry>();
|
||||
for (const skillPath of [...new Set(skillFiles.map((entry) => path.resolve(entry)))]) {
|
||||
const id = await skillId(skillPath);
|
||||
if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`);
|
||||
uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) });
|
||||
}
|
||||
const extensionEntries = [...new Set(extensionFiles.map((entry) => relativeContainedPath(root, entry)))];
|
||||
if (uniqueSkills.size === 0 && extensionEntries.length === 0) {
|
||||
throw new Error('Package does not expose a Pi extension or Skill');
|
||||
}
|
||||
const displayName = nonempty(packageJson?.name, 256)
|
||||
?? nonempty(codexManifest?.name, 256)
|
||||
?? fallbackName;
|
||||
const scripts = isRecord(packageJson?.scripts) ? packageJson.scripts : {};
|
||||
return {
|
||||
packageId: safeDevicePackageId(displayName),
|
||||
displayName,
|
||||
resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128),
|
||||
skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)),
|
||||
extensionEntries: extensionEntries.sort(),
|
||||
ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'),
|
||||
};
|
||||
}
|
||||
|
||||
export function devicePackageKind(input: Pick<InspectedDevicePackage, 'skillEntries' | 'extensionEntries'>): DevicePackageKind {
|
||||
if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed';
|
||||
return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only';
|
||||
}
|
||||
Reference in New Issue
Block a user