diff --git a/cmd/skills.go b/cmd/skills.go new file mode 100644 index 0000000..8c3e2b6 --- /dev/null +++ b/cmd/skills.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +var skillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage Render agent skills for AI coding tools", + GroupID: GroupManagement.ID, + Long: `Install and manage Render agent skills for AI coding tools such as +Claude Code, Codex, OpenCode, and Cursor. + +Skills add deployment, debugging, and monitoring capabilities to your +AI coding assistant.`, +} + +func init() { + rootCmd.AddCommand(skillsCmd) +} diff --git a/cmd/skillsinstall.go b/cmd/skillsinstall.go new file mode 100644 index 0000000..2c63224 --- /dev/null +++ b/cmd/skillsinstall.go @@ -0,0 +1,154 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/spf13/cobra" + + "github.com/render-oss/cli/pkg/command" + "github.com/render-oss/cli/pkg/skills" + renderstyle "github.com/render-oss/cli/pkg/style" +) + +var skillsInstallCmd = &cobra.Command{ + Use: "install", + Short: "Install Render skills to AI coding tools", + Long: `Install Render agent skills from https://github.com/render-oss/skills to +detected AI coding tools. + +Supported tools: Claude Code, Codex, OpenCode, Cursor. + +Skills are installed to each tool's skills directory (e.g. ~/.cursor/skills). +Only tools that are already set up on your system are detected.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return runSkillsInstall(cmd) + }, +} + +func init() { + skillsCmd.AddCommand(skillsInstallCmd) + skillsInstallCmd.Flags().String("tool", "", "install to a specific tool only (claude, codex, opencode, cursor)") + skillsInstallCmd.Flags().Bool("dry-run", false, "show what would be installed without making changes") +} + +func runSkillsInstall(cmd *cobra.Command) error { + toolFilter, _ := cmd.Flags().GetString("tool") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + okStyle := lipgloss.NewStyle().Foreground(renderstyle.ColorOK) + infoStyle := lipgloss.NewStyle().Foreground(renderstyle.ColorInfo) + warnStyle := lipgloss.NewStyle().Foreground(renderstyle.ColorWarning) + errStyle := lipgloss.NewStyle().Foreground(renderstyle.ColorError) + + check := okStyle.Render("✓") + info := infoStyle.Render("ℹ") + warn := warnStyle.Render("⚠") + cross := errStyle.Render("✗") + + // Detect tools. + command.Println(cmd, "%s Detecting installed AI coding tools...", info) + command.Println(cmd, "") + + tools, err := skills.DetectTools() + if err != nil { + return fmt.Errorf("failed to detect tools: %w", err) + } + + if toolFilter != "" { + tools = skills.FilterTools(tools, toolFilter) + if len(tools) == 0 { + return fmt.Errorf("no installed tool matching %q found", toolFilter) + } + } + + if len(tools) == 0 { + command.Println(cmd, "%s No supported AI coding tools detected", cross) + command.Println(cmd, " Supported: Claude Code, Codex, OpenCode, Cursor") + return fmt.Errorf("no tools detected") + } + + for _, t := range tools { + command.Println(cmd, " %s Found %s: %s", check, t.Name, skills.ShortenPath(t.SkillsDir)) + } + command.Println(cmd, "") + + // Clone the skills repo. + command.Println(cmd, "%s Cloning skills repository...", info) + + tmpDir, err := os.MkdirTemp("", "render-skills-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + if err := skills.CloneSkillsRepo(tmpDir); err != nil { + return err + } + command.Println(cmd, "%s Repository cloned", check) + command.Println(cmd, "") + + if dryRun { + available := skills.ReadSkillsFromRepo(tmpDir) + command.Println(cmd, "%s Dry run: would install %d skill(s) to %d tool(s)", info, len(available), len(tools)) + command.Println(cmd, "") + printSkillList(cmd, available) + return nil + } + + // Install to each tool. + command.Println(cmd, "%s Installing skills...", info) + command.Println(cmd, "") + + successCount := 0 + var lastInstalled []skills.SkillInfo + for _, t := range tools { + installed, err := skills.InstallSkills(t.SkillsDir, tmpDir) + if err != nil { + command.Println(cmd, " %s %s: %s", cross, t.Name, err) + continue + } + command.Println(cmd, " %s Installed %d skill(s) to %s", check, len(installed), skills.ShortenPath(t.SkillsDir)) + lastInstalled = installed + successCount++ + } + + command.Println(cmd, "") + + if successCount == 0 { + return fmt.Errorf("failed to install skills to any tool") + } + + // Summary. + command.Println(cmd, "%s Skills installed successfully!", check) + command.Println(cmd, "") + printSkillList(cmd, lastInstalled) + command.Println(cmd, "%s Restart your AI coding tool to load the new skills.", warn) + + return nil +} + +func printSkillList(cmd *cobra.Command, installed []skills.SkillInfo) { + dimStyle := lipgloss.NewStyle().Foreground(renderstyle.ColorDeprioritized) + + command.Println(cmd, "Available skills:") + for _, s := range installed { + desc := firstSentence(s.Description) + command.Println(cmd, " • %s:", renderstyle.Bold(s.Name)) + if desc != "" { + command.Println(cmd, " %s", dimStyle.Render(desc)) + } + } + command.Println(cmd, "") +} + +// firstSentence returns the text up to and including the first period. +func firstSentence(s string) string { + if i := strings.Index(s, ". "); i >= 0 { + return s[:i+1] + } + return s +} diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go new file mode 100644 index 0000000..8c57f17 --- /dev/null +++ b/pkg/skills/installer.go @@ -0,0 +1,314 @@ +package skills + +import ( + "bufio" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "gopkg.in/yaml.v3" +) + +const ( + repoHTTPS = "https://github.com/render-oss/skills.git" +) + +// Tool represents an AI coding tool that supports skills. +type Tool struct { + Name string + SkillsDir string +} + +// DetectTools scans for known AI coding tool directories and returns those +// that are present on the system. If the parent config directory exists but +// the skills subdirectory does not, it is created automatically. +func DetectTools() ([]Tool, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("failed to determine home directory: %w", err) + } + + candidates := []struct { + name string + parentDir string + skillsDir string + }{ + {"Claude Code (global)", filepath.Join(home, ".claude"), filepath.Join(home, ".claude", "skills")}, + {"Codex", filepath.Join(home, ".codex"), filepath.Join(home, ".codex", "skills")}, + {"OpenCode", filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "opencode", "skills")}, + {"Cursor", filepath.Join(home, ".cursor"), filepath.Join(home, ".cursor", "skills")}, + } + + var tools []Tool + for _, c := range candidates { + if !dirExists(c.parentDir) { + continue + } + + if !dirExists(c.skillsDir) { + if err := os.MkdirAll(c.skillsDir, 0o755); err != nil { + continue + } + } + + tools = append(tools, Tool{ + Name: c.name, + SkillsDir: c.skillsDir, + }) + } + + return tools, nil +} + +// FilterTools returns only the tools whose names contain the given filter +// string (case-insensitive). Useful for the --tool flag. +func FilterTools(tools []Tool, filter string) []Tool { + filter = strings.ToLower(filter) + var filtered []Tool + for _, t := range tools { + if strings.Contains(strings.ToLower(t.Name), filter) { + filtered = append(filtered, t) + } + } + return filtered +} + +// CloneSkillsRepo performs a shallow clone of the render-oss/skills repo into +// the given directory. Requires git to be installed. +func CloneSkillsRepo(destDir string) error { + if _, err := exec.LookPath("git"); err != nil { + return fmt.Errorf("git is required but not found in PATH: %w", err) + } + + cmd := exec.Command("git", "clone", "--quiet", "--depth", "1", repoHTTPS, destDir) + + // Suppress interactive git prompts. + cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to clone skills repository: %w\n%s", err, string(output)) + } + + return nil +} + +// SkillInfo holds metadata parsed from a skill's SKILL.md frontmatter. +type SkillInfo struct { + Name string `yaml:"name"` + Description string `yaml:"description"` +} + +// InstallSkills copies skill directories from sourceDir/skills/* into toolDir. +// It removes any previously installed render skill directories first. +// Returns metadata for each installed skill. +func InstallSkills(toolDir, sourceDir string) ([]SkillInfo, error) { + skillsSrc := filepath.Join(sourceDir, "skills") + if !dirExists(skillsSrc) { + return nil, fmt.Errorf("skills directory not found in cloned repo: %s", skillsSrc) + } + + // Remove old render skill installations. + if err := removeOldSkills(toolDir); err != nil { + return nil, fmt.Errorf("failed to remove old skills: %w", err) + } + + entries, err := os.ReadDir(skillsSrc) + if err != nil { + return nil, fmt.Errorf("failed to read skills source directory: %w", err) + } + + var installed []SkillInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + name := entry.Name() + + // Skip hidden/special directories. + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + + srcPath := filepath.Join(skillsSrc, name) + skillMD := filepath.Join(srcPath, "SKILL.md") + + // Only install directories that contain a SKILL.md. + if _, err := os.Stat(skillMD); os.IsNotExist(err) { + continue + } + + destPath := filepath.Join(toolDir, name) + if err := copyDir(srcPath, destPath); err != nil { + return installed, fmt.Errorf("failed to copy skill %s: %w", name, err) + } + + info := parseSkillFrontmatter(skillMD) + if info.Name == "" { + info.Name = name + } + installed = append(installed, info) + } + + return installed, nil +} + +// ReadSkillsFromRepo reads skill metadata from a cloned repo without installing. +// Useful for dry-run previews. +func ReadSkillsFromRepo(sourceDir string) []SkillInfo { + skillsSrc := filepath.Join(sourceDir, "skills") + entries, err := os.ReadDir(skillsSrc) + if err != nil { + return nil + } + + var skills []SkillInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { + continue + } + + skillMD := filepath.Join(skillsSrc, name, "SKILL.md") + if _, err := os.Stat(skillMD); os.IsNotExist(err) { + continue + } + + info := parseSkillFrontmatter(skillMD) + if info.Name == "" { + info.Name = name + } + skills = append(skills, info) + } + return skills +} + +// parseSkillFrontmatter extracts YAML frontmatter from a SKILL.md file. +// Frontmatter is delimited by --- on its own line at the start of the file. +func parseSkillFrontmatter(path string) SkillInfo { + f, err := os.Open(path) + if err != nil { + return SkillInfo{} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + + // First line must be "---". + if !scanner.Scan() || strings.TrimSpace(scanner.Text()) != "---" { + return SkillInfo{} + } + + var sb strings.Builder + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + break + } + sb.WriteString(line) + sb.WriteByte('\n') + } + + var info SkillInfo + if err := yaml.Unmarshal([]byte(sb.String()), &info); err != nil { + return SkillInfo{} + } + return info +} + +// ShortenPath replaces the home directory prefix with ~ for display. +func ShortenPath(path string) string { + home, err := os.UserHomeDir() + if err != nil { + return path + } + if runtime.GOOS == "windows" { + return path + } + if strings.HasPrefix(path, home) { + return "~" + path[len(home):] + } + return path +} + +// removeOldSkills deletes previously installed render skill directories from +// the target tool directory. +func removeOldSkills(toolDir string) error { + entries, err := os.ReadDir(toolDir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasPrefix(name, "render-") || name == "render" { + if err := os.RemoveAll(filepath.Join(toolDir, name)); err != nil { + return err + } + } + } + return nil +} + +// copyDir recursively copies a directory tree. +func copyDir(src, dst string) error { + return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + destPath := filepath.Join(dst, rel) + + if d.IsDir() { + return os.MkdirAll(destPath, 0o755) + } + + return copyFile(path, destPath) + }) +} + +// copyFile copies a single file, preserving permissions. +func copyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + info, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} + +func dirExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.IsDir() +}