package herdr import ( "context" "errors" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "time" "github.com/johannSo/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) }