Skip to content

Commit 00ebd55

Browse files
authored
fix: improve creation of global skills (#1988)
1 parent 7e3ff86 commit 00ebd55

File tree

3 files changed

+130
-12
lines changed

3 files changed

+130
-12
lines changed

crates/forge_domain/src/env.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,16 @@ impl Environment {
131131
self.base_path.join("cache")
132132
}
133133

134+
/// Returns the global skills directory path (~/forge/skills)
135+
pub fn global_skills_path(&self) -> PathBuf {
136+
self.base_path.join("skills")
137+
}
138+
139+
/// Returns the project-local skills directory path (.forge/skills)
140+
pub fn local_skills_path(&self) -> PathBuf {
141+
self.cwd.join(".forge/skills")
142+
}
143+
134144
pub fn workspace_id(&self) -> WorkspaceId {
135145
let mut hasher = DefaultHasher::default();
136146
self.cwd.hash(&mut hasher);
@@ -190,6 +200,51 @@ mod tests {
190200
// Verify they are different paths
191201
assert_ne!(agent_path, agent_cwd_path);
192202
}
203+
204+
#[test]
205+
fn test_global_skills_path() {
206+
let fixture: Environment = Faker.fake();
207+
let fixture = fixture.base_path(PathBuf::from("/home/user/.forge"));
208+
209+
let actual = fixture.global_skills_path();
210+
let expected = PathBuf::from("/home/user/.forge/skills");
211+
212+
assert_eq!(actual, expected);
213+
}
214+
215+
#[test]
216+
fn test_local_skills_path() {
217+
let fixture: Environment = Faker.fake();
218+
let fixture = fixture.cwd(PathBuf::from("/projects/my-app"));
219+
220+
let actual = fixture.local_skills_path();
221+
let expected = PathBuf::from("/projects/my-app/.forge/skills");
222+
223+
assert_eq!(actual, expected);
224+
}
225+
226+
#[test]
227+
fn test_skills_paths_independent() {
228+
let fixture: Environment = Faker.fake();
229+
let fixture = fixture
230+
.cwd(PathBuf::from("/projects/my-app"))
231+
.base_path(PathBuf::from("/home/user/.forge"));
232+
233+
let global_path = fixture.global_skills_path();
234+
let local_path = fixture.local_skills_path();
235+
236+
let expected_global = PathBuf::from("/home/user/.forge/skills");
237+
let expected_local = PathBuf::from("/projects/my-app/.forge/skills");
238+
239+
// Verify global path uses base_path
240+
assert_eq!(global_path, expected_global);
241+
242+
// Verify local path uses cwd
243+
assert_eq!(local_path, expected_local);
244+
245+
// Verify they are different paths
246+
assert_ne!(global_path, local_path);
247+
}
193248
}
194249

195250
#[test]

crates/forge_repo/src/skill_repository.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ use std::sync::Arc;
22

33
use anyhow::Context;
44
use forge_app::domain::Skill;
5-
use forge_app::{EnvironmentInfra, FileInfoInfra, FileReaderInfra, Walker, WalkerInfra};
5+
use forge_app::{
6+
EnvironmentInfra, FileInfoInfra, FileReaderInfra, TemplateEngine, Walker, WalkerInfra,
7+
};
68
use forge_domain::SkillRepository;
79
use futures::future::join_all;
810
use gray_matter::engine::YAML;
@@ -11,7 +13,7 @@ use serde::Deserialize;
1113

1214
/// Repository implementation for loading skills from multiple sources:
1315
/// 1. Built-in skills (embedded in the application)
14-
/// 2. Global custom skills (from ~/.forge/skills/ directory)
16+
/// 2. Global custom skills (from ~/forge/skills/ directory)
1517
/// 3. Project-local skills (from .forge/skills/ directory in current working
1618
/// directory)
1719
///
@@ -24,7 +26,7 @@ use serde::Deserialize;
2426
///
2527
/// ## Directory Resolution
2628
/// - **Built-in skills**: Embedded in application binary
27-
/// - **Global skills**: `{HOME}/.forge/skills/<skill-name>/SKILL.md`
29+
/// - **Global skills**: `~/forge/skills/<skill-name>/SKILL.md`
2830
/// - **CWD skills**: `./.forge/skills/<skill-name>/SKILL.md` (relative to
2931
/// current working directory)
3032
///
@@ -76,17 +78,25 @@ impl<I: FileInfoInfra + EnvironmentInfra + FileReaderInfra + WalkerInfra> SkillR
7678
skills.extend(builtin_skills);
7779

7880
// Load global skills
79-
let global_dir = env.base_path.join("skills");
81+
let global_dir = env.global_skills_path();
8082
let global_skills = self.load_skills_from_dir(&global_dir).await?;
8183
skills.extend(global_skills);
8284

8385
// Load project-local skills
84-
let cwd_dir = env.cwd.join(".forge/skills");
86+
let cwd_dir = env.local_skills_path();
8587
let cwd_skills = self.load_skills_from_dir(&cwd_dir).await?;
8688
skills.extend(cwd_skills);
8789

8890
// Resolve conflicts by keeping the last occurrence (CWD > Global > Built-in)
89-
Ok(resolve_skill_conflicts(skills))
91+
let skills = resolve_skill_conflicts(skills);
92+
93+
// Render all skills with environment context
94+
let rendered_skills = skills
95+
.into_iter()
96+
.map(|skill| self.render_skill(skill, &env))
97+
.collect::<anyhow::Result<Vec<_>>>()?;
98+
99+
Ok(rendered_skills)
90100
}
91101
}
92102

@@ -200,6 +210,26 @@ impl<I: FileInfoInfra + EnvironmentInfra + FileReaderInfra + WalkerInfra> ForgeS
200210

201211
Ok(skills)
202212
}
213+
214+
/// Renders a skill's command field with environment context
215+
///
216+
/// # Arguments
217+
/// * `skill` - The skill to render
218+
/// * `env` - The environment containing path information
219+
///
220+
/// # Errors
221+
/// Returns an error if template rendering fails
222+
fn render_skill(&self, skill: Skill, env: &forge_domain::Environment) -> anyhow::Result<Skill> {
223+
let skill_context = serde_json::json!({
224+
"global_skills_path": env.global_skills_path().display().to_string(),
225+
"local_skills_path": env.local_skills_path().display().to_string(),
226+
});
227+
228+
let rendered_command = TemplateEngine::default()
229+
.render_template(forge_domain::Template::new(&skill.command), &skill_context)?;
230+
231+
Ok(skill.command(rendered_command))
232+
}
203233
}
204234

205235
/// Private type for parsing skill YAML front matter

crates/forge_repo/src/skills/create-skill/SKILL.md

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,18 +266,51 @@ Skip this step only if the skill being developed already exists, and iteration o
266266

267267
Verb-first naming makes it immediately clear what the skill does and follows the imperative/infinitive form used throughout the skill instructions.
268268

269-
When creating a new skill from scratch, create the skill directory structure in `.forge/skills/<skill-name>/` from the current working directory. The structure should include:
269+
#### Skill Location
270+
271+
**Default: Create project-local skills unless the user explicitly requests a global skill.**
272+
273+
When creating a new skill from scratch, create it in the project-local skills directory:
274+
275+
- **Project-local (default)**: `{{local_skills_path}}/<skill-name>/` - Skills are project-specific and don't affect other projects. This is the safe default for most skills.
276+
- **Global (only when explicitly requested)**: `{{global_skills_path}}/<skill-name>/` - Skills are available across all projects. Only use this location when the user explicitly asks for a global skill or when the skill is truly reusable across all projects.
277+
278+
The structure should include:
270279

271280
- `SKILL.md` with YAML frontmatter (name, description) and markdown body
272281
- `scripts/` directory for executable code (e.g., `process.sh`, `validate.sh`)
273282
- `references/` directory for documentation loaded as needed
274283
- `assets/` directory for files used in output
275284

276-
Example initialization:
285+
Example initialization (default project-local):
286+
287+
```bash
288+
mkdir -p {{local_skills_path}}/edit-pdf/{scripts,references,assets}
289+
cat > {{local_skills_path}}/edit-pdf/SKILL.md << 'EOF'
290+
---
291+
name: edit-pdf
292+
description: TODO - Describe what this skill does and when to use it
293+
---
294+
295+
# Edit PDF
296+
297+
TODO - Add skill instructions here
298+
EOF
299+
300+
cat > {{local_skills_path}}/edit-pdf/scripts/rotate.sh << 'EOF'
301+
#!/bin/bash
302+
# Rotate PDF using pdftk
303+
pdftk "$1" cat 1-endright output "$2"
304+
EOF
305+
306+
chmod +x {{local_skills_path}}/edit-pdf/scripts/rotate.sh
307+
```
308+
309+
If the user explicitly requests a global skill, use this instead:
277310

278311
```bash
279-
mkdir -p .forge/skills/edit-pdf/{scripts,references,assets}
280-
cat > .forge/skills/edit-pdf/SKILL.md << 'EOF'
312+
mkdir -p {{global_skills_path}}/edit-pdf/{scripts,references,assets}
313+
cat > {{global_skills_path}}/edit-pdf/SKILL.md << 'EOF'
281314
---
282315
name: edit-pdf
283316
description: TODO - Describe what this skill does and when to use it
@@ -288,13 +321,13 @@ description: TODO - Describe what this skill does and when to use it
288321
TODO - Add skill instructions here
289322
EOF
290323

291-
cat > .forge/skills/edit-pdf/scripts/rotate.sh << 'EOF'
324+
cat > {{global_skills_path}}/edit-pdf/scripts/rotate.sh << 'EOF'
292325
#!/bin/bash
293326
# Rotate PDF using pdftk
294327
pdftk "$1" cat 1-endright output "$2"
295328
EOF
296329

297-
chmod +x .forge/skills/edit-pdf/scripts/rotate.sh
330+
chmod +x {{global_skills_path}}/edit-pdf/scripts/rotate.sh
298331
```
299332

300333
After initialization, customize the generated files as needed.

0 commit comments

Comments
 (0)