Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions docs/exec-plans/active/mentat-skill-distribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Mentat Skill Distribution

## Purpose
Extend mentat's sync pipeline to copy generated SKILL.md content to the
agent-specific directory layouts expected by pi/Miles, Claude Code, Codex,
Cursor, and a combined AGENTS.md — so a single `mentat sync` run keeps all
agent toolchains up to date simultaneously.

## Baseline
`just test-mentat` passes clean before any changes (confirmed 2026-04-29).

## Milestones
- [x] Milestone 1 — Create `mentat/internal/distributor/distributor.go` with
`Target`, `Config`, `SkillContent`, `DefaultConfig`, `Distribute`, and
`DistributeAll`; verify: `cd mentat && go build ./...`
- [x] Milestone 2 — Create `mentat/internal/distributor/distributor_test.go`
with table-driven tests for all five targets; verify: `cd mentat && go test ./...`
- [x] Milestone 3 — Update `mentat/cmd/mentat/commands.go`: add `--no-distribute`
flag and wire `distributor.DistributeAll` after `generator.GenerateAll`;
verify: `just build-mentat && cd mentat && go test ./...`

## Surprises & Discoveries
(fill in as you work)

## Decision Log
- `Distribute` writes the pi target too (not just the "others") — keeps the
distributor as the single place that knows about target paths; the generator
output dir stays as the authoritative pi location.
- For dry-run: skip all distributor writes (mirrors generator behaviour); log
what would be written instead.
- `DistributeAll` sorts skills by domain name before writing AGENTS.md so the
output is deterministic across runs.
- YAML frontmatter extraction uses a simple string parser to avoid adding a
new dependency (yaml.v3 is already present in go.mod).

## Outcomes & Retrospective
All three milestones completed in a single session. `just build-mentat` exits 0;
`go test ./...` and `go vet ./...` both pass clean. 23 new tests cover all five
target paths, frontmatter stripping/rewriting, dry-run, disabled targets, and
alpha-sorted AGENTS.md output.
34 changes: 34 additions & 0 deletions mentat/cmd/mentat/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ package main
import (
"fmt"
"log/slog"
"os"

"github.com/frostyard/clix"
"github.com/frostyard/firn/mentat/internal/classifier"
"github.com/frostyard/firn/mentat/internal/distributor"
"github.com/frostyard/firn/mentat/internal/generator"
"github.com/frostyard/firn/mentat/internal/scanner"
"github.com/frostyard/firn/mentat/internal/tracker"
Expand All @@ -15,6 +17,7 @@ import (
func syncCmd() *cobra.Command {
var repoPath string
var force bool
var noDistribute bool
cmd := &cobra.Command{
Use: "sync [path]",
Short: "Scan repo and generate/update SKILL.md files",
Expand Down Expand Up @@ -113,11 +116,42 @@ func syncCmd() *cobra.Command {
}
}

// Distribute generated skills to all agent-specific target locations.
if !noDistribute {
r.Message("distributing skills")
var skills []distributor.SkillContent
for _, res := range genResults {
if res.Skipped {
continue
}
content, err := os.ReadFile(res.Path)
if err != nil {
return fmt.Errorf("distribute: reading %s: %w", res.Path, err)
}
skills = append(skills, distributor.SkillContent{
Domain: res.Domain,
Content: string(content),
})
}
if len(skills) > 0 {
distCfg := distributor.DefaultConfig()
if err := distributor.DistributeAll(repoPath, skills, distCfg, slog.Default()); err != nil {
return fmt.Errorf("distribute: %w", err)
}
for _, sc := range skills {
r.Message(" distributed %s", sc.Domain)
}
}
} else {
r.Message("--no-distribute: skipping distribution")
}

return nil
},
}
cmd.Flags().StringVarP(&repoPath, "path", "p", ".", "repository path to scan")
cmd.Flags().BoolVar(&force, "force", false, "regenerate all domains, ignoring staleness check")
cmd.Flags().BoolVar(&noDistribute, "no-distribute", false, "skip distribution to agent-specific paths after generation")
return cmd
}

Expand Down
270 changes: 270 additions & 0 deletions mentat/internal/distributor/distributor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
// Package distributor copies generated SKILL.md content to the agent-specific
// directory layouts expected by different coding-agent toolchains.
//
// Supported targets:
//
// - pi — .agents/skills/{domain}/SKILL.md (pi/Miles format, as-is)
// - claude — .claude/commands/{domain}.md (no frontmatter)
// - codex — .codex/skills/{domain}.md (no frontmatter)
// - cursor — .cursor/rules/{domain}.mdc (description-only frontmatter)
// - agents-md — AGENTS.md at repo root (all domains, ## sections)
package distributor

import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"

"github.com/frostyard/clix"
)

// templateCursor marks a target that needs cursor-style frontmatter rewriting.
const templateCursor = "cursor"

// Target describes one agent toolchain's expected skill-file layout.
type Target struct {
// Name is a short identifier, e.g. "pi", "claude", "codex", "cursor", "agents-md".
Name string

// Enabled controls whether this target is active.
Enabled bool

// Dir is the directory path relative to the repo root where individual
// skill files are written. Empty for the "agents-md" target.
Dir string

// Ext is the file extension including the leading dot, e.g. ".md", ".mdc".
Ext string

// Template is an optional key that selects content transformation logic.
// Currently only "cursor" is meaningful; empty means strip-frontmatter or as-is.
Template string
}

// Config holds the set of distribution targets.
type Config struct {
Targets []Target
}

// SkillContent pairs a domain name with its raw SKILL.md content.
type SkillContent struct {
// Domain is the short domain name, e.g. "auth".
Domain string

// Content is the full raw SKILL.md text including any YAML frontmatter.
Content string
}

// DefaultConfig returns a Config with all five standard targets enabled.
func DefaultConfig() Config {
return Config{
Targets: []Target{
{Name: "pi", Enabled: true, Dir: ".agents/skills", Ext: ".md", Template: "pi"},
{Name: "claude", Enabled: true, Dir: ".claude/commands", Ext: ".md"},
{Name: "codex", Enabled: true, Dir: ".codex/skills", Ext: ".md"},
{Name: "cursor", Enabled: true, Dir: ".cursor/rules", Ext: ".mdc", Template: templateCursor},
{Name: "agents-md", Enabled: true},
},
}
}

// Distribute writes the SKILL.md content for a single domain to all enabled
// targets (except "agents-md", which requires the full set — use DistributeAll
// for that). Respects clix.DryRun: logs intent and returns without writing.
func Distribute(repoPath, domain, content string, cfg Config, log *slog.Logger) error {
if log == nil {
log = slog.Default()
}
for _, t := range cfg.Targets {
if !t.Enabled {
continue
}
if t.Name == "agents-md" {
// agents-md is handled by DistributeAll once all skills are collected.
continue
}
if err := writeTarget(repoPath, domain, content, t, log); err != nil {
return fmt.Errorf("distributor: target %q domain %q: %w", t.Name, domain, err)
}
}
return nil
}

// DistributeAll runs Distribute for every skill and then regenerates AGENTS.md
// from all distributed skills (sorted by domain name for deterministic output).
// Respects clix.DryRun.
func DistributeAll(repoPath string, skills []SkillContent, cfg Config, log *slog.Logger) error {
if log == nil {
log = slog.Default()
}

// Sort by domain name for deterministic output.
sorted := make([]SkillContent, len(skills))
copy(sorted, skills)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Domain < sorted[j].Domain
})

for _, sc := range sorted {
if err := Distribute(repoPath, sc.Domain, sc.Content, cfg, log); err != nil {
return fmt.Errorf("distributor: distributing domain %q: %w", sc.Domain, err)
}
}

// Regenerate AGENTS.md if the target is enabled.
for _, t := range cfg.Targets {
if t.Enabled && t.Name == "agents-md" {
if err := writeAgentsMD(repoPath, sorted, log); err != nil {
return fmt.Errorf("distributor: writing AGENTS.md: %w", err)
}
break
}
}
return nil
}

// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------

// writeTarget writes content for one (target, domain) pair. It applies the
// appropriate content transformation based on t.Template.
func writeTarget(repoPath, domain, content string, t Target, log *slog.Logger) error {
var transformed string
var err error

switch t.Template {
case "pi":
// pi/Miles: write as-is, but inside a {domain}/ subdirectory named SKILL.md.
transformed = content
case templateCursor:
transformed, err = transformCursor(domain, content)
if err != nil {
return fmt.Errorf("cursor transform: %w", err)
}
default:
// claude, codex: strip YAML frontmatter, plain markdown body only.
transformed = stripFrontmatter(content)
}

// Determine the output path.
var destPath string
if t.Template == "pi" {
// pi target keeps the {domain}/SKILL.md structure.
destPath = filepath.Join(repoPath, t.Dir, domain, "SKILL.md")
} else {
filename := domain + t.Ext
destPath = filepath.Join(repoPath, t.Dir, filename)
}

if clix.DryRun {
log.Info("distributor: dry-run — would write", "target", t.Name, "path", destPath)
return nil
}

if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return fmt.Errorf("creating dir %s: %w", filepath.Dir(destPath), err)
}

if err := os.WriteFile(destPath, []byte(transformed), 0o644); err != nil {
return fmt.Errorf("writing %s: %w", destPath, err)
}

log.Info("distributor: wrote", "target", t.Name, "domain", domain, "path", destPath)
return nil
}

// writeAgentsMD builds and writes (or dry-run logs) the combined AGENTS.md.
func writeAgentsMD(repoPath string, skills []SkillContent, log *slog.Logger) error {
destPath := filepath.Join(repoPath, "AGENTS.md")

if clix.DryRun {
log.Info("distributor: dry-run — would write AGENTS.md", "path", destPath, "domains", len(skills))
return nil
}

var sb strings.Builder
for _, sc := range skills {
body := stripFrontmatter(sc.Content)
sb.WriteString("## ")
sb.WriteString(sc.Domain)
sb.WriteString("\n\n")
sb.WriteString(strings.TrimSpace(body))
sb.WriteString("\n\n")
}

if err := os.WriteFile(destPath, []byte(strings.TrimRight(sb.String(), "\n")+"\n"), 0o644); err != nil {
return fmt.Errorf("writing AGENTS.md: %w", err)
}

log.Info("distributor: wrote AGENTS.md", "path", destPath, "domains", len(skills))
return nil
}

// ---------------------------------------------------------------------------
// Content transformations
// ---------------------------------------------------------------------------

// stripFrontmatter removes the leading YAML frontmatter block (between the
// first pair of "---" delimiters) and returns the remaining markdown body,
// trimmed of leading/trailing whitespace, with a final newline appended.
func stripFrontmatter(content string) string {
s := strings.TrimSpace(content)
if !strings.HasPrefix(s, "---") {
return s + "\n"
}
// Skip the opening "---".
rest := s[3:]
// Consume an optional newline after "---".
rest = strings.TrimPrefix(rest, "\n")
// Find the closing "---".
end := strings.Index(rest, "\n---")
if end == -1 {
// Malformed or no closing delimiter — return as-is.
return s + "\n"
}
body := strings.TrimSpace(rest[end+4:]) // +4 skips "\n---"
return body + "\n"
}

// extractDescription parses the "description" field from a YAML frontmatter
// block. Returns an empty string if the field is absent or the block is
// malformed.
func extractDescription(content string) string {
s := strings.TrimSpace(content)
if !strings.HasPrefix(s, "---") {
return ""
}
rest := strings.TrimPrefix(s[3:], "\n")
end := strings.Index(rest, "\n---")
if end == -1 {
return ""
}
block := rest[:end]
for _, line := range strings.Split(block, "\n") {
if after, ok := strings.CutPrefix(line, "description:"); ok {
return strings.TrimSpace(after)
}
}
return ""
}

// transformCursor rewrites frontmatter for the Cursor .mdc format:
//
// ---
// description: <original description>
// ---
//
// The markdown body is preserved as-is.
func transformCursor(domain, content string) (string, error) {
desc := extractDescription(content)
if desc == "" {
desc = domain + " domain skills"
}
body := stripFrontmatter(content)
result := fmt.Sprintf("---\ndescription: %s\n---\n\n%s", desc, strings.TrimSpace(body)+"\n")
return result, nil
}
Loading
Loading