feat(skills): add per-task skill spec auto-detection and injection

Replace external inject-spec.py hook with built-in zero-config skill
detection in codeagent-wrapper. The system auto-detects project type
from fingerprint files (go.mod, package.json, etc.), maps to installed
skills, and injects SKILL.md content directly into sub-agent prompts.

Key changes:
- Add DetectProjectSkills/ResolveSkillContent in executor/prompt.go
- Add Skills field to TaskSpec with parallel config parsing
- Add --skills CLI flag for explicit override
- Update /do SKILL.md Phase 4 with per-task skill examples
- Remove on-stop.py global hook (not needed)
- Replace inject-spec.py with no-op (detection now internal)
- Add 20 unit tests covering detection, resolution, budget, security

Security: path traversal protection via validSkillName regex,
16K char budget with tag overhead accounting, CRLF normalization.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
This commit is contained in:
cexll
2026-02-09 11:06:36 +08:00
parent 5853539cab
commit 97dfa907d9
24 changed files with 1699 additions and 419 deletions

View File

@@ -29,6 +29,7 @@ type cliOptions struct {
ReasoningEffort string
Agent string
PromptFile string
Skills string
SkipPermissions bool
Worktree bool
@@ -134,6 +135,7 @@ func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
fs.StringVar(&opts.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)")
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path")
fs.StringVar(&opts.Skills, "skills", "", "Comma-separated skill names for spec injection")
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
@@ -339,6 +341,16 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
return nil, fmt.Errorf("task required")
}
var skills []string
if cmd.Flags().Changed("skills") {
for _, s := range strings.Split(opts.Skills, ",") {
s = strings.TrimSpace(s)
if s != "" {
skills = append(skills, s)
}
}
}
cfg := &Config{
WorkDir: defaultWorkdir,
Backend: backendName,
@@ -352,6 +364,7 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
AllowedTools: resolvedAllowedTools,
DisallowedTools: resolvedDisallowedTools,
Skills: skills,
Worktree: opts.Worktree,
}
@@ -418,7 +431,7 @@ func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *vip
return 1
}
if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") {
if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") || cmd.Flags().Changed("skills") {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.")
return 1
}
@@ -585,6 +598,17 @@ func runSingleMode(cfg *Config, name string) int {
taskText = wrapTaskWithAgentPrompt(prompt, taskText)
}
// Resolve skills: explicit > auto-detect from workdir
skills := cfg.Skills
if len(skills) == 0 {
skills = detectProjectSkills(cfg.WorkDir)
}
if len(skills) > 0 {
if content := resolveSkillContent(skills, 0); content != "" {
taskText = taskText + "\n\n# Domain Best Practices\n\n" + content
}
}
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
targetArg := taskText

View File

@@ -52,3 +52,11 @@ func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText str
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
return executor.RunCodexTaskWithContext(parentCtx, taskSpec, backend, codexCommand, buildCodexArgsFn, customArgs, useCustomArgs, silent, timeoutSec)
}
func detectProjectSkills(workDir string) []string {
return executor.DetectProjectSkills(workDir)
}
func resolveSkillContent(skills []string, maxBudget int) string {
return executor.ResolveSkillContent(skills, maxBudget)
}

View File

@@ -26,6 +26,7 @@ type Config struct {
MaxParallelWorkers int
AllowedTools []string
DisallowedTools []string
Skills []string
Worktree bool // Execute in a new git worktree
}

View File

@@ -337,6 +337,16 @@ func DefaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
}
task.Task = WrapTaskWithAgentPrompt(prompt, task.Task)
}
// Resolve skills: explicit > auto-detect from workdir
skills := task.Skills
if len(skills) == 0 {
skills = DetectProjectSkills(task.WorkDir)
}
if len(skills) > 0 {
if content := ResolveSkillContent(skills, 0); content != "" {
task.Task = task.Task + "\n\n# Domain Best Practices\n\n" + content
}
}
if task.UseStdin || ShouldUseStdin(task.Task, false) {
task.UseStdin = true
}

View File

@@ -88,6 +88,13 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
task.Dependencies = append(task.Dependencies, dep)
}
}
case "skills":
for _, s := range strings.Split(value, ",") {
s = strings.TrimSpace(s)
if s != "" {
task.Skills = append(task.Skills, s)
}
}
}
}
@@ -99,17 +106,17 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
if strings.TrimSpace(task.Agent) == "" {
return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex)
}
if err := config.ValidateAgentName(task.Agent); err != nil {
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
}
backend, model, promptFile, reasoning, _, _, _, allowedTools, disallowedTools, err := config.ResolveAgentConfig(task.Agent)
if err != nil {
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
}
if task.Backend == "" {
task.Backend = backend
}
if task.Model == "" {
if err := config.ValidateAgentName(task.Agent); err != nil {
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
}
backend, model, promptFile, reasoning, _, _, _, allowedTools, disallowedTools, err := config.ResolveAgentConfig(task.Agent)
if err != nil {
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
}
if task.Backend == "" {
task.Backend = backend
}
if task.Model == "" {
task.Model = model
}
if task.ReasoningEffort == "" {

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
@@ -128,3 +129,116 @@ func ReadAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error
func WrapTaskWithAgentPrompt(prompt string, task string) string {
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
}
// techSkillMap maps file-existence fingerprints to skill names.
var techSkillMap = []struct {
Files []string // any of these files → this tech
Skills []string
}{
{Files: []string{"go.mod", "go.sum"}, Skills: []string{"golang-base-practices"}},
{Files: []string{"Cargo.toml"}, Skills: []string{"rust-best-practices"}},
{Files: []string{"pyproject.toml", "setup.py", "requirements.txt", "Pipfile"}, Skills: []string{"python-best-practices"}},
{Files: []string{"package.json"}, Skills: []string{"vercel-react-best-practices", "frontend-design"}},
{Files: []string{"vue.config.js", "vite.config.ts", "nuxt.config.ts"}, Skills: []string{"vue-web-app"}},
}
// DetectProjectSkills scans workDir for tech-stack fingerprints and returns
// skill names that are both detected and installed at ~/.claude/skills/{name}/SKILL.md.
func DetectProjectSkills(workDir string) []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
var detected []string
seen := make(map[string]bool)
for _, entry := range techSkillMap {
for _, f := range entry.Files {
if _, err := os.Stat(filepath.Join(workDir, f)); err == nil {
for _, skill := range entry.Skills {
if seen[skill] {
continue
}
skillPath := filepath.Join(home, ".claude", "skills", skill, "SKILL.md")
if _, err := os.Stat(skillPath); err == nil {
detected = append(detected, skill)
seen[skill] = true
}
}
break // one matching file is enough for this entry
}
}
}
return detected
}
const defaultSkillBudget = 16000 // chars, ~4K tokens
// validSkillName ensures skill names contain only safe characters to prevent path traversal
var validSkillName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// ResolveSkillContent reads SKILL.md files for the given skill names,
// strips YAML frontmatter, wraps each in <skill> tags, and enforces a
// character budget to prevent context bloat.
func ResolveSkillContent(skills []string, maxBudget int) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
if maxBudget <= 0 {
maxBudget = defaultSkillBudget
}
var sections []string
remaining := maxBudget
for _, name := range skills {
name = strings.TrimSpace(name)
if name == "" {
continue
}
if !validSkillName.MatchString(name) {
logWarn(fmt.Sprintf("skill %q: invalid name (must contain only [a-zA-Z0-9_-]), skipping", name))
continue
}
path := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
logWarn(fmt.Sprintf("skill %q: SKILL.md not found or empty, skipping", name))
continue
}
body := stripYAMLFrontmatter(strings.TrimSpace(string(data)))
tagOverhead := len("<skill name=\"\">") + len(name) + len("\n") + len("\n</skill>")
bodyBudget := remaining - tagOverhead
if bodyBudget <= 0 {
logWarn(fmt.Sprintf("skill %q: skipped, insufficient budget for tags", name))
break
}
if len(body) > bodyBudget {
logWarn(fmt.Sprintf("skill %q: truncated from %d to %d chars (budget)", name, len(body), bodyBudget))
body = body[:bodyBudget]
}
remaining -= len(body) + tagOverhead
sections = append(sections, "<skill name=\""+name+"\">\n"+body+"\n</skill>")
if remaining <= 0 {
break
}
}
if len(sections) == 0 {
return ""
}
return strings.Join(sections, "\n\n")
}
func stripYAMLFrontmatter(s string) string {
s = strings.ReplaceAll(s, "\r\n", "\n")
if !strings.HasPrefix(s, "---") {
return s
}
idx := strings.Index(s[3:], "\n---")
if idx < 0 {
return s
}
result := s[3+idx+4:]
if len(result) > 0 && result[0] == '\n' {
result = result[1:]
}
return strings.TrimSpace(result)
}

View File

@@ -0,0 +1,333 @@
package executor
import (
"os"
"path/filepath"
"strings"
"testing"
)
// --- helper: create a temp skill dir with SKILL.md ---
func createTempSkill(t *testing.T, name, content string) string {
t.Helper()
home := t.TempDir()
skillDir := filepath.Join(home, ".claude", "skills", name)
if err := os.MkdirAll(skillDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0644); err != nil {
t.Fatal(err)
}
return home
}
// --- ParseParallelConfig skills parsing tests ---
func TestParseParallelConfig_SkillsField(t *testing.T) {
tests := []struct {
name string
input string
taskIdx int
expectedSkills []string
}{
{
name: "single skill",
input: `---TASK---
id: t1
workdir: .
skills: golang-base-practices
---CONTENT---
Do something.
`,
taskIdx: 0,
expectedSkills: []string{"golang-base-practices"},
},
{
name: "multiple comma-separated skills",
input: `---TASK---
id: t1
workdir: .
skills: golang-base-practices, vercel-react-best-practices
---CONTENT---
Do something.
`,
taskIdx: 0,
expectedSkills: []string{"golang-base-practices", "vercel-react-best-practices"},
},
{
name: "no skills field",
input: `---TASK---
id: t1
workdir: .
---CONTENT---
Do something.
`,
taskIdx: 0,
expectedSkills: nil,
},
{
name: "empty skills value",
input: `---TASK---
id: t1
workdir: .
skills:
---CONTENT---
Do something.
`,
taskIdx: 0,
expectedSkills: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg, err := ParseParallelConfig([]byte(tt.input))
if err != nil {
t.Fatalf("ParseParallelConfig error: %v", err)
}
got := cfg.Tasks[tt.taskIdx].Skills
if len(got) != len(tt.expectedSkills) {
t.Fatalf("skills: got %v, want %v", got, tt.expectedSkills)
}
for i := range got {
if got[i] != tt.expectedSkills[i] {
t.Errorf("skills[%d]: got %q, want %q", i, got[i], tt.expectedSkills[i])
}
}
})
}
}
// --- stripYAMLFrontmatter tests ---
func TestStripYAMLFrontmatter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "with frontmatter",
input: "---\nname: test\ndescription: foo\n---\n\n# Body\nContent here.",
expected: "# Body\nContent here.",
},
{
name: "no frontmatter",
input: "# Just a body\nNo frontmatter.",
expected: "# Just a body\nNo frontmatter.",
},
{
name: "empty",
input: "",
expected: "",
},
{
name: "only frontmatter",
input: "---\nname: test\n---",
expected: "",
},
{
name: "frontmatter with allowed-tools",
input: "---\nname: do\nallowed-tools: [\"Bash\"]\n---\n\n# Skill content",
expected: "# Skill content",
},
{
name: "CRLF line endings",
input: "---\r\nname: test\r\n---\r\n\r\n# Body\r\nContent.",
expected: "# Body\nContent.",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := stripYAMLFrontmatter(tt.input)
if got != tt.expected {
t.Errorf("got %q, want %q", got, tt.expected)
}
})
}
}
// --- DetectProjectSkills tests ---
func TestDetectProjectSkills_GoProject(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0644)
skills := DetectProjectSkills(tmpDir)
// Result depends on whether golang-base-practices is installed locally
t.Logf("detected skills for Go project: %v", skills)
}
func TestDetectProjectSkills_NoFingerprints(t *testing.T) {
tmpDir := t.TempDir()
skills := DetectProjectSkills(tmpDir)
if len(skills) != 0 {
t.Errorf("expected no skills for empty dir, got %v", skills)
}
}
func TestDetectProjectSkills_FullStack(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module test"), 0644)
os.WriteFile(filepath.Join(tmpDir, "package.json"), []byte(`{"name":"test"}`), 0644)
skills := DetectProjectSkills(tmpDir)
t.Logf("detected skills for fullstack project: %v", skills)
seen := make(map[string]bool)
for _, s := range skills {
if seen[s] {
t.Errorf("duplicate skill detected: %s", s)
}
seen[s] = true
}
}
func TestDetectProjectSkills_NonexistentDir(t *testing.T) {
skills := DetectProjectSkills("/nonexistent/path/xyz")
if len(skills) != 0 {
t.Errorf("expected no skills for nonexistent dir, got %v", skills)
}
}
// --- ResolveSkillContent tests (CI-friendly with temp dirs) ---
func TestResolveSkillContent_ValidSkill(t *testing.T) {
home := createTempSkill(t, "test-skill", "---\nname: test\n---\n\n# Test Skill\nBest practices here.")
t.Setenv("HOME", home)
result := ResolveSkillContent([]string{"test-skill"}, 0)
if result == "" {
t.Fatal("expected non-empty content")
}
if !strings.Contains(result, `<skill name="test-skill">`) {
t.Error("missing opening <skill> tag")
}
if !strings.Contains(result, "</skill>") {
t.Error("missing closing </skill> tag")
}
if !strings.Contains(result, "# Test Skill") {
t.Error("missing skill body content")
}
if strings.Contains(result, "name: test") {
t.Error("frontmatter was not stripped")
}
}
func TestResolveSkillContent_NonexistentSkill(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
result := ResolveSkillContent([]string{"nonexistent-skill-xyz"}, 0)
if result != "" {
t.Errorf("expected empty for nonexistent skill, got %d bytes", len(result))
}
}
func TestResolveSkillContent_Empty(t *testing.T) {
if result := ResolveSkillContent(nil, 0); result != "" {
t.Errorf("expected empty for nil, got %q", result)
}
if result := ResolveSkillContent([]string{}, 0); result != "" {
t.Errorf("expected empty for empty, got %q", result)
}
}
func TestResolveSkillContent_Budget(t *testing.T) {
longBody := strings.Repeat("x", 500)
home := createTempSkill(t, "big-skill", "---\nname: big\n---\n\n"+longBody)
t.Setenv("HOME", home)
result := ResolveSkillContent([]string{"big-skill"}, 200)
if result == "" {
t.Fatal("expected non-empty even with small budget")
}
if len(result) > 200 {
t.Errorf("result %d bytes exceeds budget 200", len(result))
}
t.Logf("budget=200, result=%d bytes", len(result))
}
func TestResolveSkillContent_MultipleSkills(t *testing.T) {
home := t.TempDir()
for _, name := range []string{"skill-a", "skill-b"} {
skillDir := filepath.Join(home, ".claude", "skills", name)
os.MkdirAll(skillDir, 0755)
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# "+name+"\nContent."), 0644)
}
t.Setenv("HOME", home)
result := ResolveSkillContent([]string{"skill-a", "skill-b"}, 0)
if result == "" {
t.Fatal("expected non-empty for multiple skills")
}
if !strings.Contains(result, `<skill name="skill-a">`) {
t.Error("missing skill-a tag")
}
if !strings.Contains(result, `<skill name="skill-b">`) {
t.Error("missing skill-b tag")
}
}
func TestResolveSkillContent_PathTraversal(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
result := ResolveSkillContent([]string{"../../../etc/passwd"}, 0)
if result != "" {
t.Errorf("expected empty for path traversal name, got %d bytes", len(result))
}
}
func TestResolveSkillContent_InvalidNames(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
tests := []string{"../bad", "foo/bar", "skill name", "skill.name", "a b"}
for _, name := range tests {
result := ResolveSkillContent([]string{name}, 0)
if result != "" {
t.Errorf("expected empty for invalid name %q, got %d bytes", name, len(result))
}
}
}
func TestResolveSkillContent_ValidNamePattern(t *testing.T) {
if !validSkillName.MatchString("golang-base-practices") {
t.Error("golang-base-practices should be valid")
}
if !validSkillName.MatchString("my_skill_v2") {
t.Error("my_skill_v2 should be valid")
}
if validSkillName.MatchString("../bad") {
t.Error("../bad should be invalid")
}
if validSkillName.MatchString("") {
t.Error("empty should be invalid")
}
}
// --- Integration: skill injection format test ---
func TestSkillInjectionFormat(t *testing.T) {
home := createTempSkill(t, "test-go", "---\nname: go\n---\n\n# Go Best Practices\nUse gofmt.")
t.Setenv("HOME", home)
taskText := "Implement the feature."
content := ResolveSkillContent([]string{"test-go"}, 0)
injected := taskText + "\n\n# Domain Best Practices\n\n" + content
if !strings.Contains(injected, "Implement the feature.") {
t.Error("original task text lost")
}
if !strings.Contains(injected, "# Domain Best Practices") {
t.Error("missing section header")
}
if !strings.Contains(injected, `<skill name="test-go">`) {
t.Error("missing <skill> tag")
}
if !strings.Contains(injected, "Use gofmt.") {
t.Error("missing skill body")
}
}

View File

@@ -24,6 +24,7 @@ type TaskSpec struct {
Worktree bool `json:"worktree,omitempty"`
AllowedTools []string `json:"allowed_tools,omitempty"`
DisallowedTools []string `json:"disallowed_tools,omitempty"`
Skills []string `json:"skills,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`

View File

@@ -19,7 +19,7 @@ func TestTruncate(t *testing.T) {
{"zero maxLen", "hello", 0, "..."},
{"negative maxLen", "hello", -1, ""},
{"maxLen 1", "hello", 1, "h..."},
{"unicode bytes truncate", "你好世界", 10, "你好世\xe7..."}, // Truncate works on bytes, not runes
{"unicode bytes truncate", "你好世界", 10, "你好世\xe7..."}, // Truncate works on bytes, not runes
{"mixed truncate", "hello世界abc", 7, "hello\xe4\xb8..."}, // byte-based truncation
}