mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-10 03:14:32 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Config struct {
|
||||
MaxParallelWorkers int
|
||||
AllowedTools []string
|
||||
DisallowedTools []string
|
||||
Skills []string
|
||||
Worktree bool // Execute in a new git worktree
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
333
codeagent-wrapper/internal/executor/skills_test.go
Normal file
333
codeagent-wrapper/internal/executor/skills_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user