From fcbd42fca8d890999e3784ab1a18e2644a3086fa Mon Sep 17 00:00:00 2001 From: Steven Mosley <34114851+AstroSteveo@users.noreply.github.com> Date: Sun, 21 Sep 2025 03:24:42 -0500 Subject: [PATCH] feat: add CLI tools for managing configuration --- CONFIG.md | 24 +++- README.md | 10 +- apply-config.js | 98 +++++++++------- awesome-copilot.js | 282 ++++++++++++++++++++++++++++++++++++++++++--- config-manager.js | 172 +++++++++++++++++++++++++++ generate-config.js | 48 ++++---- 6 files changed, 544 insertions(+), 90 deletions(-) create mode 100644 config-manager.js diff --git a/CONFIG.md b/CONFIG.md index fdeb74b..6d66571 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -47,7 +47,11 @@ This creates: ### 2. Enable Desired Items -Edit the configuration file to set items to `true` that you want to include: +You can enable items either by editing the YAML file directly or by using the CLI helpers that toggle entries for you. + +#### Option A: Edit the configuration file manually + +Set items to `true` in the configuration file to include them: ```yaml version: "1.0" @@ -72,6 +76,24 @@ collections: csharp-dotnet-development: false ``` +#### Option B: Manage items from the CLI (recommended for quick toggles) + +```bash +# Inspect what is enabled in the default configuration file +node /path/to/awesome-copilot/awesome-copilot.js list instructions + +# Enable a single prompt +node /path/to/awesome-copilot/awesome-copilot.js toggle prompts create-readme on + +# Disable everything in a section +node /path/to/awesome-copilot/awesome-copilot.js toggle instructions all off + +# Work with a named configuration file +node /path/to/awesome-copilot/awesome-copilot.js list prompts --config team.config.yml +``` + +The CLI prints the number of enabled items and estimates the combined size of their instructions/prompts so you can avoid exceeding Copilot Agent's context window. If the total size approaches a risky threshold, you'll see a warning. + ### 3. Apply Configuration ```bash diff --git a/README.md b/README.md index ec01fb5..468448a 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Use our configuration system to manage all customizations in one place: ```bash node /path/to/awesome-copilot/awesome-copilot.js init ``` -4. **Edit the configuration** to enable the items you want: +4. **Edit the configuration** to enable the items you want (or use the new CLI helpers): ```yaml collections: frontend-web-dev: true # Enable entire collection @@ -72,6 +72,14 @@ Use our configuration system to manage all customizations in one place: instructions: typescript-best-practices: true ``` + ```bash + # List what is currently enabled + node /path/to/awesome-copilot/awesome-copilot.js list instructions + + # Enable or disable individual items without editing YAML by hand + node /path/to/awesome-copilot/awesome-copilot.js toggle prompts create-readme on + node /path/to/awesome-copilot/awesome-copilot.js toggle instructions all off + ``` 5. **Apply the configuration** to copy files to your project: ```bash node /path/to/awesome-copilot/awesome-copilot.js apply diff --git a/apply-config.js b/apply-config.js index a22fd67..675acfd 100755 --- a/apply-config.js +++ b/apply-config.js @@ -7,52 +7,58 @@ const { parseCollectionYaml } = require("./yaml-parser"); /** * Simple YAML parser for configuration files */ +function parseConfigYamlContent(content) { + const lines = content.split("\n"); + const result = {}; + let currentSection = null; + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith("#")) continue; + + if (!trimmed.includes(":")) { + continue; + } + + const colonIndex = trimmed.indexOf(":"); + const key = trimmed.substring(0, colonIndex).trim(); + let value = trimmed.substring(colonIndex + 1).trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + // Handle sections (no value) + if (!value) { + currentSection = key; + if (!result[currentSection]) { + result[currentSection] = {}; + } + continue; + } + + // Handle boolean values + if (value === "true") value = true; + else if (value === "false") value = false; + + if (currentSection) { + result[currentSection][key] = value; + } else { + result[key] = value; + } + } + + return result; +} + function parseConfigYaml(filePath) { try { const content = fs.readFileSync(filePath, "utf8"); - const lines = content.split("\n"); - const result = {}; - let currentSection = null; - - for (const line of lines) { - const trimmed = line.trim(); - - // Skip comments and empty lines - if (!trimmed || trimmed.startsWith("#")) continue; - - // Handle key-value pairs - if (trimmed.includes(":")) { - const colonIndex = trimmed.indexOf(":"); - const key = trimmed.substring(0, colonIndex).trim(); - let value = trimmed.substring(colonIndex + 1).trim(); - - // Remove quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'"))) { - value = value.slice(1, -1); - } - - // Handle sections (no value) - if (!value) { - currentSection = key; - if (!result[currentSection]) { - result[currentSection] = {}; - } - } else { - // Handle boolean values - if (value === "true") value = true; - else if (value === "false") value = false; - - if (currentSection) { - result[currentSection][key] = value; - } else { - result[key] = value; - } - } - } - } - - return result; + return parseConfigYamlContent(content); } catch (error) { console.error(`Error parsing config file ${filePath}: ${error.message}`); return null; @@ -230,4 +236,8 @@ if (require.main === module) { }); } -module.exports = { applyConfig, parseConfigYaml }; \ No newline at end of file +module.exports = { + applyConfig, + parseConfigYaml, + parseConfigYamlContent +}; diff --git a/awesome-copilot.js b/awesome-copilot.js index b06217d..a64eed5 100755 --- a/awesome-copilot.js +++ b/awesome-copilot.js @@ -1,28 +1,65 @@ #!/usr/bin/env node -const { generateConfig } = require("./generate-config"); +const fs = require("fs"); +const path = require("path"); + const { applyConfig } = require("./apply-config"); +const { + DEFAULT_CONFIG_PATH, + CONFIG_SECTIONS, + SECTION_METADATA, + loadConfig, + saveConfig, + ensureConfigStructure, + countEnabledItems, + getAllAvailableItems +} = require("./config-manager"); + +const CONFIG_FLAG_ALIASES = ["--config", "-c"]; +const CONTEXT_WARNING_CHAR_LIMIT = { + instructions: 90000, + prompts: 45000, + chatmodes: 30000 +}; + +const numberFormatter = new Intl.NumberFormat("en-US"); const commands = { init: { description: "Initialize a new project with awesome-copilot configuration", usage: "awesome-copilot init [config-file]", action: async (args) => { - const configFile = args[0] || "awesome-copilot.config.yml"; + const configFile = args[0] || DEFAULT_CONFIG_PATH; const { initializeProject } = require("./initialize-project"); await initializeProject(configFile); } }, - + apply: { description: "Apply configuration and copy files to project", usage: "awesome-copilot apply [config-file]", action: async (args) => { - const configFile = args[0] || "awesome-copilot.config.yml"; + const configFile = args[0] || DEFAULT_CONFIG_PATH; await applyConfig(configFile); } }, - + + list: { + description: "List items in the configuration with their enabled status", + usage: "awesome-copilot list [section] [--config ]", + action: (args) => { + handleListCommand(args); + } + }, + + toggle: { + description: "Enable or disable prompts, instructions, chat modes, or collections", + usage: "awesome-copilot toggle
[on|off] [--config ]", + action: (args) => { + handleToggleCommand(args); + } + }, + help: { description: "Show help information", usage: "awesome-copilot help", @@ -39,22 +76,24 @@ function showHelp() { console.log("Usage: awesome-copilot [options]"); console.log(""); console.log("Commands:"); - + for (const [name, cmd] of Object.entries(commands)) { console.log(` ${name.padEnd(10)} ${cmd.description}`); - console.log(` ${' '.repeat(10)} ${cmd.usage}`); + console.log(` ${" ".repeat(10)} ${cmd.usage}`); console.log(""); } - + console.log("Examples:"); - console.log(" awesome-copilot init # Create default config file"); - console.log(" awesome-copilot init my-config.yml # Create named config file"); - console.log(" awesome-copilot apply # Apply default config"); - console.log(" awesome-copilot apply my-config.yml # Apply specific config"); + console.log(" awesome-copilot init # Create default config file"); + console.log(" awesome-copilot init my-config.yml # Create named config file"); + console.log(" awesome-copilot apply # Apply default config"); + console.log(" awesome-copilot list instructions # See which instructions are enabled"); + console.log(" awesome-copilot toggle prompts create-readme on # Enable a specific prompt"); + console.log(" awesome-copilot toggle instructions all off --config team.yml # Disable all instructions"); console.log(""); console.log("Workflow:"); console.log(" 1. Run 'awesome-copilot init' to create a configuration file"); - console.log(" 2. Edit the configuration file to enable desired items"); + console.log(" 2. Use 'awesome-copilot list' and 'awesome-copilot toggle' to manage enabled items"); console.log(" 3. Run 'awesome-copilot apply' to copy files to your project"); } @@ -65,21 +104,228 @@ function showError(message) { process.exit(1); } +function handleListCommand(rawArgs) { + const { args, configPath } = extractConfigOption(rawArgs); + + let sectionsToShow = CONFIG_SECTIONS; + if (args.length > 0) { + const requestedSection = validateSectionType(args[0]); + sectionsToShow = [requestedSection]; + } + + const { config } = loadConfig(configPath); + const sanitizedConfig = ensureConfigStructure(config); + + console.log(`📄 Configuration: ${configPath}`); + + sectionsToShow.forEach(section => { + const availableItems = getAllAvailableItems(section); + const enabledCount = countEnabledItems(sanitizedConfig[section]); + const { totalCharacters } = calculateSectionFootprint(section, sanitizedConfig[section]); + const headingParts = [ + `${SECTION_METADATA[section].label} (${enabledCount}/${availableItems.length} enabled)` + ]; + + if (totalCharacters > 0 && section !== "collections") { + headingParts.push(`~${formatNumber(totalCharacters)} chars`); + } + + console.log(`\n${headingParts.join(", ")}`); + + if (!availableItems.length) { + console.log(" (no items available)"); + return; + } + + availableItems.forEach(itemName => { + const isEnabled = Boolean(sanitizedConfig[section]?.[itemName]); + console.log(` [${isEnabled ? "✓" : " "}] ${itemName}`); + }); + }); + + console.log("\nUse 'awesome-copilot toggle' to enable or disable specific items."); +} + +function handleToggleCommand(rawArgs) { + const { args, configPath } = extractConfigOption(rawArgs); + + if (args.length < 2) { + throw new Error("Usage: awesome-copilot toggle
[on|off] [--config ]"); + } + + const section = validateSectionType(args[0]); + const itemName = args[1]; + const stateArg = args[2]; + const desiredState = stateArg ? parseStateToken(stateArg) : null; + + const availableItems = getAllAvailableItems(section); + const availableSet = new Set(availableItems); + if (!availableItems.length) { + throw new Error(`No ${SECTION_METADATA[section].label.toLowerCase()} available to toggle.`); + } + + const { config, header } = loadConfig(configPath); + const configCopy = { + ...config, + [section]: { ...config[section] } + }; + const sectionState = configCopy[section]; + + if (itemName === "all") { + if (desiredState === null) { + throw new Error("Specify 'on' or 'off' when toggling all items."); + } + availableItems.forEach(item => { + sectionState[item] = desiredState; + }); + console.log(`${desiredState ? "Enabled" : "Disabled"} all ${SECTION_METADATA[section].label.toLowerCase()}.`); + + if (section === "instructions" && desiredState) { + console.log("⚠️ Enabling every instruction can exceed Copilot Agent's context window. Consider enabling only what you need."); + } + } else { + if (!availableSet.has(itemName)) { + const suggestion = findClosestMatch(itemName, availableItems); + if (suggestion) { + throw new Error(`Unknown ${SECTION_METADATA[section].singular} '${itemName}'. Did you mean '${suggestion}'?`); + } + throw new Error(`Unknown ${SECTION_METADATA[section].singular} '${itemName}'.`); + } + + const currentState = Boolean(sectionState[itemName]); + const newState = desiredState === null ? !currentState : desiredState; + sectionState[itemName] = newState; + console.log(`${newState ? "Enabled" : "Disabled"} ${SECTION_METADATA[section].singular} '${itemName}'.`); + } + + const sanitizedConfig = ensureConfigStructure(configCopy); + saveConfig(configPath, sanitizedConfig, header); + + const enabledCount = countEnabledItems(sanitizedConfig[section]); + const totalAvailable = availableItems.length; + const { totalCharacters } = calculateSectionFootprint(section, sanitizedConfig[section]); + + console.log(`${SECTION_METADATA[section].label}: ${enabledCount}/${totalAvailable} enabled.`); + if (totalCharacters > 0 && section !== "collections") { + 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."); +} + +function extractConfigOption(rawArgs) { + const args = [...rawArgs]; + let configPath = DEFAULT_CONFIG_PATH; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (CONFIG_FLAG_ALIASES.includes(arg)) { + if (i === args.length - 1) { + throw new Error("Missing configuration file after --config flag."); + } + configPath = args[i + 1]; + args.splice(i, 2); + i -= 1; + } + } + + if (args.length > 0) { + const potentialPath = args[args.length - 1]; + if (isConfigFilePath(potentialPath)) { + configPath = potentialPath; + args.pop(); + } + } + + return { args, configPath }; +} + +function isConfigFilePath(value) { + if (typeof value !== "string") { + return false; + } + return value.endsWith(".yml") || value.endsWith(".yaml") || value.includes("/") || value.includes("\\"); +} + +function validateSectionType(input) { + const normalized = String(input || "").toLowerCase(); + if (!SECTION_METADATA[normalized]) { + throw new Error(`Unknown section '${input}'. Expected one of: ${CONFIG_SECTIONS.join(", ")}.`); + } + return normalized; +} + +function parseStateToken(token) { + const normalized = token.toLowerCase(); + if (["on", "enable", "enabled", "true", "yes", "y"].includes(normalized)) { + return true; + } + if (["off", "disable", "disabled", "false", "no", "n"].includes(normalized)) { + return false; + } + throw new Error("State must be 'on' or 'off'."); +} + +function calculateSectionFootprint(section, state = {}) { + const meta = SECTION_METADATA[section]; + if (!meta || section === "collections") { + return { totalCharacters: 0 }; + } + + let totalCharacters = 0; + + for (const [name, enabled] of Object.entries(state)) { + if (!enabled) continue; + + const filePath = path.join(__dirname, meta.dir, `${name}${meta.ext}`); + try { + const stats = fs.statSync(filePath); + totalCharacters += stats.size; + } catch (error) { + // If the file no longer exists we skip it but continue gracefully. + } + } + + return { totalCharacters }; +} + +function maybeWarnAboutContext(section, totalCharacters) { + const limit = CONTEXT_WARNING_CHAR_LIMIT[section]; + if (!limit || totalCharacters <= 0) { + return; + } + + if (totalCharacters >= limit) { + console.log(`⚠️ Warning: Estimated ${SECTION_METADATA[section].label.toLowerCase()} size ${formatNumber(totalCharacters)} characters exceeds the recommended limit of ${formatNumber(limit)} characters. Copilot Agent may truncate or crash.`); + } else if (totalCharacters >= limit * 0.8) { + console.log(`⚠️ Heads up: Estimated ${SECTION_METADATA[section].label.toLowerCase()} size ${formatNumber(totalCharacters)} characters is approaching the recommended limit (${formatNumber(limit)} characters).`); + } +} + +function formatNumber(value) { + return numberFormatter.format(Math.round(value)); +} + +function findClosestMatch(target, candidates) { + const normalizedTarget = target.toLowerCase(); + return candidates.find(candidate => candidate.toLowerCase().includes(normalizedTarget)); +} + async function main() { const args = process.argv.slice(2); - + if (args.length === 0) { showHelp(); return; } - + const command = args[0]; const commandArgs = args.slice(1); - + if (!commands[command]) { showError(`Unknown command: ${command}`); } - + try { await commands[command].action(commandArgs); } catch (error) { @@ -91,4 +337,4 @@ if (require.main === module) { main(); } -module.exports = { main }; \ No newline at end of file +module.exports = { main }; diff --git a/config-manager.js b/config-manager.js new file mode 100644 index 0000000..628837f --- /dev/null +++ b/config-manager.js @@ -0,0 +1,172 @@ +const fs = require("fs"); +const path = require("path"); + +const { parseConfigYamlContent } = require("./apply-config"); +const { objectToYaml, generateConfigHeader, getAvailableItems } = require("./generate-config"); + +const DEFAULT_CONFIG_PATH = "awesome-copilot.config.yml"; +const SECTION_METADATA = { + prompts: { dir: "prompts", ext: ".prompt.md", label: "Prompts", singular: "prompt" }, + instructions: { dir: "instructions", ext: ".instructions.md", label: "Instructions", singular: "instruction" }, + chatmodes: { dir: "chatmodes", ext: ".chatmode.md", label: "Chat Modes", singular: "chat mode" }, + collections: { dir: "collections", ext: ".collection.yml", label: "Collections", singular: "collection" } +}; +const CONFIG_SECTIONS = Object.keys(SECTION_METADATA); + +function loadConfig(configPath = DEFAULT_CONFIG_PATH) { + if (!fs.existsSync(configPath)) { + throw new Error(`Configuration file not found: ${configPath}`); + } + + const rawContent = fs.readFileSync(configPath, "utf8"); + const { header, body } = splitHeaderAndBody(rawContent); + const parsed = parseConfigYamlContent(body || ""); + const config = ensureConfigStructure(parsed || {}); + + return { config, header }; +} + +function saveConfig(configPath, config, header) { + const ensuredConfig = ensureConfigStructure(config || {}); + const sortedConfig = sortConfigSections(ensuredConfig); + const yamlContent = objectToYaml(sortedConfig); + const headerContent = formatHeader(header); + + fs.writeFileSync(configPath, headerContent + yamlContent); +} + +function splitHeaderAndBody(content) { + const lines = content.split("\n"); + const headerLines = []; + let firstBodyIndex = 0; + + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + if (trimmed === "" || trimmed.startsWith("#")) { + headerLines.push(lines[i]); + firstBodyIndex = i + 1; + } else { + firstBodyIndex = i; + break; + } + } + + const header = headerLines.join("\n"); + const body = lines.slice(firstBodyIndex).join("\n"); + + return { header, body }; +} + +function ensureConfigStructure(config) { + const sanitized = typeof config === "object" && config !== null ? { ...config } : {}; + + if (!sanitized.version) { + sanitized.version = "1.0"; + } + + const project = typeof sanitized.project === "object" && sanitized.project !== null ? { ...sanitized.project } : {}; + if (project.output_directory === undefined) { + project.output_directory = ".awesome-copilot"; + } + sanitized.project = project; + + CONFIG_SECTIONS.forEach(section => { + sanitized[section] = sanitizeSection(sanitized[section]); + }); + + return sanitized; +} + +function sanitizeSection(section) { + if (!section || typeof section !== "object") { + return {}; + } + + const sanitized = {}; + for (const [key, value] of Object.entries(section)) { + sanitized[key] = toBoolean(value); + } + + return sanitized; +} + +function toBoolean(value) { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true") return true; + if (normalized === "false") return false; + } + + return Boolean(value); +} + +function sortConfigSections(config) { + const sorted = { ...config }; + + CONFIG_SECTIONS.forEach(section => { + sorted[section] = sortObjectKeys(sorted[section]); + }); + + return sorted; +} + +function sortObjectKeys(obj) { + if (!obj || typeof obj !== "object") { + return {}; + } + + return Object.keys(obj) + .sort((a, b) => a.localeCompare(b)) + .reduce((acc, key) => { + acc[key] = obj[key]; + return acc; + }, {}); +} + +function formatHeader(existingHeader) { + const header = existingHeader && existingHeader.trim().length > 0 + ? existingHeader + : generateConfigHeader(); + + let normalized = header; + + if (!normalized.endsWith("\n")) { + normalized += "\n"; + } + if (!normalized.endsWith("\n\n")) { + normalized += "\n"; + } + + return normalized; +} + +function countEnabledItems(section = {}) { + return Object.values(section).filter(Boolean).length; +} + +function getAllAvailableItems(type) { + const meta = SECTION_METADATA[type]; + + if (!meta) { + return []; + } + + return getAvailableItems(path.join(__dirname, meta.dir), meta.ext); +} + +module.exports = { + DEFAULT_CONFIG_PATH, + CONFIG_SECTIONS, + SECTION_METADATA, + loadConfig, + saveConfig, + splitHeaderAndBody, + ensureConfigStructure, + sortObjectKeys, + countEnabledItems, + getAllAvailableItems +}; diff --git a/generate-config.js b/generate-config.js index 6b6c52c..7ebfbaa 100755 --- a/generate-config.js +++ b/generate-config.js @@ -8,7 +8,7 @@ const path = require("path"); */ function generateConfig(outputPath = "awesome-copilot.config.yml") { const rootDir = __dirname; - + // Get all available items const prompts = getAvailableItems(path.join(rootDir, "prompts"), ".prompt.md"); const instructions = getAvailableItems(path.join(rootDir, "instructions"), ".instructions.md"); @@ -46,25 +46,8 @@ function generateConfig(outputPath = "awesome-copilot.config.yml") { config.collections[item] = false; }); - // Convert to YAML format manually (since we don't want to add dependencies) const yamlContent = objectToYaml(config); - - // Add header comment - const header = `# Awesome Copilot Configuration File -# Generated on ${new Date().toISOString()} -# -# This file allows you to enable/disable specific prompts, instructions, -# chat modes, and collections for your project. -# -# Set items to 'true' to include them in your project -# Set items to 'false' to exclude them -# -# After configuring, run: awesome-copilot apply -# - -`; - - const fullContent = header + yamlContent; + const fullContent = generateConfigHeader() + yamlContent; fs.writeFileSync(outputPath, fullContent); console.log(`Configuration file generated: ${outputPath}`); @@ -108,13 +91,21 @@ function objectToYaml(obj, indent = 0) { return yaml; } -// CLI usage -if (require.main === module) { - const outputPath = process.argv[2] || "awesome-copilot.config.yml"; - generateConfig(outputPath); -} +function generateConfigHeader(date = new Date()) { + return `# Awesome Copilot Configuration File +# Generated on ${date.toISOString()} +# +# This file allows you to enable/disable specific prompts, instructions, +# chat modes, and collections for your project. +# +# Set items to 'true' to include them in your project +# Set items to 'false' to exclude them +# +# After configuring, run: awesome-copilot apply +# -module.exports = { generateConfig, getAvailableItems }; +`; +} // CLI usage if (require.main === module) { @@ -122,4 +113,9 @@ if (require.main === module) { generateConfig(outputPath); } -module.exports = { generateConfig, getAvailableItems }; \ No newline at end of file +module.exports = { + generateConfig, + getAvailableItems, + objectToYaml, + generateConfigHeader +};