68d8bf2f7d
Hop-State: A_06FN69X2VW0BAXG4A4DT6V0 Hop-Proposal: R_06FN69VET1STZNKPWK913K8 Hop-Task: T_06FN637799RW5Q7WH9H6PJR Hop-Attempt: AT_06FN63779BJRBK1VFQ94GRR
277 lines
7.9 KiB
Go
277 lines
7.9 KiB
Go
package hop
|
|
|
|
import (
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
hopskill "githop.xyz/GnosysLabs/Hop/skills/hop"
|
|
)
|
|
|
|
type SkillInstallResult struct {
|
|
Path string `json:"path"`
|
|
Paths []string `json:"paths,omitempty"`
|
|
Files []string `json:"files"`
|
|
}
|
|
|
|
func DefaultSkillBase() (string, error) {
|
|
home := os.Getenv("CODEX_HOME")
|
|
if home == "" {
|
|
userHome, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("find home directory: %w", err)
|
|
}
|
|
home = filepath.Join(userHome, ".codex")
|
|
}
|
|
return filepath.Join(home, "skills"), nil
|
|
}
|
|
|
|
func DefaultSharedSkillBase() (string, error) {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("find home directory: %w", err)
|
|
}
|
|
return filepath.Join(home, ".agents", "skills"), nil
|
|
}
|
|
|
|
func DefaultSkillBases() ([]string, error) {
|
|
codex, err := DefaultSkillBase()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
shared, err := DefaultSharedSkillBase()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []string{codex, shared}, nil
|
|
}
|
|
|
|
func EmbeddedSkillText() (string, error) {
|
|
contents, err := hopskill.Files.ReadFile("SKILL.md")
|
|
if err != nil {
|
|
return "", fmt.Errorf("read embedded Hop skill: %w", err)
|
|
}
|
|
return string(contents), nil
|
|
}
|
|
|
|
// InstallSkill writes the embedded skill below one skills directory. Existing
|
|
// bundles require force; known files are overwritten without deleting unknown
|
|
// user files from the destination. An empty base retains the legacy Codex
|
|
// destination; the CLI uses InstallDefaultSkills for its no-path behavior.
|
|
func InstallSkill(base string, force bool) (SkillInstallResult, error) {
|
|
if strings.TrimSpace(base) == "" {
|
|
var err error
|
|
base, err = DefaultSkillBase()
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
}
|
|
target, err := resolveSkillTarget(base)
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
if err := preflightSkillTarget(target, force); err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
return writeSkillTarget(target)
|
|
}
|
|
|
|
// InstallDefaultSkills installs the same embedded files in the client-specific
|
|
// Codex directory and the cross-client .agents directory. Destinations are
|
|
// canonicalized and preflighted before any write to avoid partial upgrades when
|
|
// one existing bundle requires --force.
|
|
func InstallDefaultSkills(force bool) (SkillInstallResult, error) {
|
|
bases, err := DefaultSkillBases()
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
var targets []string
|
|
seen := make(map[string]struct{}, len(bases))
|
|
for _, base := range bases {
|
|
target, err := resolveSkillTarget(base)
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
key, err := canonicalPathKey(target)
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
targets = append(targets, target)
|
|
}
|
|
for _, target := range targets {
|
|
if err := preflightSkillTarget(target, force); err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
}
|
|
var combined SkillInstallResult
|
|
for _, target := range targets {
|
|
installed, err := writeSkillTarget(target)
|
|
if err != nil {
|
|
return SkillInstallResult{}, err
|
|
}
|
|
if combined.Path == "" {
|
|
combined.Path = installed.Path
|
|
combined.Files = installed.Files
|
|
}
|
|
combined.Paths = append(combined.Paths, installed.Path)
|
|
}
|
|
return combined, nil
|
|
}
|
|
|
|
func resolveSkillTarget(base string) (string, error) {
|
|
absBase, err := filepath.Abs(base)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve skills directory: %w", err)
|
|
}
|
|
return filepath.Join(absBase, "hop"), nil
|
|
}
|
|
|
|
func canonicalPathKey(path string) (string, error) {
|
|
if err := preflightSkillAncestors(path); err != nil {
|
|
return "", err
|
|
}
|
|
current := filepath.Clean(path)
|
|
var suffix []string
|
|
for {
|
|
resolved, err := filepath.EvalSymlinks(current)
|
|
if err == nil {
|
|
for index := len(suffix) - 1; index >= 0; index-- {
|
|
resolved = filepath.Join(resolved, suffix[index])
|
|
}
|
|
return filepath.Clean(resolved), nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return "", fmt.Errorf("canonicalize skill destination: %w", err)
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return filepath.Clean(path), nil
|
|
}
|
|
suffix = append(suffix, filepath.Base(current))
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func preflightSkillTarget(target string, force bool) error {
|
|
if err := preflightSkillAncestors(target); err != nil {
|
|
return err
|
|
}
|
|
if info, err := os.Lstat(target); err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing to install through symlink %s", target)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("skill target exists and is not a directory: %s", target)
|
|
}
|
|
if !force {
|
|
return fmt.Errorf("Hop skill already exists at %s; pass --force to update it", target)
|
|
}
|
|
} else if !os.IsNotExist(err) {
|
|
return fmt.Errorf("inspect skill target: %w", err)
|
|
}
|
|
return fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil || path == "." {
|
|
return walkErr
|
|
}
|
|
destination := filepath.Join(target, filepath.FromSlash(path))
|
|
info, err := os.Lstat(destination)
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("inspect skill destination %s: %w", destination, err)
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing to overwrite symlink %s", destination)
|
|
}
|
|
if entry.IsDir() && !info.IsDir() {
|
|
return fmt.Errorf("skill directory destination is not a directory: %s", destination)
|
|
}
|
|
if !entry.IsDir() && info.IsDir() {
|
|
return fmt.Errorf("skill file destination is a directory: %s", destination)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// preflightSkillAncestors finds the nearest existing path component before any
|
|
// writes. This distinguishes an ordinary missing destination from a dangling
|
|
// parent symlink, which filepath.EvalSymlinks otherwise reports as os.ErrNotExist.
|
|
func preflightSkillAncestors(path string) error {
|
|
target := filepath.Clean(path)
|
|
current := target
|
|
for {
|
|
info, err := os.Lstat(current)
|
|
if err == nil {
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
resolved, resolveErr := filepath.EvalSymlinks(current)
|
|
if resolveErr != nil {
|
|
return fmt.Errorf("resolve skill destination ancestor %s: %w", current, resolveErr)
|
|
}
|
|
resolvedInfo, statErr := os.Stat(resolved)
|
|
if statErr != nil {
|
|
return fmt.Errorf("inspect resolved skill destination ancestor %s: %w", current, statErr)
|
|
}
|
|
if current != target && !resolvedInfo.IsDir() {
|
|
return fmt.Errorf("skill destination ancestor is not a directory: %s", current)
|
|
}
|
|
} else if current != target && !info.IsDir() {
|
|
return fmt.Errorf("skill destination ancestor is not a directory: %s", current)
|
|
}
|
|
return nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return fmt.Errorf("inspect skill destination ancestor %s: %w", current, err)
|
|
}
|
|
parent := filepath.Dir(current)
|
|
if parent == current {
|
|
return nil
|
|
}
|
|
current = parent
|
|
}
|
|
}
|
|
|
|
func writeSkillTarget(target string) (SkillInstallResult, error) {
|
|
if err := os.MkdirAll(target, 0o755); err != nil {
|
|
return SkillInstallResult{}, fmt.Errorf("create skill target: %w", err)
|
|
}
|
|
|
|
result := SkillInstallResult{Path: target, Paths: []string{target}}
|
|
err := fs.WalkDir(hopskill.Files, ".", func(path string, entry fs.DirEntry, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if path == "." {
|
|
return nil
|
|
}
|
|
destination := filepath.Join(target, filepath.FromSlash(path))
|
|
if entry.IsDir() {
|
|
return os.MkdirAll(destination, 0o755)
|
|
}
|
|
if info, statErr := os.Lstat(destination); statErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("refusing to overwrite symlink %s", destination)
|
|
} else if statErr != nil && !os.IsNotExist(statErr) {
|
|
return statErr
|
|
}
|
|
contents, readErr := hopskill.Files.ReadFile(path)
|
|
if readErr != nil {
|
|
return readErr
|
|
}
|
|
if writeErr := os.WriteFile(destination, contents, 0o644); writeErr != nil {
|
|
return writeErr
|
|
}
|
|
result.Files = append(result.Files, path)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return SkillInstallResult{}, fmt.Errorf("install Hop skill: %w", err)
|
|
}
|
|
return result, nil
|
|
}
|