301 lines
8.4 KiB
JavaScript
301 lines
8.4 KiB
JavaScript
/**
|
|
* Platform Detection
|
|
* Analyzes a directory to determine if it's a Claude skill, Gemini extension, or universal
|
|
*/
|
|
|
|
import fs from 'fs/promises';
|
|
import path from 'path';
|
|
|
|
export const PLATFORM_TYPES = {
|
|
CLAUDE: 'claude',
|
|
GEMINI: 'gemini',
|
|
UNIVERSAL: 'universal',
|
|
UNKNOWN: 'unknown'
|
|
};
|
|
|
|
export class PlatformDetector {
|
|
/**
|
|
* Detect the platform type of a skill/extension directory
|
|
* @param {string} dirPath - Path to the directory to analyze
|
|
* @returns {Promise<{platform: string, files: object, confidence: string}>}
|
|
*/
|
|
async detect(dirPath) {
|
|
const detection = {
|
|
platform: PLATFORM_TYPES.UNKNOWN,
|
|
files: {
|
|
claude: [],
|
|
gemini: [],
|
|
shared: []
|
|
},
|
|
confidence: 'low',
|
|
metadata: {}
|
|
};
|
|
|
|
try {
|
|
const exists = await this._checkDirectoryExists(dirPath);
|
|
if (!exists) {
|
|
throw new Error(`Directory not found: ${dirPath}`);
|
|
}
|
|
|
|
// Check for Claude-specific files
|
|
const claudeFiles = await this._detectClaudeFiles(dirPath);
|
|
detection.files.claude = claudeFiles;
|
|
|
|
// Check for Gemini-specific files
|
|
const geminiFiles = await this._detectGeminiFiles(dirPath);
|
|
detection.files.gemini = geminiFiles;
|
|
|
|
// Check for shared files
|
|
const sharedFiles = await this._detectSharedFiles(dirPath);
|
|
detection.files.shared = sharedFiles;
|
|
|
|
// Determine platform type
|
|
const hasClaude = claudeFiles.length > 0;
|
|
const hasGemini = geminiFiles.length > 0;
|
|
|
|
if (hasClaude && hasGemini) {
|
|
detection.platform = PLATFORM_TYPES.UNIVERSAL;
|
|
detection.confidence = 'high';
|
|
} else if (hasClaude) {
|
|
detection.platform = PLATFORM_TYPES.CLAUDE;
|
|
detection.confidence = 'high';
|
|
} else if (hasGemini) {
|
|
detection.platform = PLATFORM_TYPES.GEMINI;
|
|
detection.confidence = 'high';
|
|
} else {
|
|
detection.platform = PLATFORM_TYPES.UNKNOWN;
|
|
detection.confidence = 'low';
|
|
}
|
|
|
|
// Extract metadata
|
|
detection.metadata = await this._extractMetadata(dirPath, detection.platform);
|
|
|
|
return detection;
|
|
} catch (error) {
|
|
throw new Error(`Detection failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if directory exists
|
|
*/
|
|
async _checkDirectoryExists(dirPath) {
|
|
try {
|
|
const stats = await fs.stat(dirPath);
|
|
return stats.isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Detect Claude-specific files
|
|
*/
|
|
async _detectClaudeFiles(dirPath) {
|
|
const claudeFiles = [];
|
|
|
|
// Check for SKILL.md
|
|
const skillPath = path.join(dirPath, 'SKILL.md');
|
|
if (await this._fileExists(skillPath)) {
|
|
const hasValidFrontmatter = await this._hasYAMLFrontmatter(skillPath);
|
|
if (hasValidFrontmatter) {
|
|
claudeFiles.push({ file: 'SKILL.md', type: 'entry', valid: true });
|
|
} else {
|
|
claudeFiles.push({ file: 'SKILL.md', type: 'entry', valid: false, issue: 'Missing or invalid YAML frontmatter' });
|
|
}
|
|
}
|
|
|
|
// Check for .claude-plugin/marketplace.json
|
|
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
|
if (await this._fileExists(marketplacePath)) {
|
|
const isValidJSON = await this._isValidJSON(marketplacePath);
|
|
if (isValidJSON) {
|
|
claudeFiles.push({ file: '.claude-plugin/marketplace.json', type: 'manifest', valid: true });
|
|
} else {
|
|
claudeFiles.push({ file: '.claude-plugin/marketplace.json', type: 'manifest', valid: false, issue: 'Invalid JSON' });
|
|
}
|
|
}
|
|
|
|
return claudeFiles;
|
|
}
|
|
|
|
/**
|
|
* Detect Gemini-specific files
|
|
*/
|
|
async _detectGeminiFiles(dirPath) {
|
|
const geminiFiles = [];
|
|
|
|
// Check for gemini-extension.json
|
|
const manifestPath = path.join(dirPath, 'gemini-extension.json');
|
|
if (await this._fileExists(manifestPath)) {
|
|
const isValidJSON = await this._isValidJSON(manifestPath);
|
|
if (isValidJSON) {
|
|
geminiFiles.push({ file: 'gemini-extension.json', type: 'manifest', valid: true });
|
|
} else {
|
|
geminiFiles.push({ file: 'gemini-extension.json', type: 'manifest', valid: false, issue: 'Invalid JSON' });
|
|
}
|
|
}
|
|
|
|
// Check for GEMINI.md
|
|
const geminiMdPath = path.join(dirPath, 'GEMINI.md');
|
|
if (await this._fileExists(geminiMdPath)) {
|
|
geminiFiles.push({ file: 'GEMINI.md', type: 'context', valid: true });
|
|
}
|
|
|
|
return geminiFiles;
|
|
}
|
|
|
|
/**
|
|
* Detect shared files (common to both platforms)
|
|
*/
|
|
async _detectSharedFiles(dirPath) {
|
|
const sharedFiles = [];
|
|
|
|
// Check for package.json
|
|
const packagePath = path.join(dirPath, 'package.json');
|
|
if (await this._fileExists(packagePath)) {
|
|
sharedFiles.push({ file: 'package.json', type: 'dependency' });
|
|
}
|
|
|
|
// Check for shared directory
|
|
const sharedDirPath = path.join(dirPath, 'shared');
|
|
if (await this._checkDirectoryExists(sharedDirPath)) {
|
|
sharedFiles.push({ file: 'shared/', type: 'directory' });
|
|
}
|
|
|
|
// Check for MCP server directory
|
|
const mcpServerPath = path.join(dirPath, 'mcp-server');
|
|
if (await this._checkDirectoryExists(mcpServerPath)) {
|
|
sharedFiles.push({ file: 'mcp-server/', type: 'directory' });
|
|
}
|
|
|
|
return sharedFiles;
|
|
}
|
|
|
|
/**
|
|
* Extract metadata from files
|
|
*/
|
|
async _extractMetadata(dirPath, platform) {
|
|
const metadata = {};
|
|
|
|
if (platform === PLATFORM_TYPES.CLAUDE || platform === PLATFORM_TYPES.UNIVERSAL) {
|
|
// Try to extract from SKILL.md
|
|
const skillPath = path.join(dirPath, 'SKILL.md');
|
|
if (await this._fileExists(skillPath)) {
|
|
const frontmatter = await this._extractYAMLFrontmatter(skillPath);
|
|
if (frontmatter) {
|
|
metadata.claude = frontmatter;
|
|
}
|
|
}
|
|
|
|
// Try to extract from marketplace.json
|
|
const marketplacePath = path.join(dirPath, '.claude-plugin', 'marketplace.json');
|
|
if (await this._fileExists(marketplacePath)) {
|
|
const content = await fs.readFile(marketplacePath, 'utf8');
|
|
try {
|
|
const json = JSON.parse(content);
|
|
metadata.claudeMarketplace = json;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
if (platform === PLATFORM_TYPES.GEMINI || platform === PLATFORM_TYPES.UNIVERSAL) {
|
|
// Try to extract from gemini-extension.json
|
|
const manifestPath = path.join(dirPath, 'gemini-extension.json');
|
|
if (await this._fileExists(manifestPath)) {
|
|
const content = await fs.readFile(manifestPath, 'utf8');
|
|
try {
|
|
const json = JSON.parse(content);
|
|
metadata.gemini = json;
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Check if file exists
|
|
*/
|
|
async _fileExists(filePath) {
|
|
try {
|
|
await fs.access(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if file is valid JSON
|
|
*/
|
|
async _isValidJSON(filePath) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
JSON.parse(content);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if file has YAML frontmatter
|
|
*/
|
|
async _hasYAMLFrontmatter(filePath) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
return /^---\n[\s\S]+?\n---/.test(content);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract YAML frontmatter from file
|
|
*/
|
|
async _extractYAMLFrontmatter(filePath) {
|
|
try {
|
|
const content = await fs.readFile(filePath, 'utf8');
|
|
const match = content.match(/^---\n([\s\S]+?)\n---/);
|
|
if (match) {
|
|
// Simple YAML parser for basic key-value pairs
|
|
const yaml = match[1];
|
|
const parsed = {};
|
|
|
|
const lines = yaml.split('\n');
|
|
let currentKey = null;
|
|
let currentValue = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('-')) {
|
|
// Array item
|
|
if (currentKey && Array.isArray(parsed[currentKey])) {
|
|
parsed[currentKey].push(line.trim().substring(1).trim());
|
|
}
|
|
} else if (line.includes(':')) {
|
|
// Key-value pair
|
|
const [key, ...valueParts] = line.split(':');
|
|
const value = valueParts.join(':').trim();
|
|
currentKey = key.trim();
|
|
|
|
if (value === '') {
|
|
// Array or multi-line value
|
|
parsed[currentKey] = [];
|
|
} else {
|
|
parsed[currentKey] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export default PlatformDetector;
|