diff --git a/changelog.md b/changelog.md index d069c77..59fcf56 100644 --- a/changelog.md +++ b/changelog.md @@ -9,7 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### +### Added + +- **Agent skill-directory symlinks** — Each supported AI agent now has a dedicated skills directory registered in `AgentRegistry` (`AGENT_SKILLS_DIRS`): + - `claude` → `.claude/skills` + - `copilot` → `.github/instructions` + - `cursor` → `.cursor/rules` + - `codex`, `gemini`, `opencode` → no dedicated skills directory (use `.agents/skills` directly) +- When a skill is installed via `coldbox ai install`, `coldbox ai skills install`, or `coldbox ai refresh`, the skill directory is created at the canonical `.agents/skills/{name}/` location and a relative directory symlink is created inside every active agent's dedicated skills directory (e.g. `.claude/skills/{name}` → `../../.agents/skills/{name}`). This lets each agent discover skills through its own expected path without duplicating content. +- Symlinks are automatically removed when a skill is removed (`coldbox ai skills remove`) or pruned during refresh. + +### Fixed - Invalid scope on `FUNCTION_PATTERN` variable in Agent Registry - `coldbox ai install` now registers the correct MCP server URL (`https://boxlang.ortusbooks.com/~gitbook/mcp`) for the BoxLang core language documentation entry instead of the BoxLang AI module URL diff --git a/models/AgentRegistry.cfc b/models/AgentRegistry.cfc index d67fb21..60d1317 100644 --- a/models/AgentRegistry.cfc +++ b/models/AgentRegistry.cfc @@ -28,6 +28,17 @@ component singleton { "gemini" : "GEMINI.md", "opencode" : "AGENTS.md" } + // Skills directories per agent (paths relative to the project root). + // Empty string means the agent has no dedicated skills directory and + // skills are only available at the canonical .agents/skills/ location. + AGENT_SKILLS_DIRS = { + "claude" : ".claude/skills", + "copilot" : ".github/instructions", + "cursor" : ".cursor/rules", + "codex" : "", + "gemini" : "", + "opencode" : "" + } // Demarcation markers that wrap the ColdBox CLI-managed section MANAGED_SECTION_START = "" MANAGED_SECTION_END = "" @@ -65,10 +76,11 @@ component singleton { } // Expose them as instance properties for easier access in commands - this.SUPPORTED_AGENTS = static.SUPPORTED_AGENTS - this.AGENT_OPTIONS = static.AGENT_OPTIONS - this.AGENT_FILES = static.AGENT_FILES - this.FUNCTION_PATTERN = static.FUNCTION_PATTERN + this.SUPPORTED_AGENTS = static.SUPPORTED_AGENTS + this.AGENT_OPTIONS = static.AGENT_OPTIONS + this.AGENT_FILES = static.AGENT_FILES + this.AGENT_SKILLS_DIRS = static.AGENT_SKILLS_DIRS + this.FUNCTION_PATTERN = static.FUNCTION_PATTERN /** * Configure agents for a project @@ -131,6 +143,130 @@ component singleton { return issues; } + /** + * Get the absolute path to an agent's dedicated skills directory within a project. + * Returns null when the agent has no dedicated skills directory. + * + * @directory The project directory + * @agent The agent name (claude, copilot, cursor, codex, gemini, opencode) + * + * @return Absolute path string, or null if the agent has no dedicated skills directory + */ + function getAgentSkillsDirectory( + required string directory, + required string agent + ){ + var relPath = static.AGENT_SKILLS_DIRS[ arguments.agent ] ?: "" + if ( !relPath.len() ) { + return javacast( "null", "" ) + } + // Normalize trailing separator + var dir = arguments.directory + if ( right( dir, 1 ) == "/" || right( dir, 1 ) == "\" ) { + dir = left( dir, len( dir ) - 1 ) + } + return "#dir#/#relPath#" + } + + /** + * Create symlinks for a skill in every active agent's dedicated skills directory. + * Each symlink is a directory-level link that points back to the canonical + * .agents/skills/{name} directory using a relative path, so it remains valid + * after the project is cloned or moved. + * + * If symlink creation is not supported by the OS / JVM (e.g. Windows without + * elevated privileges) the failure is silently swallowed with a yellow warning. + * + * @directory The project directory + * @skillName The skill directory name + * @agents Array of active agent names + */ + function createSkillSymlinks( + required string directory, + required string skillName, + required array agents + ){ + var canonicalSkillDir = "#arguments.directory#/.agents/skills/#arguments.skillName#" + var Files = createObject( "java", "java.nio.file.Files" ) + var Paths = createObject( "java", "java.nio.file.Paths" ) + // Store loop variables outside closure to avoid scope issues + var dir = arguments.directory + var skill = arguments.skillName + var canonical = canonicalSkillDir + + for ( var agent in arguments.agents ) { + var agentSkillsDir = getAgentSkillsDirectory( dir, agent ) + if ( isNull( agentSkillsDir ) ) { + continue; + } + + var linkPath = "#agentSkillsDir#/#skill#" + + // Skip if link/directory already exists + if ( directoryExists( linkPath ) || fileExists( linkPath ) ) { + continue; + } + + try { + // Create parent directory if needed + if ( !directoryExists( agentSkillsDir ) ) { + directoryCreate( agentSkillsDir, true ) + } + + // Compute a relative path from the link's parent dir → canonical dir + var agentDirPath = Paths.get( agentSkillsDir ) + var targetDirPath = Paths.get( canonical ) + var relativePath = agentDirPath.relativize( targetDirPath ) + + Files.createSymbolicLink( Paths.get( linkPath ), relativePath ) + } catch ( any e ) { + variables.print + .yellowLine( " ⚠️ Could not create symlink for agent '#agent#': #e.message#" ) + .toConsole() + } + } + } + + /** + * Remove symlinks for a skill from every active agent's dedicated skills directory. + * Only removes entries that are genuine symbolic links; real directories and files + * are left untouched. + * + * @directory The project directory + * @skillName The skill directory name + * @agents Array of active agent names + */ + function removeSkillSymlinks( + required string directory, + required string skillName, + required array agents + ){ + var Files = createObject( "java", "java.nio.file.Files" ) + var Paths = createObject( "java", "java.nio.file.Paths" ) + var dir = arguments.directory + var skill = arguments.skillName + + for ( var agent in arguments.agents ) { + var agentSkillsDir = getAgentSkillsDirectory( dir, agent ) + if ( isNull( agentSkillsDir ) ) { + continue; + } + + var linkPath = "#agentSkillsDir#/#skill#" + + try { + var path = Paths.get( linkPath ) + if ( Files.isSymbolicLink( path ) ) { + Files.delete( path ) + } + } catch ( any e ) { + variables.print + .yellowLine( " ⚠️ Could not remove symlink for agent '#agent#': #e.message#" ) + .toConsole() + } + } + } + // ======================================== // Private Helpers // ======================================== diff --git a/models/SkillManager.cfc b/models/SkillManager.cfc index 8087b92..d1f04ea 100644 --- a/models/SkillManager.cfc +++ b/models/SkillManager.cfc @@ -32,6 +32,7 @@ component singleton { property name="wirebox" inject="wirebox"; property name="utility" inject="Utility@coldbox-cli"; property name="aiService" inject="AIService@coldbox-cli"; + property name="agentRegistry" inject="AgentRegistry@coldbox-cli"; property name="settings" inject="box:modulesettings:coldbox-cli"; // ========================================================================= @@ -291,7 +292,7 @@ component singleton { toRemove.each( ( name ) => { variables.print.yellowLine( " 🗑️ Removing orphaned module skill: #name#" ).toConsole() - deleteSkillDir( directory, name ) + deleteSkillDir( directory, name, manifest.agents ?: [] ) manifest.skills = manifest.skills.filter( ( s ) => s.name != name ) changes.removed.append( name ) } ) @@ -828,6 +829,23 @@ component singleton { ) } + // Remove from manifest (both skills and customSkills sections) + var manifest = variables.aiService.loadManifest( arguments.directory ) + manifest.skills = manifest.skills.filter( ( s ) => s.name != name ) + if ( structKeyExists( manifest, "customSkills" ) ) { + manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name ) + } + + // Remove agent symlinks before deleting the canonical directory + var activeAgents = manifest.agents ?: [] + if ( activeAgents.len() ) { + variables.agentRegistry.removeSkillSymlinks( + arguments.directory, + arguments.name, + activeAgents + ) + } + // Delete whichever directory exists if ( directoryExists( skillDir ) ) { directoryDelete( skillDir, true ) @@ -836,13 +854,6 @@ component singleton { directoryDelete( customSkillDir, true ) } - // Remove from manifest (both skills and customSkills sections) - var manifest = variables.aiService.loadManifest( arguments.directory ) - manifest.skills = manifest.skills.filter( ( s ) => s.name != name ) - if ( structKeyExists( manifest, "customSkills" ) ) { - manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name ) - } - // Track the explicit exclusion so refresh() does not auto-reinstall it ensureExcludesSection( manifest ) if ( !manifest.excludes.findNoCase( arguments.name ) ) { @@ -1341,6 +1352,16 @@ component singleton { arguments.content ) + // Create symlinks in each active agent's dedicated skills directory + var activeAgents = arguments.manifest.agents ?: [] + if ( activeAgents.len() ) { + variables.agentRegistry.createSkillSymlinks( + arguments.directory, + resolvedName, + activeAgents + ) + } + // Upsert manifest entry var existingIndex = 0 for ( var i = 1; i <= arguments.manifest.skills.len(); i++ ) { @@ -1440,15 +1461,26 @@ component singleton { } /** - * Delete a skill directory under .ai/skills/ if it exists. + * Delete a skill directory under .agents/skills/ if it exists, + * and remove any agent symlinks pointing to it. * * @directory The project directory * @name The skill name (directory name) + * @agents Optional array of active agent names (for symlink cleanup) */ private function deleteSkillDir( required string directory, - required string name + required string name, + array agents = [] ){ + // Remove agent symlinks before deleting the canonical directory + if ( arguments.agents.len() ) { + variables.agentRegistry.removeSkillSymlinks( + arguments.directory, + arguments.name, + arguments.agents + ) + } var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#" if ( directoryExists( skillDir ) ) { directoryDelete( skillDir, true )