package herdr
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"time"
"codey/internal/skills"
)
// SkillURL is where herdr publishes the skill that teaches a coding agent to
// drive it: splitting panes, starting commands without stealing focus, reading
// another pane's output, waiting until another agent is genuinely blocked.
//
// It is fetched rather than vendored because the file is a manual for a CLI
// that is still moving, and a copy frozen into this binary would go on
// teaching last year's flags long after they stopped working. The skill itself
// says the installed binary is the authority for syntax, which is the same
// instinct one level down.
const SkillURL = "https://raw.githubusercontent.com/herdrdev/herdr/master/skills/herdr/SKILL.md"
// skillURL is where the fetch actually goes. It exists so the tests can serve
// the file themselves: a test suite that reaches GitHub is one that fails on a
// train, and none of the behaviour worth testing here is GitHub's.
var skillURL = SkillURL
// SkillName is the slug the skill is installed under, which is both its
// directory name and the name in its frontmatter — they have to agree, and
// Codey's loader treats the directory as the authority.
const SkillName = "herdr"
// skillFetchTimeout bounds the download. Someone is sitting in front of a
// setup wizard that has said it is fetching; this is the point at which
// telling them it failed beats making them wonder.
const skillFetchTimeout = 15 * time.Second
// maxSkillBytes is the most that will be read from the network. The real file
// is a few kilobytes of Markdown; anything approaching this is not it, and the
// cap is what keeps a redirect to something enormous from being written into
// the home directory.
const maxSkillBytes = 256 << 10
// Present reports whether herdr is on this machine at all — running as the
// pane we are inside, or merely installed.
//
// It is the question of whether the skill is worth offering, which is a lower
// bar than Detect's: someone who has herdr installed but is running Codey in
// an ordinary terminal will want the skill for the next time they are not, and
// asking everyone else about a tool they have never heard of is noise.
func Present() bool {
if os.Getenv("HERDR_ENV") == "1" {
return true
}
_, err := exec.LookPath("herdr")
return err == nil
}
// InstallSkill fetches herdr's agent skill and writes it where Codey looks for
// personal skills, returning the path written.
//
// It is one function rather than a fetch and a write because the failure that
// matters is the one in between: what comes back may be an error page, a login
// redirect, or a file whose shape has changed, and none of those should end up
// on disk as a skill that only fails at the next launch. The download is
// therefore parsed with Codey's own loader before anything is written, and
// written whole or not at all.
func InstallSkill(ctx context.Context, home string) (path string, err error) {
if home == "" {
return "", errors.New("no home directory to install into")
}
content, err := fetchSkill(ctx)
if err != nil {
return "", err
}
// Parsed as the startup loader will parse it, against the slug it will be
// found under. A file that cannot answer for itself here would be a
// problem printed on every future launch.
if _, err := skills.Parse(content, SkillName); err != nil {
return "", fmt.Errorf("what %s served is not a skill Codey can read: %w", skillURL, err)
}
dir := filepath.Join(home, skills.Dirname, SkillName)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path = filepath.Join(dir, "SKILL.md")
if err := writeFileAtomic(path, content); err != nil {
return "", err
}
return path, nil
}
// fetchSkill gets the skill's bytes, with its own deadline on top of whatever
// the caller's context carries.
func fetchSkill(ctx context.Context) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, skillFetchTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, skillURL, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching %s: %w", skillURL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetching %s: %s", skillURL, resp.Status)
}
// One byte over the cap is read deliberately, so that hitting the limit
// can be told from a file that happens to be exactly that long.
content, err := io.ReadAll(io.LimitReader(resp.Body, maxSkillBytes+1))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", skillURL, err)
}
if len(content) > maxSkillBytes {
return nil, fmt.Errorf("%s served more than %d bytes; that is not the skill file", skillURL, maxSkillBytes)
}
return content, nil
}
// writeFileAtomic writes through a temporary file in the same directory, so
// that an interrupted install leaves the old skill rather than half a new one.
func writeFileAtomic(path string, content []byte) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".SKILL.md.*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(content); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
// CreateTemp makes it 0600; a skill is ordinary readable content, and the
// surprise would be a file the user cannot read in their own editor.
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}