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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Eval-level default expect checks under `cases.defaults.expect`, with
append-and-deduplicate semantics for list checks and case-level overrides for
`exit_code` and `golden_file`.
- Skill references now support explicit doublestar `include` and `exclude`
patterns for both run-agent and judge Skill installation.

### Fixed
- Claude Code session lookup now normalizes Windows workspace paths to the
Expand Down
16 changes: 16 additions & 0 deletions docs/guide/writing-evals.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ mcp:
skills:
- source: local_path # local_path (a directory on disk)
path: . # Path to the Skill
include: # Optional doublestar globs relative to path; empty means all files
- SKILL.md
- references/**
- scripts/**
exclude: # Optional; applied after include, so exclude wins
- references/drafts/**
- .qoder/repowiki/**

# ========== 5. Agent Engine ==========
engine:
Expand Down Expand Up @@ -118,6 +125,15 @@ report:
artifacts: [transcript]
```

`skills[].include` and `skills[].exclude` use
[doublestar](https://github.com/bmatcuk/doublestar) syntax (`*` matches one
path segment and `**` crosses directories). Patterns are relative to
`skills[].path`, use `/` separators, and `exclude` takes precedence over
`include`. When `include` is omitted, every file is selected by default.
The evaluation harness directory `evals/` is always excluded. If an include
list is provided, include `SKILL.md` explicitly so the installed directory
remains a valid Skill. The same fields are supported by `judge.skills`.

`cases.parallelism` is the file-level default. To override it for a single run, use `skill-up run --parallelism N` without modifying `eval.yaml`. Allowed range: **1 to 256**.

### Engine kwargs (agent-specific switches)
Expand Down
14 changes: 14 additions & 0 deletions docs/zh/guide/writing-evals.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ mcp:
skills:
- source: local_path # local_path(本地目录)
path: . # Skill 所在路径
include: # 可选:相对 path 的 doublestar glob;不配置表示包含全部文件
- SKILL.md
- references/**
- scripts/**
exclude: # 可选:在 include 后应用,因此 exclude 优先
- references/drafts/**
- .qoder/repowiki/**

# ========== 5. Agent Engine ==========
engine:
Expand Down Expand Up @@ -117,6 +124,13 @@ report:
artifacts: [transcript] # 报告中包含的产物
```

`skills[].include` 和 `skills[].exclude` 使用
[doublestar](https://github.com/bmatcuk/doublestar) 语法(`*` 匹配单层路径,
`**` 可跨目录)。pattern 相对 `skills[].path`,统一使用 `/` 分隔;
`exclude` 优先于 `include`。未配置 `include` 时默认选择全部文件。
评测框架目录 `evals/` 始终排除。配置 include 列表时,应显式包含
`SKILL.md`,确保安装后的目录仍是有效 Skill。`judge.skills` 同样支持这两个字段。

`cases.parallelism` 是配置文件中的默认用例并行数;临时运行时可以用 `skill-up run --parallelism N` 覆盖它,不需要修改 `eval.yaml`。命令行覆盖值必须在 1 到 256 之间。

### 采集 workspace 产物(`collect_artifacts`)
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func (a *BaseAgent) installSkillDefault(ctx context.Context, rt Runtime, skillCf
if target == "" && a.Cfg.SkillPath != "" {
target = filepath.Join(a.Cfg.SkillPath, filepath.Base(skillCfg.Source))
}
return installSkill(ctx, rt, skillCfg.Source, target)
return installSkill(ctx, rt, skillCfg.Source, target, skillCfg.Include, skillCfg.Exclude)
}

func persistRuntimeArtifact(ctx context.Context, rt Runtime, targetPath, content string) error {
Expand Down
133 changes: 128 additions & 5 deletions internal/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
goruntime "runtime"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -38,7 +39,6 @@ func TestListSkillFiles_ExcludesEvals(t *testing.T) {
if err := os.WriteFile(filepath.Join(subdir, "README.md"), []byte("readme"), 0o600); err != nil {
t.Fatal(err)
}

// Create evals directory (should be excluded)
evalsDir := filepath.Join(dir, "evals")
if err := os.MkdirAll(evalsDir, 0o755); err != nil {
Expand All @@ -57,7 +57,7 @@ func TestListSkillFiles_ExcludesEvals(t *testing.T) {
t.Fatal(err)
}

files, err := ListSkillFiles(dir)
files, err := ListSkillFiles(dir, nil, nil)
if err != nil {
t.Fatalf("ListSkillFiles failed: %v", err)
}
Expand All @@ -74,7 +74,6 @@ func TestListSkillFiles_ExcludesEvals(t *testing.T) {
if !fileSet[filepath.Join("subdir", "README.md")] {
t.Error("subdir/README.md should be included")
}

// Check excluded files
if fileSet["evals/test.yaml"] {
t.Error("evals/test.yaml should be excluded")
Expand All @@ -84,6 +83,94 @@ func TestListSkillFiles_ExcludesEvals(t *testing.T) {
}
}

func TestListSkillFiles_DefaultIncludesHiddenFiles(t *testing.T) {
t.Parallel()

dir := t.TempDir()
fixtures := map[string]string{
".claude/memory.md": "memory",
".qoder/repowiki/knowledge/zh/_module.yaml": "yaml",
".qoder/repowiki/knowledge/zh/_module.yamlx": "yamlx",
}
for name, content := range fixtures {
filePath := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

files, err := ListSkillFiles(dir, nil, nil)
if err != nil {
t.Fatalf("ListSkillFiles failed: %v", err)
}
for name := range fixtures {
if !slices.Contains(files, filepath.FromSlash(name)) {
t.Errorf("%q should be included by default, got %v", name, files)
}
}
}

func TestListSkillFiles_AppliesIncludeThenExclude(t *testing.T) {
t.Parallel()

dir := t.TempDir()
fixtures := map[string]string{
"SKILL.md": "# Skill",
"README.md": "not selected",
"resources/config.yaml": "enabled: true",
"resources/private/secret.yaml": "secret: true",
".claude/memory.md": "important memory",
".qoder/repowiki/knowledge/zh/_module.yaml": "generated: true",
".qoder/repowiki/knowledge/zh/notes.tmp": "generated",
"resources/nested/generated/temporary-file.tmp": "generated",
}
for name, content := range fixtures {
path := filepath.Join(dir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

include := []string{"SKILL.md", "resources/**", ".claude/**", ".qoder/**"}
exclude := []string{".qoder/repowiki/**", "resources/private/**", "**/*.tmp"}
files, err := ListSkillFiles(dir, include, exclude)
if err != nil {
t.Fatalf("ListSkillFiles failed: %v", err)
}

for _, want := range []string{"SKILL.md", "resources/config.yaml", ".claude/memory.md"} {
if !slices.Contains(files, filepath.FromSlash(want)) {
t.Errorf("expected %q to be included, got %v", want, files)
}
}
for _, unwanted := range []string{
"README.md",
"resources/private/secret.yaml",
".qoder/repowiki/knowledge/zh/_module.yaml",
".qoder/repowiki/knowledge/zh/notes.tmp",
"resources/nested/generated/temporary-file.tmp",
} {
if slices.Contains(files, filepath.FromSlash(unwanted)) {
t.Errorf("expected %q to be excluded, got %v", unwanted, files)
}
}
}

func TestListSkillFiles_InvalidPattern(t *testing.T) {
t.Parallel()

_, err := ListSkillFiles(t.TempDir(), []string{"["}, nil)
if err == nil || !strings.Contains(err.Error(), "invalid skill file pattern") {
t.Fatalf("ListSkillFiles error = %v, want invalid pattern", err)
}
}

func TestInstallSkill_PreservesExecutableScripts(t *testing.T) {
t.Parallel()
if goruntime.GOOS == "windows" {
Expand All @@ -110,7 +197,7 @@ func TestInstallSkill_PreservesExecutableScripts(t *testing.T) {
}
defer func() { _ = rt.Close() }()

if err := installSkill(context.Background(), rt, src, "skill"); err != nil {
if err := installSkill(context.Background(), rt, src, "skill", nil, nil); err != nil {
t.Fatalf("installSkill failed: %v", err)
}

Expand All @@ -123,11 +210,47 @@ func TestInstallSkill_PreservesExecutableScripts(t *testing.T) {
}
}

func TestInstallSkill_AppliesFilters(t *testing.T) {
t.Parallel()

source := t.TempDir()
fixtures := map[string]string{
"SKILL.md": "# Skill",
".claude/memory.md": "required memory",
".qoder/repowiki/knowledge/zh/_module.yaml": "generated metadata",
}
for name, content := range fixtures {
path := filepath.Join(source, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}

rt := &runtime.NoneRuntime{}
if err := rt.Create(context.Background()); err != nil {
t.Fatal(err)
}
defer func() { _ = rt.Close() }()

if err := installSkill(context.Background(), rt, source, "skill", nil, []string{".qoder/repowiki/**"}); err != nil {
t.Fatalf("installSkill failed: %v", err)
}
if _, err := os.Stat(filepath.Join(rt.Workspace(), "skill", ".claude", "memory.md")); err != nil {
t.Fatalf("included memory file missing: %v", err)
}
if _, err := os.Stat(filepath.Join(rt.Workspace(), "skill", ".qoder", "repowiki", "knowledge", "zh", "_module.yaml")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("excluded RepoWiki file stat error = %v, want not exist", err)
}
}

func TestListSkillFiles_EmptyDir(t *testing.T) {
t.Parallel()

dir := t.TempDir()
files, err := ListSkillFiles(dir)
files, err := ListSkillFiles(dir, nil, nil)
if err != nil {
t.Fatalf("ListSkillFiles failed: %v", err)
}
Expand Down
4 changes: 4 additions & 0 deletions internal/agent/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package agent
import (
"bytes"
"context"
"errors"
"fmt"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -83,6 +84,9 @@ func (a *CLIAgent) InstallSkill(ctx context.Context, rt Runtime, skillCfg runtim
if a.Cfg.InstallSkillCmd == "" {
return a.installSkillDefault(ctx, rt, skillCfg)
}
if len(skillCfg.Include) > 0 || len(skillCfg.Exclude) > 0 {
return errors.New("skill include/exclude filters are not supported with a custom InstallSkillCmd")
}

tmpl, err := template.New("installSkill").Parse(a.Cfg.InstallSkillCmd)
if err != nil {
Expand Down
17 changes: 17 additions & 0 deletions internal/agent/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
goruntime "runtime"
"strings"
"testing"

"github.com/alibaba/skill-up/internal/platform"
Expand Down Expand Up @@ -224,6 +225,22 @@ func TestCLIAgent_InstallSkillWithCmd(t *testing.T) {
}
}

func TestCLIAgent_InstallSkillWithCmdRejectsFilters(t *testing.T) {
t.Parallel()

ag := &CLIAgent{BaseAgent: BaseAgent{Cfg: Config{
Name: "test-agent",
InstallSkillCmd: "echo install",
}}}
err := ag.InstallSkill(context.Background(), nil, runtime.SkillConfig{
Source: "my-skill",
Exclude: []string{".qoder/repowiki/**"},
})
if err == nil || !strings.Contains(err.Error(), "not supported with a custom InstallSkillCmd") {
t.Fatalf("InstallSkill error = %v, want unsupported filters", err)
}
}

func TestCLIAgent_InstallMCPEmpty(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading