Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
144 changes: 140 additions & 4 deletions models/AgentRegistry.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<!-- COLDBOX-CLI:START -->"
MANAGED_SECTION_END = "<!-- COLDBOX-CLI:END -->"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
// ========================================
Expand Down
52 changes: 42 additions & 10 deletions models/SkillManager.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// =========================================================================
Expand Down Expand Up @@ -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 )
} )
Expand Down Expand Up @@ -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 )
Expand All @@ -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 ) ) {
Expand Down Expand Up @@ -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++ ) {
Expand Down Expand Up @@ -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 )
Expand Down