Skip to content

Commit 64fdbdd

Browse files
authored
Merge pull request #83 from ColdBox/copilot/install-skills-and-create-symlinks
feat: create agent-specific skill symlinks on install/remove
2 parents f2f0b2c + 9e6a521 commit 64fdbdd

3 files changed

Lines changed: 193 additions & 15 deletions

File tree

‎changelog.md‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12-
###
12+
### Added
13+
14+
- **Agent skill-directory symlinks** — Each supported AI agent now has a dedicated skills directory registered in `AgentRegistry` (`AGENT_SKILLS_DIRS`):
15+
- `claude` → `.claude/skills`
16+
- `copilot` → `.github/instructions`
17+
- `cursor` → `.cursor/rules`
18+
- `codex`, `gemini`, `opencode` → no dedicated skills directory (use `.agents/skills` directly)
19+
- 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.
20+
- Symlinks are automatically removed when a skill is removed (`coldbox ai skills remove`) or pruned during refresh.
21+
22+
### Fixed
1323

1424
- Invalid scope on `FUNCTION_PATTERN` variable in Agent Registry
1525
- `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

‎models/AgentRegistry.cfc‎

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ component singleton {
2828
"gemini" : "GEMINI.md",
2929
"opencode" : "AGENTS.md"
3030
}
31+
// Skills directories per agent (paths relative to the project root).
32+
// Empty string means the agent has no dedicated skills directory and
33+
// skills are only available at the canonical .agents/skills/ location.
34+
AGENT_SKILLS_DIRS = {
35+
"claude" : ".claude/skills",
36+
"copilot" : ".github/instructions",
37+
"cursor" : ".cursor/rules",
38+
"codex" : "",
39+
"gemini" : "",
40+
"opencode" : ""
41+
}
3142
// Demarcation markers that wrap the ColdBox CLI-managed section
3243
MANAGED_SECTION_START = "<!-- COLDBOX-CLI:START -->"
3344
MANAGED_SECTION_END = "<!-- COLDBOX-CLI:END -->"
@@ -65,10 +76,11 @@ component singleton {
6576
}
6677

6778
// Expose them as instance properties for easier access in commands
68-
this.SUPPORTED_AGENTS = static.SUPPORTED_AGENTS
69-
this.AGENT_OPTIONS = static.AGENT_OPTIONS
70-
this.AGENT_FILES = static.AGENT_FILES
71-
this.FUNCTION_PATTERN = static.FUNCTION_PATTERN
79+
this.SUPPORTED_AGENTS = static.SUPPORTED_AGENTS
80+
this.AGENT_OPTIONS = static.AGENT_OPTIONS
81+
this.AGENT_FILES = static.AGENT_FILES
82+
this.AGENT_SKILLS_DIRS = static.AGENT_SKILLS_DIRS
83+
this.FUNCTION_PATTERN = static.FUNCTION_PATTERN
7284

7385
/**
7486
* Configure agents for a project
@@ -131,6 +143,130 @@ component singleton {
131143
return issues;
132144
}
133145

146+
/**
147+
* Get the absolute path to an agent's dedicated skills directory within a project.
148+
* Returns null when the agent has no dedicated skills directory.
149+
*
150+
* @directory The project directory
151+
* @agent The agent name (claude, copilot, cursor, codex, gemini, opencode)
152+
*
153+
* @return Absolute path string, or null if the agent has no dedicated skills directory
154+
*/
155+
function getAgentSkillsDirectory(
156+
required string directory,
157+
required string agent
158+
){
159+
var relPath = static.AGENT_SKILLS_DIRS[ arguments.agent ] ?: ""
160+
if ( !relPath.len() ) {
161+
return javacast( "null", "" )
162+
}
163+
// Normalize trailing separator
164+
var dir = arguments.directory
165+
if ( right( dir, 1 ) == "/" || right( dir, 1 ) == "\" ) {
166+
dir = left( dir, len( dir ) - 1 )
167+
}
168+
return "#dir#/#relPath#"
169+
}
170+
171+
/**
172+
* Create symlinks for a skill in every active agent's dedicated skills directory.
173+
* Each symlink is a directory-level link that points back to the canonical
174+
* .agents/skills/{name} directory using a relative path, so it remains valid
175+
* after the project is cloned or moved.
176+
*
177+
* If symlink creation is not supported by the OS / JVM (e.g. Windows without
178+
* elevated privileges) the failure is silently swallowed with a yellow warning.
179+
*
180+
* @directory The project directory
181+
* @skillName The skill directory name
182+
* @agents Array of active agent names
183+
*/
184+
function createSkillSymlinks(
185+
required string directory,
186+
required string skillName,
187+
required array agents
188+
){
189+
var canonicalSkillDir = "#arguments.directory#/.agents/skills/#arguments.skillName#"
190+
var Files = createObject( "java", "java.nio.file.Files" )
191+
var Paths = createObject( "java", "java.nio.file.Paths" )
192+
// Store loop variables outside closure to avoid scope issues
193+
var dir = arguments.directory
194+
var skill = arguments.skillName
195+
var canonical = canonicalSkillDir
196+
197+
for ( var agent in arguments.agents ) {
198+
var agentSkillsDir = getAgentSkillsDirectory( dir, agent )
199+
if ( isNull( agentSkillsDir ) ) {
200+
continue;
201+
}
202+
203+
var linkPath = "#agentSkillsDir#/#skill#"
204+
205+
// Skip if link/directory already exists
206+
if ( directoryExists( linkPath ) || fileExists( linkPath ) ) {
207+
continue;
208+
}
209+
210+
try {
211+
// Create parent directory if needed
212+
if ( !directoryExists( agentSkillsDir ) ) {
213+
directoryCreate( agentSkillsDir, true )
214+
}
215+
216+
// Compute a relative path from the link's parent dir → canonical dir
217+
var agentDirPath = Paths.get( agentSkillsDir )
218+
var targetDirPath = Paths.get( canonical )
219+
var relativePath = agentDirPath.relativize( targetDirPath )
220+
221+
Files.createSymbolicLink( Paths.get( linkPath ), relativePath )
222+
} catch ( any e ) {
223+
variables.print
224+
.yellowLine( " ⚠️ Could not create symlink for agent '#agent#': #e.message#" )
225+
.toConsole()
226+
}
227+
}
228+
}
229+
230+
/**
231+
* Remove symlinks for a skill from every active agent's dedicated skills directory.
232+
* Only removes entries that are genuine symbolic links; real directories and files
233+
* are left untouched.
234+
*
235+
* @directory The project directory
236+
* @skillName The skill directory name
237+
* @agents Array of active agent names
238+
*/
239+
function removeSkillSymlinks(
240+
required string directory,
241+
required string skillName,
242+
required array agents
243+
){
244+
var Files = createObject( "java", "java.nio.file.Files" )
245+
var Paths = createObject( "java", "java.nio.file.Paths" )
246+
var dir = arguments.directory
247+
var skill = arguments.skillName
248+
249+
for ( var agent in arguments.agents ) {
250+
var agentSkillsDir = getAgentSkillsDirectory( dir, agent )
251+
if ( isNull( agentSkillsDir ) ) {
252+
continue;
253+
}
254+
255+
var linkPath = "#agentSkillsDir#/#skill#"
256+
257+
try {
258+
var path = Paths.get( linkPath )
259+
if ( Files.isSymbolicLink( path ) ) {
260+
Files.delete( path )
261+
}
262+
} catch ( any e ) {
263+
variables.print
264+
.yellowLine( " ⚠️ Could not remove symlink for agent '#agent#': #e.message#" )
265+
.toConsole()
266+
}
267+
}
268+
}
269+
134270
// ========================================
135271
// Private Helpers
136272
// ========================================

‎models/SkillManager.cfc‎

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ component singleton {
3232
property name="wirebox" inject="wirebox";
3333
property name="utility" inject="Utility@coldbox-cli";
3434
property name="aiService" inject="AIService@coldbox-cli";
35+
property name="agentRegistry" inject="AgentRegistry@coldbox-cli";
3536
property name="settings" inject="box:modulesettings:coldbox-cli";
3637

3738
// =========================================================================
@@ -291,7 +292,7 @@ component singleton {
291292

292293
toRemove.each( ( name ) => {
293294
variables.print.yellowLine( " 🗑️ Removing orphaned module skill: #name#" ).toConsole()
294-
deleteSkillDir( directory, name )
295+
deleteSkillDir( directory, name, manifest.agents ?: [] )
295296
manifest.skills = manifest.skills.filter( ( s ) => s.name != name )
296297
changes.removed.append( name )
297298
} )
@@ -828,6 +829,23 @@ component singleton {
828829
)
829830
}
830831

832+
// Remove from manifest (both skills and customSkills sections)
833+
var manifest = variables.aiService.loadManifest( arguments.directory )
834+
manifest.skills = manifest.skills.filter( ( s ) => s.name != name )
835+
if ( structKeyExists( manifest, "customSkills" ) ) {
836+
manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name )
837+
}
838+
839+
// Remove agent symlinks before deleting the canonical directory
840+
var activeAgents = manifest.agents ?: []
841+
if ( activeAgents.len() ) {
842+
variables.agentRegistry.removeSkillSymlinks(
843+
arguments.directory,
844+
arguments.name,
845+
activeAgents
846+
)
847+
}
848+
831849
// Delete whichever directory exists
832850
if ( directoryExists( skillDir ) ) {
833851
directoryDelete( skillDir, true )
@@ -836,13 +854,6 @@ component singleton {
836854
directoryDelete( customSkillDir, true )
837855
}
838856

839-
// Remove from manifest (both skills and customSkills sections)
840-
var manifest = variables.aiService.loadManifest( arguments.directory )
841-
manifest.skills = manifest.skills.filter( ( s ) => s.name != name )
842-
if ( structKeyExists( manifest, "customSkills" ) ) {
843-
manifest.customSkills = manifest.customSkills.filter( ( s ) => s.name != name )
844-
}
845-
846857
// Track the explicit exclusion so refresh() does not auto-reinstall it
847858
ensureExcludesSection( manifest )
848859
if ( !manifest.excludes.findNoCase( arguments.name ) ) {
@@ -1341,6 +1352,16 @@ component singleton {
13411352
arguments.content
13421353
)
13431354

1355+
// Create symlinks in each active agent's dedicated skills directory
1356+
var activeAgents = arguments.manifest.agents ?: []
1357+
if ( activeAgents.len() ) {
1358+
variables.agentRegistry.createSkillSymlinks(
1359+
arguments.directory,
1360+
resolvedName,
1361+
activeAgents
1362+
)
1363+
}
1364+
13441365
// Upsert manifest entry
13451366
var existingIndex = 0
13461367
for ( var i = 1; i <= arguments.manifest.skills.len(); i++ ) {
@@ -1440,15 +1461,26 @@ component singleton {
14401461
}
14411462

14421463
/**
1443-
* Delete a skill directory under .ai/skills/ if it exists.
1464+
* Delete a skill directory under .agents/skills/ if it exists,
1465+
* and remove any agent symlinks pointing to it.
14441466
*
14451467
* @directory The project directory
14461468
* @name The skill name (directory name)
1469+
* @agents Optional array of active agent names (for symlink cleanup)
14471470
*/
14481471
private function deleteSkillDir(
14491472
required string directory,
1450-
required string name
1473+
required string name,
1474+
array agents = []
14511475
){
1476+
// Remove agent symlinks before deleting the canonical directory
1477+
if ( arguments.agents.len() ) {
1478+
variables.agentRegistry.removeSkillSymlinks(
1479+
arguments.directory,
1480+
arguments.name,
1481+
arguments.agents
1482+
)
1483+
}
14521484
var skillDir = getSkillsDirectory( arguments.directory ) & "/#arguments.name#"
14531485
if ( directoryExists( skillDir ) ) {
14541486
directoryDelete( skillDir, true )

0 commit comments

Comments
 (0)