Files
larksuite__cli/tests/plugin_e2e/skills_test.go
2026-08-11 16:27:37 +08:00

534 lines
19 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/tidwall/gjson"
)
// skillSelectionPlugin proves that a plugin's Allow/Remove selection composes
// against the base embedded by the external wrapper main. Before the wrapper
// wired cmd.SetEmbeddedSkillContent, this configuration installed a fatal guard
// that made every command exit 2.
const skillSelectionPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("skill-selection", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Allow: []string{"lark-a", "lark-b"},
Remove: []string{"lark-b"},
}).
MustBuild())
}
`
const incompleteSkillDependencyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("incomplete-skill-dependency", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Allow: []string{"lark-doc"},
}).
MustBuild())
}
`
const completeSkillDependencyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("complete-skill-dependency", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Allow: []string{"lark-doc", "lark-shared"},
}).
MustBuild())
}
`
const replacementSkillTreePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"testing/fstest"
"github.com/larksuite/cli/extension/platform"
)
func init() {
base := fstest.MapFS{
"acme-docx/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: acme-docx\ndescription: custom docs\n---\n")},
"acme-docx/guides/create.md": &fstest.MapFile{Data: []byte("# acme create\n")},
"acme-docx/references/lark-doc-fetch.md": &fstest.MapFile{Data: []byte("# acme fetch\n")},
"acme-docx/references/lark-doc-history.md": &fstest.MapFile{Data: []byte("# acme history\n")},
"acme-docx/references/lark-doc-md.md": &fstest.MapFile{Data: []byte("# acme markdown\n")},
"acme-docx/references/lark-doc-update.md": &fstest.MapFile{Data: []byte("# acme update\n")},
"acme-docx/references/lark-doc-xml.md": &fstest.MapFile{Data: []byte("# acme xml\n")},
}
platform.Register(
platform.NewPlugin("replacement-skills", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Base: base,
ReferenceRemaps: []platform.SkillRefRemap{
platform.RemapSkillRef("lark-doc", "acme-docx"),
platform.RemapSkillRef(
"lark-doc/references/lark-doc-create.md",
"acme-docx/guides/create.md",
),
},
}).
MustBuild())
}
`
const removeDocsSkillPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("remove-docs-skill", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Remove: []string{"lark-doc"},
}).
MustBuild())
}
`
const concealSkillsCommandPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"testing/fstest"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("conceal-skills-command", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Overlay: fstest.MapFS{
"lark-im/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: lark-im\ndescription: IM guide\n---\n")},
},
}).
Restrict(&platform.Rule{
Deny: []string{"skills", "skills/**"},
MaxRisk: platform.RiskHighRiskWrite,
AllowUnannotated: true,
}).
MustBuild())
}
`
const runtimePanicSkillFSPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"io/fs"
"testing/fstest"
"github.com/larksuite/cli/extension/platform"
)
var panicOnRead bool
type latePanicFS struct{ fs.FS }
func (f latePanicFS) ReadFile(name string) ([]byte, error) {
if panicOnRead {
panic("runtime skill read exploded")
}
return fs.ReadFile(f.FS, name)
}
func init() {
content := fstest.MapFS{
"acme-guide/SKILL.md": &fstest.MapFile{Data: []byte("---\nname: acme-guide\ndescription: panic boundary\n---\n")},
"acme-guide/references/x.md": &fstest.MapFile{Data: []byte("# x\n")},
}
platform.Register(
platform.NewPlugin("runtime-panic-fs", "0.1.0").
EmbeddedSkills(&platform.SkillsOverlay{Base: latePanicFS{FS: content}}).
On(platform.Startup, "arm-panic",
func(context.Context, *platform.LifecycleContext) error {
panicOnRead = true
return nil
}).
MustBuild())
}
`
func TestForkMainWiresEmbeddedSkillsWithoutOverlay(t *testing.T) {
bin := buildFork(t, "smoke", noopPlugin)
t.Setenv("PATH", filepath.Dir(bin)+string(os.PathListSeparator)+os.Getenv("PATH"))
const cli = "lark-cli"
res := run(t, cli, "skills", "list")
if res.exit != 0 || !gjson.Valid(res.stdout) {
t.Fatalf("skills list exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if got := gjson.Get(res.stdout, "count").Int(); got != 4 {
t.Fatalf("skills count=%d want 4; stdout=%s", got, res.stdout)
}
if got := gjson.Get(res.stdout, "skills.#.name").Array(); len(got) != 4 ||
got[0].String() != "lark-a" || got[1].String() != "lark-b" ||
got[2].String() != "lark-doc" || got[3].String() != "lark-shared" {
t.Fatalf("skill names=%v want [lark-a lark-b lark-doc lark-shared]; stdout=%s", got, res.stdout)
}
docsHelp := run(t, cli, "docs", "--help")
if docsHelp.exit != 0 ||
!strings.Contains(docsHelp.stdout,
"Domain guide (concepts, command choice, conventions): lark-cli skills read lark-doc") ||
strings.Contains(docsHelp.stdout, "Domain skills (concepts, command choice, conventions):") {
t.Fatalf("docs help canonical skill pointer missing: exit=%d stdout=%s stderr=%s",
docsHelp.exit, docsHelp.stdout, docsHelp.stderr)
}
mainSkill := run(t, cli, "skills", "read", "lark-doc")
if mainSkill.exit != 0 || !strings.Contains(mainSkill.stdout, "name: lark-doc") {
t.Fatalf("canonical docs skill pointer is not readable: exit=%d stdout=%s stderr=%s",
mainSkill.exit, mainSkill.stdout, mainSkill.stderr)
}
for _, tc := range []struct {
shortcut string
references []string
}{
{shortcut: "+create", references: []string{"lark-doc-create.md", "lark-doc-xml.md", "lark-doc-md.md"}},
{shortcut: "+fetch", references: []string{"lark-doc-fetch.md"}},
{shortcut: "+update", references: []string{"lark-doc-update.md", "lark-doc-xml.md", "lark-doc-md.md"}},
{shortcut: "+history-list", references: []string{"lark-doc-history.md"}},
{shortcut: "+history-revert", references: []string{"lark-doc-history.md"}},
{shortcut: "+history-revert-status", references: []string{"lark-doc-history.md"}},
} {
help := run(t, cli, "docs", tc.shortcut, "--help")
if help.exit != 0 {
t.Errorf("docs %s help failed: exit=%d stdout=%s stderr=%s",
tc.shortcut, help.exit, help.stdout, help.stderr)
continue
}
for _, reference := range tc.references {
path := "references/" + reference
pointer := "lark-cli skills read lark-doc/" + path
if !strings.Contains(help.stdout, pointer) {
t.Errorf("docs %s reference pointer %q missing; stdout=%s",
tc.shortcut, pointer, help.stdout)
continue
}
// Execute the exact binary name and arguments printed by help:
// PATH resolution here catches a fork shipped only as "larkx".
rules := run(t, cli, "skills", "read", "lark-doc/"+path)
if rules.exit != 0 || !strings.Contains(rules.stdout, "Plugin E2E "+reference) {
t.Errorf("docs %s reference pointer %q is not readable: exit=%d stdout=%s stderr=%s",
tc.shortcut, pointer, rules.exit, rules.stdout, rules.stderr)
}
}
}
}
func TestForkDomainHelpDisplaysConfiguredSkills(t *testing.T) {
affordanceSrc := strings.Replace(
customerAffordanceDocs,
"> skill: lark-doc\n",
"> skill: lark-doc\n\n## Skills\n- lark-a\n- lark-missing\n",
1,
)
bin := buildForkWithAffordance(t, "domain-skills", noopPlugin, affordanceSrc)
help := run(t, bin, "docs", "--help")
if help.exit != 0 {
t.Fatalf("docs help: exit=%d stdout=%s stderr=%s", help.exit, help.stdout, help.stderr)
}
if !strings.Contains(help.stdout, "Domain skills (concepts, command choice, conventions):") {
t.Fatalf("configured domain skills did not render as a list:\n%s", help.stdout)
}
docAt := strings.Index(help.stdout, "lark-cli skills read lark-doc")
aAt := strings.Index(help.stdout, "lark-cli skills read lark-a")
if docAt < 0 || aAt < 0 || docAt >= aAt {
t.Fatalf("domain skill pointers missing or out of order (doc=%d a=%d):\n%s", docAt, aAt, help.stdout)
}
if strings.Contains(help.stdout, "lark-missing") {
t.Fatalf("unreadable domain skill pointer was not filtered:\n%s", help.stdout)
}
for _, skill := range []string{"lark-doc", "lark-a"} {
read := run(t, bin, "skills", "read", skill)
if read.exit != 0 || !strings.Contains(read.stdout, "name: "+skill) {
t.Errorf("rendered domain skill %q is not readable: exit=%d stdout=%s stderr=%s",
skill, read.exit, read.stdout, read.stderr)
}
}
commandHelp := run(t, bin, "docs", "+create", "--help")
if commandHelp.exit != 0 {
t.Fatalf("docs +create help: exit=%d stdout=%s stderr=%s",
commandHelp.exit, commandHelp.stdout, commandHelp.stderr)
}
if strings.Contains(commandHelp.stdout, "lark-cli skills read lark-a") {
t.Fatalf("domain-only skill leaked into command help:\n%s", commandHelp.stdout)
}
}
func TestForkSkillsAllowRemoveComposeAgainstEmbeddedBase(t *testing.T) {
bin := buildFork(t, "skill-selection", skillSelectionPlugin)
res := run(t, bin, "skills", "list")
if res.exit != 0 || !gjson.Valid(res.stdout) {
t.Fatalf("skills list exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if got := gjson.Get(res.stdout, "count").Int(); got != 1 {
t.Fatalf("skills count=%d want 1; stdout=%s", got, res.stdout)
}
if got := gjson.Get(res.stdout, "skills.0.name").String(); got != "lark-a" {
t.Fatalf("skill name=%q want lark-a; stdout=%s", got, res.stdout)
}
// Pin that the invalid-overlay fatal guard was not installed globally.
help := run(t, bin, "im", "--help")
if help.exit != 0 {
t.Fatalf("unrelated command was bricked: exit=%d stdout=%s stderr=%s", help.exit, help.stdout, help.stderr)
}
}
func TestForkSkillsMissingRequiredSkillUsesTypedStartupGuard(t *testing.T) {
bin := buildFork(t, "incomplete-skill-dependency", incompleteSkillDependencyPlugin)
res := run(t, bin, "skills", "list")
if res.exit != 2 || !gjson.Valid(res.stderr) {
t.Fatalf("skills list exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation; stderr=%s", got, res.stderr)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition; stderr=%s", got, res.stderr)
}
hint := gjson.Get(res.stderr, "error.hint").String()
reasonMatches := regexp.MustCompile(`\breason_code ([a-z0-9_]+)\b`).FindAllStringSubmatch(hint, -1)
if len(reasonMatches) != 1 || reasonMatches[0][1] != "invalid_skills_overlay" {
t.Errorf("error.hint reason_codes=%v want exactly [invalid_skills_overlay]; hint=%q", reasonMatches, hint)
}
message := gjson.Get(res.stderr, "error.message").String()
affectedSkills := make(map[string]struct{})
for _, match := range regexp.MustCompile(`\bskill "([^"]+)"`).FindAllStringSubmatch(message, -1) {
affectedSkills[match[1]] = struct{}{}
}
if len(affectedSkills) != 2 {
t.Errorf("affected skills=%v want exactly [lark-doc lark-shared]; message=%q", affectedSkills, message)
} else {
for _, want := range []string{"lark-doc", "lark-shared"} {
if _, ok := affectedSkills[want]; !ok {
t.Errorf("affected skills=%v missing %q; message=%q", affectedSkills, want, message)
}
}
}
}
func TestForkSkillsIncludingRequiredSkillSucceeds(t *testing.T) {
bin := buildFork(t, "complete-skill-dependency", completeSkillDependencyPlugin)
list := run(t, bin, "skills", "list")
if list.exit != 0 || !gjson.Valid(list.stdout) {
t.Fatalf("skills list exit=%d stdout=%s stderr=%s", list.exit, list.stdout, list.stderr)
}
if got := gjson.Get(list.stdout, "skills.#.name").Array(); len(got) != 2 ||
got[0].String() != "lark-doc" || got[1].String() != "lark-shared" {
t.Fatalf("skill names=%v want [lark-doc lark-shared]; stdout=%s", got, list.stdout)
}
for _, name := range []string{"lark-doc", "lark-shared"} {
read := run(t, bin, "skills", "read", name)
if read.exit != 0 || !strings.Contains(read.stdout, "name: "+name) {
t.Errorf("skill %q unreadable: exit=%d stdout=%s stderr=%s", name, read.exit, read.stdout, read.stderr)
}
}
}
func TestForkSkillsBaseReplacementAndReferenceRemapWithoutHostBase(t *testing.T) {
bin := buildForkWithMain(
t,
"replacement-skill-tree",
replacementSkillTreePlugin,
customerMainWithoutSkills,
)
list := run(t, bin, "skills", "list")
if list.exit != 0 || gjson.Get(list.stdout, "count").Int() != 1 ||
gjson.Get(list.stdout, "skills.0.name").String() != "acme-docx" {
t.Fatalf("replacement skills list: exit=%d stdout=%s stderr=%s",
list.exit, list.stdout, list.stderr)
}
help := run(t, bin, "docs", "+create", "--help")
if help.exit != 0 {
t.Fatalf("remapped docs help: exit=%d stdout=%s stderr=%s",
help.exit, help.stdout, help.stderr)
}
for _, want := range []string{
"lark-cli skills read acme-docx/guides/create.md",
"lark-cli skills read acme-docx/references/lark-doc-xml.md",
"lark-cli skills read acme-docx/references/lark-doc-md.md",
} {
if !strings.Contains(help.stdout, want) {
t.Errorf("remapped docs help missing %q:\n%s", want, help.stdout)
}
}
if strings.Contains(help.stdout, "lark-cli skills read lark-doc") {
t.Errorf("remapped docs help leaked canonical runtime name:\n%s", help.stdout)
}
read := run(t, bin, "skills", "read", "acme-docx/guides/create.md")
if read.exit != 0 || !strings.Contains(read.stdout, "# acme create") {
t.Fatalf("remapped pointer unreadable: exit=%d stdout=%s stderr=%s",
read.exit, read.stdout, read.stderr)
}
configDir := t.TempDir()
writeFile(t, filepath.Join(configDir, "config.json"),
`{"apps":[{"appId":"cli_plugin_e2e","appSecret":"secret","brand":"feishu","users":[]}]}`)
legacyEnv := append(baseEnv(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+configDir,
"LARKSUITE_CLI_REMOTE_META=off",
)
legacy := runWithEnv(t, bin, legacyEnv,
"docs", "+update", "--doc", "doccn-plugin-e2e", "--mode", "replace", "--as", "user")
if legacy.exit != 2 || !gjson.Valid(legacy.stderr) {
t.Fatalf("remapped v2-only error: exit=%d stdout=%s stderr=%s",
legacy.exit, legacy.stdout, legacy.stderr)
}
if got := gjson.Get(legacy.stderr, "error.subtype").String(); got != "invalid_argument" {
t.Fatalf("remapped v2-only subtype=%q want invalid_argument; stderr=%s",
got, legacy.stderr)
}
if got := gjson.Get(legacy.stderr, "error.param").String(); got != "--mode" {
t.Fatalf("remapped v2-only param=%q want --mode; stderr=%s",
got, legacy.stderr)
}
hint := gjson.Get(legacy.stderr, "error.hint").String()
for _, want := range []string{
"`lark-cli skills read acme-docx`",
"`lark-cli skills read acme-docx/references/lark-doc-update.md`",
"`lark-cli skills read acme-docx/references/lark-doc-xml.md`",
"`lark-cli skills read acme-docx/references/lark-doc-md.md`",
} {
if !strings.Contains(hint, want) {
t.Errorf("remapped v2-only hint missing %q: %q", want, hint)
}
}
if strings.Contains(hint, "lark-cli skills read lark-doc") {
t.Errorf("remapped v2-only hint leaked canonical skill reference: %q", hint)
}
}
func TestForkRemovingDocsSkillDropsPointersButKeepsStandaloneGuidance(t *testing.T) {
bin := buildFork(t, "remove-docs-skill", removeDocsSkillPlugin)
for _, args := range [][]string{
{"docs", "--help"},
{"docs", "+create", "--help"},
} {
help := run(t, bin, args...)
if help.exit != 0 {
t.Fatalf("%v: exit=%d stdout=%s stderr=%s", args, help.exit, help.stdout, help.stderr)
}
for _, dead := range []string{"skills read", "AI agents MUST read"} {
if strings.Contains(help.stdout, dead) {
t.Errorf("%v retained removed-skill guidance %q:\n%s", args, dead, help.stdout)
}
}
if len(args) > 1 && args[1] == "+create" {
for _, want := range []string{"Tips:", "Match --doc-format", "Prefer @file"} {
if !strings.Contains(help.stdout, want) {
t.Errorf("%v lost standalone help guidance %q after skill removal:\n%s", args, want, help.stdout)
}
}
}
}
legacy := run(t, bin, "docs", "+update", "--mode", "replace")
if legacy.exit != 2 {
t.Fatalf("legacy v2-only probe: exit=%d stdout=%s stderr=%s",
legacy.exit, legacy.stdout, legacy.stderr)
}
if strings.Contains(legacy.stderr, "skills read") {
t.Errorf("v2-only error retained removed-skill pointer: %s", legacy.stderr)
}
}
func TestForkConcealingSkillsCommandDropsDocsPointersEvenWhenContentExists(t *testing.T) {
bin := buildConcealedFork(t, "concealed-skills-command", concealSkillsCommandPlugin)
help := run(t, bin, "docs", "+create", "--help")
if help.exit != 0 {
t.Fatalf("docs help: exit=%d stdout=%s stderr=%s", help.exit, help.stdout, help.stderr)
}
if strings.Contains(help.stdout, "skills read") {
t.Errorf("docs help points at concealed skills/read:\n%s", help.stdout)
}
imHelp := run(t, bin, "im", "--help")
if imHelp.exit != 0 {
t.Fatalf("im help: exit=%d stdout=%s stderr=%s",
imHelp.exit, imHelp.stdout, imHelp.stderr)
}
if strings.Contains(imHelp.stdout, "skills read") {
t.Errorf("generic domain help points at concealed skills/read:\n%s", imHelp.stdout)
}
legacy := run(t, bin, "docs", "+update", "--mode", "replace")
if legacy.exit != 2 {
t.Fatalf("legacy v2-only probe: exit=%d stdout=%s stderr=%s",
legacy.exit, legacy.stdout, legacy.stderr)
}
if strings.Contains(legacy.stderr, "skills read") {
t.Errorf("v2-only error points at concealed skills/read: %s", legacy.stderr)
}
}
func TestForkPluginFSPanicBecomesTypedRuntimeError(t *testing.T) {
bin := buildForkWithMain(
t,
"runtime-panic-skill-fs",
runtimePanicSkillFSPlugin,
customerMainWithoutSkills,
)
res := run(t, bin, "skills", "read", "acme-guide/references/x.md")
if res.exit != 5 || !gjson.Valid(res.stderr) {
t.Fatalf("runtime plugin FS panic exit=%d stdout=%s stderr=%s",
res.exit, res.stdout, res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
t.Errorf("error.type=%q want internal; stderr=%s", got, res.stderr)
}
for _, want := range []string{
`plugin \"runtime-panic-fs\"`,
"Base filesystem",
"readfile",
"acme-guide/references/x.md",
} {
if !strings.Contains(res.stderr, want) {
t.Errorf("stderr missing %q: %s", want, res.stderr)
}
}
if strings.Contains(res.stderr, "goroutine ") {
t.Errorf("runtime plugin FS panic escaped as a stack trace: %s", res.stderr)
}
}