chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package skillcontent reads embedded skill content from an injected fs.FS
|
||||
// rooted at the skill list (entries like "lark-calendar/SKILL.md").
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Reader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func New(fsys fs.FS) *Reader { return &Reader{fsys: fsys} }
|
||||
|
||||
type SkillInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// DirEntry.Path is skill-prefixed (e.g. "lark-doc/references/x.md") so it can be
|
||||
// fed straight back into `read`.
|
||||
type DirEntry struct {
|
||||
Path string `json:"path"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
||||
func (r *Reader) List() ([]SkillInfo, error) {
|
||||
entries, err := fs.ReadDir(r.fsys, ".")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO, "failed to read embedded skills: %v", err)
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Skip dirs that aren't real skills (no SKILL.md).
|
||||
if info, ok := r.skillInfo(e.Name()); ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) skillInfo(name string) (SkillInfo, bool) {
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return SkillInfo{}, false
|
||||
}
|
||||
desc, version, metadata := parseFrontmatter(data)
|
||||
return SkillInfo{Name: name, Description: desc, Version: version, Metadata: metadata}, true
|
||||
}
|
||||
|
||||
// ListPath lists one directory layer (no recursion) under "<name>" or
|
||||
// "<name>/<sub>", returning the entries and the cleaned path listed.
|
||||
func (r *Reader) ListPath(arg string) ([]DirEntry, string, error) {
|
||||
name, sub := SplitArg(arg)
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir := name
|
||||
if sub != "" {
|
||||
cleaned, err := cleanSubPath(sub)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
dir = name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q not found in skill %q", sub, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"path %q is a file, not a directory; use 'lark-cli skills read %s/%s' to read it", sub, name, cleaned)
|
||||
}
|
||||
}
|
||||
entries, err := fs.ReadDir(r.fsys, dir)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
out := make([]DirEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
out = append(out, DirEntry{Path: dir + "/" + e.Name(), IsDir: e.IsDir()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||||
return out, dir, nil
|
||||
}
|
||||
|
||||
// SplitArg splits "<name>/<rest>" at the first separator; an argument with no
|
||||
// separator is a bare skill name (rest "").
|
||||
func SplitArg(arg string) (name, rest string) {
|
||||
name, rest, _ = strings.Cut(arg, "/")
|
||||
return name, rest
|
||||
}
|
||||
|
||||
// parseFrontmatter best-effort-extracts the frontmatter fields; missing or
|
||||
// unparseable frontmatter yields ("", "", nil), never an error.
|
||||
func parseFrontmatter(skillMD []byte) (description, version string, metadata map[string]any) {
|
||||
lines := strings.Split(string(skillMD), "\n")
|
||||
if strings.TrimRight(lines[0], "\r") != "---" {
|
||||
return "", "", nil
|
||||
}
|
||||
block := make([]string, 0, len(lines))
|
||||
closed := false
|
||||
for _, ln := range lines[1:] {
|
||||
if strings.TrimRight(ln, "\r") == "---" {
|
||||
closed = true
|
||||
break
|
||||
}
|
||||
block = append(block, ln)
|
||||
}
|
||||
if !closed {
|
||||
return "", "", nil
|
||||
}
|
||||
var fm struct {
|
||||
Description string `yaml:"description"`
|
||||
Version string `yaml:"version"`
|
||||
Metadata map[string]any `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(strings.Join(block, "\n")), &fm); err != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
return fm.Description, fm.Version, fm.Metadata
|
||||
}
|
||||
|
||||
func (r *Reader) ReadSkill(name string) ([]byte, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, name+"/SKILL.md")
|
||||
if err != nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (r *Reader) ensureSkill(name string) error {
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || name == "." || name == ".." {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
info, err := fs.Stat(r.fsys, name)
|
||||
if err != nil || !info.IsDir() {
|
||||
return unknownSkill(name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknownSkill(name string) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown skill %q", name).
|
||||
WithHint("run 'lark-cli skills list' to see available skills")
|
||||
}
|
||||
|
||||
// cleanSubPath returns the cleaned form of relpath, rejecting absolute paths and
|
||||
// ".." escapes. relpath must be non-empty (callers handle the skill-root case).
|
||||
func cleanSubPath(relpath string) (string, error) {
|
||||
cleaned := path.Clean(relpath)
|
||||
// path.Clean only treats '/' as a separator, so a Windows-style "..\" prefix
|
||||
// survives; reject it explicitly alongside "../".
|
||||
if relpath == "" || path.IsAbs(relpath) || cleaned == "." ||
|
||||
cleaned == ".." || strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, `..\`) {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid path %q: must be a relative path without '..'", relpath)
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// ReadReference returns the bytes of <name>/<relpath> and the cleaned path.
|
||||
func (r *Reader) ReadReference(name, relpath string) ([]byte, string, error) {
|
||||
if err := r.ensureSkill(name); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cleaned, err := cleanSubPath(relpath)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
full := name + "/" + cleaned
|
||||
info, err := fs.Stat(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q not found in skill %q", relpath, name).
|
||||
WithHint("run 'lark-cli skills list " + name + "' to see files in this skill")
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"reference %q is a directory, not a file", relpath)
|
||||
}
|
||||
data, err := fs.ReadFile(r.fsys, full)
|
||||
if err != nil {
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeFileIO,
|
||||
"failed to read embedded skill content: %v", err)
|
||||
}
|
||||
return data, cleaned, nil
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package skillcontent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func testFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Calendar skill\"\nmetadata:\n requires:\n bins: [\"lark-cli\"]\n cliHelp: \"lark-cli calendar --help\"\n---\nbody\n")},
|
||||
"lark-calendar/references/agenda.md": {Data: []byte("# Agenda")},
|
||||
"lark-calendar/references/create.md": {Data: []byte("# Create")},
|
||||
"lark-calendar/assets/tpl.html": {Data: []byte("<html></html>")},
|
||||
"lark-im/SKILL.md": {Data: []byte("no frontmatter here\n")},
|
||||
"lark-im/references/send.md": {Data: []byte("# Send")},
|
||||
}
|
||||
}
|
||||
|
||||
func TestList(t *testing.T) {
|
||||
r := New(testFS())
|
||||
skills, err := r.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List() error: %v", err)
|
||||
}
|
||||
if len(skills) != 2 {
|
||||
t.Fatalf("got %d skills, want 2", len(skills))
|
||||
}
|
||||
if skills[0].Name != "lark-calendar" || skills[1].Name != "lark-im" {
|
||||
t.Fatalf("skills not sorted by name: %v", skills)
|
||||
}
|
||||
if skills[0].Description != "Calendar skill" {
|
||||
t.Errorf("description: got %q, want %q", skills[0].Description, "Calendar skill")
|
||||
}
|
||||
// version is the frontmatter `version:` field, passed through for drift checks.
|
||||
if skills[0].Version != "1.0.0" {
|
||||
t.Errorf("version: got %q, want %q", skills[0].Version, "1.0.0")
|
||||
}
|
||||
// metadata is the frontmatter `metadata:` block, passed through verbatim.
|
||||
if skills[0].Metadata == nil {
|
||||
t.Fatal("expected metadata for lark-calendar")
|
||||
}
|
||||
if skills[0].Metadata["cliHelp"] != "lark-cli calendar --help" {
|
||||
t.Errorf("metadata.cliHelp: got %v", skills[0].Metadata["cliHelp"])
|
||||
}
|
||||
// No frontmatter → empty description and nil metadata (omitted from JSON).
|
||||
if skills[1].Description != "" {
|
||||
t.Errorf("lark-im description: got %q, want empty", skills[1].Description)
|
||||
}
|
||||
if skills[1].Metadata != nil {
|
||||
t.Errorf("lark-im metadata: got %v, want nil", skills[1].Metadata)
|
||||
}
|
||||
if skills[1].Version != "" {
|
||||
t.Errorf("lark-im version: got %q, want empty", skills[1].Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPath(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
// Skill root: direct children only (one layer), each path skill-prefixed.
|
||||
entries, listed, err := r.ListPath("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath root error: %v", err)
|
||||
}
|
||||
if listed != "lark-calendar" {
|
||||
t.Errorf("listed path: got %q", listed)
|
||||
}
|
||||
want := map[string]bool{ // path → isDir
|
||||
"lark-calendar/SKILL.md": false,
|
||||
"lark-calendar/references": true,
|
||||
"lark-calendar/assets": true,
|
||||
}
|
||||
if len(entries) != len(want) {
|
||||
t.Fatalf("root entries: got %v, want %d entries", entries, len(want))
|
||||
}
|
||||
for _, e := range entries {
|
||||
isDir, ok := want[e.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected entry %q", e.Path)
|
||||
continue
|
||||
}
|
||||
if e.IsDir != isDir {
|
||||
t.Errorf("%q is_dir: got %v, want %v", e.Path, e.IsDir, isDir)
|
||||
}
|
||||
}
|
||||
// Entries are sorted by path.
|
||||
if entries[0].Path != "lark-calendar/SKILL.md" {
|
||||
t.Errorf("entries not sorted: %v", entries)
|
||||
}
|
||||
|
||||
// Subdirectory: one layer under <name>/<subpath>.
|
||||
subEntries, subListed, err := r.ListPath("lark-calendar/references")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPath subdir error: %v", err)
|
||||
}
|
||||
if subListed != "lark-calendar/references" {
|
||||
t.Errorf("listed subpath: got %q", subListed)
|
||||
}
|
||||
if len(subEntries) != 2 ||
|
||||
subEntries[0].Path != "lark-calendar/references/agenda.md" ||
|
||||
subEntries[1].Path != "lark-calendar/references/create.md" {
|
||||
t.Errorf("subdir entries: got %v", subEntries)
|
||||
}
|
||||
|
||||
// Unknown skill → typed validation error.
|
||||
if _, _, err := r.ListPath("no-such-skill"); err == nil {
|
||||
t.Error("expected error for unknown skill")
|
||||
} else {
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Path that points at a file (not a dir) → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/SKILL.md"); err == nil {
|
||||
t.Error("expected error listing a file")
|
||||
} else if !strings.Contains(err.Error(), "is a file") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Nonexistent subpath → validation error.
|
||||
if _, _, err := r.ListPath("lark-calendar/nope"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
// Traversal in the subpath is rejected, no listing leaked.
|
||||
for _, bad := range []string{"lark-calendar/../lark-im", "lark-calendar/../../etc", `lark-calendar/..\x`} {
|
||||
entries, _, err := r.ListPath(bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if entries != nil {
|
||||
t.Errorf("entries leaked for %q: %v", bad, entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkill(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, err := r.ReadSkill("lark-calendar")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadSkill error: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(data), "---\nname: lark-calendar") {
|
||||
t.Errorf("unexpected content: %q", string(data))
|
||||
}
|
||||
|
||||
_, err = r.ReadSkill("no-such-skill")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown skill")
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if !strings.Contains(verr.Message, `unknown skill "no-such-skill"`) {
|
||||
t.Errorf("message: got %q", verr.Message)
|
||||
}
|
||||
|
||||
if _, err := r.ReadSkill("../etc"); err == nil {
|
||||
t.Error("expected error for name with separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadReference(t *testing.T) {
|
||||
r := New(testFS())
|
||||
|
||||
data, cleaned, err := r.ReadReference("lark-calendar", "references/agenda.md")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadReference error: %v", err)
|
||||
}
|
||||
if string(data) != "# Agenda" {
|
||||
t.Errorf("content: got %q", string(data))
|
||||
}
|
||||
if cleaned != "references/agenda.md" {
|
||||
t.Errorf("cleaned path: got %q", cleaned)
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references/nope.md"); err == nil {
|
||||
t.Error("expected not-found error")
|
||||
} else if !strings.Contains(err.Error(), "not found") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
if _, _, err := r.ReadReference("lark-calendar", "references"); err == nil {
|
||||
t.Error("expected directory error")
|
||||
} else if !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Errorf("message: got %q", err.Error())
|
||||
}
|
||||
|
||||
for _, bad := range []string{"../../etc/passwd", "/etc/passwd", "..", "", "references/../../im/SKILL.md", `..\..\x`} {
|
||||
data, _, err := r.ReadReference("lark-calendar", bad)
|
||||
if err == nil {
|
||||
t.Errorf("expected rejection for %q", bad)
|
||||
}
|
||||
if data != nil {
|
||||
t.Errorf("content leaked for %q: %q", bad, string(data))
|
||||
}
|
||||
var verr *errs.ValidationError
|
||||
if !errors.As(err, &verr) {
|
||||
t.Errorf("expected validation error for %q, got %T", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFrontmatter(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantDesc string
|
||||
wantVer string
|
||||
wantHasMeta bool
|
||||
}{
|
||||
{
|
||||
name: "description, version and metadata",
|
||||
input: "---\ndescription: My skill\nversion: 2.1.0\nmetadata:\n cliHelp: \"x\"\n---\nbody\n",
|
||||
wantDesc: "My skill",
|
||||
wantVer: "2.1.0",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "description only, no metadata",
|
||||
input: "---\ndescription: Plain\n---\nbody\n",
|
||||
wantDesc: "Plain",
|
||||
},
|
||||
{
|
||||
name: "no frontmatter",
|
||||
input: "no frontmatter here\n",
|
||||
},
|
||||
{
|
||||
name: "unclosed frontmatter",
|
||||
input: "---\ndescription: Never closed\n",
|
||||
},
|
||||
{
|
||||
name: "malformed YAML inside frontmatter",
|
||||
input: "---\n: bad: yaml: [\n---\nbody\n",
|
||||
},
|
||||
{
|
||||
name: "CRLF line endings",
|
||||
input: "---\r\ndescription: CRLF skill\r\nmetadata:\r\n cliHelp: \"y\"\r\n---\r\nbody\r\n",
|
||||
wantDesc: "CRLF skill",
|
||||
wantHasMeta: true,
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: "",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
desc, ver, meta := parseFrontmatter([]byte(tc.input))
|
||||
if desc != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", desc, tc.wantDesc)
|
||||
}
|
||||
if ver != tc.wantVer {
|
||||
t.Errorf("version = %q, want %q", ver, tc.wantVer)
|
||||
}
|
||||
if (meta != nil) != tc.wantHasMeta {
|
||||
t.Errorf("metadata = %v, wantHasMeta %v", meta, tc.wantHasMeta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadSkillMissingFile(t *testing.T) {
|
||||
// Use a separate MapFS so testFS() (and TestList) are unaffected.
|
||||
emptyFS := fstest.MapFS{
|
||||
"lark-empty/references/x.md": {Data: []byte("# X")},
|
||||
}
|
||||
r := New(emptyFS)
|
||||
_, err := r.ReadSkill("lark-empty")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when SKILL.md is absent")
|
||||
}
|
||||
var ierr *errs.InternalError
|
||||
if !errors.As(err, &ierr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user