@intellectronica/ruler 0.3.17 → 0.3.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -557,15 +557,20 @@ export CODEX_HOME="$(pwd)/.codex"
|
|
|
557
557
|
|
|
558
558
|
## Skills Support (Experimental)
|
|
559
559
|
|
|
560
|
-
**⚠️ Experimental Feature**: Skills support is currently experimental and requires `uv` (the Python package manager) to be installed on your system for MCP-based agent integration.
|
|
560
|
+
**⚠️ Experimental Feature**: Skills support is currently experimental and requires `uv` (the Python package manager) to be installed on your system for MCP-based agent integration (agents without native skills support).
|
|
561
561
|
|
|
562
|
-
Ruler can manage and propagate
|
|
562
|
+
Ruler can manage and propagate skills to supported AI agents. Skills are stored in `.ruler/skills/` and are automatically distributed to compatible agents when you run `ruler apply`.
|
|
563
563
|
|
|
564
564
|
### How It Works
|
|
565
565
|
|
|
566
566
|
Skills are specialized knowledge packages that extend AI agent capabilities with domain-specific expertise, workflows, or tool integrations. Ruler discovers skills in your `.ruler/skills/` directory and propagates them to compatible agents:
|
|
567
567
|
|
|
568
|
-
- **
|
|
568
|
+
- **Agents with native skills support**: Skills are copied directly to each agent's native skills directory:
|
|
569
|
+
- **Claude Code**: `.claude/skills/`
|
|
570
|
+
- **GitHub Copilot**: `.claude/skills/` (shared with Claude Code)
|
|
571
|
+
- **OpenAI Codex CLI**: `.codex/skills/`
|
|
572
|
+
- **OpenCode**: `.opencode/skill/`
|
|
573
|
+
- **Goose**: `.agents/skills/`
|
|
569
574
|
- **Other MCP-compatible agents**: Skills are copied to `.skillz/` and a Skillz MCP server is automatically configured via `uvx`
|
|
570
575
|
|
|
571
576
|
### Skills Directory Structure
|
|
@@ -615,12 +620,14 @@ enabled = true # or false to disable
|
|
|
615
620
|
|
|
616
621
|
### Skillz MCP Server
|
|
617
622
|
|
|
618
|
-
For agents that support MCP but don't have native skills support
|
|
623
|
+
For agents that support MCP but don't have native skills support, Ruler automatically:
|
|
619
624
|
|
|
620
625
|
1. Copies skills to `.skillz/` directory
|
|
621
626
|
2. Configures a Skillz MCP server in the agent's configuration
|
|
622
627
|
3. Uses `uvx` to launch the server with the absolute path to `.skillz`
|
|
623
628
|
|
|
629
|
+
Agents using native skills support (Claude Code, GitHub Copilot, OpenAI Codex CLI, OpenCode, and Goose) **do not** use the Skillz MCP server and instead use their own native skills directories.
|
|
630
|
+
|
|
624
631
|
Example auto-generated MCP server configuration:
|
|
625
632
|
|
|
626
633
|
```toml
|
|
@@ -633,15 +640,18 @@ args = ["skillz@latest", "/absolute/path/to/project/.skillz"]
|
|
|
633
640
|
|
|
634
641
|
When skills support is enabled and gitignore integration is active, Ruler automatically adds:
|
|
635
642
|
|
|
636
|
-
- `.claude/skills/` (for Claude Code
|
|
637
|
-
- `.
|
|
643
|
+
- `.claude/skills/` (for Claude Code and GitHub Copilot)
|
|
644
|
+
- `.codex/skills/` (for OpenAI Codex CLI)
|
|
645
|
+
- `.opencode/skill/` (for OpenCode)
|
|
646
|
+
- `.agents/skills/` (for Goose)
|
|
647
|
+
- `.skillz/` (for other MCP-based agents)
|
|
638
648
|
|
|
639
649
|
to your `.gitignore` file within the managed Ruler block.
|
|
640
650
|
|
|
641
651
|
### Requirements
|
|
642
652
|
|
|
643
|
-
- **For Claude Code
|
|
644
|
-
- **For MCP agents**: `uv` must be installed and available in your PATH
|
|
653
|
+
- **For agents with native skills support** (Claude Code, GitHub Copilot, OpenAI Codex CLI, OpenCode, Goose): No additional requirements
|
|
654
|
+
- **For other MCP agents**: `uv` must be installed and available in your PATH
|
|
645
655
|
```bash
|
|
646
656
|
# Install uv if needed
|
|
647
657
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
@@ -688,7 +698,10 @@ EOF
|
|
|
688
698
|
ruler apply
|
|
689
699
|
|
|
690
700
|
# 3. Skills are now available to compatible agents:
|
|
691
|
-
# - Claude Code: .claude/skills/my-skill/
|
|
701
|
+
# - Claude Code & GitHub Copilot: .claude/skills/my-skill/
|
|
702
|
+
# - OpenAI Codex CLI: .codex/skills/my-skill/
|
|
703
|
+
# - OpenCode: .opencode/skill/my-skill/
|
|
704
|
+
# - Goose: .agents/skills/my-skill/
|
|
692
705
|
# - Other MCP agents: .skillz/my-skill/ + Skillz MCP server configured
|
|
693
706
|
```
|
|
694
707
|
|
package/dist/constants.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.SKILLZ_MCP_SERVER_NAME = exports.SKILL_MD_FILENAME = exports.SKILLZ_DIR = exports.CLAUDE_SKILLS_PATH = exports.RULER_SKILLS_PATH = exports.SKILLS_DIR = exports.DEFAULT_RULES_FILENAME = exports.ERROR_PREFIX = void 0;
|
|
3
|
+
exports.SKILLZ_MCP_SERVER_NAME = exports.SKILL_MD_FILENAME = exports.SKILLZ_DIR = exports.GOOSE_SKILLS_PATH = exports.OPENCODE_SKILLS_PATH = exports.CODEX_SKILLS_PATH = exports.CLAUDE_SKILLS_PATH = exports.RULER_SKILLS_PATH = exports.SKILLS_DIR = exports.DEFAULT_RULES_FILENAME = exports.ERROR_PREFIX = void 0;
|
|
4
4
|
exports.actionPrefix = actionPrefix;
|
|
5
5
|
exports.createRulerError = createRulerError;
|
|
6
6
|
exports.logVerbose = logVerbose;
|
|
@@ -53,6 +53,9 @@ function logVerboseInfo(message, isVerbose, dryRun = false) {
|
|
|
53
53
|
exports.SKILLS_DIR = 'skills';
|
|
54
54
|
exports.RULER_SKILLS_PATH = '.ruler/skills';
|
|
55
55
|
exports.CLAUDE_SKILLS_PATH = '.claude/skills';
|
|
56
|
+
exports.CODEX_SKILLS_PATH = '.codex/skills';
|
|
57
|
+
exports.OPENCODE_SKILLS_PATH = '.opencode/skill';
|
|
58
|
+
exports.GOOSE_SKILLS_PATH = '.agents/skills';
|
|
56
59
|
exports.SKILLZ_DIR = '.skillz';
|
|
57
60
|
exports.SKILL_MD_FILENAME = 'SKILL.md';
|
|
58
61
|
exports.SKILLZ_MCP_SERVER_NAME = 'skillz';
|
|
@@ -37,6 +37,9 @@ exports.discoverSkills = discoverSkills;
|
|
|
37
37
|
exports.getSkillsGitignorePaths = getSkillsGitignorePaths;
|
|
38
38
|
exports.propagateSkills = propagateSkills;
|
|
39
39
|
exports.propagateSkillsForClaude = propagateSkillsForClaude;
|
|
40
|
+
exports.propagateSkillsForCodex = propagateSkillsForCodex;
|
|
41
|
+
exports.propagateSkillsForOpenCode = propagateSkillsForOpenCode;
|
|
42
|
+
exports.propagateSkillsForGoose = propagateSkillsForGoose;
|
|
40
43
|
exports.propagateSkillsForSkillz = propagateSkillsForSkillz;
|
|
41
44
|
exports.buildSkillzMcpConfig = buildSkillzMcpConfig;
|
|
42
45
|
const path = __importStar(require("path"));
|
|
@@ -74,9 +77,12 @@ async function getSkillsGitignorePaths(projectRoot) {
|
|
|
74
77
|
return [];
|
|
75
78
|
}
|
|
76
79
|
// Import here to avoid circular dependency
|
|
77
|
-
const { CLAUDE_SKILLS_PATH, SKILLZ_DIR } = await Promise.resolve().then(() => __importStar(require('../constants')));
|
|
80
|
+
const { CLAUDE_SKILLS_PATH, CODEX_SKILLS_PATH, OPENCODE_SKILLS_PATH, GOOSE_SKILLS_PATH, SKILLZ_DIR, } = await Promise.resolve().then(() => __importStar(require('../constants')));
|
|
78
81
|
return [
|
|
79
82
|
path.join(projectRoot, CLAUDE_SKILLS_PATH),
|
|
83
|
+
path.join(projectRoot, CODEX_SKILLS_PATH),
|
|
84
|
+
path.join(projectRoot, OPENCODE_SKILLS_PATH),
|
|
85
|
+
path.join(projectRoot, GOOSE_SKILLS_PATH),
|
|
80
86
|
path.join(projectRoot, SKILLZ_DIR),
|
|
81
87
|
];
|
|
82
88
|
}
|
|
@@ -100,11 +106,14 @@ function warnOnceExperimentalAndUv(verbose, dryRun) {
|
|
|
100
106
|
(0, constants_1.logWarn)('Skills MCP server (Skillz) requires uv. Install: https://github.com/astral-sh/uv', dryRun);
|
|
101
107
|
}
|
|
102
108
|
/**
|
|
103
|
-
* Cleans up skills directories
|
|
109
|
+
* Cleans up skills directories when skills are disabled.
|
|
104
110
|
* This ensures that stale skills from previous runs don't persist when skills are turned off.
|
|
105
111
|
*/
|
|
106
112
|
async function cleanupSkillsDirectories(projectRoot, dryRun, verbose) {
|
|
107
113
|
const claudeSkillsPath = path.join(projectRoot, constants_1.CLAUDE_SKILLS_PATH);
|
|
114
|
+
const codexSkillsPath = path.join(projectRoot, constants_1.CODEX_SKILLS_PATH);
|
|
115
|
+
const opencodeSkillsPath = path.join(projectRoot, constants_1.OPENCODE_SKILLS_PATH);
|
|
116
|
+
const gooseSkillsPath = path.join(projectRoot, constants_1.GOOSE_SKILLS_PATH);
|
|
108
117
|
const skillzPath = path.join(projectRoot, constants_1.SKILLZ_DIR);
|
|
109
118
|
// Clean up .claude/skills
|
|
110
119
|
try {
|
|
@@ -120,6 +129,48 @@ async function cleanupSkillsDirectories(projectRoot, dryRun, verbose) {
|
|
|
120
129
|
catch {
|
|
121
130
|
// Directory doesn't exist, nothing to clean
|
|
122
131
|
}
|
|
132
|
+
// Clean up .codex/skills
|
|
133
|
+
try {
|
|
134
|
+
await fs.access(codexSkillsPath);
|
|
135
|
+
if (dryRun) {
|
|
136
|
+
(0, constants_1.logVerboseInfo)(`DRY RUN: Would remove ${constants_1.CODEX_SKILLS_PATH}`, verbose, dryRun);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
await fs.rm(codexSkillsPath, { recursive: true, force: true });
|
|
140
|
+
(0, constants_1.logVerboseInfo)(`Removed ${constants_1.CODEX_SKILLS_PATH} (skills disabled)`, verbose, dryRun);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Directory doesn't exist, nothing to clean
|
|
145
|
+
}
|
|
146
|
+
// Clean up .opencode/skill
|
|
147
|
+
try {
|
|
148
|
+
await fs.access(opencodeSkillsPath);
|
|
149
|
+
if (dryRun) {
|
|
150
|
+
(0, constants_1.logVerboseInfo)(`DRY RUN: Would remove ${constants_1.OPENCODE_SKILLS_PATH}`, verbose, dryRun);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
await fs.rm(opencodeSkillsPath, { recursive: true, force: true });
|
|
154
|
+
(0, constants_1.logVerboseInfo)(`Removed ${constants_1.OPENCODE_SKILLS_PATH} (skills disabled)`, verbose, dryRun);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Directory doesn't exist, nothing to clean
|
|
159
|
+
}
|
|
160
|
+
// Clean up .agents/skills
|
|
161
|
+
try {
|
|
162
|
+
await fs.access(gooseSkillsPath);
|
|
163
|
+
if (dryRun) {
|
|
164
|
+
(0, constants_1.logVerboseInfo)(`DRY RUN: Would remove ${constants_1.GOOSE_SKILLS_PATH}`, verbose, dryRun);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
await fs.rm(gooseSkillsPath, { recursive: true, force: true });
|
|
168
|
+
(0, constants_1.logVerboseInfo)(`Removed ${constants_1.GOOSE_SKILLS_PATH} (skills disabled)`, verbose, dryRun);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Directory doesn't exist, nothing to clean
|
|
173
|
+
}
|
|
123
174
|
// Clean up .skillz
|
|
124
175
|
try {
|
|
125
176
|
await fs.access(skillzPath);
|
|
@@ -178,8 +229,14 @@ async function propagateSkills(projectRoot, agents, skillsEnabled, verbose, dryR
|
|
|
178
229
|
}
|
|
179
230
|
// Copy to Claude skills directory if needed
|
|
180
231
|
if (hasNativeSkillsAgent) {
|
|
181
|
-
(0, constants_1.logVerboseInfo)(`Copying skills to ${constants_1.CLAUDE_SKILLS_PATH} for Claude Code`, verbose, dryRun);
|
|
232
|
+
(0, constants_1.logVerboseInfo)(`Copying skills to ${constants_1.CLAUDE_SKILLS_PATH} for Claude Code and GitHub Copilot`, verbose, dryRun);
|
|
182
233
|
await propagateSkillsForClaude(projectRoot, { dryRun });
|
|
234
|
+
(0, constants_1.logVerboseInfo)(`Copying skills to ${constants_1.CODEX_SKILLS_PATH} for OpenAI Codex CLI`, verbose, dryRun);
|
|
235
|
+
await propagateSkillsForCodex(projectRoot, { dryRun });
|
|
236
|
+
(0, constants_1.logVerboseInfo)(`Copying skills to ${constants_1.OPENCODE_SKILLS_PATH} for OpenCode`, verbose, dryRun);
|
|
237
|
+
await propagateSkillsForOpenCode(projectRoot, { dryRun });
|
|
238
|
+
(0, constants_1.logVerboseInfo)(`Copying skills to ${constants_1.GOOSE_SKILLS_PATH} for Goose`, verbose, dryRun);
|
|
239
|
+
await propagateSkillsForGoose(projectRoot, { dryRun });
|
|
183
240
|
}
|
|
184
241
|
// Copy to .skillz directory if needed
|
|
185
242
|
if (hasMcpAgent) {
|
|
@@ -237,6 +294,156 @@ async function propagateSkillsForClaude(projectRoot, options) {
|
|
|
237
294
|
}
|
|
238
295
|
return [];
|
|
239
296
|
}
|
|
297
|
+
/**
|
|
298
|
+
* Propagates skills for OpenAI Codex CLI by copying .ruler/skills to .codex/skills.
|
|
299
|
+
* Uses atomic replace to ensure safe overwriting of existing skills.
|
|
300
|
+
* Returns dry-run steps if dryRun is true, otherwise returns empty array.
|
|
301
|
+
*/
|
|
302
|
+
async function propagateSkillsForCodex(projectRoot, options) {
|
|
303
|
+
const skillsDir = path.join(projectRoot, constants_1.RULER_SKILLS_PATH);
|
|
304
|
+
const codexSkillsPath = path.join(projectRoot, constants_1.CODEX_SKILLS_PATH);
|
|
305
|
+
const codexDir = path.dirname(codexSkillsPath);
|
|
306
|
+
// Check if source skills directory exists
|
|
307
|
+
try {
|
|
308
|
+
await fs.access(skillsDir);
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// No skills directory - return empty
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
314
|
+
if (options.dryRun) {
|
|
315
|
+
return [`Copy skills from ${constants_1.RULER_SKILLS_PATH} to ${constants_1.CODEX_SKILLS_PATH}`];
|
|
316
|
+
}
|
|
317
|
+
// Ensure .codex directory exists
|
|
318
|
+
await fs.mkdir(codexDir, { recursive: true });
|
|
319
|
+
// Use atomic replace: copy to temp, then rename
|
|
320
|
+
const tempDir = path.join(codexDir, `skills.tmp-${Date.now()}`);
|
|
321
|
+
try {
|
|
322
|
+
// Copy to temp directory
|
|
323
|
+
await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
|
|
324
|
+
// Atomically replace the target
|
|
325
|
+
// First, remove existing target if it exists
|
|
326
|
+
try {
|
|
327
|
+
await fs.rm(codexSkillsPath, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// Target didn't exist, that's fine
|
|
331
|
+
}
|
|
332
|
+
// Rename temp to target
|
|
333
|
+
await fs.rename(tempDir, codexSkillsPath);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
// Clean up temp directory on error
|
|
337
|
+
try {
|
|
338
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// Ignore cleanup errors
|
|
342
|
+
}
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
return [];
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Propagates skills for OpenCode by copying .ruler/skills to .opencode/skill.
|
|
349
|
+
* Uses atomic replace to ensure safe overwriting of existing skills.
|
|
350
|
+
* Returns dry-run steps if dryRun is true, otherwise returns empty array.
|
|
351
|
+
*/
|
|
352
|
+
async function propagateSkillsForOpenCode(projectRoot, options) {
|
|
353
|
+
const skillsDir = path.join(projectRoot, constants_1.RULER_SKILLS_PATH);
|
|
354
|
+
const opencodeSkillsPath = path.join(projectRoot, constants_1.OPENCODE_SKILLS_PATH);
|
|
355
|
+
const opencodeDir = path.dirname(opencodeSkillsPath);
|
|
356
|
+
// Check if source skills directory exists
|
|
357
|
+
try {
|
|
358
|
+
await fs.access(skillsDir);
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
// No skills directory - return empty
|
|
362
|
+
return [];
|
|
363
|
+
}
|
|
364
|
+
if (options.dryRun) {
|
|
365
|
+
return [`Copy skills from ${constants_1.RULER_SKILLS_PATH} to ${constants_1.OPENCODE_SKILLS_PATH}`];
|
|
366
|
+
}
|
|
367
|
+
// Ensure .opencode directory exists
|
|
368
|
+
await fs.mkdir(opencodeDir, { recursive: true });
|
|
369
|
+
// Use atomic replace: copy to temp, then rename
|
|
370
|
+
const tempDir = path.join(opencodeDir, `skill.tmp-${Date.now()}`);
|
|
371
|
+
try {
|
|
372
|
+
// Copy to temp directory
|
|
373
|
+
await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
|
|
374
|
+
// Atomically replace the target
|
|
375
|
+
// First, remove existing target if it exists
|
|
376
|
+
try {
|
|
377
|
+
await fs.rm(opencodeSkillsPath, { recursive: true, force: true });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// Target didn't exist, that's fine
|
|
381
|
+
}
|
|
382
|
+
// Rename temp to target
|
|
383
|
+
await fs.rename(tempDir, opencodeSkillsPath);
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
// Clean up temp directory on error
|
|
387
|
+
try {
|
|
388
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// Ignore cleanup errors
|
|
392
|
+
}
|
|
393
|
+
throw error;
|
|
394
|
+
}
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Propagates skills for Goose by copying .ruler/skills to .agents/skills.
|
|
399
|
+
* Uses atomic replace to ensure safe overwriting of existing skills.
|
|
400
|
+
* Returns dry-run steps if dryRun is true, otherwise returns empty array.
|
|
401
|
+
*/
|
|
402
|
+
async function propagateSkillsForGoose(projectRoot, options) {
|
|
403
|
+
const skillsDir = path.join(projectRoot, constants_1.RULER_SKILLS_PATH);
|
|
404
|
+
const gooseSkillsPath = path.join(projectRoot, constants_1.GOOSE_SKILLS_PATH);
|
|
405
|
+
const gooseDir = path.dirname(gooseSkillsPath);
|
|
406
|
+
// Check if source skills directory exists
|
|
407
|
+
try {
|
|
408
|
+
await fs.access(skillsDir);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// No skills directory - return empty
|
|
412
|
+
return [];
|
|
413
|
+
}
|
|
414
|
+
if (options.dryRun) {
|
|
415
|
+
return [`Copy skills from ${constants_1.RULER_SKILLS_PATH} to ${constants_1.GOOSE_SKILLS_PATH}`];
|
|
416
|
+
}
|
|
417
|
+
// Ensure .agents directory exists
|
|
418
|
+
await fs.mkdir(gooseDir, { recursive: true });
|
|
419
|
+
// Use atomic replace: copy to temp, then rename
|
|
420
|
+
const tempDir = path.join(gooseDir, `skills.tmp-${Date.now()}`);
|
|
421
|
+
try {
|
|
422
|
+
// Copy to temp directory
|
|
423
|
+
await (0, SkillsUtils_1.copySkillsDirectory)(skillsDir, tempDir);
|
|
424
|
+
// Atomically replace the target
|
|
425
|
+
// First, remove existing target if it exists
|
|
426
|
+
try {
|
|
427
|
+
await fs.rm(gooseSkillsPath, { recursive: true, force: true });
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
// Target didn't exist, that's fine
|
|
431
|
+
}
|
|
432
|
+
// Rename temp to target
|
|
433
|
+
await fs.rename(tempDir, gooseSkillsPath);
|
|
434
|
+
}
|
|
435
|
+
catch (error) {
|
|
436
|
+
// Clean up temp directory on error
|
|
437
|
+
try {
|
|
438
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
// Ignore cleanup errors
|
|
442
|
+
}
|
|
443
|
+
throw error;
|
|
444
|
+
}
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
240
447
|
/**
|
|
241
448
|
* Propagates skills for MCP agents by copying .ruler/skills to .skillz.
|
|
242
449
|
* Uses atomic replace to ensure safe overwriting of existing skills.
|