Skip to content

Commit 2547095

Browse files
committed
feat: add project skill helpers
1 parent 3a76e8b commit 2547095

8 files changed

Lines changed: 188 additions & 62 deletions

File tree

go.mod

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
module github.com/goforj/atlas
22

3-
go 1.25.5
3+
go 1.25.0
44

55
require (
66
github.com/go-git/go-git/v5 v5.19.1
7-
github.com/mark3labs/mcp-go v0.54.1
7+
github.com/mark3labs/mcp-go v0.48.0
88
)
99

1010
require (
@@ -23,7 +23,6 @@ require (
2323
github.com/kevinburke/ssh_config v1.2.0 // indirect
2424
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
2525
github.com/pjbgf/sha1cd v0.6.0 // indirect
26-
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
2726
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
2827
github.com/skeema/knownhosts v1.3.1 // indirect
2928
github.com/spf13/cast v1.7.1 // indirect
@@ -32,6 +31,5 @@ require (
3231
golang.org/x/crypto v0.50.0 // indirect
3332
golang.org/x/net v0.53.0 // indirect
3433
golang.org/x/sys v0.43.0 // indirect
35-
golang.org/x/text v0.36.0 // indirect
3634
gopkg.in/warnings.v0 v0.1.2 // indirect
3735
)

go.sum

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1
1616
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1717
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1818
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
19-
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
20-
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
2119
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
2220
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
2321
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -55,8 +53,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
5553
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
5654
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
5755
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
58-
github.com/mark3labs/mcp-go v0.54.1 h1:Ap/ptEB9FtWzFKM8NDsTA7QDxerQOC06eZigrTldVj0=
59-
github.com/mark3labs/mcp-go v0.54.1/go.mod h1:+8WclSK1ZUweCP3hvktSji8n8ABG/95QaEkeVE/Uwas=
56+
github.com/mark3labs/mcp-go v0.48.0 h1:o+MXuGW/HCeR2ny5LcAcZQn2bo6I2xaZMEHnpRG+dtw=
57+
github.com/mark3labs/mcp-go v0.48.0/go.mod h1:JKTC7R2LLVagkEWK7Kwu7DbmA6iIvnNAod6yrHiQMag=
6058
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
6159
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
6260
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -67,8 +65,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
6765
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
6866
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
6967
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
70-
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
71-
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
7268
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
7369
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
7470
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=

guidelines/composer.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ func Compose(p project.Project) string {
2929
out.WriteString("- Update app registration and Wire files through the selected app's composition points.\n")
3030
out.WriteString("- Keep MCP and Atlas operations read-only unless an explicit write feature is added later.\n")
3131
out.WriteString("- For GoForj framework validation renders, use `/tmp`, never the GoForj repo directory.\n\n")
32+
out.WriteString("## Capturing Project Knowledge\n\n")
33+
out.WriteString("- When the user teaches a durable repo-specific convention, workflow, command, or review expectation, briefly ask whether it should become a project-owned Atlas skill in `.ai/skills/<name>/SKILL.md`.\n")
34+
out.WriteString("- Suggest this only for patterns likely to matter again; do not suggest skills for one-off preferences or temporary debugging steps.\n")
35+
out.WriteString("- Keep project-owned skills short, specific, and focused on what agents should do differently in this codebase.\n\n")
3236

3337
out.WriteString("## Apps\n\n")
3438
for _, app := range p.Apps {

mcp/server.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ func New(cfg Server) *server.MCPServer {
3434
cfg.Version,
3535
server.WithToolCapabilities(false),
3636
server.WithRecovery(),
37-
server.WithStrictInputSchemaDefault(),
38-
server.WithInputSchemaValidation(),
3937
)
4038
cfg.register(s)
4139
return s

mcp/tools.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ func toolProjectLayout() mcpgo.Tool {
3434
func toolSearchDocs() mcpgo.Tool {
3535
return baseTool("search-docs", "Searches version-aware GoForj documentation.",
3636
mcpgo.WithString("query", mcpgo.Description("Search query."), mcpgo.Required()),
37-
mcpgo.WithInteger("limit", mcpgo.Description("Maximum number of results.")),
38-
mcpgo.WithInteger("token_limit", mcpgo.Description("Maximum words per snippet.")),
37+
mcpgo.WithNumber("limit", mcpgo.Description("Maximum number of results.")),
38+
mcpgo.WithNumber("token_limit", mcpgo.Description("Maximum words per snippet.")),
3939
)
4040
}
4141

@@ -44,7 +44,7 @@ func toolReadDocSection() mcpgo.Tool {
4444
return baseTool("read-doc-section", "Reads one bounded Markdown section.",
4545
mcpgo.WithString("path", mcpgo.Description("Document path."), mcpgo.Required()),
4646
mcpgo.WithString("heading", mcpgo.Description("Heading to read.")),
47-
mcpgo.WithInteger("token_limit", mcpgo.Description("Maximum words to return.")),
47+
mcpgo.WithNumber("token_limit", mcpgo.Description("Maximum words to return.")),
4848
)
4949
}
5050

@@ -53,9 +53,9 @@ func toolReadDocNeighborhood() mcpgo.Tool {
5353
return baseTool("read-doc-neighborhood", "Reads one Markdown section plus nearby sections.",
5454
mcpgo.WithString("path", mcpgo.Description("Document path."), mcpgo.Required()),
5555
mcpgo.WithString("heading", mcpgo.Description("Heading to read."), mcpgo.Required()),
56-
mcpgo.WithInteger("before", mcpgo.Description("Sections before.")),
57-
mcpgo.WithInteger("after", mcpgo.Description("Sections after.")),
58-
mcpgo.WithInteger("token_limit", mcpgo.Description("Maximum words per section.")),
56+
mcpgo.WithNumber("before", mcpgo.Description("Sections before.")),
57+
mcpgo.WithNumber("after", mcpgo.Description("Sections after.")),
58+
mcpgo.WithNumber("token_limit", mcpgo.Description("Maximum words per section.")),
5959
)
6060
}
6161

@@ -105,15 +105,15 @@ func toolDatabaseQuery() mcpgo.Tool {
105105
return baseTool("database-query", "Runs a bounded read-only database query.",
106106
mcpgo.WithString("connection", mcpgo.Description("Database connection name."), mcpgo.Required()),
107107
mcpgo.WithString("sql", mcpgo.Description("Read-only SQL query."), mcpgo.Required()),
108-
mcpgo.WithInteger("limit", mcpgo.Description("Maximum rows to return.")),
108+
mcpgo.WithNumber("limit", mcpgo.Description("Maximum rows to return.")),
109109
)
110110
}
111111

112112
// toolReadLogEntries defines the recent log reader tool.
113113
func toolReadLogEntries() mcpgo.Tool {
114114
return baseTool("read-log-entries", "Returns recent framework log entries.",
115115
appArg(),
116-
mcpgo.WithInteger("limit", mcpgo.Description("Maximum entries to return.")),
116+
mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return.")),
117117
)
118118
}
119119

@@ -134,7 +134,7 @@ func toolGetAbsoluteURL() mcpgo.Tool {
134134
func toolBrowserLogs() mcpgo.Tool {
135135
return baseTool("browser-logs", "Returns recent browser console logs captured during local development.",
136136
appArg(),
137-
mcpgo.WithInteger("limit", mcpgo.Description("Maximum entries to return.")),
137+
mcpgo.WithNumber("limit", mcpgo.Description("Maximum entries to return.")),
138138
)
139139
}
140140

skills/project.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package skills
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"regexp"
8+
"sort"
9+
"strings"
10+
)
11+
12+
var projectSkillNamePattern = regexp.MustCompile(`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`)
13+
14+
// ProjectSkill describes a repo-owned Atlas skill under .ai/skills.
15+
type ProjectSkill struct {
16+
Name string
17+
Path string
18+
File bool
19+
}
20+
21+
// ProjectSkills returns repo-owned Atlas skills from .ai/skills.
22+
func ProjectSkills(root string) ([]ProjectSkill, error) {
23+
sourceRoot := filepath.Join(firstNonEmpty(root, "."), ".ai", "skills")
24+
entries, err := os.ReadDir(sourceRoot)
25+
if os.IsNotExist(err) {
26+
return nil, nil
27+
}
28+
if err != nil {
29+
return nil, err
30+
}
31+
32+
projectSkills := []ProjectSkill{}
33+
for _, entry := range entries {
34+
path := filepath.Join(sourceRoot, entry.Name())
35+
if entry.IsDir() {
36+
skillPath := filepath.Join(path, "SKILL.md")
37+
if !fileExists(skillPath) {
38+
continue
39+
}
40+
projectSkills = append(projectSkills, ProjectSkill{
41+
Name: entry.Name(),
42+
Path: skillPath,
43+
})
44+
continue
45+
}
46+
if strings.HasSuffix(entry.Name(), ".md") {
47+
projectSkills = append(projectSkills, ProjectSkill{
48+
Name: strings.TrimSuffix(entry.Name(), ".md"),
49+
Path: path,
50+
File: true,
51+
})
52+
}
53+
}
54+
55+
sort.Slice(projectSkills, func(i int, j int) bool {
56+
return projectSkills[i].Name < projectSkills[j].Name
57+
})
58+
return projectSkills, nil
59+
}
60+
61+
// ScaffoldProjectSkill creates a repo-owned Atlas skill template.
62+
func ScaffoldProjectSkill(root string, name string) (string, error) {
63+
name = strings.TrimSpace(name)
64+
if !projectSkillNamePattern.MatchString(name) {
65+
return "", fmt.Errorf("invalid skill name %q; use lowercase kebab-case such as checkout-rules", name)
66+
}
67+
68+
path := filepath.Join(firstNonEmpty(root, "."), ".ai", "skills", name, "SKILL.md")
69+
if fileExists(path) {
70+
return "", fmt.Errorf("project skill %q already exists at %s", name, path)
71+
}
72+
content := projectSkillTemplate(name)
73+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
74+
return "", err
75+
}
76+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
77+
return "", err
78+
}
79+
return path, nil
80+
}
81+
82+
// firstNonEmpty returns the first non-blank value.
83+
func firstNonEmpty(values ...string) string {
84+
for _, value := range values {
85+
if strings.TrimSpace(value) != "" {
86+
return value
87+
}
88+
}
89+
return ""
90+
}
91+
92+
func projectSkillTemplate(name string) string {
93+
title := title(name)
94+
return fmt.Sprintf(`# %s
95+
96+
Describe the durable repo-specific convention, workflow, command, or review expectation this agent should remember.
97+
98+
Use this skill when:
99+
100+
- the task touches this project's specific rules
101+
- the user asks for behavior covered by this convention
102+
- the agent is about to generate, review, or modify related code
103+
104+
Guidance:
105+
106+
- Keep changes consistent with the convention.
107+
- Prefer concrete project commands and paths over broad advice.
108+
- If the convention no longer applies, ask before following it.
109+
`, title)
110+
}

skills/writer.go

Lines changed: 18 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -104,69 +104,46 @@ func writeCopilot(opts WriteOptions) ([]string, error) {
104104

105105
// copyUserSkills preserves project-owned skills alongside generated Atlas skills.
106106
func copyUserSkills(projectRoot string, targetRoot string) ([]string, error) {
107-
sourceRoot := filepath.Join(projectRoot, ".ai", "skills")
108-
entries, err := os.ReadDir(sourceRoot)
109-
if os.IsNotExist(err) {
110-
return nil, nil
111-
}
107+
projectSkills, err := ProjectSkills(projectRoot)
112108
if err != nil {
113109
return nil, err
114110
}
115111

116112
paths := []string{}
117-
for _, entry := range entries {
118-
source := filepath.Join(sourceRoot, entry.Name())
119-
target := filepath.Join(targetRoot, entry.Name())
120-
if entry.IsDir() {
121-
copied, err := copyDir(source, target)
122-
if err != nil {
113+
for _, skill := range projectSkills {
114+
if skill.File {
115+
target := filepath.Join(targetRoot, filepath.Base(skill.Path))
116+
if err := copyFile(skill.Path, target); err != nil {
123117
return nil, err
124118
}
125-
paths = append(paths, copied...)
119+
paths = append(paths, target)
126120
continue
127121
}
128-
if strings.HasSuffix(entry.Name(), ".md") {
129-
if err := copyFile(source, target); err != nil {
130-
return nil, err
131-
}
132-
paths = append(paths, target)
122+
source := filepath.Dir(skill.Path)
123+
target := filepath.Join(targetRoot, skill.Name)
124+
copied, err := copyDir(source, target)
125+
if err != nil {
126+
return nil, err
133127
}
128+
paths = append(paths, copied...)
134129
}
135130
return paths, nil
136131
}
137132

138133
// writeCopilotUserSkills adapts project-owned SKILL.md files to Copilot instructions.
139134
func writeCopilotUserSkills(projectRoot string, targetRoot string) ([]string, error) {
140-
sourceRoot := filepath.Join(projectRoot, ".ai", "skills")
141-
entries, err := os.ReadDir(sourceRoot)
142-
if os.IsNotExist(err) {
143-
return nil, nil
144-
}
135+
projectSkills, err := ProjectSkills(projectRoot)
145136
if err != nil {
146137
return nil, err
147138
}
148139

149140
paths := []string{}
150-
for _, entry := range entries {
151-
if entry.IsDir() {
152-
source := filepath.Join(sourceRoot, entry.Name(), "SKILL.md")
153-
if !fileExists(source) {
154-
continue
155-
}
156-
target := filepath.Join(targetRoot, entry.Name()+".instructions.md")
157-
if err := copyFile(source, target); err != nil {
158-
return nil, err
159-
}
160-
paths = append(paths, target)
161-
continue
162-
}
163-
if strings.HasSuffix(entry.Name(), ".md") {
164-
target := filepath.Join(targetRoot, strings.TrimSuffix(entry.Name(), ".md")+".instructions.md")
165-
if err := copyFile(filepath.Join(sourceRoot, entry.Name()), target); err != nil {
166-
return nil, err
167-
}
168-
paths = append(paths, target)
141+
for _, skill := range projectSkills {
142+
target := filepath.Join(targetRoot, skill.Name+".instructions.md")
143+
if err := copyFile(skill.Path, target); err != nil {
144+
return nil, err
169145
}
146+
paths = append(paths, target)
170147
}
171148
return paths, nil
172149
}

skills/writer_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,52 @@ func TestWriteCopilotMapsUserSkillsToInstructions(t *testing.T) {
6767
assertFile(t, filepath.Join(root, ".github", "instructions", "local-skill.instructions.md"))
6868
}
6969

70+
func TestProjectSkillsListsDirectoriesAndMarkdownFiles(t *testing.T) {
71+
root := t.TempDir()
72+
writeSkillTestFile(t, filepath.Join(root, ".ai", "skills", "local-skill", "SKILL.md"), "# Local Skill\n")
73+
writeSkillTestFile(t, filepath.Join(root, ".ai", "skills", "runbook.md"), "# Runbook\n")
74+
writeSkillTestFile(t, filepath.Join(root, ".ai", "skills", "draft", "notes.md"), "# Ignored\n")
75+
76+
projectSkills, err := ProjectSkills(root)
77+
if err != nil {
78+
t.Fatalf("project skills: %v", err)
79+
}
80+
if len(projectSkills) != 2 {
81+
t.Fatalf("expected 2 project skills, got %#v", projectSkills)
82+
}
83+
if projectSkills[0].Name != "local-skill" || projectSkills[1].Name != "runbook" {
84+
t.Fatalf("unexpected project skills %#v", projectSkills)
85+
}
86+
}
87+
88+
func TestScaffoldProjectSkill(t *testing.T) {
89+
root := t.TempDir()
90+
path, err := ScaffoldProjectSkill(root, "checkout-rules")
91+
if err != nil {
92+
t.Fatalf("scaffold project skill: %v", err)
93+
}
94+
assertFile(t, path)
95+
if _, err := ScaffoldProjectSkill(root, "checkout-rules"); err == nil {
96+
t.Fatal("expected duplicate skill error")
97+
}
98+
if _, err := ScaffoldProjectSkill(root, "CheckoutRules"); err == nil {
99+
t.Fatal("expected invalid skill name error")
100+
}
101+
}
102+
70103
func assertFile(t *testing.T, path string) {
71104
t.Helper()
72105
if _, err := os.Stat(path); err != nil {
73106
t.Fatalf("expected file %s: %v", path, err)
74107
}
75108
}
109+
110+
func writeSkillTestFile(t *testing.T, path string, content string) {
111+
t.Helper()
112+
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
113+
t.Fatalf("mkdir %s: %v", path, err)
114+
}
115+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
116+
t.Fatalf("write %s: %v", path, err)
117+
}
118+
}

0 commit comments

Comments
 (0)