Vectrum
Sign In
153 lines · 5.5 KB · Go
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)
}