From 805177c054663092bcd1da539f2581b72a31ad46 Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Mon, 29 Dec 2025 00:14:13 -0500 Subject: [PATCH] cmd: add skill CLI and REPL commands Add skill management commands and interactive REPL support: CLI commands (cmd/skill_cmd.go): ollama skill push NAME PATH - Push skill to registry ollama skill pull NAME - Pull skill from registry ollama skill list - List installed skills ollama skill show NAME - Show skill details ollama skill rm NAME - Remove a skill Skill loading (cmd/skills.go): - Load skills from model manifests - Parse SKILL.md frontmatter for metadata - Inject skill instructions into system prompt - Provide run_skill_script tool for script execution Interactive mode (cmd/interactive.go): /skills - Show available skills /skill add PATH - Add skill from local path /skill remove NAME - Remove skill from session /skill list - List session skills --- cmd/cmd.go | 118 +++++++++- cmd/interactive.go | 179 ++++++++++++++ cmd/skill_cmd.go | 570 ++++++++++++++++++++++++++++++++++++++++++++ cmd/skills.go | 574 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1439 insertions(+), 2 deletions(-) create mode 100644 cmd/skill_cmd.go create mode 100644 cmd/skills.go diff --git a/cmd/cmd.go b/cmd/cmd.go index 35074ad2b..4b1d2cf48 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -494,6 +494,14 @@ func RunHandler(cmd *cobra.Command, args []string) error { opts.ParentModel = info.Details.ParentModel + // Check if this is an agent + isAgent := info.AgentType != "" || len(info.Skills) > 0 + if isAgent { + opts.IsAgent = true + opts.AgentType = info.AgentType + opts.Skills = info.Skills + } + // Check if this is an embedding model isEmbeddingModel := slices.Contains(info.Capabilities, model.CapabilityEmbedding) @@ -545,6 +553,14 @@ func RunHandler(cmd *cobra.Command, args []string) error { return generateInteractive(cmd, opts) } + + // For agents, use chat API even in non-interactive mode to support tools + if opts.IsAgent { + opts.Messages = append(opts.Messages, api.Message{Role: "user", Content: opts.Prompt}) + _, err := chat(cmd, opts) + return err + } + return generate(cmd, opts) } @@ -1189,6 +1205,9 @@ type runOptions struct { Think *api.ThinkValue HideThinking bool ShowConnect bool + IsAgent bool + AgentType string + Skills []api.SkillRef } func (r runOptions) Copy() runOptions { @@ -1218,6 +1237,12 @@ func (r runOptions) Copy() runOptions { think = &cThink } + var skills []api.SkillRef + if r.Skills != nil { + skills = make([]api.SkillRef, len(r.Skills)) + copy(skills, r.Skills) + } + return runOptions{ Model: r.Model, ParentModel: r.ParentModel, @@ -1233,6 +1258,9 @@ func (r runOptions) Copy() runOptions { Think: think, HideThinking: r.HideThinking, ShowConnect: r.ShowConnect, + IsAgent: r.IsAgent, + AgentType: r.AgentType, + Skills: skills, } } @@ -1316,6 +1344,22 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) { return nil, err } + // Load skills for agents + var skillsCatalog *skillCatalog + if opts.IsAgent && len(opts.Skills) > 0 { + skillsCatalog, err = loadSkillsFromRefs(opts.Skills) + if err != nil { + return nil, fmt.Errorf("failed to load skills: %w", err) + } + if skillsCatalog != nil && len(skillsCatalog.Skills) > 0 { + var skillNames []string + for _, s := range skillsCatalog.Skills { + skillNames = append(skillNames, s.Name) + } + fmt.Fprintf(os.Stderr, "Loaded skills: %s\n", strings.Join(skillNames, ", ")) + } + } + p := progress.NewProgress(os.Stderr) defer p.StopAndClear() @@ -1339,6 +1383,7 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) { var fullResponse strings.Builder var thinkTagOpened bool = false var thinkTagClosed bool = false + var pendingToolCalls []api.ToolCall role := "assistant" @@ -1379,7 +1424,13 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) { if response.Message.ToolCalls != nil { toolCalls := response.Message.ToolCalls if len(toolCalls) > 0 { - fmt.Print(renderToolCalls(toolCalls, false)) + if skillsCatalog != nil { + // Store tool calls for execution after response is complete + pendingToolCalls = append(pendingToolCalls, toolCalls...) + } else { + // No skills catalog, just display tool calls + fmt.Print(renderToolCalls(toolCalls, false)) + } } } @@ -1392,14 +1443,37 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) { opts.Format = `"` + opts.Format + `"` } + // Prepare messages with agent-specific system prompt + messages := opts.Messages + if skillsCatalog != nil { + // Add skills system prompt as the first system message + skillsPrompt := skillsCatalog.SystemPrompt() + if skillsPrompt != "" { + // Insert skills prompt at the beginning, or append to existing system message + if len(messages) > 0 && messages[0].Role == "system" { + // Append to existing system message + messages[0].Content = messages[0].Content + "\n\n" + skillsPrompt + } else { + // Insert new system message at the beginning + systemMsg := api.Message{Role: "system", Content: skillsPrompt} + messages = append([]api.Message{systemMsg}, messages...) + } + } + } + req := &api.ChatRequest{ Model: opts.Model, - Messages: opts.Messages, + Messages: messages, Format: json.RawMessage(opts.Format), Options: opts.Options, Think: opts.Think, } + // Add tools for agents + if skillsCatalog != nil { + req.Tools = skillsCatalog.Tools() + } + if opts.KeepAlive != nil { req.KeepAlive = opts.KeepAlive } @@ -1419,6 +1493,45 @@ func chat(cmd *cobra.Command, opts runOptions) (*api.Message, error) { return nil, err } + // Execute tool calls for agents + if len(pendingToolCalls) > 0 && skillsCatalog != nil { + fmt.Fprintf(os.Stderr, "\n") + + // Execute each tool call and collect results + for _, call := range pendingToolCalls { + // Show what's being executed + switch call.Function.Name { + case "run_skill_script": + skill, _ := call.Function.Arguments["skill"].(string) + command, _ := call.Function.Arguments["command"].(string) + fmt.Fprintf(os.Stderr, "Running script in %s: %s\n", skill, command) + case "read_skill_file": + skill, _ := call.Function.Arguments["skill"].(string) + path, _ := call.Function.Arguments["path"].(string) + fmt.Fprintf(os.Stderr, "Reading file from %s: %s\n", skill, path) + default: + fmt.Fprintf(os.Stderr, "Executing: %s\n", call.Function.Name) + } + + result, handled, err := skillsCatalog.RunToolCall(call) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + continue + } + if !handled { + fmt.Fprintf(os.Stderr, "Warning: Unknown tool %s\n", call.Function.Name) + continue + } + + // Display tool output + if result.Content != "" { + fmt.Fprintf(os.Stderr, "Output:\n%s\n", result.Content) + } + } + + fmt.Fprintf(os.Stderr, "\n") + } + if len(opts.Messages) > 0 { fmt.Println() fmt.Println() @@ -1908,6 +2021,7 @@ func NewCLI() *cobra.Command { copyCmd, deleteCmd, runnerCmd, + NewSkillCommand(), ) return rootCmd diff --git a/cmd/interactive.go b/cmd/interactive.go index cf0aced14..c82adeff7 100644 --- a/cmd/interactive.go +++ b/cmd/interactive.go @@ -34,6 +34,8 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error { fmt.Fprintln(os.Stderr, "Available Commands:") fmt.Fprintln(os.Stderr, " /set Set session variables") fmt.Fprintln(os.Stderr, " /show Show model information") + fmt.Fprintln(os.Stderr, " /skills Show available skills") + fmt.Fprintln(os.Stderr, " /skill Add or remove skills dynamically") fmt.Fprintln(os.Stderr, " /load Load a session or model") fmt.Fprintln(os.Stderr, " /save Save your current session") fmt.Fprintln(os.Stderr, " /clear Clear session context") @@ -443,6 +445,177 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error { } else { usageShow() } + case strings.HasPrefix(line, "/skill "): + args := strings.Fields(line) + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "Usage:") + fmt.Fprintln(os.Stderr, " /skill add Add a skill from local path") + fmt.Fprintln(os.Stderr, " /skill remove Remove a skill by name") + fmt.Fprintln(os.Stderr, " /skill list List current skills") + continue + } + + switch args[1] { + case "add": + if len(args) < 3 { + fmt.Println("Usage: /skill add ") + continue + } + skillPath := args[2] + + // Expand ~ to home directory + if strings.HasPrefix(skillPath, "~") { + home, err := os.UserHomeDir() + if err != nil { + fmt.Printf("Error expanding path: %v\n", err) + continue + } + skillPath = filepath.Join(home, skillPath[1:]) + } + + // Make absolute + absPath, err := filepath.Abs(skillPath) + if err != nil { + fmt.Printf("Error resolving path: %v\n", err) + continue + } + + // Verify SKILL.md exists + skillMdPath := filepath.Join(absPath, "SKILL.md") + if _, err := os.Stat(skillMdPath); err != nil { + fmt.Printf("Error: %s does not contain SKILL.md\n", skillPath) + continue + } + + // Extract skill name from SKILL.md + content, err := os.ReadFile(skillMdPath) + if err != nil { + fmt.Printf("Error reading SKILL.md: %v\n", err) + continue + } + skillName, _ := extractSkillMetadata(string(content)) + if skillName == "" { + skillName = filepath.Base(absPath) + } + + // Check if already added + for _, s := range opts.Skills { + if s.Name == skillName { + fmt.Printf("Skill '%s' is already loaded\n", skillName) + continue + } + } + + // Add to skills (using path as Name, no digest for local skills) + opts.Skills = append(opts.Skills, api.SkillRef{Name: absPath}) + opts.IsAgent = true // Enable agent mode if not already + fmt.Printf("Added skill '%s' from %s\n", skillName, skillPath) + + case "remove", "rm": + if len(args) < 3 { + fmt.Println("Usage: /skill remove ") + continue + } + skillName := args[2] + + found := false + newSkills := make([]api.SkillRef, 0, len(opts.Skills)) + for _, s := range opts.Skills { + // Match by name or by path basename + name := s.Name + if strings.Contains(name, string(os.PathSeparator)) { + name = filepath.Base(name) + } + if name == skillName || s.Name == skillName { + found = true + fmt.Printf("Removed skill '%s'\n", skillName) + } else { + newSkills = append(newSkills, s) + } + } + if !found { + fmt.Printf("Skill '%s' not found\n", skillName) + } else { + opts.Skills = newSkills + } + + case "list", "ls": + if len(opts.Skills) == 0 { + fmt.Println("No skills loaded in this session.") + } else { + fmt.Println("Skills loaded in this session:") + for _, skill := range opts.Skills { + if skill.Digest != "" { + fmt.Printf(" %s (%s)\n", skill.Name, skill.Digest[:19]) + } else { + // For local paths, show basename + name := skill.Name + if strings.Contains(name, string(os.PathSeparator)) { + name = filepath.Base(name) + " (local: " + skill.Name + ")" + } + fmt.Printf(" %s\n", name) + } + } + } + fmt.Println() + + default: + fmt.Printf("Unknown skill command '%s'. Use /skill add, /skill remove, or /skill list\n", args[1]) + } + continue + + case strings.HasPrefix(line, "/skills"): + // Show skills from model (bundled) + session skills + client, err := api.ClientFromEnvironment() + if err != nil { + fmt.Println("error: couldn't connect to ollama server") + return err + } + req := &api.ShowRequest{ + Name: opts.Model, + } + resp, err := client.Show(cmd.Context(), req) + if err != nil { + fmt.Println("error: couldn't get model info") + return err + } + + // Combine model skills with session skills + allSkills := make([]api.SkillRef, 0) + allSkills = append(allSkills, resp.Skills...) + + // Add session skills that aren't already in model skills + for _, sessionSkill := range opts.Skills { + found := false + for _, modelSkill := range resp.Skills { + if modelSkill.Name == sessionSkill.Name || modelSkill.Digest == sessionSkill.Digest { + found = true + break + } + } + if !found { + allSkills = append(allSkills, sessionSkill) + } + } + + if len(allSkills) == 0 { + fmt.Println("No skills available.") + } else { + fmt.Println("Available Skills:") + for _, skill := range allSkills { + if skill.Digest != "" { + fmt.Printf(" %s (%s)\n", skill.Name, skill.Digest[:19]) + } else { + name := skill.Name + if strings.Contains(name, string(os.PathSeparator)) { + name = filepath.Base(name) + " (session)" + } + fmt.Printf(" %s\n", name) + } + } + } + fmt.Println() + continue case strings.HasPrefix(line, "/help"), strings.HasPrefix(line, "/?"): args := strings.Fields(line) if len(args) > 1 { @@ -451,6 +624,12 @@ func generateInteractive(cmd *cobra.Command, opts runOptions) error { usageSet() case "show", "/show": usageShow() + case "skill", "/skill": + fmt.Fprintln(os.Stderr, "Available Commands:") + fmt.Fprintln(os.Stderr, " /skill add Add a skill from local path") + fmt.Fprintln(os.Stderr, " /skill remove Remove a skill by name") + fmt.Fprintln(os.Stderr, " /skill list List current session skills") + fmt.Fprintln(os.Stderr, "") case "shortcut", "shortcuts": usageShortcuts() } diff --git a/cmd/skill_cmd.go b/cmd/skill_cmd.go new file mode 100644 index 000000000..faabc3b13 --- /dev/null +++ b/cmd/skill_cmd.go @@ -0,0 +1,570 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/format" + "github.com/ollama/ollama/progress" + "github.com/ollama/ollama/server" + "github.com/ollama/ollama/types/model" +) + +// SkillPushHandler handles the skill push command. +func SkillPushHandler(cmd *cobra.Command, args []string) error { + if len(args) != 2 { + return fmt.Errorf("usage: ollama skill push NAME[:TAG] PATH") + } + + name := args[0] + path := args[1] + + // Expand path + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("expanding home directory: %w", err) + } + path = filepath.Join(home, path[1:]) + } + + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("resolving path: %w", err) + } + + // Validate skill directory + skillMdPath := filepath.Join(absPath, "SKILL.md") + if _, err := os.Stat(skillMdPath); err != nil { + return fmt.Errorf("skill directory must contain SKILL.md: %w", err) + } + + // Parse skill name (will set Kind="skill") + n := server.ParseSkillName(name) + if n.Model == "" { + return fmt.Errorf("invalid skill name: %s", name) + } + + p := progress.NewProgress(os.Stderr) + defer p.Stop() + + // Create skill layer + displayName := n.DisplayShortest() + status := fmt.Sprintf("Creating skill layer for %s", displayName) + spinner := progress.NewSpinner(status) + p.Add(status, spinner) + + layer, err := server.CreateSkillLayer(absPath) + if err != nil { + return fmt.Errorf("creating skill layer: %w", err) + } + + spinner.Stop() + + // Create skill manifest + manifest, configLayer, err := createSkillManifest(absPath, layer) + if err != nil { + return fmt.Errorf("creating skill manifest: %w", err) + } + + // Write manifest locally + manifestPath, err := server.GetSkillManifestPath(n) + if err != nil { + return fmt.Errorf("getting manifest path: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + return fmt.Errorf("creating manifest directory: %w", err) + } + + manifestJSON, err := json.Marshal(manifest) + if err != nil { + return fmt.Errorf("marshaling manifest: %w", err) + } + + if err := os.WriteFile(manifestPath, manifestJSON, 0o644); err != nil { + return fmt.Errorf("writing manifest: %w", err) + } + + fmt.Fprintf(os.Stderr, "Skill %s created locally\n", displayName) + fmt.Fprintf(os.Stderr, " Config: %s (%s)\n", configLayer.Digest, format.HumanBytes(configLayer.Size)) + fmt.Fprintf(os.Stderr, " Layer: %s (%s)\n", layer.Digest, format.HumanBytes(layer.Size)) + + // Push to registry + client, err := api.ClientFromEnvironment() + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + insecure, _ := cmd.Flags().GetBool("insecure") + + // For now, we'll use the existing push mechanism + fmt.Fprintf(os.Stderr, "\nPushing to registry...\n") + + fn := func(resp api.ProgressResponse) error { + if resp.Digest != "" { + bar := progress.NewBar(resp.Status, resp.Total, resp.Completed) + p.Add(resp.Digest, bar) + } else if resp.Status != "" { + spinner := progress.NewSpinner(resp.Status) + p.Add(resp.Status, spinner) + } + return nil + } + + req := &api.PushRequest{ + Model: displayName, + Insecure: insecure, + } + + if err := client.Push(context.Background(), req, fn); err != nil { + // If push fails, still show success for local creation + fmt.Fprintf(os.Stderr, "\nNote: Local skill created but push failed: %v\n", err) + fmt.Fprintf(os.Stderr, "You can try pushing later with: ollama skill push %s\n", name) + return nil + } + + fmt.Fprintf(os.Stderr, "Successfully pushed %s\n", displayName) + return nil +} + +// SkillPullHandler handles the skill pull command. +func SkillPullHandler(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: ollama skill pull NAME[:TAG]") + } + + name := args[0] + n := server.ParseSkillName(name) + if n.Model == "" { + return fmt.Errorf("invalid skill name: %s", name) + } + + client, err := api.ClientFromEnvironment() + if err != nil { + return fmt.Errorf("creating client: %w", err) + } + + insecure, _ := cmd.Flags().GetBool("insecure") + + p := progress.NewProgress(os.Stderr) + defer p.Stop() + + fn := func(resp api.ProgressResponse) error { + if resp.Digest != "" { + bar := progress.NewBar(resp.Status, resp.Total, resp.Completed) + p.Add(resp.Digest, bar) + } else if resp.Status != "" { + spinner := progress.NewSpinner(resp.Status) + p.Add(resp.Status, spinner) + } + return nil + } + + displayName := n.DisplayShortest() + req := &api.PullRequest{ + Model: displayName, + Insecure: insecure, + } + + if err := client.Pull(context.Background(), req, fn); err != nil { + return fmt.Errorf("pulling skill: %w", err) + } + + fmt.Fprintf(os.Stderr, "Successfully pulled %s\n", displayName) + return nil +} + +// SkillListHandler handles the skill list command. +func SkillListHandler(cmd *cobra.Command, args []string) error { + skills, err := listLocalSkills() + if err != nil { + return fmt.Errorf("listing skills: %w", err) + } + + if len(skills) == 0 { + fmt.Println("No skills installed") + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tTAG\tSIZE\tMODIFIED") + + for _, skill := range skills { + fmt.Fprintf(w, "%s/%s\t%s\t%s\t%s\n", + skill.Namespace, + skill.Name, + skill.Tag, + format.HumanBytes(skill.Size), + format.HumanTime(skill.ModifiedAt, "Never"), + ) + } + + return w.Flush() +} + +// SkillRemoveHandler handles the skill rm command. +func SkillRemoveHandler(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: ollama skill rm NAME[:TAG] [NAME[:TAG]...]") + } + + for _, name := range args { + n := server.ParseSkillName(name) + if n.Model == "" { + fmt.Fprintf(os.Stderr, "Invalid skill name: %s\n", name) + continue + } + + displayName := n.DisplayShortest() + manifestPath, err := server.GetSkillManifestPath(n) + if err != nil { + fmt.Fprintf(os.Stderr, "Error getting manifest path for %s: %v\n", name, err) + continue + } + + if _, err := os.Stat(manifestPath); os.IsNotExist(err) { + fmt.Fprintf(os.Stderr, "Skill not found: %s\n", displayName) + continue + } + + if err := os.Remove(manifestPath); err != nil { + fmt.Fprintf(os.Stderr, "Error removing %s: %v\n", displayName, err) + continue + } + + // Clean up empty parent directories + dir := filepath.Dir(manifestPath) + for dir != filepath.Join(os.Getenv("HOME"), ".ollama", "models", "manifests") { + entries, _ := os.ReadDir(dir) + if len(entries) == 0 { + os.Remove(dir) + dir = filepath.Dir(dir) + } else { + break + } + } + + fmt.Fprintf(os.Stderr, "Deleted '%s'\n", displayName) + } + + return nil +} + +// SkillShowHandler handles the skill show command. +func SkillShowHandler(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return fmt.Errorf("usage: ollama skill show NAME[:TAG]") + } + + name := args[0] + n := server.ParseSkillName(name) + if n.Model == "" { + return fmt.Errorf("invalid skill name: %s", name) + } + + displayName := n.DisplayShortest() + manifestPath, err := server.GetSkillManifestPath(n) + if err != nil { + return fmt.Errorf("getting manifest path: %w", err) + } + + data, err := os.ReadFile(manifestPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("skill not found: %s", displayName) + } + return fmt.Errorf("reading manifest: %w", err) + } + + var manifest server.Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + return fmt.Errorf("parsing manifest: %w", err) + } + + fmt.Printf("Skill: %s\n\n", displayName) + + fmt.Println("Layers:") + for _, layer := range manifest.Layers { + fmt.Printf(" %s %s %s\n", layer.MediaType, layer.Digest[:19], format.HumanBytes(layer.Size)) + } + + // Try to read and display SKILL.md content + if len(manifest.Layers) > 0 { + for _, layer := range manifest.Layers { + if layer.MediaType == server.MediaTypeSkill { + skillPath, err := server.GetSkillsPath(layer.Digest) + if err == nil { + skillMdPath := filepath.Join(skillPath, "SKILL.md") + if content, err := os.ReadFile(skillMdPath); err == nil { + fmt.Println("\nContent:") + fmt.Println(string(content)) + } + } + } + } + } + + return nil +} + +// SkillInfo represents information about an installed skill. +type SkillInfo struct { + Namespace string + Name string + Tag string + Size int64 + ModifiedAt time.Time +} + +// listLocalSkills returns a list of locally installed skills. +// Skills are stored with 5-part paths: host/namespace/kind/model/tag +// where kind is "skill". +func listLocalSkills() ([]SkillInfo, error) { + manifestsPath := filepath.Join(os.Getenv("HOME"), ".ollama", "models", "manifests") + + var skills []SkillInfo + + // Walk through all registries + registries, err := os.ReadDir(manifestsPath) + if err != nil { + if os.IsNotExist(err) { + return skills, nil + } + return nil, err + } + + for _, registry := range registries { + if !registry.IsDir() { + continue + } + + // Walk namespaces + namespaces, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name())) + if err != nil { + continue + } + + for _, namespace := range namespaces { + if !namespace.IsDir() { + continue + } + + // Walk kinds looking for "skill" + kinds, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name())) + if err != nil { + continue + } + + for _, kind := range kinds { + if !kind.IsDir() { + continue + } + + // Only process skill kind + if kind.Name() != server.SkillNamespace { + continue + } + + // Walk skill names (model names) + skillNames, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name())) + if err != nil { + continue + } + + for _, skillName := range skillNames { + if !skillName.IsDir() { + continue + } + + // Walk tags + tags, err := os.ReadDir(filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name(), skillName.Name())) + if err != nil { + continue + } + + for _, tag := range tags { + manifestPath := filepath.Join(manifestsPath, registry.Name(), namespace.Name(), kind.Name(), skillName.Name(), tag.Name()) + fi, err := os.Stat(manifestPath) + if err != nil || fi.IsDir() { + continue + } + + // Read manifest to get size + data, err := os.ReadFile(manifestPath) + if err != nil { + continue + } + + var manifest server.Manifest + if err := json.Unmarshal(data, &manifest); err != nil { + continue + } + + var totalSize int64 + for _, layer := range manifest.Layers { + totalSize += layer.Size + } + + // Build display name using model.Name + n := model.Name{ + Host: registry.Name(), + Namespace: namespace.Name(), + Kind: kind.Name(), + Model: skillName.Name(), + Tag: tag.Name(), + } + + skills = append(skills, SkillInfo{ + Namespace: n.Namespace + "/" + n.Kind, + Name: n.Model, + Tag: n.Tag, + Size: totalSize, + ModifiedAt: fi.ModTime(), + }) + } + } + } + } + } + + return skills, nil +} + +// createSkillManifest creates a manifest for a standalone skill. +func createSkillManifest(skillDir string, layer server.Layer) (*server.Manifest, *server.Layer, error) { + // Read SKILL.md to extract metadata + skillMdPath := filepath.Join(skillDir, "SKILL.md") + content, err := os.ReadFile(skillMdPath) + if err != nil { + return nil, nil, fmt.Errorf("reading SKILL.md: %w", err) + } + + // Extract name and description from frontmatter + name, description := extractSkillMetadata(string(content)) + if name == "" { + return nil, nil, errors.New("skill name not found in SKILL.md frontmatter") + } + + // Create config + config := map[string]any{ + "name": name, + "description": description, + "architecture": "amd64", + "os": "linux", + } + + configJSON, err := json.Marshal(config) + if err != nil { + return nil, nil, fmt.Errorf("marshaling config: %w", err) + } + + // Create config layer + configLayer, err := server.NewLayer(strings.NewReader(string(configJSON)), "application/vnd.docker.container.image.v1+json") + if err != nil { + return nil, nil, fmt.Errorf("creating config layer: %w", err) + } + + manifest := &server.Manifest{ + SchemaVersion: 2, + MediaType: "application/vnd.docker.distribution.manifest.v2+json", + Config: configLayer, + Layers: []server.Layer{layer}, + } + + return manifest, &configLayer, nil +} + +// extractSkillMetadata extracts name and description from SKILL.md frontmatter. +func extractSkillMetadata(content string) (name, description string) { + lines := strings.Split(content, "\n") + + inFrontmatter := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == "---" { + if !inFrontmatter { + inFrontmatter = true + continue + } else { + break // End of frontmatter + } + } + + if inFrontmatter { + if strings.HasPrefix(trimmed, "name:") { + name = strings.TrimSpace(strings.TrimPrefix(trimmed, "name:")) + } else if strings.HasPrefix(trimmed, "description:") { + description = strings.TrimSpace(strings.TrimPrefix(trimmed, "description:")) + } + } + } + + return name, description +} + +// NewSkillCommand creates the skill parent command with subcommands. +func NewSkillCommand() *cobra.Command { + skillCmd := &cobra.Command{ + Use: "skill", + Short: "Manage skills", + Long: "Commands for managing agent skills (push, pull, list, rm, show)", + } + + pushCmd := &cobra.Command{ + Use: "push NAME[:TAG] PATH", + Short: "Push a skill to a registry", + Long: "Package a local skill directory and push it to a registry", + Args: cobra.ExactArgs(2), + PreRunE: checkServerHeartbeat, + RunE: SkillPushHandler, + } + pushCmd.Flags().Bool("insecure", false, "Use an insecure registry") + + pullCmd := &cobra.Command{ + Use: "pull NAME[:TAG]", + Short: "Pull a skill from a registry", + Args: cobra.ExactArgs(1), + PreRunE: checkServerHeartbeat, + RunE: SkillPullHandler, + } + pullCmd.Flags().Bool("insecure", false, "Use an insecure registry") + + listCmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List installed skills", + Args: cobra.NoArgs, + RunE: SkillListHandler, + } + + rmCmd := &cobra.Command{ + Use: "rm NAME[:TAG] [NAME[:TAG]...]", + Aliases: []string{"remove", "delete"}, + Short: "Remove a skill", + Args: cobra.MinimumNArgs(1), + RunE: SkillRemoveHandler, + } + + showCmd := &cobra.Command{ + Use: "show NAME[:TAG]", + Short: "Show skill details", + Args: cobra.ExactArgs(1), + RunE: SkillShowHandler, + } + + skillCmd.AddCommand(pushCmd, pullCmd, listCmd, rmCmd, showCmd) + + return skillCmd +} diff --git a/cmd/skills.go b/cmd/skills.go new file mode 100644 index 000000000..798f51fad --- /dev/null +++ b/cmd/skills.go @@ -0,0 +1,574 @@ +package cmd + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "time" + + "gopkg.in/yaml.v3" + + "github.com/ollama/ollama/api" + "github.com/ollama/ollama/server" +) + +const ( + skillFileName = "SKILL.md" + maxSkillDescription = 1024 + maxSkillNameLength = 64 +) + +var skillNamePattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) + +type skillMetadata struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +type skillDefinition struct { + Name string + Description string + Content string // Full SKILL.md content (without frontmatter) + Dir string + SkillPath string +} + +type skillCatalog struct { + Skills []skillDefinition + byName map[string]skillDefinition +} + +func loadSkills(paths []string) (*skillCatalog, error) { + if len(paths) == 0 { + return nil, nil + } + + var skills []skillDefinition + byName := make(map[string]skillDefinition) + for _, root := range paths { + info, err := os.Stat(root) + if err != nil { + return nil, fmt.Errorf("skills directory %q: %w", root, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("skills path %q is not a directory", root) + } + + err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + if entry.Name() != skillFileName { + return nil + } + + skillDir := filepath.Dir(path) + skill, err := parseSkillFile(path, skillDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: skipping skill at %s: %v\n", path, err) + return nil + } + + if _, exists := byName[skill.Name]; exists { + fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q at %s\n", skill.Name, path) + return nil + } + + byName[skill.Name] = skill + skills = append(skills, skill) + return nil + }) + if err != nil { + return nil, err + } + } + + if len(skills) == 0 { + return nil, nil + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].Name < skills[j].Name + }) + + return &skillCatalog{Skills: skills, byName: byName}, nil +} + +// loadSkillsFromRefs loads skills from a list of SkillRef objects. +// Skills can be referenced by: +// - Digest: loaded from the extracted skill cache (for bundled/pulled skills) +// - Name (local path): loaded from the filesystem (for development) +func loadSkillsFromRefs(refs []api.SkillRef) (*skillCatalog, error) { + if len(refs) == 0 { + return nil, nil + } + + var skills []skillDefinition + byName := make(map[string]skillDefinition) + + for _, ref := range refs { + var skillDir string + + if ref.Digest != "" { + // Load from extracted skill cache + path, err := server.GetSkillsPath(ref.Digest) + if err != nil { + return nil, fmt.Errorf("getting skill path for %s: %w", ref.Digest, err) + } + + // Check if skill is already extracted + skillMdPath := filepath.Join(path, skillFileName) + if _, err := os.Stat(skillMdPath); os.IsNotExist(err) { + // Try to extract the skill blob + path, err = server.ExtractSkillBlob(ref.Digest) + if err != nil { + return nil, fmt.Errorf("extracting skill %s: %w", ref.Digest, err) + } + } + + skillDir = path + } else if ref.Name != "" { + // Local path - resolve it + skillPath := ref.Name + if strings.HasPrefix(skillPath, "~") { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("expanding home directory: %w", err) + } + skillPath = filepath.Join(home, skillPath[1:]) + } + + absPath, err := filepath.Abs(skillPath) + if err != nil { + return nil, fmt.Errorf("resolving skill path %q: %w", ref.Name, err) + } + + // Check if this is a directory containing skills or a single skill + info, err := os.Stat(absPath) + if err != nil { + return nil, fmt.Errorf("skill path %q: %w", ref.Name, err) + } + + if info.IsDir() { + // Check if it's a skill directory (has SKILL.md) or a parent of skill directories + skillMdPath := filepath.Join(absPath, skillFileName) + if _, err := os.Stat(skillMdPath); err == nil { + // Direct skill directory + skillDir = absPath + } 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() != skillFileName { + return nil + } + + skillSubDir := filepath.Dir(path) + skill, err := parseSkillFile(path, skillSubDir) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: skipping skill at %s: %v\n", path, err) + return nil + } + + if _, exists := byName[skill.Name]; exists { + fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q at %s\n", skill.Name, path) + return nil + } + + byName[skill.Name] = skill + skills = append(skills, skill) + return nil + }) + if err != nil { + return nil, err + } + continue + } + } else { + return nil, fmt.Errorf("skill path %q is not a directory", ref.Name) + } + } else { + // Both empty - skip + continue + } + + // Parse the skill from skillDir if set + if skillDir != "" { + skillMdPath := filepath.Join(skillDir, skillFileName) + skill, err := parseSkillFile(skillMdPath, skillDir) + if err != nil { + return nil, fmt.Errorf("parsing skill at %s: %w", skillDir, err) + } + + if _, exists := byName[skill.Name]; exists { + fmt.Fprintf(os.Stderr, "Warning: duplicate skill name %q\n", skill.Name) + continue + } + + byName[skill.Name] = skill + skills = append(skills, skill) + } + } + + if len(skills) == 0 { + return nil, nil + } + + sort.Slice(skills, func(i, j int) bool { + return skills[i].Name < skills[j].Name + }) + + return &skillCatalog{Skills: skills, byName: byName}, nil +} + +func parseSkillFile(path, skillDir string) (skillDefinition, error) { + rawContent, err := os.ReadFile(path) + if err != nil { + return skillDefinition{}, err + } + + frontmatter, bodyContent, err := extractFrontmatterAndContent(string(rawContent)) + if err != nil { + return skillDefinition{}, err + } + + var meta skillMetadata + if err := yaml.Unmarshal([]byte(frontmatter), &meta); err != nil { + return skillDefinition{}, fmt.Errorf("invalid frontmatter: %w", err) + } + + if err := validateSkillMetadata(meta, skillDir); err != nil { + return skillDefinition{}, err + } + + absPath, err := filepath.Abs(path) + if err != nil { + return skillDefinition{}, err + } + absDir, err := filepath.Abs(skillDir) + if err != nil { + return skillDefinition{}, err + } + + return skillDefinition{ + Name: meta.Name, + Description: meta.Description, + Content: bodyContent, + Dir: absDir, + SkillPath: absPath, + }, nil +} + +func extractFrontmatterAndContent(content string) (frontmatter string, body string, err error) { + scanner := bufio.NewScanner(strings.NewReader(content)) + if !scanner.Scan() { + return "", "", errors.New("empty SKILL.md") + } + if strings.TrimSpace(scanner.Text()) != "---" { + return "", "", errors.New("missing YAML frontmatter") + } + + var fmLines []string + foundEnd := false + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + foundEnd = true + break + } + fmLines = append(fmLines, line) + } + if !foundEnd { + return "", "", errors.New("frontmatter not terminated") + } + + // Collect remaining content as body + var bodyLines []string + for scanner.Scan() { + bodyLines = append(bodyLines, scanner.Text()) + } + + return strings.Join(fmLines, "\n"), strings.TrimSpace(strings.Join(bodyLines, "\n")), nil +} + +func validateSkillMetadata(meta skillMetadata, skillDir string) error { + name := strings.TrimSpace(meta.Name) + description := strings.TrimSpace(meta.Description) + + switch { + case name == "": + return errors.New("missing skill name") + case len(name) > maxSkillNameLength: + return fmt.Errorf("skill name exceeds %d characters", maxSkillNameLength) + case !skillNamePattern.MatchString(name): + return fmt.Errorf("invalid skill name %q", name) + } + + if description == "" { + return errors.New("missing skill description") + } + if len(description) > maxSkillDescription { + return fmt.Errorf("skill description exceeds %d characters", maxSkillDescription) + } + + // Skip directory name check for digest-based paths (extracted from blobs) + dirName := filepath.Base(skillDir) + if !strings.HasPrefix(dirName, "sha256-") && dirName != name { + return fmt.Errorf("skill directory %q does not match name %q", dirName, name) + } + + return nil +} + +func (c *skillCatalog) SystemPrompt() string { + if c == nil || len(c.Skills) == 0 { + return "" + } + + var b strings.Builder + b.WriteString("# Skills\n\n") + b.WriteString("You have the following skills loaded. Each skill provides instructions and may include executable scripts.\n\n") + b.WriteString("## Available Tools\n\n") + b.WriteString("- `run_skill_script`: Execute a script bundled with a skill. Use this when the skill instructions tell you to run a script.\n") + b.WriteString("- `read_skill_file`: Read additional files from a skill directory.\n\n") + + for _, skill := range c.Skills { + fmt.Fprintf(&b, "## Skill: %s\n\n", skill.Name) + fmt.Fprintf(&b, "%s\n\n", skill.Content) + b.WriteString("---\n\n") + } + + return b.String() +} + +func (c *skillCatalog) Tools() api.Tools { + if c == nil || len(c.Skills) == 0 { + return nil + } + + return api.Tools{ + { + Type: "function", + Function: api.ToolFunction{ + Name: "run_skill_script", + Description: "Execute a script or command within a skill's directory. Use this to run Python scripts, shell scripts, or other executables bundled with a skill.", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Required: []string{"skill", "command"}, + Properties: map[string]api.ToolProperty{ + "skill": { + Type: api.PropertyType{"string"}, + Description: "The name of the skill containing the script", + }, + "command": { + Type: api.PropertyType{"string"}, + Description: "The command to execute (e.g., 'python scripts/calculate.py 25 4' or './scripts/run.sh')", + }, + }, + }, + }, + }, + { + Type: "function", + Function: api.ToolFunction{ + Name: "read_skill_file", + Description: "Read a file from a skill's directory. Use this to read additional documentation, reference files, or data files bundled with a skill.", + Parameters: api.ToolFunctionParameters{ + Type: "object", + Required: []string{"skill", "path"}, + Properties: map[string]api.ToolProperty{ + "skill": { + Type: api.PropertyType{"string"}, + Description: "The name of the skill containing the file", + }, + "path": { + Type: api.PropertyType{"string"}, + Description: "The relative path to the file within the skill directory", + }, + }, + }, + }, + }, + } +} + +func (c *skillCatalog) RunToolCall(call api.ToolCall) (api.Message, bool, error) { + switch call.Function.Name { + case "read_skill_file": + skillName, err := requireStringArg(call.Function.Arguments, "skill") + if err != nil { + return toolMessage(call, err.Error()), true, nil + } + relPath, err := requireStringArg(call.Function.Arguments, "path") + if err != nil { + return toolMessage(call, err.Error()), true, nil + } + skill, ok := c.byName[skillName] + if !ok { + return toolMessage(call, fmt.Sprintf("unknown skill %q", skillName)), true, nil + } + content, err := readSkillFile(skill.Dir, relPath) + if err != nil { + return toolMessage(call, err.Error()), true, nil + } + return toolMessage(call, content), true, nil + + case "run_skill_script": + skillName, err := requireStringArg(call.Function.Arguments, "skill") + if err != nil { + return toolMessage(call, err.Error()), true, nil + } + command, err := requireStringArg(call.Function.Arguments, "command") + if err != nil { + return toolMessage(call, err.Error()), true, nil + } + skill, ok := c.byName[skillName] + if !ok { + return toolMessage(call, fmt.Sprintf("unknown skill %q", skillName)), true, nil + } + output, err := runSkillScript(skill.Dir, command) + if err != nil { + return toolMessage(call, fmt.Sprintf("error: %v\noutput: %s", err, output)), true, nil + } + return toolMessage(call, output), true, nil + + default: + return api.Message{}, false, nil + } +} + +// runSkillScript executes a shell command within a skill's directory. +// +// SECURITY LIMITATIONS (TODO): +// - No sandboxing: commands run with full user permissions +// - No path validation: model can run any command, not just scripts in skill dir +// - Shell injection risk: sh -c is used, malicious input could be crafted +// - No executable allowlist: any program can be called (curl, rm, etc.) +// - No environment isolation: scripts inherit full environment variables +// +// POTENTIAL IMPROVEMENTS: +// - Restrict commands to only reference files within skill directory +// - Allowlist specific executables (python3, node, bash) +// - Use sandboxing (Docker, nsjail, seccomp) +// - Require explicit script registration in SKILL.md frontmatter +// - Add per-skill configurable timeouts +func runSkillScript(skillDir, command string) (string, error) { + // Validate the skill directory exists + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return "", err + } + if _, err := os.Stat(absSkillDir); err != nil { + return "", fmt.Errorf("skill directory not found: %w", err) + } + + // Create command with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + cmd.Dir = absSkillDir + + // Capture both stdout and stderr + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err = cmd.Run() + + // Combine output + output := stdout.String() + if stderr.Len() > 0 { + if output != "" { + output += "\n" + } + output += stderr.String() + } + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return output, fmt.Errorf("command timed out after 30 seconds") + } + return output, err + } + + return output, nil +} + +func readSkillFile(skillDir, relPath string) (string, error) { + relPath = filepath.Clean(strings.TrimSpace(relPath)) + if relPath == "" { + return "", errors.New("path is required") + } + if filepath.IsAbs(relPath) { + return "", errors.New("path must be relative to the skill directory") + } + + target := filepath.Join(skillDir, relPath) + absTarget, err := filepath.Abs(target) + if err != nil { + return "", err + } + absSkillDir, err := filepath.Abs(skillDir) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absSkillDir, absTarget) + if err != nil { + return "", err + } + if strings.HasPrefix(rel, "..") { + return "", errors.New("path escapes the skill directory") + } + + content, err := os.ReadFile(absTarget) + if err != nil { + return "", fmt.Errorf("failed to read %q: %w", relPath, err) + } + + return string(content), nil +} + +func requireStringArg(args api.ToolCallFunctionArguments, name string) (string, error) { + value, ok := args[name] + if !ok { + return "", fmt.Errorf("missing required argument %q", name) + } + str, ok := value.(string) + if !ok { + return "", fmt.Errorf("argument %q must be a string", name) + } + if strings.TrimSpace(str) == "" { + return "", fmt.Errorf("argument %q cannot be empty", name) + } + return str, nil +} + +func toolMessage(call api.ToolCall, content string) api.Message { + msg := api.Message{ + Role: "tool", + Content: content, + ToolName: call.Function.Name, + } + if call.ID != "" { + msg.ToolCallID = call.ID + } + return msg +}