mirror of
https://github.com/foomo/posh.git
synced 2026-10-04 22:17:06 +00:00
309 lines
12 KiB
Go
309 lines
12 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// DefaultSkillsPath is the skills directory `posh agent skill install` writes
|
|
// into unless given an explicit path.
|
|
const DefaultSkillsPath = ".claude/skills"
|
|
|
|
// Skill directory naming. The root skill is RootSkillName; every command gets
|
|
// CommandSkillPrefix + its name.
|
|
//
|
|
// The prefix is the uninstall contract: `agent skill uninstall` removes what
|
|
// matches it and nothing else, so a hand-written skill sitting in the same
|
|
// directory survives. Keeping it a constant shared by writer and remover is what
|
|
// stops the two from drifting - a manifest would be the alternative, but it is
|
|
// extra on-disk state that can itself go stale.
|
|
const (
|
|
RootSkillName = "posh"
|
|
CommandSkillPrefix = "posh-"
|
|
)
|
|
|
|
// Defaults for the generated frontmatter, used for any SkillMetadata field a
|
|
// project leaves unset.
|
|
//
|
|
// The description names triggering conditions rather than describing what posh
|
|
// is: it is the only thing an agent runtime sees when deciding whether to load
|
|
// the skill, so a summary of the tool ("drive this project's posh shell") gives
|
|
// it nothing to match a request against and the skill never fires.
|
|
const (
|
|
defaultSkillName = RootSkillName
|
|
defaultSkillDescription = "Use when running any task in this project - building, testing, " +
|
|
"deploying, or working with its clusters and services - and when asking how a task is " +
|
|
"run here at all. This project drives such tasks through posh (Project Oriented Shell) " +
|
|
"rather than ad-hoc shell commands."
|
|
)
|
|
|
|
// skillDetails points at the two places that carry a command's full argument and
|
|
// flag detail.
|
|
//
|
|
// The detail is deliberately not inlined. It is large - in a real project the
|
|
// flag and argument tree was over a quarter of the generated skill - and mostly
|
|
// redundant, because every leaf repeats the flag set it inherits. Both escape
|
|
// hatches below already render it on demand, and neither costs the agent
|
|
// anything until it asks.
|
|
const skillDetails = "Flags and arguments: `posh agent catalog` (JSON), or `posh help <command>`.\n\n"
|
|
|
|
// skillSetup documents posh's own subcommands, as opposed to the project
|
|
// commands the catalog is generated from.
|
|
//
|
|
// It is a fixed string rather than a walk of the cobra tree: these commands are
|
|
// the same in every project, they are wired up in cmd/ - which imports this
|
|
// package, so the dependency cannot go the other way - and an agent mostly
|
|
// needs them when `posh execute` fails because the environment is not set up.
|
|
const skillSetup = "## Setup\n\n" +
|
|
"All of it is configured in this project's `.posh.yaml`; read that file to see\n" +
|
|
"what is actually required or installed before running anything below.\n\n" +
|
|
"- `posh require` - validate the preconditions from the `require` key (env\n" +
|
|
" vars, packages, scripts). Run it first when a command fails unexpectedly.\n" +
|
|
"- `posh brew` - install the pinned tool versions from the `ownbrew` key. Run\n" +
|
|
" it when `posh require` reports a missing package; `--dry` only prints what\n" +
|
|
" would be installed.\n" +
|
|
"- `posh prompt` - the interactive shell, configured by the `prompt` key. Do\n" +
|
|
" not run it under an agent harness: it blocks waiting on a TTY. Use\n" +
|
|
" `posh execute` for individual commands instead.\n\n"
|
|
|
|
// skillConventions states once what would otherwise be repeated in every
|
|
// command's own skill.
|
|
//
|
|
// Provider prose converged on the same handful of sentences - one real project
|
|
// repeated "that URL is also the schema's `$id`" twenty times across its
|
|
// fragments. Stating them here costs the agent one read of the root skill
|
|
// instead of one per command it loads.
|
|
const skillConventions = "## Conventions\n\n" +
|
|
"These hold across this project's commands, so the individual command skills\n" +
|
|
"do not repeat them:\n\n" +
|
|
"- A schema URL in a config file doubles as that schema's `$id`.\n" +
|
|
"- Defaults shown in a command's skill are overridable via `.posh.yaml`; that\n" +
|
|
" file is the authority when the two disagree.\n" +
|
|
"- Confirm a value against both `.posh.yaml` and the command's own output\n" +
|
|
" before relying on it.\n\n"
|
|
|
|
// SkillCommand pairs a described command with the extra markdown and
|
|
// frontmatter its command.Skiller and command.SkillMetadataer implementations
|
|
// contributed, if any.
|
|
//
|
|
// The catalog shape stays untouched: skill prose is a rendering concern, so it
|
|
// rides alongside CommandInfo rather than inside it.
|
|
type SkillCommand struct {
|
|
CommandInfo
|
|
|
|
// Skill is the markdown contributed by command.Skiller, or empty.
|
|
Skill string
|
|
|
|
// Metadata is the frontmatter contributed by command.SkillMetadataer. Unset
|
|
// fields fall back to values derived from the command.
|
|
Metadata SkillMetadata
|
|
}
|
|
|
|
// SkillName is the directory name this command's skill is generated into.
|
|
func (c SkillCommand) SkillName() string {
|
|
if c.Metadata.Name != "" {
|
|
return c.Metadata.Name
|
|
}
|
|
|
|
// Nested paths ("cache clear") cannot appear here - only top level commands
|
|
// get their own skill - but a name is still a path token, so anything
|
|
// directory-unsafe is flattened rather than trusted.
|
|
return CommandSkillPrefix + strings.ReplaceAll(c.FullPath, " ", "-")
|
|
}
|
|
|
|
// Describes reports whether a command says anything a skill of its own is worth
|
|
// spending tokens on: prose from command.Skiller, or a subcommand tree whose
|
|
// runnable leaf paths a flat index cannot show.
|
|
//
|
|
// Arguments and flags deliberately do not count. A skill never prints them - it
|
|
// points at `posh agent catalog` and `posh help` instead, see skillDetails - so
|
|
// a command whose only structure is an argument list would get a file saying
|
|
// nothing its index entry does not already say.
|
|
//
|
|
// The test is deliberately structural rather than "does it implement the
|
|
// interfaces". command.Describe already collapses a non-Describer into a
|
|
// CommandInfo carrying only a path and description, so by the time it arrives
|
|
// here a command that never opted in is indistinguishable from a Describer that
|
|
// legitimately describes a bare leaf - and both are equally uninformative.
|
|
//
|
|
// A command that fails the test still appears in the root skill's index with its
|
|
// name and description; it just does not get a skill of its own.
|
|
func (c SkillCommand) Describes() bool {
|
|
return strings.TrimSpace(c.Skill) != "" || len(c.Subcommands) > 0
|
|
}
|
|
|
|
// RenderRootSkill renders the root SKILL.md: how to invoke posh, how to set it
|
|
// up, and an index of every command.
|
|
//
|
|
// It deliberately carries no per-command detail and no hazard prose. Nothing
|
|
// routes off it - each command that has more to say gets its own skill, which an
|
|
// agent loads on its own - so the root stays small enough to be worth loading
|
|
// unconditionally.
|
|
func RenderRootSkill(meta SkillMetadata, commands []SkillCommand) string {
|
|
var b strings.Builder
|
|
|
|
writeSkillFrontmatter(&b, meta, defaultSkillName, defaultSkillDescription)
|
|
|
|
b.WriteString("# posh\n\n")
|
|
b.WriteString("Run project tasks via `posh execute <command...>` (alias `posh x`) instead of\n")
|
|
b.WriteString("guessing shell commands directly. JSON output is automatic under an agent\n")
|
|
b.WriteString("harness; `POSH_AGENT_MODE=0` forces human output. Re-run\n")
|
|
b.WriteString("`posh agent skill update` after this project's commands change to refresh\n")
|
|
b.WriteString("these files.\n\n")
|
|
b.WriteString("posh does not enforce access control: whatever the surrounding harness or CI\n")
|
|
b.WriteString("permits is the real boundary. A command being listed here does not mean it is\n")
|
|
b.WriteString("safe to run unattended.\n\n")
|
|
b.WriteString(skillSetup)
|
|
b.WriteString(skillConventions)
|
|
b.WriteString("## Commands\n\n")
|
|
b.WriteString("Run `posh agent catalog` for every command as JSON, including arguments and\n")
|
|
b.WriteString("flags, or `posh help` for the human-readable list.\n\n")
|
|
|
|
for _, c := range commands {
|
|
fmt.Fprintf(&b, "- `%s`", c.FullPath)
|
|
|
|
if c.Description != "" {
|
|
b.WriteString(" - " + c.Description)
|
|
}
|
|
|
|
// Commands with more to say have a skill of their own; name it, so an
|
|
// agent reading the index knows there is more and where it is.
|
|
if c.Describes() {
|
|
fmt.Fprintf(&b, " (skill: `%s`)", c.SkillName())
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// RenderCommandSkill renders one command's own SKILL.md: its runnable paths and
|
|
// whatever prose it contributes.
|
|
func RenderCommandSkill(c SkillCommand) string {
|
|
var b strings.Builder
|
|
|
|
writeSkillFrontmatter(&b, c.Metadata, c.SkillName(), defaultCommandSkillDescription(c))
|
|
|
|
heading := c.FullPath
|
|
if usage := c.Usage(); usage != "" {
|
|
heading += " " + usage
|
|
}
|
|
|
|
fmt.Fprintf(&b, "# `%s`\n\n", heading)
|
|
|
|
if c.Description != "" {
|
|
b.WriteString(c.Description + "\n\n")
|
|
}
|
|
|
|
b.WriteString("Run it as `posh execute " + c.FullPath + "` (alias `posh x`). " +
|
|
"See the `posh` skill\nfor setup and project-wide conventions.\n\n")
|
|
b.WriteString(skillDetails)
|
|
|
|
writeSkillSubcommands(&b, c.Subcommands, 0)
|
|
|
|
// Trimmed so a contribution ending in a newline does not stack blank lines
|
|
// on top of the one added here.
|
|
if skill := strings.TrimSpace(c.Skill); skill != "" {
|
|
b.WriteString(skill + "\n")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// defaultCommandSkillDescription derives a description for a command that did
|
|
// not supply one.
|
|
//
|
|
// It is a fallback, not a good description: it can only restate the one-line
|
|
// description, which says what the command is rather than when to reach for it.
|
|
// `agent skill install` reports every command that lands here, see
|
|
// FallbackSkillDescriptions.
|
|
func defaultCommandSkillDescription(c SkillCommand) string {
|
|
ret := "Use when running `" + c.FullPath + "` commands in this project."
|
|
|
|
if c.Description != "" {
|
|
ret += " " + strings.ToUpper(c.Description[:1]) + c.Description[1:]
|
|
|
|
if !strings.HasSuffix(ret, ".") {
|
|
ret += "."
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
// FallbackSkillDescriptions returns the paths of the commands that did not
|
|
// supply a frontmatter description, so a caller can report the gap rather than
|
|
// let it pass silently.
|
|
//
|
|
// The description is the only thing an agent runtime matches a request against,
|
|
// so a derived one usually means that command's skill never loads.
|
|
func FallbackSkillDescriptions(commands []SkillCommand) []string {
|
|
var ret []string
|
|
|
|
for _, c := range commands {
|
|
if c.Describes() && c.Metadata.Description == "" {
|
|
ret = append(ret, c.FullPath)
|
|
}
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
// writeSkillFrontmatter renders the YAML frontmatter, falling back to the given
|
|
// defaults for unset fields.
|
|
//
|
|
// The values are marshalled rather than concatenated: a description containing
|
|
// a colon - or starting with a "#" - would otherwise produce invalid YAML.
|
|
func writeSkillFrontmatter(b *strings.Builder, meta SkillMetadata, name, description string) {
|
|
if meta.Name == "" {
|
|
meta.Name = name
|
|
}
|
|
|
|
if meta.Description == "" {
|
|
meta.Description = description
|
|
}
|
|
|
|
b.WriteString("---\n")
|
|
|
|
// Marshal cannot fail for a struct of strings, so a failure here leaves the
|
|
// frontmatter empty rather than taking the whole skill down.
|
|
if out, err := yaml.Marshal(meta); err == nil {
|
|
b.Write(out)
|
|
}
|
|
|
|
b.WriteString("---\n\n")
|
|
}
|
|
|
|
// writeSkillSubcommands renders a command's descendants as an indented list. An
|
|
// agent needs the runnable leaf paths, which a flat listing cannot show.
|
|
//
|
|
// Arguments and flags are left out on purpose, see skillDetails.
|
|
func writeSkillSubcommands(b *strings.Builder, commands []CommandInfo, depth int) {
|
|
for _, c := range commands {
|
|
pad := strings.Repeat(" ", depth)
|
|
|
|
b.WriteString(pad + "- `" + c.FullPath)
|
|
|
|
if usage := c.Usage(); usage != "" {
|
|
b.WriteString(" " + usage)
|
|
}
|
|
|
|
b.WriteString("`")
|
|
|
|
if c.Description != "" {
|
|
b.WriteString(" - " + c.Description)
|
|
}
|
|
|
|
b.WriteString("\n")
|
|
|
|
writeSkillSubcommands(b, c.Subcommands, depth+1)
|
|
}
|
|
|
|
if depth == 0 && len(commands) > 0 {
|
|
b.WriteString("\n")
|
|
}
|
|
}
|