diff --git a/README.md b/README.md index 50d0e8e..4c95c18 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,22 @@ external/// vendored content, mirrors the same four types agent-lib.lock.json lockfile v2 — pins every source ``` +This layout is **exactly** what OpenCode natively consumes — agent-lib is +deliberately on the opencode standard, no conversion layer: + +| Type | Canonical form | +|---|---| +| skills | `/SKILL.md` folder (any depth, category nesting ok) | +| commands | flat `commands/.md` | +| agents | flat `agents/.md` | +| mcp | flat `mcp/.yaml` (inventoried, never deployed) | + +**Migrating from folder-style agent repos** (e.g. `agents//AGENT.md` +layouts): flatten `agents//AGENT.md` → `agents/.md`, and move +documentation and manifests (`SCHEMA.md`, `agents.json`, `README.md`) out of +the typed directories. No folder support for agents/commands is planned — +adapting the source repo to the standard beats shipping a renderer. + ### Vendoring a source ```sh @@ -61,8 +77,12 @@ standard directories (`skills/`, `commands/`, `agents/`, `mcp/`); skills are folders containing `SKILL.md` — found at **any depth**, so category-nested layouts (`skills/engineering/tdd/SKILL.md`) work too, with the folder's base name as the skill id — commands and agents are flat `.md` files, MCP -fragments are `.yaml`. Everything is read-only scanned — upstream code is -never executed. +fragments are `.yaml`. A flat `.md` under `commands/` or `agents/` whose +frontmatter contains none of the definition keys (`name`, `description`, +`mode`, `agent`, `model`, `argument-hint`, `temperature`, `permission`) is +treated as dropped documentation: skipped with a recorded warning, never an +inventory entry. Everything is read-only scanned — upstream code is never +executed. ### Selection rules diff --git a/internal/discovery/discovery.go b/internal/discovery/discovery.go index 916cd2d..4925f31 100644 --- a/internal/discovery/discovery.go +++ b/internal/discovery/discovery.go @@ -36,6 +36,11 @@ type Item struct { RelPath string Frontmatter *Frontmatter Warnings []string + // Skipped marks a file discovery recognized but deliberately excluded — + // e.g. a documentation .md under agents/ or commands/ without definition + // frontmatter. Skipped items carry warnings but must never enter a + // selection, inventory or availability listing. + Skipped bool } // Scan discovers items of all four content types under cfg. Discovery is a @@ -156,6 +161,12 @@ func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ continue } it := Item{Type: typ, UpstreamID: id, RelPath: rel} + if !HasDefinitionFrontmatter(data) { + it.Skipped = true + it.Warnings = append(it.Warnings, fmt.Sprintf("%s: no %s frontmatter, skipped", rel, strings.TrimSuffix(typ, "s"))) + items = append(items, it) + continue + } fm, warnings := ParseFrontmatter(data) it.Frontmatter = &fm for _, w := range warnings { diff --git a/internal/discovery/frontmatter.go b/internal/discovery/frontmatter.go index 7f22740..56d46a7 100644 --- a/internal/discovery/frontmatter.go +++ b/internal/discovery/frontmatter.go @@ -20,16 +20,11 @@ type Frontmatter struct { func ParseFrontmatter(data []byte) (Frontmatter, []string) { var fm Frontmatter var warnings []string - lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") - if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" { + lines, fenced, closed := parseFence(data) + if !fenced { return fm, nil } - closed := false - for _, line := range lines[1:] { - if strings.TrimSpace(line) == "---" { - closed = true - break - } + for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" { continue @@ -58,6 +53,52 @@ func ParseFrontmatter(data []byte) (Frontmatter, []string) { return fm, warnings } +// definitionKeys mark a flat .md under agents/ or commands/ as an intentional +// agent/command definition rather than dropped documentation. +var definitionKeys = map[string]bool{ + "name": true, + "description": true, + "mode": true, + "agent": true, + "model": true, + "argument-hint": true, + "temperature": true, + "permission": true, +} + +// HasDefinitionFrontmatter reports whether data's leading frontmatter block +// contains any key that marks a flat .md as an agent/command definition. +func HasDefinitionFrontmatter(data []byte) bool { + lines, fenced, _ := parseFence(data) + if !fenced { + return false + } + for _, line := range lines { + key, _, ok := strings.Cut(strings.TrimSpace(line), ":") + if ok && definitionKeys[strings.ToLower(strings.TrimSpace(key))] { + return true + } + } + return false +} + +// parseFence returns the lines between the leading `---` fences: the block +// lines, whether the file is fenced at all, and whether the fence closes. +func parseFence(data []byte) (lines []string, fenced, closed bool) { + all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") + if len(all) == 0 || strings.TrimSpace(all[0]) != "---" { + return nil, false, false + } + fenced = true + for _, line := range all[1:] { + if strings.TrimSpace(line) == "---" { + return lines, fenced, true + } + lines = append(lines, line) + } + return lines, fenced, false +} + func splitList(v string) []string { if v == "" { return nil diff --git a/internal/discovery/skip_test.go b/internal/discovery/skip_test.go new file mode 100644 index 0000000..ea95c5d --- /dev/null +++ b/internal/discovery/skip_test.go @@ -0,0 +1,81 @@ +package discovery + +import ( + "strings" + "testing" +) + +func TestHasDefinitionFrontmatter(t *testing.T) { + cases := []struct { + name string + in string + want bool + }{ + {name: "no frontmatter at all", in: "# Schema docs\nplain documentation\n", want: false}, + {name: "empty fence", in: "---\n---\nbody\n", want: false}, + {name: "only unknown keys", in: "---\ntitle: Schema\nlayout: docs\n---\n", want: false}, + {name: "name key", in: "---\nname: Scout\n---\nbody\n", want: true}, + {name: "description key", in: "---\ndescription: scouts things\n---\n", want: true}, + {name: "mode key", in: "---\nmode: primary\n---\n", want: true}, + {name: "model key", in: "---\nmodel: sonnet\n---\n", want: true}, + {name: "argument-hint key", in: "---\nargument-hint: [msg]\n---\n", want: true}, + {name: "temperature key", in: "---\ntemperature: 0.2\n---\n", want: true}, + {name: "permission key", in: "---\npermission: edit\n---\n", want: true}, + {name: "mixed unknown and known", in: "---\ntitle: X\ndescription: real agent\n---\n", want: true}, + {name: "unclosed block still counts keys", in: "---\nname: Broken\n", want: true}, + {name: "case-insensitive keys", in: "---\nDescription: Y\n---\n", want: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := HasDefinitionFrontmatter([]byte(tc.in)); got != tc.want { + t.Errorf("HasDefinitionFrontmatter(%q) = %v, want %v", tc.in, got, tc.want) + } + }) + } +} + +func TestScanSkipsDocMarkdownInFlatDirs(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "agents/scout.md": "---\ndescription: a real agent\n---\nbody\n", + "agents/SCHEMA.md": "# Schema\npure documentation, no frontmatter\n", + "commands/review.md": "---\nargument-hint: [files]\n---\nbody\n", + "commands/README.md": "Documentation for the commands directory.\n", + "commands/notes.md": "---\ntitle: Notes\n---\nunknown keys only\n", + "skills/good/SKILL.md": "---\nname: Good\n---\n# Good\n", + }) + items, err := Scan(&FsTree{Root: dir}, Config{}) + if err != nil { + t.Fatal(err) + } + + got := map[string]bool{} + skippedWarnings := []string{} + for _, it := range items { + if it.Skipped { + skippedWarnings = append(skippedWarnings, it.Warnings...) + continue + } + got[it.Type+"/"+it.UpstreamID] = true + } + for _, want := range []string{"agents/scout", "commands/review", "skills/good"} { + if !got[want] { + t.Errorf("%s must still be discovered; got %v", want, got) + } + } + for _, doc := range []string{"agents/SCHEMA", "commands/README", "commands/notes"} { + if got[doc] { + t.Errorf("%s is documentation and must be skipped; got %v", doc, got) + } + } + joined := strings.Join(skippedWarnings, "\n") + for _, want := range []string{ + "agents/SCHEMA.md: no agent frontmatter, skipped", + "commands/README.md: no command frontmatter, skipped", + "commands/notes.md: no command frontmatter, skipped", + } { + if !strings.Contains(joined, want) { + t.Errorf("skip warning %q missing; got %v", want, skippedWarnings) + } + } +} diff --git a/internal/e2e/skip_docs_test.go b/internal/e2e/skip_docs_test.go new file mode 100644 index 0000000..0c37c42 --- /dev/null +++ b/internal/e2e/skip_docs_test.go @@ -0,0 +1,86 @@ +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func docFixture(h *harness, t *testing.T) { + t.Helper() + h.write(t, "skills/good-skill/SKILL.md", "---\nname: Good Skill\ndescription: works\n---\n# Good\n") + h.write(t, "agents/scout.md", "---\ndescription: a real agent\n---\nbody\n") + h.write(t, "agents/SCHEMA.md", "# Schema\npure documentation, no frontmatter\n") + h.write(t, "commands/review.md", "---\nargument-hint: [files]\n---\nbody\n") + h.write(t, "commands/README.md", "Documentation for the commands directory.\n") + h.commitUpstream(t, "fixture with documentation files") +} + +func TestVendorAddSkipsDocMarkdownWithWarning(t *testing.T) { + h := newHarness(t) + docFixture(h, t) + + out := h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir) + if !contains(out, "agents/SCHEMA.md: no agent frontmatter, skipped") { + t.Errorf("add report must warn about skipped SCHEMA.md:\n%s", out) + } + if !contains(out, "commands/README.md: no command frontmatter, skipped") { + t.Errorf("add report must warn about skipped README.md:\n%s", out) + } + + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if !contains(lock, "agents/SCHEMA.md: no agent frontmatter, skipped") { + t.Errorf("lockfile warnings must record the skip:\n%s", lock) + } + for _, dir := range []string{"agents", "commands"} { + entries, err := os.ReadDir(filepath.Join(h.workDir, "external", "superpowers", dir)) + if err != nil { + t.Fatalf("external %s area must exist: %v", dir, err) + } + for _, e := range entries { + name := e.Name() + if (dir == "agents" && name == "SCHEMA.md") || (dir == "commands" && name == "README.md") { + t.Errorf("documentation file %s/%s must not be materialized", dir, name) + } + } + } + if !contains(out, "1 agents") || !contains(out, "1 commands") { + t.Errorf("counts must reflect real items only:\n%s", out) + } + h.mustRun(t, "validate") +} + +func TestVendorAddIncludeErrorOmitsSkippedDocs(t *testing.T) { + h := newHarness(t) + docFixture(h, t) + + out, err := h.run(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", "typo") + if err == nil { + t.Fatalf("include with typo must fail:\n%s", out) + } + if contains(out, "SCHEMA") || contains(out, "README") { + t.Errorf("skipped documentation must not appear as available ids:\n%s", out) + } + for _, want := range []string{"scout", "review", "good-skill"} { + if !contains(out, want) { + t.Errorf("real ids must still be listed as available (missing %s):\n%s", want, out) + } + } +} + +func TestVendorUpdateSelectKeepSkippingDocs(t *testing.T) { + h := newHarness(t) + docFixture(h, t) + h.mustRun(t, "vendor", "add", "superpowers", h.upstreamDir, "--include", "scout,review") + + out := h.mustRun(t, "vendor", "select", "superpowers", "--add", "good-skill") + if contains(out, "SCHEMA") { + t.Errorf("select report must not surface skipped docs:\n%s", out) + } + lock := readFile(t, filepath.Join(h.workDir, "agent-lib.lock.json")) + if got := strings.Count(lock, "no agent frontmatter, skipped"); got != 1 { + t.Errorf("warning must stay recorded exactly once in the lockfile, got %d", got) + } + h.mustRun(t, "validate") +} diff --git a/internal/vendor/add.go b/internal/vendor/add.go index 662a31a..00a3a14 100644 --- a/internal/vendor/add.go +++ b/internal/vendor/add.go @@ -150,7 +150,9 @@ func discoveryFromTreeRoot(root string) lockfile.Discovery { func checkIncludeEntries(items []discovery.Item, include []string, name string) error { available := map[string]bool{} for _, it := range items { - available[it.UpstreamID] = true + if !it.Skipped { + available[it.UpstreamID] = true + } } for _, want := range include { if !available[want] { @@ -171,6 +173,9 @@ func applySelection(items []discovery.Item, sel lockfile.Selection) []discovery. } var out []discovery.Item for _, it := range items { + if it.Skipped { + continue + } switch sel.Mode { case lockfile.ModeInclude: if include[it.UpstreamID] { @@ -216,10 +221,18 @@ func materialize(workDir, name string, tree discovery.Tree, selected []discovery } func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selected []discovery.Item) { + real := 0 + for _, it := range discovered { + if !it.Skipped { + real++ + } + } counts := func(items []discovery.Item) string { per := map[string]int{} for _, it := range items { - per[it.Type]++ + if !it.Skipped { + per[it.Type]++ + } } var parts []string for _, t := range lockfile.Types { @@ -236,7 +249,7 @@ func reportAdd(w io.Writer, name string, src *lockfile.Source, discovered, selec fmt.Fprintf(w, " url: %s\n", src.URL) fmt.Fprintf(w, " ref: %s\n", src.Ref) fmt.Fprintf(w, " rev: %s\n", src.Rev) - fmt.Fprintf(w, "discovered %d items (%s)\n", len(discovered), counts(discovered)) + fmt.Fprintf(w, "discovered %d items (%s)\n", real, counts(discovered)) fmt.Fprintf(w, "selected %d items (%s)\n", len(selected), counts(selected)) fmt.Fprintf(w, "materialized under %s/\n", filepath.Join(externalDir, name)) if len(src.Warnings) > 0 { diff --git a/internal/vendor/collide.go b/internal/vendor/collide.go index 427ce86..e7d12d9 100644 --- a/internal/vendor/collide.go +++ b/internal/vendor/collide.go @@ -39,6 +39,9 @@ func ownDeployed(workDir string) (deployedIndex, error) { } idx := deployedIndex{} for _, it := range items { + if it.Skipped { + continue + } idx.add(it.Type, it.UpstreamID, owner{source: "own", id: it.UpstreamID}) } return idx, nil diff --git a/internal/vendor/update.go b/internal/vendor/update.go index 0f48a89..5fbf0e9 100644 --- a/internal/vendor/update.go +++ b/internal/vendor/update.go @@ -226,7 +226,7 @@ func availableSince(old map[string]map[string]bool, items []discovery.Item, sele inSelection := idSet(selected) out := map[string][]string{} for _, it := range items { - if old[it.Type][it.UpstreamID] || inSelection[it.Type][it.UpstreamID] { + if it.Skipped || old[it.Type][it.UpstreamID] || inSelection[it.Type][it.UpstreamID] { continue } out[it.Type] = append(out[it.Type], it.UpstreamID)