Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .claude/skills/github-pr-review.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .claude/skills/openspec-development.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .creed/skills/github-pr-review.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: github-pr-review
description: Review checklist for Creed pull requests.
---

# GitHub PR Review Skill

Use this when reviewing a Creed pull request.
Expand Down
5 changes: 5 additions & 0 deletions .creed/skills/openspec-development.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: openspec-development
description: Workflow for adding significant Creed behavior via OpenSpec changes.
---

# OpenSpec Development Skill

Use this when adding or changing significant Creed behavior.
Expand Down
5 changes: 5 additions & 0 deletions .cursor/rules/github-pr-review.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions .cursor/rules/openspec-development.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,33 @@ engine renders those descriptors instead of inferring behavior from filenames.
`AGENTS.md`, `GEMINI.md`, `.windsurfrules`, or Aider's `CONVENTIONS.md`.
- Skill directory outputs receive one file per skill, such as `.claude/skills/`
`.cursor/rules/`, and `.gemini/`.
- **Directory-shaped skills.** A skill entry may point at a directory that
contains `SKILL.md` instead of a single flat file. The whole directory —
`references/`, `templates/`, `scripts/`, `assets/`, any regular files — is
emitted under `<skill-dir>/<name>/`, so Hermes-style skills with support
files sync with zero data loss:

```yaml
skills:
- name: techgodhq
path: skills/techgodhq # directory containing SKILL.md
```

emits `.claude/skills/techgodhq/SKILL.md` plus every support file, byte
for byte. Symlinks and non-regular files inside a skill directory are
rejected. A directory that contains only `SKILL.md` is an error — declare
the file path directly instead.
- **Skill frontmatter validation.** When a skill file carries YAML
frontmatter, creed validates the discovery contract: `name` must match the
manifest entry name and `description` must be present. `validate` reports
violations as errors naming the file and the problem; `sync` refuses to
render a skill that breaks the contract. Skills without frontmatter are
legal but produce a `missing_skill_frontmatter` warning, because
downstream tools (Claude Code, Hermes) discover skills through these
fields.
- `validate` also warns when declared skills have no enabled target with a
skill output (for example a skills-only source with just the `agents`
target enabled) — previously that combination was a silent no-op.
- Target-specific config outputs are rendered by explicit per-target renderers.
Aider receives `.aider.conf.yml` pointing Aider at `CONVENTIONS.md`, plus the
separate `CONVENTIONS.md` context file.
Expand Down
74 changes: 74 additions & 0 deletions internal/adapters/localfs/source.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"runtime"
Expand Down Expand Up @@ -172,6 +173,9 @@ func (s *Source) ReadManifestBytes(ctx context.Context) ([]byte, error) {
}

// ReadSkill reads a skill's full content by name.
// A skill entry may point at a single markdown file or at a directory
// containing SKILL.md; directory skills are read as a full support-file
// tree (see readSkillDirectory).
func (s *Source) ReadSkill(ctx context.Context, name string) (*domain.Skill, error) {
manifest, err := s.ReadManifest(ctx)
if err != nil {
Expand All @@ -185,6 +189,9 @@ func (s *Source) ReadSkill(ctx context.Context, name string) (*domain.Skill, err
if err != nil {
return nil, fmt.Errorf("invalid skill path %s: %w", entry.Path, err)
}
if info, statErr := os.Lstat(skillPath); statErr == nil && info.IsDir() {
return s.readSkillDirectory(entry, skillPath)
}
content, err := readContainedFile(s.rootDir, skillPath)
if err != nil {
return nil, fmt.Errorf("failed to read skill file %s: %w", skillPath, err)
Expand All @@ -200,6 +207,73 @@ func (s *Source) ReadSkill(ctx context.Context, name string) (*domain.Skill, err
return nil, fmt.Errorf("skill not found: %s", name)
}

// readSkillDirectory reads a directory-shaped skill: SKILL.md becomes the
// skill content and every other regular file becomes a support file keyed by
// slash-separated path relative to the skill directory. The walk rejects
// symlinks and non-regular files so a skill directory cannot smuggle content
// outside the source tree or depend on link targets.
func (s *Source) readSkillDirectory(entry domain.SkillEntry, skillDir string) (*domain.Skill, error) {
skillRel := filepath.ToSlash(filepath.Clean(entry.Path))
files := make(map[string][]byte)
err := filepath.WalkDir(skillDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == skillDir {
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("skill directory %s contains symlink %s", skillRel, relSlash(skillDir, path))
}
rel := relSlash(skillDir, path)
if d.IsDir() {
// Reserved support directories emitted verbatim; anything else
// rides along as opaque content.
return nil
}
if !d.Type().IsRegular() {
return fmt.Errorf("skill directory %s contains non-regular file %s", skillRel, rel)
}
if rel == skillFileName {
return nil
}
content, err := readContainedFile(s.rootDir, path)
if err != nil {
return fmt.Errorf("failed to read skill file %s: %w", path, err)
}
files[rel] = content
return nil
})
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, fmt.Errorf("skill directory %s contains only SKILL.md; declare the file path directly instead", skillRel)
}
skillMDPath := filepath.Join(skillDir, skillFileName)
content, err := readContainedFile(s.rootDir, skillMDPath)
if err != nil {
return nil, fmt.Errorf("failed to read skill file %s: %w", skillMDPath, err)
}
return &domain.Skill{
Name: entry.Name,
Path: entry.Path,
Content: content,
Files: files,
}, nil
}

// skillFileName is the required markdown entrypoint of a directory-shaped skill.
const skillFileName = "SKILL.md"

func relSlash(base, path string) string {
rel, err := filepath.Rel(base, path)
if err != nil {
return filepath.ToSlash(path)
}
return filepath.ToSlash(rel)
}

// ListSkills returns lightweight info for all skills declared in the manifest.
func (s *Source) ListSkills(ctx context.Context) ([]domain.SkillInfo, error) {
manifest, err := s.ReadManifest(ctx)
Expand Down
107 changes: 107 additions & 0 deletions internal/adapters/localfs/source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,110 @@ func TestReadManifestRejectsAncestorSymlinkSourcePath(t *testing.T) {
t.Fatal("ReadManifest traversed an ancestor symlink")
}
}

// createDirectorySkillProject sets up a project whose skill entry points at a
// directory containing SKILL.md plus support files.
func createDirectorySkillProject(t *testing.T) string {
t.Helper()
root := t.TempDir()
creedDir := filepath.Join(root, ".creed")
if err := os.MkdirAll(filepath.Join(creedDir, "skills", "techgodhq", "references"), 0755); err != nil {
t.Fatal(err)
}
manifest := `version: 1
source:
type: local
path: .creed

targets:
- name: claude
enabled: true
output_dir: .

skills:
- name: techgodhq
path: skills/techgodhq

config: []
`
if err := os.WriteFile(filepath.Join(creedDir, "manifest.yaml"), []byte(manifest), 0644); err != nil {
t.Fatal(err)
}
skillMD := "---\nname: techgodhq\ndescription: Org-wide agent procedures.\n---\n# TechGodHQ Skill\nUse the references.\n"
if err := os.WriteFile(filepath.Join(creedDir, "skills", "techgodhq", "SKILL.md"), []byte(skillMD), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(creedDir, "skills", "techgodhq", "references", "git.md"), []byte("# Git policy\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(creedDir, "skills", "techgodhq", "references", "review.md"), []byte("# Review policy\n"), 0644); err != nil {
t.Fatal(err)
}
return root
}

func TestReadSkillDirectory(t *testing.T) {
root := createDirectorySkillProject(t)
source := NewSource(root)
skill, err := source.ReadSkill(context.Background(), "techgodhq")
if err != nil {
t.Fatalf("ReadSkill directory skill: %v", err)
}
if !skill.IsDirectory() {
t.Fatal("expected directory-shaped skill")
}
want := "---\nname: techgodhq\ndescription: Org-wide agent procedures.\n---\n# TechGodHQ Skill\nUse the references.\n"
if string(skill.Content) != want {
t.Fatalf("SKILL.md content mismatch: %q", skill.Content)
}
if len(skill.Files) != 2 {
t.Fatalf("expected 2 support files, got %d: %v", len(skill.Files), skill.Files)
}
if string(skill.Files["references/git.md"]) != "# Git policy\n" {
t.Fatalf("support file git.md mismatch: %q", skill.Files["references/git.md"])
}
if string(skill.Files["references/review.md"]) != "# Review policy\n" {
t.Fatalf("support file review.md mismatch: %q", skill.Files["references/review.md"])
}
}

func TestReadSkillDirectoryMissingSkillMD(t *testing.T) {
root := createDirectorySkillProject(t)
if err := os.Remove(filepath.Join(root, ".creed", "skills", "techgodhq", "SKILL.md")); err != nil {
t.Fatal(err)
}
source := NewSource(root)
if _, err := source.ReadSkill(context.Background(), "techgodhq"); err == nil {
t.Fatal("expected error when directory skill lacks SKILL.md")
}
}

func TestReadSkillDirectoryRejectsSymlink(t *testing.T) {
root := createDirectorySkillProject(t)
outside := filepath.Join(root, "outside.md")
if err := os.WriteFile(outside, []byte("escaped content\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, ".creed", "skills", "techgodhq", "references", "escape.md")); err != nil {
t.Fatal(err)
}
source := NewSource(root)
_, err := source.ReadSkill(context.Background(), "techgodhq")
if err == nil {
t.Fatal("expected symlink inside skill directory to be rejected")
}
}

func TestReadSkillDirectoryOnlySkillMD(t *testing.T) {
root := createDirectorySkillProject(t)
if err := os.Remove(filepath.Join(root, ".creed", "skills", "techgodhq", "references", "git.md")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(root, ".creed", "skills", "techgodhq", "references", "review.md")); err != nil {
t.Fatal(err)
}
source := NewSource(root)
if _, err := source.ReadSkill(context.Background(), "techgodhq"); err == nil {
t.Fatal("expected directory with only SKILL.md to be rejected in favor of a direct file entry")
}
}
17 changes: 15 additions & 2 deletions internal/domain/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,27 @@ package domain

import "time"

// Skill represents an AI skill file stored in the creed source.
// Skill represents an AI skill stored in the creed source.
// A skill is either a single markdown file (Content set, Files nil) or a
// directory-shaped skill (Files set): a directory containing SKILL.md plus
// optional support files such as references/, templates/, scripts/, or assets/.
type Skill struct {
// Name is the canonical skill identifier (e.g., "code-review").
Name string
// Path is the relative path to the skill file within the source.
Path string
// Content is the raw file content of the skill.
// Content is the raw file content of the skill. For directory-shaped
// skills this holds the SKILL.md content.
Content []byte
// Files holds the support files of a directory-shaped skill, keyed by
// path relative to the skill directory (e.g. "references/api.md").
// Nil for flat single-file skills.
Files map[string][]byte
}

// IsDirectory reports whether the skill is directory-shaped.
func (s Skill) IsDirectory() bool {
return s.Files != nil
}

// SkillInfo is a lightweight summary of a skill, without content payload.
Expand Down
7 changes: 6 additions & 1 deletion internal/service/impl.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ Document build, test, lint, and release commands here.
},
{
Path: "skills/review.md",
Content: `# Review Guidelines
Content: `---
name: review
description: Guidelines for agents reviewing changes in this project.
---

# Review Guidelines

Describe how agents should review changes in this project.
`,
Expand Down
Loading
Loading