Implement enhanced collection toggle, improved list display, auto-apply, file removal, and config validation
Co-authored-by: AstroSteveo <34114851+AstroSteveo@users.noreply.github.com>
This commit is contained in:
parent
6d98a21966
commit
6bdda3841a
176
apply-config.js
176
apply-config.js
@ -5,14 +5,17 @@ const path = require("path");
|
||||
const { parseCollectionYaml } = require("./yaml-parser");
|
||||
|
||||
/**
|
||||
* Simple YAML parser for configuration files
|
||||
* Simple YAML parser for configuration files with enhanced error handling
|
||||
*/
|
||||
function parseConfigYamlContent(content) {
|
||||
const lines = content.split("\n");
|
||||
const result = {};
|
||||
let currentSection = null;
|
||||
let lineNumber = 0;
|
||||
|
||||
try {
|
||||
for (const line of lines) {
|
||||
lineNumber++;
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip comments and empty lines
|
||||
@ -26,6 +29,11 @@ function parseConfigYamlContent(content) {
|
||||
const key = trimmed.substring(0, colonIndex).trim();
|
||||
let value = trimmed.substring(colonIndex + 1).trim();
|
||||
|
||||
// Validate key format
|
||||
if (!key || key.includes(" ") && !currentSection) {
|
||||
throw new Error(`Invalid key format on line ${lineNumber}: "${key}"`);
|
||||
}
|
||||
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
@ -53,13 +61,78 @@ function parseConfigYamlContent(content) {
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(`YAML parsing error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration structure and content
|
||||
*/
|
||||
function validateConfig(config) {
|
||||
const errors = [];
|
||||
|
||||
if (!config || typeof config !== 'object') {
|
||||
errors.push("Configuration must be a valid object");
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check version
|
||||
if (!config.version) {
|
||||
errors.push("Configuration must have a 'version' field");
|
||||
}
|
||||
|
||||
// Check project structure
|
||||
if (config.project && typeof config.project !== 'object') {
|
||||
errors.push("'project' field must be an object");
|
||||
}
|
||||
|
||||
// Validate sections
|
||||
const validSections = ['prompts', 'instructions', 'chatmodes', 'collections'];
|
||||
validSections.forEach(section => {
|
||||
if (config[section] && typeof config[section] !== 'object') {
|
||||
errors.push(`'${section}' field must be an object`);
|
||||
} else if (config[section]) {
|
||||
// Validate section items
|
||||
Object.entries(config[section]).forEach(([key, value]) => {
|
||||
if (typeof value !== 'boolean') {
|
||||
errors.push(`${section}.${key} must be a boolean value (true/false)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check for unknown top-level fields
|
||||
const knownFields = ['version', 'project', ...validSections];
|
||||
Object.keys(config).forEach(key => {
|
||||
if (!knownFields.includes(key)) {
|
||||
errors.push(`Unknown configuration field: '${key}'`);
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function parseConfigYaml(filePath) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
return parseConfigYamlContent(content);
|
||||
const config = parseConfigYamlContent(content);
|
||||
|
||||
// Validate the parsed configuration
|
||||
const validationErrors = validateConfig(config);
|
||||
if (validationErrors.length > 0) {
|
||||
console.error(`Configuration validation errors in ${filePath}:`);
|
||||
validationErrors.forEach(error => {
|
||||
console.error(` ❌ ${error}`);
|
||||
});
|
||||
throw new Error("Configuration validation failed");
|
||||
}
|
||||
|
||||
return config;
|
||||
} catch (error) {
|
||||
if (error.message === "Configuration validation failed") {
|
||||
throw error;
|
||||
}
|
||||
console.error(`Error parsing config file ${filePath}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
@ -93,6 +166,7 @@ async function applyConfig(configPath = "awesome-copilot.config.yml") {
|
||||
ensureDirectoryExists(path.join(outputDir, "chatmodes"));
|
||||
|
||||
let copiedCount = 0;
|
||||
let removedCount = 0;
|
||||
const summary = {
|
||||
prompts: 0,
|
||||
instructions: 0,
|
||||
@ -120,67 +194,90 @@ async function applyConfig(configPath = "awesome-copilot.config.yml") {
|
||||
}
|
||||
}
|
||||
|
||||
// Process prompts
|
||||
if (config.prompts) {
|
||||
for (const [promptName, enabled] of Object.entries(config.prompts)) {
|
||||
if (enabled) {
|
||||
const sourcePath = path.join(rootDir, "prompts", `${promptName}.prompt.md`);
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const destPath = path.join(outputDir, "prompts", `${promptName}.prompt.md`);
|
||||
// Helper function to process section files
|
||||
function processSection(sectionName, sourceDir, destDir, fileExtension) {
|
||||
const enabledInSection = new Set();
|
||||
|
||||
if (config[sectionName]) {
|
||||
for (const [itemName, enabled] of Object.entries(config[sectionName])) {
|
||||
const sourcePath = path.join(rootDir, sourceDir, `${itemName}${fileExtension}`);
|
||||
const destPath = path.join(outputDir, destDir, `${itemName}${fileExtension}`);
|
||||
|
||||
if (enabled && fs.existsSync(sourcePath)) {
|
||||
copyFile(sourcePath, destPath);
|
||||
copiedCount++;
|
||||
summary.prompts++;
|
||||
summary[sectionName]++;
|
||||
enabledInSection.add(itemName);
|
||||
} else if (!enabled && fs.existsSync(destPath)) {
|
||||
// Remove file if it's disabled
|
||||
fs.unlinkSync(destPath);
|
||||
console.log(`✗ Removed: ${itemName}${fileExtension}`);
|
||||
removedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove any files in destination that are not enabled
|
||||
const destDirPath = path.join(outputDir, destDir);
|
||||
if (fs.existsSync(destDirPath)) {
|
||||
const existingFiles = fs.readdirSync(destDirPath)
|
||||
.filter(file => file.endsWith(fileExtension));
|
||||
|
||||
existingFiles.forEach(file => {
|
||||
const itemName = file.replace(fileExtension, '');
|
||||
if (!enabledInSection.has(itemName) && !isItemInEnabledCollection(file, enabledItems)) {
|
||||
const filePath = path.join(destDirPath, file);
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`✗ Removed orphaned: ${file}`);
|
||||
removedCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if an item is in an enabled collection
|
||||
function isItemInEnabledCollection(filename, enabledItems) {
|
||||
for (const itemPath of enabledItems) {
|
||||
if (path.basename(itemPath) === filename) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Process prompts
|
||||
processSection("prompts", "prompts", "prompts", ".prompt.md");
|
||||
|
||||
// Process instructions
|
||||
if (config.instructions) {
|
||||
for (const [instructionName, enabled] of Object.entries(config.instructions)) {
|
||||
if (enabled) {
|
||||
const sourcePath = path.join(rootDir, "instructions", `${instructionName}.instructions.md`);
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const destPath = path.join(outputDir, "instructions", `${instructionName}.instructions.md`);
|
||||
copyFile(sourcePath, destPath);
|
||||
copiedCount++;
|
||||
summary.instructions++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processSection("instructions", "instructions", "instructions", ".instructions.md");
|
||||
|
||||
// Process chat modes
|
||||
if (config.chatmodes) {
|
||||
for (const [chatmodeName, enabled] of Object.entries(config.chatmodes)) {
|
||||
if (enabled) {
|
||||
const sourcePath = path.join(rootDir, "chatmodes", `${chatmodeName}.chatmode.md`);
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const destPath = path.join(outputDir, "chatmodes", `${chatmodeName}.chatmode.md`);
|
||||
copyFile(sourcePath, destPath);
|
||||
copiedCount++;
|
||||
summary.chatmodes++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
processSection("chatmodes", "chatmodes", "chatmodes", ".chatmode.md");
|
||||
|
||||
// Process items from enabled collections
|
||||
// Process items from enabled collections, but respect individual overrides
|
||||
for (const itemPath of enabledItems) {
|
||||
const sourcePath = path.join(rootDir, itemPath);
|
||||
if (fs.existsSync(sourcePath)) {
|
||||
const fileName = path.basename(itemPath);
|
||||
const itemName = fileName.replace(/\.(prompt|instructions|chatmode)\.md$/, '');
|
||||
let destPath;
|
||||
let section;
|
||||
|
||||
if (fileName.endsWith('.prompt.md')) {
|
||||
destPath = path.join(outputDir, "prompts", fileName);
|
||||
section = "prompts";
|
||||
} else if (fileName.endsWith('.chatmode.md')) {
|
||||
destPath = path.join(outputDir, "chatmodes", fileName);
|
||||
section = "chatmodes";
|
||||
} else if (fileName.endsWith('.instructions.md')) {
|
||||
destPath = path.join(outputDir, "instructions", fileName);
|
||||
section = "instructions";
|
||||
}
|
||||
|
||||
if (destPath && !fs.existsSync(destPath)) {
|
||||
// Only copy if not explicitly disabled in individual settings
|
||||
const isExplicitlyDisabled = config[section] && config[section][itemName] === false;
|
||||
|
||||
if (destPath && !fs.existsSync(destPath) && !isExplicitlyDisabled) {
|
||||
copyFile(sourcePath, destPath);
|
||||
copiedCount++;
|
||||
}
|
||||
@ -193,6 +290,9 @@ async function applyConfig(configPath = "awesome-copilot.config.yml") {
|
||||
console.log("=".repeat(50));
|
||||
console.log(`📂 Output directory: ${outputDir}`);
|
||||
console.log(`📝 Total files copied: ${copiedCount}`);
|
||||
if (removedCount > 0) {
|
||||
console.log(`🗑️ Total files removed: ${removedCount}`);
|
||||
}
|
||||
console.log(`🎯 Prompts: ${summary.prompts}`);
|
||||
console.log(`📋 Instructions: ${summary.instructions}`);
|
||||
console.log(`💭 Chat modes: ${summary.chatmodes}`);
|
||||
|
||||
@ -12,7 +12,10 @@ const {
|
||||
saveConfig,
|
||||
ensureConfigStructure,
|
||||
countEnabledItems,
|
||||
getAllAvailableItems
|
||||
getAllAvailableItems,
|
||||
getCollectionItems,
|
||||
updateCollectionItems,
|
||||
getItemToCollectionsMap
|
||||
} = require("./config-manager");
|
||||
|
||||
const CONFIG_FLAG_ALIASES = ["--config", "-c"];
|
||||
@ -115,10 +118,22 @@ function handleListCommand(rawArgs) {
|
||||
|
||||
const { config } = loadConfig(configPath);
|
||||
const sanitizedConfig = ensureConfigStructure(config);
|
||||
const itemToCollectionsMap = getItemToCollectionsMap();
|
||||
|
||||
console.log(`📄 Configuration: ${configPath}`);
|
||||
|
||||
// Always show collections first if they're in the sections to show
|
||||
const orderedSections = [];
|
||||
if (sectionsToShow.includes('collections')) {
|
||||
orderedSections.push('collections');
|
||||
}
|
||||
sectionsToShow.forEach(section => {
|
||||
if (section !== 'collections') {
|
||||
orderedSections.push(section);
|
||||
}
|
||||
});
|
||||
|
||||
orderedSections.forEach(section => {
|
||||
const availableItems = getAllAvailableItems(section);
|
||||
const enabledCount = countEnabledItems(sanitizedConfig[section]);
|
||||
const { totalCharacters } = calculateSectionFootprint(section, sanitizedConfig[section]);
|
||||
@ -139,11 +154,27 @@ function handleListCommand(rawArgs) {
|
||||
|
||||
availableItems.forEach(itemName => {
|
||||
const isEnabled = Boolean(sanitizedConfig[section]?.[itemName]);
|
||||
console.log(` [${isEnabled ? "✓" : " "}] ${itemName}`);
|
||||
let itemDisplay = ` [${isEnabled ? "✓" : " "}] ${itemName}`;
|
||||
|
||||
// Add collection indicators for non-collection items
|
||||
if (section !== 'collections' && itemToCollectionsMap[itemName]) {
|
||||
const collections = itemToCollectionsMap[itemName].collections;
|
||||
itemDisplay += ` ${collections.map(c => `📦${c}`).join(' ')}`;
|
||||
}
|
||||
|
||||
// For collections, show how many items they contain
|
||||
if (section === 'collections') {
|
||||
const collectionItems = getCollectionItems(itemName);
|
||||
const totalItems = collectionItems.prompts.length + collectionItems.instructions.length + collectionItems.chatmodes.length;
|
||||
itemDisplay += ` (${totalItems} items)`;
|
||||
}
|
||||
|
||||
console.log(itemDisplay);
|
||||
});
|
||||
});
|
||||
|
||||
console.log("\nUse 'awesome-copilot toggle' to enable or disable specific items.");
|
||||
console.log("📦 indicates items that are part of collections.");
|
||||
}
|
||||
|
||||
function handleToggleCommand(rawArgs) {
|
||||
@ -165,7 +196,7 @@ function handleToggleCommand(rawArgs) {
|
||||
}
|
||||
|
||||
const { config, header } = loadConfig(configPath);
|
||||
const configCopy = {
|
||||
let configCopy = {
|
||||
...config,
|
||||
[section]: { ...config[section] }
|
||||
};
|
||||
@ -195,7 +226,32 @@ function handleToggleCommand(rawArgs) {
|
||||
const currentState = Boolean(sectionState[itemName]);
|
||||
const newState = desiredState === null ? !currentState : desiredState;
|
||||
sectionState[itemName] = newState;
|
||||
|
||||
// Special handling for collections - also toggle individual items
|
||||
if (section === "collections") {
|
||||
console.log(`${newState ? "Enabled" : "Disabled"} ${SECTION_METADATA[section].singular} '${itemName}'.`);
|
||||
|
||||
// Update individual items in the collection
|
||||
configCopy = updateCollectionItems(configCopy, itemName, newState);
|
||||
|
||||
const collectionItems = getCollectionItems(itemName);
|
||||
const totalItems = collectionItems.prompts.length + collectionItems.instructions.length + collectionItems.chatmodes.length;
|
||||
|
||||
if (totalItems > 0) {
|
||||
console.log(`${newState ? "Enabled" : "Disabled"} ${totalItems} individual items from collection '${itemName}'.`);
|
||||
if (collectionItems.prompts.length > 0) {
|
||||
console.log(` Prompts: ${collectionItems.prompts.join(", ")}`);
|
||||
}
|
||||
if (collectionItems.instructions.length > 0) {
|
||||
console.log(` Instructions: ${collectionItems.instructions.join(", ")}`);
|
||||
}
|
||||
if (collectionItems.chatmodes.length > 0) {
|
||||
console.log(` Chat modes: ${collectionItems.chatmodes.join(", ")}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`${newState ? "Enabled" : "Disabled"} ${SECTION_METADATA[section].singular} '${itemName}'.`);
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedConfig = ensureConfigStructure(configCopy);
|
||||
@ -210,7 +266,12 @@ function handleToggleCommand(rawArgs) {
|
||||
console.log(`Estimated ${SECTION_METADATA[section].label.toLowerCase()} context size: ${formatNumber(totalCharacters)} characters.`);
|
||||
}
|
||||
maybeWarnAboutContext(section, totalCharacters);
|
||||
console.log("Run 'awesome-copilot apply' to copy updated selections into your project.");
|
||||
|
||||
// Auto-apply functionality - automatically run apply after toggle
|
||||
console.log("Applying configuration automatically...");
|
||||
applyConfig(configPath).catch(error => {
|
||||
console.error("Error during auto-apply:", error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function extractConfigOption(rawArgs) {
|
||||
|
||||
@ -3,6 +3,7 @@ const path = require("path");
|
||||
|
||||
const { parseConfigYamlContent } = require("./apply-config");
|
||||
const { objectToYaml, generateConfigHeader, getAvailableItems } = require("./generate-config");
|
||||
const { parseCollectionYaml } = require("./yaml-parser");
|
||||
|
||||
const DEFAULT_CONFIG_PATH = "awesome-copilot.config.yml";
|
||||
const SECTION_METADATA = {
|
||||
@ -158,6 +159,115 @@ function getAllAvailableItems(type) {
|
||||
return getAvailableItems(path.join(__dirname, meta.dir), meta.ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get individual items from a collection
|
||||
*/
|
||||
function getCollectionItems(collectionName) {
|
||||
const collectionPath = path.join(__dirname, "collections", `${collectionName}.collection.yml`);
|
||||
if (!fs.existsSync(collectionPath)) {
|
||||
return { prompts: [], instructions: [], chatmodes: [] };
|
||||
}
|
||||
|
||||
const collection = parseCollectionYaml(collectionPath);
|
||||
if (!collection || !collection.items) {
|
||||
return { prompts: [], instructions: [], chatmodes: [] };
|
||||
}
|
||||
|
||||
const result = { prompts: [], instructions: [], chatmodes: [] };
|
||||
|
||||
collection.items.forEach(item => {
|
||||
const filePath = item.path;
|
||||
const filename = path.basename(filePath);
|
||||
|
||||
if (filename.endsWith('.prompt.md')) {
|
||||
const name = filename.replace('.prompt.md', '');
|
||||
result.prompts.push(name);
|
||||
} else if (filename.endsWith('.instructions.md')) {
|
||||
const name = filename.replace('.instructions.md', '');
|
||||
result.instructions.push(name);
|
||||
} else if (filename.endsWith('.chatmode.md')) {
|
||||
const name = filename.replace('.chatmode.md', '');
|
||||
result.chatmodes.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update individual items when a collection is toggled
|
||||
*/
|
||||
function updateCollectionItems(config, collectionName, enabled) {
|
||||
const items = getCollectionItems(collectionName);
|
||||
const configCopy = { ...config };
|
||||
|
||||
// Ensure sections exist
|
||||
CONFIG_SECTIONS.forEach(section => {
|
||||
if (!configCopy[section]) {
|
||||
configCopy[section] = {};
|
||||
}
|
||||
});
|
||||
|
||||
// Update individual items
|
||||
items.prompts.forEach(prompt => {
|
||||
configCopy.prompts[prompt] = enabled;
|
||||
});
|
||||
|
||||
items.instructions.forEach(instruction => {
|
||||
configCopy.instructions[instruction] = enabled;
|
||||
});
|
||||
|
||||
items.chatmodes.forEach(chatmode => {
|
||||
configCopy.chatmodes[chatmode] = enabled;
|
||||
});
|
||||
|
||||
return configCopy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get which collections contain specific items
|
||||
*/
|
||||
function getItemToCollectionsMap() {
|
||||
const map = {};
|
||||
const collectionsDir = path.join(__dirname, "collections");
|
||||
|
||||
if (!fs.existsSync(collectionsDir)) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const collectionFiles = fs.readdirSync(collectionsDir)
|
||||
.filter(file => file.endsWith('.collection.yml'));
|
||||
|
||||
collectionFiles.forEach(file => {
|
||||
const collectionName = file.replace('.collection.yml', '');
|
||||
const collectionPath = path.join(collectionsDir, file);
|
||||
const collection = parseCollectionYaml(collectionPath);
|
||||
|
||||
if (collection && collection.items) {
|
||||
collection.items.forEach(item => {
|
||||
const filename = path.basename(item.path);
|
||||
let itemName;
|
||||
|
||||
if (filename.endsWith('.prompt.md')) {
|
||||
itemName = filename.replace('.prompt.md', '');
|
||||
if (!map[itemName]) map[itemName] = { section: 'prompts', collections: [] };
|
||||
map[itemName].collections.push(collectionName);
|
||||
} else if (filename.endsWith('.instructions.md')) {
|
||||
itemName = filename.replace('.instructions.md', '');
|
||||
if (!map[itemName]) map[itemName] = { section: 'instructions', collections: [] };
|
||||
map[itemName].collections.push(collectionName);
|
||||
} else if (filename.endsWith('.chatmode.md')) {
|
||||
itemName = filename.replace('.chatmode.md', '');
|
||||
if (!map[itemName]) map[itemName] = { section: 'chatmodes', collections: [] };
|
||||
map[itemName].collections.push(collectionName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CONFIG_PATH,
|
||||
CONFIG_SECTIONS,
|
||||
@ -168,5 +278,8 @@ module.exports = {
|
||||
ensureConfigStructure,
|
||||
sortObjectKeys,
|
||||
countEnabledItems,
|
||||
getAllAvailableItems
|
||||
getAllAvailableItems,
|
||||
getCollectionItems,
|
||||
updateCollectionItems,
|
||||
getItemToCollectionsMap
|
||||
};
|
||||
|
||||
16
test-invalid.yml
Normal file
16
test-invalid.yml
Normal file
@ -0,0 +1,16 @@
|
||||
# Invalid config for testing
|
||||
version: 1.0
|
||||
|
||||
project:
|
||||
name: "Test Project"
|
||||
output_directory: ".awesome-copilot"
|
||||
|
||||
prompts:
|
||||
breakdown-epic-arch: invalid_value # This should be boolean
|
||||
breakdown-epic-pm: true
|
||||
|
||||
invalid_section:
|
||||
some_key: true
|
||||
|
||||
instructions:
|
||||
task-implementation: "yes" # This should be boolean
|
||||
Loading…
x
Reference in New Issue
Block a user