feat(agent): bundle skill creator

This commit is contained in:
ParthSareen
2026-07-16 19:00:17 -07:00
parent 0593581f97
commit 8670acf56e
6 changed files with 193 additions and 7 deletions

View File

@@ -22,6 +22,54 @@ const (
SkillsDirEnv = "OLLAMA_SKILLS"
skillFilename = "SKILL.md"
maxSkillBytes = 1 << 20
bundledSkillCreatorName = "skill-creator"
bundledSkillCreatorContent = `---
name: skill-creator
description: Create or improve reusable skills. Use when the user wants a reusable skill, asks how to author SKILL.md, or needs help installing a skill.
---
# Create a skill
Create a focused, reusable instruction package. Treat a skill as guidance for the model, not as a way to gain new permissions or bypass safety controls.
## Choose the location
Create user skills beside this one. The skill directory shown in the loaded skill context is this skill's location; its parent is the user skill root. This bundled skill normally lives at ~/.ollama/skills/skill-creator, so new user skills normally go at ~/.ollama/skills/<skill-name>/SKILL.md.
Use a project-local skill directory only when the user asks to keep the skill with that project. Do not overwrite an existing skill without the user's approval. New and changed skills are discovered when the agent starts, so tell the user to begin a new agent session afterward.
## Follow the required shape
Use the directory name as the skill name. Use lowercase letters, numbers, and single hyphens only. Keep the name short and no longer than 64 characters.
Every skill needs a SKILL.md with YAML frontmatter followed by Markdown instructions:
~~~md
---
name: release-notes
description: Draft concise release notes from completed changes. Use when the user asks for a changelog, release notes, or GitHub release copy.
---
# Draft release notes
Write the workflow here.
~~~
Require a non-empty description that says both what the skill does and when to use it. Keep the body procedural and concise. Put detailed schemas, long examples, and variant-specific guidance in references/ only when the skill needs them.
Use scripts/ for repeatable or fragile operations that benefit from deterministic execution. Use assets/ for files that belong in generated output. Do not add README files, changelogs, or setup notes that do not help the model perform the task.
## Create safely
1. Identify the repeated task, expected inputs, and useful output.
2. Choose the smallest name and description that reliably trigger the skill.
3. Create the folder and SKILL.md; add resources only when they remove real repeated work.
4. Re-read the completed file and verify its frontmatter, directory-name match, and relative resource paths.
5. Tell the user where it was created and that a new agent session will discover it.
Skills provide instructions only. They do not grant filesystem, network, shell, or approval privileges, and they do not make a tool available. Use only the tools that are actually available, follow their normal approval rules, and ask before actions that need user authorization.
`
)
var skillName = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
@@ -51,11 +99,13 @@ type Skill struct {
}
func (s Skill) Content() string {
dir := filepath.Dir(s.Path)
var b strings.Builder
fmt.Fprintf(&b, "<skill name=%q>\n%s\n", s.Name, strings.TrimSpace(s.Instructions))
if s.Path != "" {
dir := filepath.Dir(s.Path)
fmt.Fprintf(&b, "Skill directory: %s\n", dir)
b.WriteString("Relative paths in this skill are relative to the skill directory.\n")
}
if resources := s.resources(); len(resources) > 0 {
b.WriteString("<skill_resources>\n")
for _, r := range resources {
@@ -70,6 +120,9 @@ func (s Skill) Content() string {
// resources lists bundled files one level deep under scripts/, references/,
// and assets/ without reading them, so the model can load them on demand.
func (s Skill) resources() []string {
if s.Path == "" {
return nil
}
dir := filepath.Dir(s.Path)
var resources []string
for _, sub := range []string{"scripts", "references", "assets"} {
@@ -159,6 +212,14 @@ func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
return nil, err
}
catalog := &SkillCatalog{skills: make(map[string]Skill)}
bundled, err := bundledSkillCreator()
if err != nil {
return nil, err
}
catalog.skills[bundled.Name] = bundled
if err := installBundledSkillCreator(); err != nil {
catalog.diagnostics = append(catalog.diagnostics, err)
}
for _, root := range roots {
sub, err := DiscoverSkills(root.path)
if err != nil {
@@ -176,6 +237,36 @@ func LoadDefaultSkills(projectDir string) (*SkillCatalog, error) {
return catalog, nil
}
func bundledSkillCreator() (Skill, error) {
skill, err := parseSkillContent("", bundledSkillCreatorName, bundledSkillCreatorContent)
if err != nil {
return Skill{}, fmt.Errorf("load bundled %s skill: %w", bundledSkillCreatorName, err)
}
return skill, nil
}
func installBundledSkillCreator() error {
dir, err := SkillsDir()
if err != nil {
return fmt.Errorf("resolve bundled skill directory: %w", err)
}
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create bundled skill directory: %w", err)
}
contents, err := os.ReadFile(path)
if err == nil && string(contents) == bundledSkillCreatorContent {
return nil
}
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("read bundled skill: %w", err)
}
if err := os.WriteFile(path, []byte(bundledSkillCreatorContent), 0o644); err != nil {
return fmt.Errorf("write bundled skill: %w", err)
}
return nil
}
type skillRoot struct {
path string
}
@@ -284,7 +375,11 @@ func parseSkill(path, directoryName string) (Skill, error) {
if err != nil {
return Skill{}, fmt.Errorf("read skill %q: %w", directoryName, err)
}
instructions := strings.TrimSpace(string(data))
return parseSkillContent(path, directoryName, string(data))
}
func parseSkillContent(path, directoryName, input string) (Skill, error) {
instructions := strings.TrimSpace(input)
if instructions == "" {
return Skill{}, fmt.Errorf("skill %q: %s is empty", directoryName, skillFilename)
}

View File

@@ -118,6 +118,9 @@ func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
if _, err := catalog.Load("release-notes"); err != nil {
t.Fatalf("valid skill was hidden by bad root: %v", err)
}
if _, err := catalog.Load(bundledSkillCreatorName); err != nil {
t.Fatalf("bundled skill was hidden by bad root: %v", err)
}
var foundDiagnostic bool
for _, diagnostic := range catalog.Diagnostics() {
if strings.Contains(diagnostic.Error(), badRoot) {
@@ -130,6 +133,51 @@ func TestLoadDefaultSkillsContinuesAfterBadRoot(t *testing.T) {
}
}
func TestLoadDefaultSkillsInstallsBundledSkillCreator(t *testing.T) {
dir := t.TempDir()
t.Setenv(SkillsDirEnv, dir)
catalog, err := LoadDefaultSkills("")
if err != nil {
t.Fatal(err)
}
skill, err := catalog.Load(bundledSkillCreatorName)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, bundledSkillCreatorName, skillFilename)
contents, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(contents) != bundledSkillCreatorContent {
t.Fatalf("installed skill = %q, want bundled contents", contents)
}
if skill.Path != path {
t.Fatalf("skill path = %q, want %q", skill.Path, path)
}
if !strings.Contains(skill.Content(), "Skill directory: "+filepath.Dir(path)) {
t.Fatalf("skill content does not identify its directory: %q", skill.Content())
}
}
func TestLoadDefaultSkillsUpdatesExistingSkillCreator(t *testing.T) {
dir := t.TempDir()
t.Setenv(SkillsDirEnv, dir)
writeCatalogSkill(t, dir, bundledSkillCreatorName, "custom instructions")
if _, err := LoadDefaultSkills(""); err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(filepath.Join(dir, bundledSkillCreatorName, skillFilename))
if err != nil {
t.Fatal(err)
}
if string(contents) != bundledSkillCreatorContent {
t.Fatalf("installed skill = %q, want bundled contents", contents)
}
}
func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
base := t.TempDir()
@@ -153,6 +201,14 @@ func TestSkillsDirUsesOverrideAndXDG(t *testing.T) {
if got, err := SkillsDir(); err != nil || got != filepath.Join(xdg, "ollama", "skills") {
t.Fatalf("SkillsDir xdg = %q, want %q, %v", got, filepath.Join(xdg, "ollama", "skills"), err)
}
t.Setenv("XDG_CONFIG_HOME", "")
home := filepath.Join(base, "home")
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
if got, err := SkillsDir(); err != nil || got != filepath.Join(home, ".ollama", "skills") {
t.Fatalf("SkillsDir default = %q, want %q, %v", got, filepath.Join(home, ".ollama", "skills"), err)
}
}
func TestLoadDefaultSkillsPrecedenceAndCollisions(t *testing.T) {

View File

@@ -1107,10 +1107,14 @@ func (m chatModel) completions() []chatCompletion {
}
func (m chatModel) slashCompletions() []chatCompletion {
input := strings.TrimSpace(string(m.input))
rawInput := string(m.input)
input := strings.TrimSpace(rawInput)
if !strings.HasPrefix(input, "/") {
return nil
}
if m.skillSlashPromptStarted(rawInput) {
return nil
}
commands := matchingSlashCommands(input)
completions := make([]chatCompletion, 0, len(commands))
@@ -1150,6 +1154,16 @@ func (m chatModel) slashCompletions() []chatCompletion {
return completions
}
func (m chatModel) skillSlashPromptStarted(input string) bool {
input = strings.TrimLeftFunc(input, unicode.IsSpace)
end := strings.IndexFunc(input, unicode.IsSpace)
if end < 0 {
return false
}
_, _, ok := m.skillSlashInvocation(input[:end])
return ok
}
func matchingSlashCommands(input string) []chatSlashCommand {
prefix := strings.ToLower(strings.TrimSpace(input))
if prefix == "" {

View File

@@ -636,6 +636,18 @@ func TestSkillSlashCommandAppearsInCompletions(t *testing.T) {
}
}
func TestSkillSlashPromptHidesCommandCompletions(t *testing.T) {
catalog := writeTestSkillCatalog(t)
for _, input := range []string{"/release-notes ", "/release-notes draft the release notes"} {
t.Run(input, func(t *testing.T) {
m := chatModel{opts: Options{Skills: catalog}, input: []rune(input)}
if lines := m.completionLines(80); len(lines) != 0 {
t.Fatalf("completion lines = %#v, want none", lines)
}
})
}
}
func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
catalog := writeTestSkillCatalog(t)
m := &chatModel{opts: Options{Skills: catalog}}

View File

@@ -1084,9 +1084,9 @@ func toolActionPhrase(action string, count int) string {
return "Fetched a URL"
case "skill":
if plural {
return fmt.Sprintf("Ran %d skills", count)
return fmt.Sprintf("Loaded %d skills", count)
}
return "Ran a skill"
return "Loaded a skill"
default:
if plural {
return fmt.Sprintf("Used %d tools", count)

View File

@@ -1049,6 +1049,15 @@ func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) {
}
}
func TestToolActionPhraseLoadsSkills(t *testing.T) {
if got := toolActionPhrase("skill", 1); got != "Loaded a skill" {
t.Fatalf("single skill action = %q", got)
}
if got := toolActionPhrase("skill", 2); got != "Loaded 2 skills" {
t.Fatalf("multiple skill action = %q", got)
}
}
func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) {
args := api.NewToolCallFunctionArguments()
args.Set("command", "pwd")