From ea65c2051fc19d2f7d1e6391332cc9f6bacfa900 Mon Sep 17 00:00:00 2001 From: Brian Ketelsen Date: Wed, 29 Apr 2026 20:34:21 -0400 Subject: [PATCH] feat(mentat): skill distribution to agent-specific paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add distributor package that copies generated SKILL.md content to the directory layouts expected by pi/Miles, Claude Code, Codex, Cursor, and a combined AGENTS.md at repo root. Targets: pi — .agents/skills/{domain}/SKILL.md (as-is) claude — .claude/commands/{domain}.md (frontmatter stripped) codex — .codex/skills/{domain}.md (frontmatter stripped) cursor — .cursor/rules/{domain}.mdc (description-only frontmatter) agents-md — AGENTS.md (all domains, ## sections, sorted) Wire distributor.DistributeAll into syncCmd after generator.GenerateAll. Add --no-distribute flag to skip distribution (useful for fast stale-only regeneration runs). Tests: 23 new cases covering all five targets, frontmatter handling, dry-run, disabled targets, and deterministic alpha-sorted AGENTS.md output. --- .../active/mentat-skill-distribution.md | 40 ++ mentat/cmd/mentat/commands.go | 34 ++ mentat/internal/distributor/distributor.go | 270 ++++++++++++ .../internal/distributor/distributor_test.go | 402 ++++++++++++++++++ 4 files changed, 746 insertions(+) create mode 100644 docs/exec-plans/active/mentat-skill-distribution.md create mode 100644 mentat/internal/distributor/distributor.go create mode 100644 mentat/internal/distributor/distributor_test.go diff --git a/docs/exec-plans/active/mentat-skill-distribution.md b/docs/exec-plans/active/mentat-skill-distribution.md new file mode 100644 index 0000000..7a56703 --- /dev/null +++ b/docs/exec-plans/active/mentat-skill-distribution.md @@ -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. diff --git a/mentat/cmd/mentat/commands.go b/mentat/cmd/mentat/commands.go index c02aadc..4d2530b 100644 --- a/mentat/cmd/mentat/commands.go +++ b/mentat/cmd/mentat/commands.go @@ -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" @@ -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", @@ -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 } diff --git a/mentat/internal/distributor/distributor.go b/mentat/internal/distributor/distributor.go new file mode 100644 index 0000000..5fed554 --- /dev/null +++ b/mentat/internal/distributor/distributor.go @@ -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: +// --- +// +// 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 +} diff --git a/mentat/internal/distributor/distributor_test.go b/mentat/internal/distributor/distributor_test.go new file mode 100644 index 0000000..9e8dadf --- /dev/null +++ b/mentat/internal/distributor/distributor_test.go @@ -0,0 +1,402 @@ +package distributor_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/frostyard/clix" + "github.com/frostyard/firn/mentat/internal/distributor" +) + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// noopLogger returns a logger that discards all output so tests stay quiet. +// We use the default slog.Logger (nil is accepted by Distribute/DistributeAll). +func makeRepo(t *testing.T) string { + t.Helper() + return t.TempDir() +} + +// validSkillMD is a minimal valid SKILL.md with all fields distributor needs. +const validSkillMD = `--- +name: auth +description: Handles user authentication and session management. +--- + +## Purpose + +The auth domain manages login, logout, and session state. + +## Key Abstractions + +- Session: active user session struct. +- Authenticator: interface for verifying credentials. +` + +// --------------------------------------------------------------------------- +// stripFrontmatter-style assertions (via Distribute outputs) +// --------------------------------------------------------------------------- + +func readFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + return string(b) +} + +func assertFileExists(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file to exist: %s — %v", path, err) + } +} + +func assertNoFrontmatter(t *testing.T, content, label string) { + t.Helper() + if strings.HasPrefix(strings.TrimSpace(content), "---") { + // Check whether the opening --- is truly a frontmatter block + // (i.e. there is a matching closing --- before substantial content). + rest := strings.TrimPrefix(strings.TrimSpace(content), "---") + rest = strings.TrimPrefix(rest, "\n") + if strings.Contains(rest, "\n---") { + t.Errorf("%s: content still contains YAML frontmatter", label) + } + } +} + +// --------------------------------------------------------------------------- +// Distribute — single domain, all targets +// --------------------------------------------------------------------------- + +func TestDistribute_WritesAllTargets(t *testing.T) { + repo := makeRepo(t) + cfg := distributor.DefaultConfig() + + err := distributor.Distribute(repo, "auth", validSkillMD, cfg, nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + cases := []struct { + label string + path string + }{ + {"pi", filepath.Join(repo, ".agents", "skills", "auth", "SKILL.md")}, + {"claude", filepath.Join(repo, ".claude", "commands", "auth.md")}, + {"codex", filepath.Join(repo, ".codex", "skills", "auth.md")}, + {"cursor", filepath.Join(repo, ".cursor", "rules", "auth.mdc")}, + } + for _, tc := range cases { + t.Run(tc.label, func(t *testing.T) { + assertFileExists(t, tc.path) + }) + } +} + +// --------------------------------------------------------------------------- +// Target: pi — content written as-is +// --------------------------------------------------------------------------- + +func TestDistribute_Pi_ContentUnchanged(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + got := readFile(t, filepath.Join(repo, ".agents", "skills", "auth", "SKILL.md")) + wantPrefix := "---\nname: auth" + if !strings.HasPrefix(got, wantPrefix) { + t.Errorf("pi target: want content starting with %q, got %q", wantPrefix, got[:min(60, len(got))]) + } +} + +// --------------------------------------------------------------------------- +// Target: Claude — no frontmatter +// --------------------------------------------------------------------------- + +func TestDistribute_Claude_NoFrontmatter(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + got := readFile(t, filepath.Join(repo, ".claude", "commands", "auth.md")) + assertNoFrontmatter(t, got, "claude") + if !strings.Contains(got, "## Purpose") { + t.Error("claude target: markdown body missing") + } +} + +// --------------------------------------------------------------------------- +// Target: Codex — no frontmatter +// --------------------------------------------------------------------------- + +func TestDistribute_Codex_NoFrontmatter(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + got := readFile(t, filepath.Join(repo, ".codex", "skills", "auth.md")) + assertNoFrontmatter(t, got, "codex") + if !strings.Contains(got, "## Purpose") { + t.Error("codex target: markdown body missing") + } +} + +// --------------------------------------------------------------------------- +// Target: Cursor — .mdc extension + description-only frontmatter +// --------------------------------------------------------------------------- + +func TestDistribute_Cursor_ExtensionIsMDC(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + path := filepath.Join(repo, ".cursor", "rules", "auth.mdc") + assertFileExists(t, path) +} + +func TestDistribute_Cursor_FrontmatterHasDescription(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + got := readFile(t, filepath.Join(repo, ".cursor", "rules", "auth.mdc")) + + if !strings.HasPrefix(got, "---\n") { + t.Fatalf("cursor target: expected frontmatter, got: %q", got[:min(60, len(got))]) + } + if !strings.Contains(got, "description:") { + t.Error("cursor target: frontmatter missing 'description' field") + } + // Should NOT contain 'name:' (cursor frontmatter has description only). + lines := strings.SplitN(got, "\n---\n", 2) + if len(lines) == 2 { + frontmatter := lines[0][4:] // strip leading "---\n" + if strings.Contains(frontmatter, "name:") { + t.Error("cursor target: frontmatter should not contain 'name' field") + } + } + // Body should still be present. + if !strings.Contains(got, "## Purpose") { + t.Error("cursor target: markdown body missing after frontmatter") + } +} + +func TestDistribute_Cursor_DescriptionMatchesOriginal(t *testing.T) { + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + got := readFile(t, filepath.Join(repo, ".cursor", "rules", "auth.mdc")) + if !strings.Contains(got, "Handles user authentication and session management.") { + t.Error("cursor target: description from original frontmatter not preserved") + } +} + +// --------------------------------------------------------------------------- +// DistributeAll — AGENTS.md generation +// --------------------------------------------------------------------------- + +func TestDistributeAll_AgentsMDCreated(t *testing.T) { + repo := makeRepo(t) + skills := []distributor.SkillContent{ + {Domain: "auth", Content: validSkillMD}, + {Domain: "billing", Content: strings.ReplaceAll(validSkillMD, "auth", "billing")}, + } + + err := distributor.DistributeAll(repo, skills, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("DistributeAll: %v", err) + } + + agentsPath := filepath.Join(repo, "AGENTS.md") + assertFileExists(t, agentsPath) +} + +func TestDistributeAll_AgentsMD_HasH2Sections(t *testing.T) { + repo := makeRepo(t) + skills := []distributor.SkillContent{ + {Domain: "auth", Content: validSkillMD}, + {Domain: "billing", Content: strings.ReplaceAll(validSkillMD, "auth", "billing")}, + } + + err := distributor.DistributeAll(repo, skills, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("DistributeAll: %v", err) + } + + got := readFile(t, filepath.Join(repo, "AGENTS.md")) + if !strings.Contains(got, "## auth") { + t.Error("AGENTS.md: missing ## auth section") + } + if !strings.Contains(got, "## billing") { + t.Error("AGENTS.md: missing ## billing section") + } +} + +func TestDistributeAll_AgentsMD_NoFrontmatter(t *testing.T) { + repo := makeRepo(t) + skills := []distributor.SkillContent{ + {Domain: "auth", Content: validSkillMD}, + } + + err := distributor.DistributeAll(repo, skills, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("DistributeAll: %v", err) + } + + got := readFile(t, filepath.Join(repo, "AGENTS.md")) + // AGENTS.md must not contain raw YAML frontmatter blocks. + if strings.Contains(got, "name: auth") { + t.Error("AGENTS.md: contains raw YAML frontmatter field 'name: auth'") + } + if strings.Contains(got, "description: Handles") { + t.Error("AGENTS.md: contains raw YAML frontmatter field 'description: Handles'") + } +} + +func TestDistributeAll_AgentsMD_SortedByDomain(t *testing.T) { + repo := makeRepo(t) + // Provide skills in reverse alphabetical order; output must be sorted. + skills := []distributor.SkillContent{ + {Domain: "storage", Content: strings.ReplaceAll(validSkillMD, "auth", "storage")}, + {Domain: "auth", Content: validSkillMD}, + {Domain: "billing", Content: strings.ReplaceAll(validSkillMD, "auth", "billing")}, + } + + err := distributor.DistributeAll(repo, skills, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("DistributeAll: %v", err) + } + + got := readFile(t, filepath.Join(repo, "AGENTS.md")) + authIdx := strings.Index(got, "## auth") + billingIdx := strings.Index(got, "## billing") + storageIdx := strings.Index(got, "## storage") + + if !(authIdx < billingIdx && billingIdx < storageIdx) { + t.Errorf("AGENTS.md sections not sorted: auth@%d billing@%d storage@%d", + authIdx, billingIdx, storageIdx) + } +} + +// --------------------------------------------------------------------------- +// Disabled target +// --------------------------------------------------------------------------- + +func TestDistribute_DisabledTargetSkipped(t *testing.T) { + repo := makeRepo(t) + cfg := distributor.DefaultConfig() + // Disable the claude target. + for i := range cfg.Targets { + if cfg.Targets[i].Name == "claude" { + cfg.Targets[i].Enabled = false + } + } + + err := distributor.Distribute(repo, "auth", validSkillMD, cfg, nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + claudePath := filepath.Join(repo, ".claude", "commands", "auth.md") + if _, err := os.Stat(claudePath); !os.IsNotExist(err) { + t.Errorf("disabled claude target: file should not exist, got stat err: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Dry-run +// --------------------------------------------------------------------------- + +func TestDistribute_DryRun_NoWrites(t *testing.T) { + original := clix.DryRun + clix.DryRun = true + t.Cleanup(func() { clix.DryRun = original }) + + repo := makeRepo(t) + err := distributor.Distribute(repo, "auth", validSkillMD, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute dry-run: %v", err) + } + + paths := []string{ + filepath.Join(repo, ".agents", "skills", "auth", "SKILL.md"), + filepath.Join(repo, ".claude", "commands", "auth.md"), + filepath.Join(repo, ".codex", "skills", "auth.md"), + filepath.Join(repo, ".cursor", "rules", "auth.mdc"), + } + for _, p := range paths { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("dry-run: file should not exist: %s", p) + } + } +} + +func TestDistributeAll_DryRun_NoAgentsMD(t *testing.T) { + original := clix.DryRun + clix.DryRun = true + t.Cleanup(func() { clix.DryRun = original }) + + repo := makeRepo(t) + skills := []distributor.SkillContent{ + {Domain: "auth", Content: validSkillMD}, + } + err := distributor.DistributeAll(repo, skills, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("DistributeAll dry-run: %v", err) + } + + agentsPath := filepath.Join(repo, "AGENTS.md") + if _, err := os.Stat(agentsPath); !os.IsNotExist(err) { + t.Errorf("dry-run: AGENTS.md should not exist") + } +} + +// --------------------------------------------------------------------------- +// Content with no frontmatter +// --------------------------------------------------------------------------- + +func TestDistribute_ContentWithoutFrontmatter(t *testing.T) { + repo := makeRepo(t) + noFM := "## Purpose\n\nDoes things.\n" + + err := distributor.Distribute(repo, "misc", noFM, distributor.DefaultConfig(), nil) + if err != nil { + t.Fatalf("Distribute: %v", err) + } + + // Claude target should contain the body. + got := readFile(t, filepath.Join(repo, ".claude", "commands", "misc.md")) + if !strings.Contains(got, "## Purpose") { + t.Error("claude target: body missing when source has no frontmatter") + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func min(a, b int) int { + if a < b { + return a + } + return b +}