diff --git a/cmd/build.go b/cmd/build.go index 84828ed7..a31d41a6 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -6,6 +6,7 @@ package cmd import ( "context" "io" + "io/fs" "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" @@ -16,6 +17,7 @@ import ( "github.com/larksuite/cli/cmd/profile" "github.com/larksuite/cli/cmd/schema" "github.com/larksuite/cli/cmd/service" + "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/build" @@ -51,6 +53,18 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption { } } +// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent +// at build time. It is registered by the repo-root package main's init via +// SetEmbeddedSkillContent — it cannot be threaded through main.go without +// breaking the single-file preview build (see skills_embed.go). nil in builds +// that embed no skills; the `skills` commands then return a typed internal error. +var embeddedSkillContent fs.FS + +// SetEmbeddedSkillContent registers the embedded skill tree. Called from the +// repo-root package main's init; a wrapper main can call it before Execute to +// supply its own skill content. +func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys } + // HideProfile sets the visibility policy for the root-level --profile flag. // When hide is true the flag stays registered (so existing invocations still // parse) but is omitted from help and shell completion. Typically called as @@ -103,6 +117,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B if cfg.keychain != nil { f.Keychain = cfg.keychain } + f.SkillContent = embeddedSkillContent rootCmd := &cobra.Command{ Use: "lark-cli", Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls", @@ -140,6 +155,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(completion.NewCmdCompletion(f)) rootCmd.AddCommand(cmdupdate.NewCmdUpdate(f)) rootCmd.AddCommand(cmdevent.NewCmdEvents(f)) + rootCmd.AddCommand(skill.NewCmdSkill(f)) service.RegisterServiceCommandsWithContext(ctx, rootCmd, f) shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) diff --git a/cmd/skill/skill.go b/cmd/skill/skill.go new file mode 100644 index 00000000..351dda77 --- /dev/null +++ b/cmd/skill/skill.go @@ -0,0 +1,183 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package skill implements the `lark-cli skills` command group, which serves +// binary-embedded skill content to AI agents. The package is "skill"; the +// user-facing verb is "skills". +package skill + +import ( + "fmt" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/skillcontent" + "github.com/spf13/cobra" +) + +func newReader(f *cmdutil.Factory) (*skillcontent.Reader, error) { + if f.SkillContent == nil { + return nil, errs.NewInternalError(errs.SubtypeFileIO, + "skill content not embedded in this build") + } + return skillcontent.New(f.SkillContent), nil +} + +type readEnvelope struct { + Skill string `json:"skill"` + Path string `json:"path"` + Content string `json:"content"` + Guidance string `json:"guidance,omitempty"` +} + +type listEnvelope struct { + OK bool `json:"ok"` + Skills []skillcontent.SkillInfo `json:"skills"` + Count int `json:"count"` +} + +type listPathEnvelope struct { + OK bool `json:"ok"` + Path string `json:"path"` + Entries []skillcontent.DirEntry `json:"entries"` + Count int `json:"count"` +} + +func NewCmdSkill(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "skills", + Short: "Read embedded skill content (list / read)", + Long: "Read agent-readable skill content (SKILL.md and reference files) embedded in " + + "the CLI binary at build time, so it stays in sync with the CLI version. " + + "Machine resources such as assets/ and scripts/ are not embedded.", + } + // Risk is set on each leaf (GetRisk does not walk parents); the group has none. + cmdutil.DisableAuthCheck(cmd) + cmd.AddCommand(newListCmd(f), newReadCmd(f)) + return cmd +} + +func newListCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "list [name[/path]]", + Short: "List skills, or list one layer under a skill path (like ls)", + Example: ` lark-cli skills list # all skills: name, description, version + lark-cli skills list lark-doc # one layer under a skill (like ls) + lark-cli skills list lark-doc/references # one layer under a subdirectory`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 1 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "list takes at most 1 argument: [name[/path]]"). + WithHint("run 'lark-cli skills list --help'") + } + r, err := newReader(f) + if err != nil { + return err + } + if len(args) == 0 { + skills, err := r.List() + if err != nil { + return err + } + output.PrintJson(f.IOStreams.Out, listEnvelope{OK: true, Skills: skills, Count: len(skills)}) + return nil + } + entries, listed, err := r.ListPath(args[0]) + if err != nil { + return err + } + output.PrintJson(f.IOStreams.Out, listPathEnvelope{OK: true, Path: listed, Entries: entries, Count: len(entries)}) + return nil + }, + } + // --json is a no-op (list is always JSON), accepted only to stay symmetric with read. + cmd.Flags().Bool("json", false, "no-op (list output is always JSON)") + cmdutil.SetRisk(cmd, "read") + cmdutil.DisableAuthCheck(cmd) + return cmd +} + +func newReadCmd(f *cmdutil.Factory) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "read [/] [path]", + Short: "Print a skill's SKILL.md, or a file under the skill (raw markdown by default)", + Example: ` lark-cli skills read lark-doc # the skill's SKILL.md + lark-cli skills read lark-doc references/lark-doc-fetch.md # a file under the skill + lark-cli skills read lark-doc/references/lark-doc-fetch.md # same, slash form + lark-cli skills read lark-doc --json # JSON envelope`, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + name, relpath, err := parseReadTarget(args) + if err != nil { + return err + } + r, err := newReader(f) + if err != nil { + return err + } + + var content []byte + var pathOut string + if relpath == "" { + content, err = r.ReadSkill(name) + pathOut = "SKILL.md" + } else { + content, pathOut, err = r.ReadReference(name, relpath) + } + if err != nil { + return err + } + + isMain := pathOut == "SKILL.md" + if asJSON { + env := readEnvelope{Skill: name, Path: pathOut, Content: string(content)} + if isMain { + env.Guidance = readGuidance(name) + } + output.PrintJson(f.IOStreams.Out, env) + return nil + } + // Raw stdout stays byte-identical to the file; guidance goes to stderr. + if _, err := f.IOStreams.Out.Write(content); err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "failed to write output: %v", err) + } + if isMain { + fmt.Fprintln(f.IOStreams.ErrOut, readGuidance(name)) + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "output as a JSON envelope instead of raw markdown") + cmdutil.SetRisk(cmd, "read") + cmdutil.DisableAuthCheck(cmd) + return cmd +} + +// parseReadTarget maps 1-or-2 positional args to (name, relpath); a lone +// "/" splits on the first '/', and relpath "" reads the main SKILL.md. +func parseReadTarget(args []string) (name, relpath string, err error) { + switch len(args) { + case 1: + name, relpath = skillcontent.SplitArg(args[0]) + return name, relpath, nil + case 2: + return args[0], args[1], nil + default: + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, + "read requires 1 or 2 arguments: [/] [path]"). + WithHint("run 'lark-cli skills read --help'") + } +} + +// readGuidance routes cross-skill "../lark-foo/..." references back through +// `skills read lark-foo/...`: the path guard rejects a literal "../", so the +// relative form must be rewritten. +func readGuidance(name string) string { + return fmt.Sprintf("> Tip: read this skill's own files (e.g. `references/...`) with "+ + "`lark-cli skills read %s ` to keep them in sync with this CLI version. "+ + "A reference to another skill (`../lark-foo/...`) uses the same command with the "+ + "leading `../` removed: `lark-cli skills read lark-foo/...`.", name) +} diff --git a/cmd/skill/skill_test.go b/cmd/skill/skill_test.go new file mode 100644 index 00000000..af7b2caa --- /dev/null +++ b/cmd/skill/skill_test.go @@ -0,0 +1,306 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package skill + +import ( + "encoding/json" + "io" + "io/fs" + "strings" + "testing" + "testing/fstest" + + "github.com/larksuite/cli/internal/cmdutil" +) + +// calFS is the default single-skill content tree for these tests. The embedded +// FS is now injected through the Factory (no package global), so tests pass it +// explicitly to run() — nothing is shared, so they are safe under -parallel. +func calFS() fstest.MapFS { + return fstest.MapFS{ + "lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\nversion: 1.0.0\ndescription: \"Cal\"\nmetadata:\n cliHelp: \"lark-cli calendar --help\"\n---\nbody")}, + "lark-calendar/references/agenda.md": {Data: []byte("# Agenda")}, + } +} + +// run executes the skills command tree against the given content FS (may be nil +// to exercise the not-embedded path) and returns stdout/stderr/err. +func run(t *testing.T, fsys fs.FS, args ...string) (stdout, stderr string, err error) { + t.Helper() + // Isolate CLI config state so tests never read/write the real config dir + // (repo convention). + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + f, out, errOut, _ := cmdutil.TestFactory(t, nil) + f.SkillContent = fsys + cmd := NewCmdSkill(f) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err = cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestSkillList(t *testing.T) { + stdout, _, err := run(t, calFS(), "list") + if err != nil { + t.Fatalf("list error: %v", err) + } + var got struct { + OK bool `json:"ok"` + Skills []map[string]any `json:"skills"` + Count int `json:"count"` + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v\n%s", e, stdout) + } + // "ok" is an explicit success marker (the list envelope is a typed struct; + // no automatic _notice attaches). + if !got.OK { + t.Error("expected ok=true in list envelope") + } + if got.Count != 1 || len(got.Skills) != 1 { + t.Fatalf("count: got %d", got.Count) + } + if got.Skills[0]["name"] != "lark-calendar" { + t.Errorf("name: got %v", got.Skills[0]["name"]) + } + // Top-level list carries version + metadata, not a references list. + if _, ok := got.Skills[0]["references"]; ok { + t.Error("top-level list must not include references") + } + if got.Skills[0]["version"] != "1.0.0" { + t.Errorf("version: got %v, want 1.0.0", got.Skills[0]["version"]) + } + if _, ok := got.Skills[0]["metadata"]; !ok { + t.Error("expected metadata in list entry") + } +} + +func TestSkillListJSONFlagAccepted(t *testing.T) { + // `list --json` must be accepted (no-op), not rejected as an unknown flag, + // so it stays symmetric with read --json. + stdout, _, err := run(t, calFS(), "list", "--json") + if err != nil { + t.Fatalf("list --json error: %v", err) + } + var got struct { + OK bool `json:"ok"` + Count int `json:"count"` + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v\n%s", e, stdout) + } + if !got.OK || got.Count != 1 { + t.Errorf("envelope: %+v", got) + } +} + +func TestSkillListPath(t *testing.T) { + stdout, _, err := run(t, calFS(), "list", "lark-calendar") + if err != nil { + t.Fatalf("list error: %v", err) + } + var got struct { + OK bool `json:"ok"` + Path string `json:"path"` + Entries []struct { + Path string `json:"path"` + IsDir bool `json:"is_dir"` + } `json:"entries"` + Count int `json:"count"` + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v\n%s", e, stdout) + } + if !got.OK || got.Path != "lark-calendar" { + t.Errorf("envelope: %+v", got) + } + // One layer under the skill root: SKILL.md (file) + references (dir). + if got.Count != 2 || len(got.Entries) != 2 { + t.Fatalf("entries: got %+v", got.Entries) + } + if got.Entries[0].Path != "lark-calendar/SKILL.md" || got.Entries[0].IsDir { + t.Errorf("entry[0]: got %+v", got.Entries[0]) + } + if got.Entries[1].Path != "lark-calendar/references" || !got.Entries[1].IsDir { + t.Errorf("entry[1]: got %+v", got.Entries[1]) + } +} + +func TestSkillListPathUnknown(t *testing.T) { + _, _, err := run(t, calFS(), "list", "no-such-skill") + if err == nil || !strings.Contains(err.Error(), "unknown skill") { + t.Fatalf("expected 'unknown skill' error, got %v", err) + } +} + +func TestSkillListPathTraversal(t *testing.T) { + stdout, _, err := run(t, calFS(), "list", "lark-calendar/../../etc") + if err == nil || !strings.Contains(err.Error(), "invalid path") { + t.Fatalf("expected 'invalid path' error, got %v", err) + } + if stdout != "" { + t.Errorf("stdout must be empty on rejection, got %q", stdout) + } +} + +func TestSkillListTooManyArgs(t *testing.T) { + _, _, err := run(t, calFS(), "list", "a", "b") + if err == nil || !strings.Contains(err.Error(), "at most 1 argument") { + t.Fatalf("expected 'at most 1 argument' error, got %v", err) + } +} + +// TestSkillListSkipsDirWithoutSKILLmd proves a top-level dir lacking SKILL.md is +// omitted from the catalog (no blank entry). +func TestSkillListSkipsDirWithoutSKILLmd(t *testing.T) { + fsys := fstest.MapFS{ + "lark-calendar/SKILL.md": {Data: []byte("---\nname: lark-calendar\ndescription: \"Cal\"\n---\nb")}, + "not-a-skill/readme.txt": {Data: []byte("junk")}, // dir without SKILL.md + } + stdout, _, err := run(t, fsys, "list") + if err != nil { + t.Fatalf("list error: %v", err) + } + var got struct { + Skills []map[string]any `json:"skills"` + Count int `json:"count"` + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v\n%s", e, stdout) + } + if got.Count != 1 || got.Skills[0]["name"] != "lark-calendar" { + t.Fatalf("expected only lark-calendar, got %+v", got.Skills) + } +} + +func TestSkillReadRaw(t *testing.T) { + stdout, stderr, err := run(t, calFS(), "read", "lark-calendar") + if err != nil { + t.Fatalf("read error: %v", err) + } + if !strings.HasPrefix(stdout, "---\nname: lark-calendar") { + t.Errorf("raw output: got %q", stdout) + } + // Raw stdout is byte-pure SKILL.md — the guidance tip must NOT be appended. + if strings.Contains(stdout, "Tip:") { + t.Errorf("raw stdout must not carry the guidance tip: got %q", stdout) + } + // Guidance goes to stderr: own files via `skills read ...`, and + // cross-skill refs routed to `skills read ...` (version- + // consistent), not "read directly". + if !strings.Contains(stderr, "lark-cli skills read lark-calendar ") { + t.Errorf("expected own-files guidance on stderr: got %q", stderr) + } + if !strings.Contains(stderr, "lark-cli skills read lark-foo/...") { + t.Errorf("expected cross-skill refs routed to skills read: got %q", stderr) + } + if strings.Contains(stderr, "instead of opening them directly") || + strings.Contains(stderr, "read those directly") { + t.Errorf("guidance must not steer cross-skill refs to direct reads: got %q", stderr) + } +} + +func TestSkillReadJSON(t *testing.T) { + stdout, _, err := run(t, calFS(), "read", "lark-calendar", "--json") + if err != nil { + t.Fatalf("read --json error: %v", err) + } + var got struct { + Skill, Path, Content, Guidance string + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v", e) + } + if got.Skill != "lark-calendar" || got.Path != "SKILL.md" || got.Content == "" { + t.Errorf("envelope: %+v", got) + } + // Guidance is a separate field, not merged into content. + if got.Guidance == "" { + t.Error("expected guidance field for main SKILL.md") + } + if strings.Contains(got.Content, "Tip:") { + t.Error("guidance must not be merged into content") + } +} + +func TestSkillReadFile(t *testing.T) { + // Both the 2-arg and slash forms read the same file, with no guidance tip. + for _, args := range [][]string{ + {"read", "lark-calendar", "references/agenda.md"}, + {"read", "lark-calendar/references/agenda.md"}, + } { + stdout, stderr, err := run(t, calFS(), args...) + if err != nil { + t.Fatalf("read %v error: %v", args, err) + } + if stdout != "# Agenda" { + t.Errorf("read %v output: got %q", args, stdout) + } + // Reference reads carry no guidance on either stream. + if strings.Contains(stderr, "Tip:") { + t.Errorf("read %v must not emit guidance on stderr: got %q", args, stderr) + } + } +} + +func TestSkillReadFileJSON(t *testing.T) { + stdout, _, err := run(t, calFS(), "read", "lark-calendar", "references/agenda.md", "--json") + if err != nil { + t.Fatalf("read file --json error: %v", err) + } + var got struct { + Skill, Path, Content, Guidance string + } + if e := json.Unmarshal([]byte(stdout), &got); e != nil { + t.Fatalf("invalid JSON: %v\n%s", e, stdout) + } + if got.Skill != "lark-calendar" || got.Path != "references/agenda.md" || got.Content != "# Agenda" { + t.Errorf("envelope: %+v", got) + } + // Reference reads do not carry the guidance tip. + if got.Guidance != "" { + t.Errorf("reference read must not include guidance, got %q", got.Guidance) + } +} + +func TestSkillReadUnknown(t *testing.T) { + _, _, err := run(t, calFS(), "read", "no-such") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "unknown skill") { + t.Errorf("err: %v", err) + } +} + +func TestSkillReadMissingArg(t *testing.T) { + _, _, err := run(t, calFS(), "read") + if err == nil || !strings.Contains(err.Error(), "requires 1 or 2 arguments") { + t.Fatalf("expected arg error, got %v", err) + } +} + +func TestSkillReadTraversal(t *testing.T) { + stdout, _, err := run(t, calFS(), "read", "lark-calendar", "../../etc/passwd") + if err == nil { + t.Fatal("expected rejection") + } + if !strings.Contains(err.Error(), "invalid path") { + t.Errorf("err: %v", err) + } + if stdout != "" { + t.Errorf("stdout must be empty on rejection, got %q", stdout) + } +} + +func TestSkillNilContentFS(t *testing.T) { + _, _, err := run(t, nil, "list") + if err == nil { + t.Fatal("expected error when SkillContent is nil") + } + if !strings.Contains(err.Error(), "not embedded") { + t.Errorf("err: %v", err) + } +} diff --git a/internal/cmdutil/factory.go b/internal/cmdutil/factory.go index 827d7e58..1b167b78 100644 --- a/internal/cmdutil/factory.go +++ b/internal/cmdutil/factory.go @@ -6,6 +6,7 @@ package cmdutil import ( "context" "io" + "io/fs" "net/http" "strings" @@ -43,6 +44,8 @@ type Factory struct { Credential *credential.CredentialProvider FileIOProvider fileio.Provider // file transfer provider (default: local filesystem) + + SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills } // ResolveFileIO resolves a FileIO instance using the current execution context. diff --git a/internal/skillcontent/reader.go b/internal/skillcontent/reader.go new file mode 100644 index 00000000..d2be5ed2 --- /dev/null +++ b/internal/skillcontent/reader.go @@ -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 "" or +// "/", 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 "/" 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 / 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 +} diff --git a/internal/skillcontent/reader_test.go b/internal/skillcontent/reader_test.go new file mode 100644 index 00000000..cb36d41d --- /dev/null +++ b/internal/skillcontent/reader_test.go @@ -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("")}, + "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 /. + 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) + } +} diff --git a/skills_embed.go b/skills_embed.go new file mode 100644 index 00000000..c5cdbbf3 --- /dev/null +++ b/skills_embed.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "embed" + "fmt" + "io/fs" + "os" + + "github.com/larksuite/cli/cmd" +) + +// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md + +// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves +// content matching the binary version; machine-resource dirs (assets/, scripts/) +// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is +// silently omitted until added here. +// +//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes +var skillsEmbedFS embed.FS + +// init wires the embedded tree in as the default skill content. It compiles into +// `go build .` but not the single-file preview build (`go build ./main.go`), so +// main.go stays self-contained and that build still compiles (shipping no +// embedded skills). Assembly failure warns on stderr rather than panicking. +func init() { + sub, err := fs.Sub(skillsEmbedFS, "skills") + if err != nil { + fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) + return + } + cmd.SetEmbeddedSkillContent(sub) +}