diff --git a/server/create.go b/server/create.go index 15e364e1e..5ebc06d07 100644 --- a/server/create.go +++ b/server/create.go @@ -62,6 +62,8 @@ func (s *Server) CreateHandler(c *gin.Context) { config.Renderer = r.Renderer config.Parser = r.Parser config.Requires = r.Requires + config.Skills = r.Skills + config.AgentType = r.AgentType for v := range r.Files { if !fs.ValidPath(v) { @@ -543,6 +545,12 @@ func createModel(r api.CreateRequest, name model.Name, baseLayers []*layerGGML, return err } + // Handle skill layers for agents + layers, config.Skills, err = setSkillLayers(layers, config.Skills, fn) + if err != nil { + return err + } + configLayer, err := createConfigLayer(layers, *config) if err != nil { return err @@ -793,6 +801,99 @@ func setMessages(layers []Layer, m []api.Message) ([]Layer, error) { return layers, nil } +// setSkillLayers creates skill layers for local skill paths and updates the skill refs. +// Local paths are converted to bundled skill layers with digests. +// Registry references are kept as-is for later resolution during pull. +func setSkillLayers(layers []Layer, skills []model.SkillRef, fn func(resp api.ProgressResponse)) ([]Layer, []model.SkillRef, error) { + if len(skills) == 0 { + return layers, skills, nil + } + + // Remove any existing skill layers + layers = removeLayer(layers, MediaTypeSkill) + + var updatedSkills []model.SkillRef + + for _, skill := range skills { + // Check if this is a local path + if IsLocalSkillPath(skill.Name) { + // Expand home directory if needed + skillPath := skill.Name + if strings.HasPrefix(skillPath, "~") { + home, err := os.UserHomeDir() + if err != nil { + return nil, nil, fmt.Errorf("expanding home directory: %w", err) + } + skillPath = filepath.Join(home, skillPath[1:]) + } + + // Make absolute + absPath, err := filepath.Abs(skillPath) + if err != nil { + return nil, nil, fmt.Errorf("resolving skill path %q: %w", skill.Name, err) + } + + // Check if this is a direct skill directory or a parent containing skills + skillMdPath := filepath.Join(absPath, "SKILL.md") + if _, err := os.Stat(skillMdPath); err == nil { + // Direct skill directory + fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", filepath.Base(absPath))}) + + layer, err := CreateSkillLayer(absPath) + if err != nil { + return nil, nil, fmt.Errorf("creating skill layer for %q: %w", skill.Name, err) + } + + layers = append(layers, layer) + updatedSkills = append(updatedSkills, model.SkillRef{ + Name: filepath.Base(absPath), + Digest: layer.Digest, + }) + } else { + // Parent directory - walk to find skill subdirectories + err := filepath.WalkDir(absPath, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if entry.Name() != "SKILL.md" { + return nil + } + + skillDir := filepath.Dir(path) + skillName := filepath.Base(skillDir) + fn(api.ProgressResponse{Status: fmt.Sprintf("packaging skill: %s", skillName)}) + + layer, err := CreateSkillLayer(skillDir) + if err != nil { + return fmt.Errorf("creating skill layer for %q: %w", skillDir, err) + } + + layers = append(layers, layer) + updatedSkills = append(updatedSkills, model.SkillRef{ + Name: skillName, + Digest: layer.Digest, + }) + return nil + }) + if err != nil { + return nil, nil, fmt.Errorf("walking skill directory %q: %w", skill.Name, err) + } + } + } else if skill.Digest != "" { + // Already has a digest (from a pulled agent), keep as-is + updatedSkills = append(updatedSkills, skill) + } else { + // Registry reference - keep as-is for later resolution + updatedSkills = append(updatedSkills, skill) + } + } + + return layers, updatedSkills, nil +} + func createConfigLayer(layers []Layer, config model.ConfigV2) (*Layer, error) { digests := make([]string, len(layers)) for i, layer := range layers { diff --git a/server/images.go b/server/images.go index 951f7ac6e..9e9114d72 100644 --- a/server/images.go +++ b/server/images.go @@ -657,6 +657,16 @@ func PullModel(ctx context.Context, name string, regOpts *registryOptions, fn fu } } + // Extract skill layers to the skills cache + for _, layer := range manifest.Layers { + if layer.MediaType == MediaTypeSkill { + fn(api.ProgressResponse{Status: fmt.Sprintf("extracting skill %s", layer.Digest)}) + if _, err := ExtractSkillBlob(layer.Digest); err != nil { + return fmt.Errorf("extracting skill layer %s: %w", layer.Digest, err) + } + } + } + fn(api.ProgressResponse{Status: "writing manifest"}) manifestJSON, err := json.Marshal(manifest) diff --git a/server/routes.go b/server/routes.go index b19a40fbc..2bac04a05 100644 --- a/server/routes.go +++ b/server/routes.go @@ -1107,6 +1107,8 @@ func GetModelInfo(req api.ShowRequest) (*api.ShowResponse, error) { Capabilities: m.Capabilities(), ModifiedAt: manifest.fi.ModTime(), Requires: m.Config.Requires, + Skills: m.Config.Skills, + AgentType: m.Config.AgentType, } if m.Config.RemoteHost != "" { diff --git a/server/skill.go b/server/skill.go new file mode 100644 index 000000000..0e42fabf6 --- /dev/null +++ b/server/skill.go @@ -0,0 +1,325 @@ +package server + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/ollama/ollama/envconfig" + "github.com/ollama/ollama/types/model" +) + +// MediaTypeSkill is the media type for skill layers in manifests. +const MediaTypeSkill = "application/vnd.ollama.image.skill" + +// GetSkillsPath returns the path to the extracted skills cache directory. +// If digest is empty, returns the skills directory itself. +// If digest is provided, returns the path to the extracted skill for that digest. +func GetSkillsPath(digest string) (string, error) { + // only accept actual sha256 digests + pattern := "^sha256[:-][0-9a-fA-F]{64}$" + re := regexp.MustCompile(pattern) + + if digest != "" && !re.MatchString(digest) { + return "", ErrInvalidDigestFormat + } + + digest = strings.ReplaceAll(digest, ":", "-") + path := filepath.Join(envconfig.Models(), "skills", digest) + dirPath := filepath.Dir(path) + if digest == "" { + dirPath = path + } + + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return "", fmt.Errorf("%w: ensure path elements are traversable", err) + } + + return path, nil +} + +// ExtractSkillBlob extracts a skill tar.gz blob to the skills cache. +// The blob is expected to be at the blobs path for the given digest. +// Returns the path to the extracted skill directory. +func ExtractSkillBlob(digest string) (string, error) { + // Get the blob path + blobPath, err := GetBlobsPath(digest) + if err != nil { + return "", fmt.Errorf("getting blob path: %w", err) + } + + // Get the extraction path + skillPath, err := GetSkillsPath(digest) + if err != nil { + return "", fmt.Errorf("getting skill path: %w", err) + } + + // Check if already extracted + if _, err := os.Stat(filepath.Join(skillPath, "SKILL.md")); err == nil { + return skillPath, nil + } + + // Open the blob + f, err := os.Open(blobPath) + if err != nil { + return "", fmt.Errorf("opening blob: %w", err) + } + defer f.Close() + + // Create gzip reader + gzr, err := gzip.NewReader(f) + if err != nil { + return "", fmt.Errorf("creating gzip reader: %w", err) + } + defer gzr.Close() + + // Create tar reader + tr := tar.NewReader(gzr) + + // Create the skill directory + if err := os.MkdirAll(skillPath, 0o755); err != nil { + return "", fmt.Errorf("creating skill directory: %w", err) + } + + // Extract files + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return "", fmt.Errorf("reading tar: %w", err) + } + + // Clean the name and ensure it doesn't escape the target directory + name := filepath.Clean(header.Name) + if strings.HasPrefix(name, "..") { + return "", fmt.Errorf("invalid path in archive: %s", header.Name) + } + + target := filepath.Join(skillPath, name) + + // Verify the target is within skillPath + if !strings.HasPrefix(target, filepath.Clean(skillPath)+string(os.PathSeparator)) && target != filepath.Clean(skillPath) { + return "", fmt.Errorf("path escapes skill directory: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return "", fmt.Errorf("creating directory: %w", err) + } + case tar.TypeReg: + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return "", fmt.Errorf("creating parent directory: %w", err) + } + + outFile, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode)) + if err != nil { + return "", fmt.Errorf("creating file: %w", err) + } + + if _, err := io.Copy(outFile, tr); err != nil { + outFile.Close() + return "", fmt.Errorf("writing file: %w", err) + } + outFile.Close() + } + } + + return skillPath, nil +} + +// CreateSkillLayer creates a skill layer from a local directory. +// The directory must contain a SKILL.md file. +// Returns the created layer. +func CreateSkillLayer(skillDir string) (Layer, error) { + // Verify SKILL.md exists + skillMdPath := filepath.Join(skillDir, "SKILL.md") + if _, err := os.Stat(skillMdPath); err != nil { + return Layer{}, fmt.Errorf("skill directory must contain SKILL.md: %w", err) + } + + // Create a temporary file for the tar.gz + blobsPath, err := GetBlobsPath("") + if err != nil { + return Layer{}, fmt.Errorf("getting blobs path: %w", err) + } + + tmpFile, err := os.CreateTemp(blobsPath, "skill-*.tar.gz") + if err != nil { + return Layer{}, fmt.Errorf("creating temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer func() { + tmpFile.Close() + os.Remove(tmpPath) + }() + + // Create gzip writer + gzw := gzip.NewWriter(tmpFile) + defer gzw.Close() + + // Create tar writer + tw := tar.NewWriter(gzw) + defer tw.Close() + + // Walk the skill directory and add files to tar + err = filepath.Walk(skillDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Get relative path + relPath, err := filepath.Rel(skillDir, path) + if err != nil { + return err + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + + // Write file contents if it's a regular file + if !info.IsDir() { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + if _, err := io.Copy(tw, f); err != nil { + return err + } + } + + return nil + }) + if err != nil { + return Layer{}, fmt.Errorf("creating tar archive: %w", err) + } + + // Close writers to flush + if err := tw.Close(); err != nil { + return Layer{}, fmt.Errorf("closing tar writer: %w", err) + } + if err := gzw.Close(); err != nil { + return Layer{}, fmt.Errorf("closing gzip writer: %w", err) + } + if err := tmpFile.Close(); err != nil { + return Layer{}, fmt.Errorf("closing temp file: %w", err) + } + + // Open the temp file for reading + tmpFile, err = os.Open(tmpPath) + if err != nil { + return Layer{}, fmt.Errorf("reopening temp file: %w", err) + } + defer tmpFile.Close() + + // Create the layer (this will compute the digest and move to blobs) + layer, err := NewLayer(tmpFile, MediaTypeSkill) + if err != nil { + return Layer{}, fmt.Errorf("creating layer: %w", err) + } + + // Extract the skill to the cache so it's ready to use + if _, err := ExtractSkillBlob(layer.Digest); err != nil { + return Layer{}, fmt.Errorf("extracting skill: %w", err) + } + + return layer, nil +} + +// IsLocalSkillPath checks if a skill reference looks like a local path. +func IsLocalSkillPath(name string) bool { + // Local paths start with /, ./, or contain path separators + return strings.HasPrefix(name, "/") || + strings.HasPrefix(name, "./") || + strings.HasPrefix(name, "../") || + strings.HasPrefix(name, "~") || + strings.Contains(name, string(os.PathSeparator)) +} + +// SkillNamespace is the namespace used for standalone skills in the registry. +const SkillNamespace = "skill" + +// IsSkillReference checks if a name refers to a skill (has skill/ prefix). +func IsSkillReference(name string) bool { + // Check for skill/ prefix (handles both "skill/foo" and "registry/skill/foo") + name = strings.ReplaceAll(name, string(os.PathSeparator), "/") + parts := strings.Split(name, "/") + + // skill/name or skill/name:tag + if len(parts) >= 1 && parts[0] == SkillNamespace { + return true + } + // namespace/skill/name (e.g., myuser/skill/calc) - not a skill ref + // registry/skill/name (e.g., registry.ollama.ai/skill/calc) + if len(parts) >= 2 && parts[1] == SkillNamespace { + return true + } + return false +} + +// ParseSkillName parses a skill reference string into a model.Name. +// The Kind field is set to "skill". +// Examples: +// - "calculator" -> library/skill/calculator:latest +// - "myname/calculator" -> myname/skill/calculator:latest +// - "myname/skill/calculator:1.0.0" -> myname/skill/calculator:1.0.0 +func ParseSkillName(name string) model.Name { + // Use the standard parser which now handles Kind + n := model.ParseName(name) + + // If Kind wasn't set (old format without skill/), set it + if n.Kind == "" { + n.Kind = SkillNamespace + } + + return n +} + +// SkillDisplayName returns a user-friendly display name for a skill. +func SkillDisplayName(n model.Name) string { + return n.DisplayShortest() +} + +// GetSkillManifestPath returns the path to the skill manifest file. +// Uses the 5-part structure: host/namespace/kind/model/tag +func GetSkillManifestPath(n model.Name) (string, error) { + if n.Model == "" { + return "", fmt.Errorf("skill name is required") + } + + // Ensure Kind is set + if n.Kind == "" { + n.Kind = SkillNamespace + } + + path := filepath.Join( + envconfig.Models(), + "manifests", + n.Filepath(), + ) + + return path, nil +}