From e9f2da086fa087654209573e4f420b2e05cf900d Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 18 Jun 2026 16:10:47 +0800 Subject: [PATCH 01/40] feat: add plugin package and instance management commands for apps domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 8 new shortcut commands under `lark-cli apps`: Plugin package management (aligned with fullstack-cli): - +plugin-install: download tgz, extract to node_modules, update package.json - +plugin-uninstall: remove from node_modules and package.json actionPlugins - +plugin-list: list declared plugins with installation status Plugin instance CRUD (aligned with feida-ai): - +plugin-instance-create: validate + write capability JSON with formValue validation - +plugin-instance-update: merge mutable fields, re-validate formValue - +plugin-instance-delete: idempotent file removal - +plugin-instance-get: read capability JSON - +plugin-instance-list: scan capabilities directory Shared infrastructure (plugin_common.go): - 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection) - formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema type constraints, input ref existence, unconsumed params, array double-wrap auto-fix) - tgz extraction with path traversal protection - package.json actionPlugins management - Install version check with mismatch warnings --- shortcuts/apps/plugin_common.go | 606 ++++++++++++++++++ shortcuts/apps/plugin_common_test.go | 424 ++++++++++++ shortcuts/apps/plugin_install.go | 295 +++++++++ shortcuts/apps/plugin_install_test.go | 201 ++++++ shortcuts/apps/plugin_instance_create.go | 168 +++++ shortcuts/apps/plugin_instance_create_test.go | 281 ++++++++ shortcuts/apps/plugin_instance_delete.go | 73 +++ shortcuts/apps/plugin_instance_delete_test.go | 81 +++ shortcuts/apps/plugin_instance_get.go | 91 +++ shortcuts/apps/plugin_instance_get_test.go | 93 +++ shortcuts/apps/plugin_instance_list.go | 94 +++ shortcuts/apps/plugin_instance_list_test.go | 151 +++++ shortcuts/apps/plugin_instance_update.go | 131 ++++ shortcuts/apps/plugin_instance_update_test.go | 164 +++++ shortcuts/apps/plugin_list.go | 78 +++ shortcuts/apps/plugin_list_test.go | 106 +++ shortcuts/apps/plugin_uninstall.go | 77 +++ shortcuts/apps/plugin_uninstall_test.go | 97 +++ shortcuts/apps/shortcuts.go | 8 + 19 files changed, 3219 insertions(+) create mode 100644 shortcuts/apps/plugin_common.go create mode 100644 shortcuts/apps/plugin_common_test.go create mode 100644 shortcuts/apps/plugin_install.go create mode 100644 shortcuts/apps/plugin_install_test.go create mode 100644 shortcuts/apps/plugin_instance_create.go create mode 100644 shortcuts/apps/plugin_instance_create_test.go create mode 100644 shortcuts/apps/plugin_instance_delete.go create mode 100644 shortcuts/apps/plugin_instance_delete_test.go create mode 100644 shortcuts/apps/plugin_instance_get.go create mode 100644 shortcuts/apps/plugin_instance_get_test.go create mode 100644 shortcuts/apps/plugin_instance_list.go create mode 100644 shortcuts/apps/plugin_instance_list_test.go create mode 100644 shortcuts/apps/plugin_instance_update.go create mode 100644 shortcuts/apps/plugin_instance_update_test.go create mode 100644 shortcuts/apps/plugin_list.go create mode 100644 shortcuts/apps/plugin_list_test.go create mode 100644 shortcuts/apps/plugin_uninstall.go create mode 100644 shortcuts/apps/plugin_uninstall_test.go diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go new file mode 100644 index 000000000..9fd1cfd66 --- /dev/null +++ b/shortcuts/apps/plugin_common.go @@ -0,0 +1,606 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" +) + +// pluginIDPattern validates semantic instance ids: lowercase alphanumeric + hyphens, +// not starting or ending with a hyphen. +var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) + +// pluginResolveProjectPath resolves --project-path to an absolute path, +// defaulting to cwd when empty. +func pluginResolveProjectPath(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded. + if err != nil { + return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err) + } + return cwd, nil + } + if err := validate.RejectControlChars(raw, "--project-path"); err != nil { + return "", err + } + return filepath.Clean(raw), nil +} + +// pluginCheckProjectDir validates that projectPath contains a package.json. +func pluginCheckProjectDir(projectPath string) error { + info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check. + if err != nil { + if os.IsNotExist(err) { + return appsFailedPreconditionError("package.json not found in %s", projectPath). + WithHint("run 'lark-cli apps +init' to initialize the project first") + } + return appsFileIOError(err, "cannot access package.json in %s", projectPath) + } + if !info.Mode().IsRegular() { + return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath) + } + return nil +} + +// pluginResolveCapDir resolves the capabilities directory using a 4-level fallback: +// 1. capDirFlag (explicit --capabilities-dir) +// 2. MIAODA_CAPABILITIES_DIR env var +// 3. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) +// 3.5 Read .env.local for MIAODA_APP_TYPE +// 4. Detect by checking which directories exist under projectPath +func pluginResolveCapDir(projectPath, capDirFlag string) (string, error) { + if dir := strings.TrimSpace(capDirFlag); dir != "" { + if filepath.IsAbs(dir) { + return dir, nil + } + return filepath.Join(projectPath, dir), nil + } + + if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional. + if filepath.IsAbs(dir) { + return dir, nil + } + return filepath.Join(projectPath, dir), nil + } + + // 3. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ + appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional. + if appType == "" { + appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") + } + if appType == "6" { + return filepath.Join(projectPath, "shared", "capabilities"), nil + } + if appType != "" { + return filepath.Join(projectPath, "server", "capabilities"), nil + } + + // 4. Directory detection + serverDir := filepath.Join(projectPath, "server", "capabilities") + sharedDir := filepath.Join(projectPath, "shared", "capabilities") + serverOK := pluginDirExists(serverDir) + sharedOK := pluginDirExists(sharedDir) + + switch { + case serverOK && sharedOK: + return "", appsFailedPreconditionError( + "ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist", + ).WithHint("use --capabilities-dir to specify which capabilities directory to use") + case serverOK: + return serverDir, nil + case sharedOK: + return sharedDir, nil + default: + // Default to server/capabilities/ (most common app type) + return filepath.Join(projectPath, "server", "capabilities"), nil + } +} + +// pluginReadEnvLocalValue reads a value from .env.local by key name. +func pluginReadEnvLocalValue(projectPath, key string) string { + data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read. + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + v = strings.TrimSpace(v) + v = strings.Trim(v, "\"'") + return v + } + return "" +} + +func pluginDirExists(path string) bool { + info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check. + return err == nil && info.IsDir() +} + +// pluginReadCapJSON reads and parses a single capability JSON file. +func pluginReadCapJSON(path string) (map[string]interface{}, error) { + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local capability file read. + if err != nil { + return nil, err + } + var cap map[string]interface{} + if err := json.Unmarshal(data, &cap); err != nil { + return nil, fmt.Errorf("invalid JSON in %s: %w", filepath.Base(path), err) + } + return cap, nil +} + +// pluginListCapabilities reads all *.json files from capDir. +// Returns nil (not error) if the directory does not exist. +func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { + entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing. + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir) + } + + var caps []map[string]interface{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + cap, err := pluginReadCapJSON(filepath.Join(capDir, entry.Name())) + if err != nil { + continue + } + caps = append(caps, cap) + } + return caps, nil +} + +// pluginGetCapability reads a single capability by id from capDir. +// The file is expected at capDir/{id}.json. +func pluginGetCapability(capDir, id string) (map[string]interface{}, error) { + path := filepath.Join(capDir, id+".json") + cap, err := pluginReadCapJSON(path) + if err != nil { + if os.IsNotExist(err) { + return nil, appsValidationError("instance %q not found", id). + WithHint("list instances with 'lark-cli apps +plugin-instance-list'") + } + return nil, appsFileIOError(err, "cannot read capability %s", path) + } + return cap, nil +} + +// pluginReadManifest reads manifest.json from node_modules for the given pluginKey. +func pluginReadManifest(projectPath, pluginKey string) (map[string]interface{}, error) { + path := filepath.Join(projectPath, "node_modules", pluginKey, "manifest.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local manifest read. + if err != nil { + return nil, err + } + var manifest map[string]interface{} + if err := json.Unmarshal(data, &manifest); err != nil { + return nil, fmt.Errorf("invalid manifest.json for %s: %w", pluginKey, err) + } + return manifest, nil +} + +// pluginParseKeyVersion splits "key@version" into (key, version). +// The key may start with "@" (scoped npm package), so the split is at the last "@". +func pluginParseKeyVersion(s string) (string, string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "", "", appsValidationParamError("--plugin", "--plugin is required") + } + idx := strings.LastIndex(s, "@") + if idx <= 0 { + return "", "", appsValidationParamError("--plugin", + "invalid format %q; expected key@version (e.g. @official-plugins/ai-text-generate@1.0.0)", s) + } + key, version := s[:idx], s[idx+1:] + if key == "" || version == "" { + return "", "", appsValidationParamError("--plugin", + "invalid format %q; expected key@version", s) + } + return key, version, nil +} + +// pluginDeriveID derives an instance id from a plugin key. +// "@official-plugins/ai-text-generate" → "official-plugins-ai-text-generate" +func pluginDeriveID(pluginKey string) string { + id := strings.TrimPrefix(pluginKey, "@") + id = strings.ReplaceAll(id, "/", "-") + return id +} + +// pluginValidateID checks that id is a valid semantic instance id. +func pluginValidateID(id string) error { + if !pluginIDPattern.MatchString(id) { + return appsValidationParamError("--id", + "invalid id %q; must be lowercase alphanumeric with hyphens, not starting/ending with hyphen", id) + } + return nil +} + +// pluginValidateJSONFlag checks that value is non-empty valid JSON. +func pluginValidateJSONFlag(flagName, value string) error { + value = strings.TrimSpace(value) + if value == "" { + return appsValidationParamError(flagName, "%s value is required", flagName) + } + if !json.Valid([]byte(value)) { + return appsValidationParamError(flagName, "%s must be valid JSON", flagName) + } + return nil +} + +// pluginCheckInstalled verifies that the plugin package is installed in node_modules. +func pluginCheckInstalled(projectPath, pluginKey string) error { + manifestPath := filepath.Join(projectPath, "node_modules", pluginKey, "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. + if os.IsNotExist(err) { + return appsFailedPreconditionError("plugin %q is not installed", pluginKey). + WithHint("run 'lark-cli apps +plugin-install %s' first", pluginKey) + } + return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) + } + return nil +} + +// pluginCheckInstalledVersion checks that the plugin is installed and warns if +// the installed version differs from the declared version. Returns (warnings, error). +func pluginCheckInstalledVersion(projectPath, pluginKey, declaredVersion string) ([]string, error) { + if err := pluginCheckInstalled(projectPath, pluginKey); err != nil { + return nil, err + } + var warnings []string + if installed := pluginInstalledVersion(projectPath, pluginKey); installed != "" && installed != declaredVersion { + warnings = append(warnings, fmt.Sprintf("installed %s differs from declared %s", installed, declaredVersion)) + } + return warnings, nil +} + +// ── formValue validation (aligned with feida-ai validatePluginInstance) ── + +// Forbidden Handlebars block-level helpers. +var pluginForbiddenTemplatePatterns = []*regexp.Regexp{ + regexp.MustCompile(`\{\{#if\b`), + regexp.MustCompile(`\{\{#each\b`), + regexp.MustCompile(`\{\{#unless\b`), + regexp.MustCompile(`\{\{/if\}\}`), + regexp.MustCompile(`\{\{/each\}\}`), + regexp.MustCompile(`\{\{/unless\}\}`), + regexp.MustCompile(`\{\{else\}\}`), +} + +// pluginInputRefPattern matches {{input.xxx}} template references. +var pluginInputRefPattern = regexp.MustCompile(`\{\{input\.(\w+)\}\}`) + +// pluginTemplateRefExact matches a string that is exactly one {{input.xxx}} with no surrounding text. +var pluginTemplateRefExact = regexp.MustCompile(`^\{\{input\.(\w+)\}\}$`) + +// pluginValidateFormValue validates formValue and paramsSchema following feida-ai's +// validatePluginInstance rules. Returns all violations; empty means valid. +// Also auto-fixes array double-wrapping in formValue (mutates fvMap in place). +func pluginValidateFormValue(formValue, paramsSchema interface{}) []string { + var errors []string + + fvMap, _ := formValue.(map[string]interface{}) + + // Rule 1: Forbidden Handlebars control syntax (recursive) + pluginTraverseValues(formValue, "formValue", func(s, path string) { + for _, pat := range pluginForbiddenTemplatePatterns { + if pat.MatchString(s) { + errors = append(errors, fmt.Sprintf("forbidden Handlebars syntax at %s: %s", path, pat.FindString(s))) + } + } + }) + + // If no paramsSchema provided, skip schema-dependent rules + psMap, _ := paramsSchema.(map[string]interface{}) + properties, _ := psMap["properties"].(map[string]interface{}) + definedParams := make(map[string]bool, len(properties)) + for k := range properties { + definedParams[k] = true + } + + // Rule 2: paramsSchema property type validation + allowedTypes := map[string]bool{"string": true, "array": true} + allowedFormats := map[string]bool{"plugin-image-url": true, "plugin-file-url": true} + for paramName, paramDef := range properties { + def, ok := paramDef.(map[string]interface{}) + if !ok { + continue + } + paramType, _ := def["type"].(string) + if !allowedTypes[paramType] { + errors = append(errors, fmt.Sprintf("paramsSchema property %q type %q is invalid; only string or array allowed", paramName, paramType)) + } + if paramType == "array" { + if _, hasItems := def["items"]; !hasItems { + errors = append(errors, fmt.Sprintf("paramsSchema property %q is array but missing items", paramName)) + } + } + if f, ok := def["format"].(string); ok && !allowedFormats[f] { + errors = append(errors, fmt.Sprintf("paramsSchema property %q format %q is invalid; only plugin-image-url or plugin-file-url allowed", paramName, f)) + } + if _, hasDesc := def["description"]; !hasDesc { + errors = append(errors, fmt.Sprintf("paramsSchema property %q missing description", paramName)) + } + } + + // Rule 3: {{input.xxx}} references must exist in paramsSchema + pluginTraverseValues(formValue, "formValue", func(s, path string) { + for _, match := range pluginInputRefPattern.FindAllStringSubmatch(s, -1) { + if !definedParams[match[1]] { + errors = append(errors, fmt.Sprintf("{{input.%s}} at %s is not defined in paramsSchema", match[1], path)) + } + } + }) + + // Rule 4: every paramsSchema property must be consumed by {{input.xxx}} in formValue + if len(definedParams) > 0 && fvMap != nil { + fvStr, _ := json.Marshal(fvMap) + fvJSON := string(fvStr) + for paramName := range definedParams { + ref := "{{input." + paramName + "}}" + if !strings.Contains(fvJSON, ref) { + errors = append(errors, fmt.Sprintf("paramsSchema property %q is never referenced as %s in formValue", paramName, ref)) + } + } + } + + // Rule 5: array double-wrapping auto-fix + // If paramsSchema declares a field as type:array, and formValue wraps it in + // ["{{input.xxx}}"], auto-fix to "{{input.xxx}}" to prevent runtime [[val]] nesting. + if fvMap != nil { + arrayParams := make(map[string]bool) + for paramName, paramDef := range properties { + if def, ok := paramDef.(map[string]interface{}); ok { + if t, _ := def["type"].(string); t == "array" { + arrayParams[paramName] = true + } + } + } + if len(arrayParams) > 0 { + pluginAutoFixArrayWrapping(fvMap, arrayParams) + } + } + + return errors +} + +// pluginTraverseValues recursively visits all string leaf values in a nested +// structure (object / array / string), calling visitor for each. +func pluginTraverseValues(value interface{}, path string, visitor func(s, path string)) { + switch v := value.(type) { + case string: + visitor(v, path) + case []interface{}: + for i, item := range v { + pluginTraverseValues(item, fmt.Sprintf("%s[%d]", path, i), visitor) + } + case map[string]interface{}: + for key, val := range v { + pluginTraverseValues(val, path+"."+key, visitor) + } + } +} + +// pluginAutoFixArrayWrapping fixes ["{{input.xxx}}"] → "{{input.xxx}}" for +// array-typed params to prevent runtime double-wrapping. +func pluginAutoFixArrayWrapping(obj map[string]interface{}, arrayParams map[string]bool) { + for key, value := range obj { + arr, ok := value.([]interface{}) + if ok && len(arr) == 1 { + if s, ok := arr[0].(string); ok { + if m := pluginTemplateRefExact.FindStringSubmatch(s); m != nil && arrayParams[m[1]] { + obj[key] = s + } + } + } + if nested, ok := value.(map[string]interface{}); ok { + pluginAutoFixArrayWrapping(nested, arrayParams) + } + } +} + +// pluginWriteCapJSON writes a capability map to capDir/{id}.json atomically. +func pluginWriteCapJSON(capPath string, cap map[string]interface{}) error { + data, err := json.MarshalIndent(cap, "", " ") + if err != nil { + return appsFileIOError(err, "cannot marshal capability JSON") + } + data = append(data, '\n') + return validate.AtomicWrite(capPath, data, 0o644) +} + +// pluginCapRelPath returns the capability file path relative to projectPath. +func pluginCapRelPath(projectPath, capPath string) string { + rel, err := filepath.Rel(projectPath, capPath) + if err != nil { + return capPath + } + return rel +} + +// ── package.json helpers ── + +// pluginReadPackageJSON reads and parses the project's package.json. +func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) { + path := filepath.Join(projectPath, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read. + if err != nil { + return nil, appsFileIOError(err, "cannot read package.json") + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, appsValidationError("invalid package.json: %v", err).WithCause(err) + } + return pkg, nil +} + +// pluginWritePackageJSON writes package.json atomically, preserving formatting. +func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error { + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return appsFileIOError(err, "cannot marshal package.json") + } + data = append(data, '\n') + return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644) +} + +// pluginGetActionPlugins extracts actionPlugins from package.json as key→version. +func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string { + raw, ok := pkg["actionPlugins"] + if !ok { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins. +func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + pkg["actionPlugins"] = m + } + m[key] = version +} + +// pluginRemoveActionPlugin removes a plugin entry from actionPlugins. +func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + return + } + delete(m, key) +} + +// pluginParseInstallTarget parses "key[@version]" where version is optional. +// For scoped packages like "@scope/name@1.0.0", the split is at the last "@". +func pluginParseInstallTarget(s string) (key string, version string) { + s = strings.TrimSpace(s) + if s == "" { + return "", "" + } + idx := strings.LastIndex(s, "@") + if idx <= 0 { + return s, "" + } + return s[:idx], s[idx+1:] +} + +// pluginInstalledVersion reads the version of an installed plugin from its +// package.json in node_modules. Returns "" if not found or unreadable. +func pluginInstalledVersion(projectPath, pluginKey string) string { + path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return "" + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return "" + } + v, _ := pkg["version"].(string) + return v +} + +// ── tgz extraction ── + +// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the +// first path component (npm convention: tarballs contain a "package/" prefix). +// Path traversal entries are silently skipped. +func pluginExtractTGZ(r io.Reader, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + + cleanDest := filepath.Clean(destDir) + string(filepath.Separator) + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + + name := pluginStripFirstComponent(hdr.Name) + if name == "" { + continue + } + if strings.Contains(name, "..") { + continue + } + + target := filepath.Join(destDir, name) + if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) && + filepath.Clean(target) != filepath.Clean(destDir) { + continue + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction. + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size + f.Close() + return err + } + f.Close() + } + } + return nil +} + +// pluginStripFirstComponent removes the first path component ("package/foo" → "foo"). +func pluginStripFirstComponent(name string) string { + name = filepath.ToSlash(name) + if i := strings.Index(name, "/"); i >= 0 { + return name[i+1:] + } + return "" +} diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go new file mode 100644 index 000000000..bc4971c00 --- /dev/null +++ b/shortcuts/apps/plugin_common_test.go @@ -0,0 +1,424 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +// --- pluginResolveProjectPath --- + +func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) { + got, err := pluginResolveProjectPath("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cwd, _ := os.Getwd() //nolint:forbidigo + if got != cwd { + t.Errorf("got %q, want cwd %q", got, cwd) + } +} + +func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) { + got, err := pluginResolveProjectPath("/tmp/myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/myapp" { + t.Errorf("got %q, want /tmp/myapp", got) + } +} + +// --- pluginCheckProjectDir --- + +func TestPluginCheckProjectDir_OK(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := pluginCheckProjectDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPluginCheckProjectDir_Missing(t *testing.T) { + dir := t.TempDir() + err := pluginCheckProjectDir(dir) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +// --- pluginResolveCapDir --- + +func TestPluginResolveCapDir_ExplicitFlag(t *testing.T) { + got, err := pluginResolveCapDir("/proj", "my/caps") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "my/caps"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_ExplicitFlagAbsolute(t *testing.T) { + got, err := pluginResolveCapDir("/proj", "/absolute/caps") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/absolute/caps" { + t.Errorf("got %q, want /absolute/caps", got) + } +} + +func TestPluginResolveCapDir_EnvVar(t *testing.T) { + t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps") + got, err := pluginResolveCapDir("/proj", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "envdir/caps"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "2") + got, err := pluginResolveCapDir("/proj", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "6") + got, err := pluginResolveCapDir("/proj", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_EnvLocal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectServer(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectShared(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_Ambiguous(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + _, err := pluginResolveCapDir(dir, "") + if err == nil { + t.Fatal("expected ambiguous error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) { + dir := t.TempDir() + got, err := pluginResolveCapDir(dir, "") + if err != nil { + t.Fatalf("should default to server/capabilities, got error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "3") + got, err := pluginResolveCapDir("/proj", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q (appType=3 should use server)", got, want) + } +} + +// --- pluginListCapabilities --- + +func TestPluginListCapabilities_Empty(t *testing.T) { + dir := t.TempDir() + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 0 { + t.Errorf("got %d caps, want 0", len(caps)) + } +} + +func TestPluginListCapabilities_DirNotExist(t *testing.T) { + caps, err := pluginListCapabilities("/nonexistent/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if caps != nil { + t.Errorf("got %v, want nil", caps) + } +} + +func TestPluginListCapabilities_WithFiles(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"}) + writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"}) + // non-JSON file should be skipped + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 2 { + t.Fatalf("got %d caps, want 2", len(caps)) + } +} + +func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"}) + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 1 { + t.Fatalf("got %d caps, want 1", len(caps)) + } +} + +// --- pluginGetCapability --- + +func TestPluginGetCapability_Found(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "my-instance.json", map[string]interface{}{"id": "my-instance", "name": "My Instance"}) + + cap, err := pluginGetCapability(dir, "my-instance") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cap["id"] != "my-instance" { + t.Errorf("id = %v, want my-instance", cap["id"]) + } +} + +func TestPluginGetCapability_NotFound(t *testing.T) { + dir := t.TempDir() + _, err := pluginGetCapability(dir, "nonexistent") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", p.Subtype) + } +} + +// --- pluginValidateFormValue --- + +func TestValidateFormValue_Valid(t *testing.T) { + fv := map[string]interface{}{"prompt": "{{input.text}}"} + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "text": map[string]interface{}{"type": "string", "description": "input text"}, + }, + } + if errs := pluginValidateFormValue(fv, ps); len(errs) > 0 { + t.Errorf("expected no errors, got %v", errs) + } +} + +func TestValidateFormValue_ForbiddenHandlebars(t *testing.T) { + fv := map[string]interface{}{"body": "{{#if x}}yes{{/if}}"} + errs := pluginValidateFormValue(fv, nil) + if len(errs) == 0 { + t.Fatal("expected forbidden Handlebars error") + } +} + +func TestValidateFormValue_UndefinedRef(t *testing.T) { + fv := map[string]interface{}{"prompt": "{{input.typo}}"} + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "text": map[string]interface{}{"type": "string", "description": "d"}, + }, + } + errs := pluginValidateFormValue(fv, ps) + found := false + for _, e := range errs { + if strings.Contains(e, "typo") && strings.Contains(e, "not defined") { + found = true + } + } + if !found { + t.Errorf("expected undefined ref error for 'typo', got %v", errs) + } +} + +func TestValidateFormValue_UnconsumedParam(t *testing.T) { + fv := map[string]interface{}{"prompt": "hello"} + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "unused": map[string]interface{}{"type": "string", "description": "d"}, + }, + } + errs := pluginValidateFormValue(fv, ps) + found := false + for _, e := range errs { + if strings.Contains(e, "unused") && strings.Contains(e, "never referenced") { + found = true + } + } + if !found { + t.Errorf("expected unconsumed param error, got %v", errs) + } +} + +func TestValidateFormValue_ParamsSchemaTypeValidation(t *testing.T) { + fv := map[string]interface{}{"f": "{{input.x}}"} + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "x": map[string]interface{}{"type": "number"}, + }, + } + errs := pluginValidateFormValue(fv, ps) + found := false + for _, e := range errs { + if strings.Contains(e, "number") && strings.Contains(e, "invalid") { + found = true + } + } + if !found { + t.Errorf("expected type validation error, got %v", errs) + } +} + +func TestValidateFormValue_ArrayAutoFix(t *testing.T) { + fv := map[string]interface{}{ + "files": []interface{}{"{{input.fileUrl}}"}, + } + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "fileUrl": map[string]interface{}{ + "type": "array", "items": map[string]interface{}{"type": "string"}, + "description": "d", + }, + }, + } + errs := pluginValidateFormValue(fv, ps) + if len(errs) != 0 { + t.Errorf("expected no errors after auto-fix, got %v", errs) + } + // Verify auto-fix: array should be unwrapped to string + if s, ok := fv["files"].(string); !ok || s != "{{input.fileUrl}}" { + t.Errorf("expected auto-fix to unwrap array, got %v (%T)", fv["files"], fv["files"]) + } +} + +func TestValidateFormValue_MissingDescription(t *testing.T) { + fv := map[string]interface{}{"f": "{{input.x}}"} + ps := map[string]interface{}{ + "properties": map[string]interface{}{ + "x": map[string]interface{}{"type": "string"}, + }, + } + errs := pluginValidateFormValue(fv, ps) + found := false + for _, e := range errs { + if strings.Contains(e, "missing description") { + found = true + } + } + if !found { + t.Errorf("expected missing description error, got %v", errs) + } +} + +// --- helpers --- + +func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) { + t.Helper() + b, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } +} diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go new file mode 100644 index 000000000..f3738b617 --- /dev/null +++ b/shortcuts/apps/plugin_install.go @@ -0,0 +1,295 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstall downloads a plugin package from the registry, extracts it +// to node_modules, and updates package.json actionPlugins. +// +// Without --name it batch-installs all plugins declared in actionPlugins that +// are not yet present in node_modules. +var AppsPluginInstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-install", + Description: "Install a plugin package (download, extract, update package.json)", + Risk: "write", + Scopes: []string{"spark:plugin:readonly"}, + AuthTypes: []string{"user"}, + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key[@version] (e.g. @official-plugins/ai-text-generate@1.0.0); omit to install all declared plugins"}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + name := strings.TrimSpace(rctx.Str("name")) + if name == "" { + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugins/-/versions/batch_get"). + Desc("Batch-install all declared plugins from package.json actionPlugins"). + Set("mode", "batch") + } + key, version := pluginParseInstallTarget(name) + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugins/-/versions/batch_get"). + Desc("Fetch plugin version metadata, then download .tgz package"). + Set("plugin_key", key). + Set("version", version) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + name := strings.TrimSpace(rctx.Str("name")) + if name == "" { + return pluginInstallAll(ctx, rctx, projectPath) + } + return pluginInstallOne(ctx, rctx, projectPath, name) + }, +} + +// pluginInstallOne installs a single plugin by key[@version]. +func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, name string) error { + key, version := pluginParseInstallTarget(name) + if key == "" { + return appsValidationParamError("--name", "invalid plugin name %q", name) + } + + // Check if already installed with same version + if version != "" && version != "latest" { + if installed := pluginInstalledVersion(projectPath, key); installed == version { + result := map[string]interface{}{ + "key": key, "version": version, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version) + }) + return nil + } + } + + // Resolve version via API + resolvedVersion, downloadURL, approach, err := pluginResolveVersion(ctx, rctx, key, version) + if err != nil { + return err + } + + // Download tgz + tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion, downloadURL, approach) + if err != nil { + return err + } + + // Extract to node_modules + destDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract. + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create %s", destDir) + } + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + return appsFileIOError(err, "cannot extract plugin package for %s", key) + } + + // Update package.json + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, resolvedVersion) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion) + }) + return nil +} + +// pluginInstallAll installs all plugins declared in actionPlugins that are +// missing from node_modules. +func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + declared := pluginGetActionPlugins(pkg) + if len(declared) == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + }) + return nil + } + + var installed int + for key, version := range declared { + if existing := pluginInstalledVersion(projectPath, key); existing != "" { + continue + } + target := key + "@" + version + if err := pluginInstallOne(ctx, rctx, projectPath, target); err != nil { + return fmt.Errorf("install %s: %w", key, err) + } + installed++ + } + + if installed == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) { + fmt.Fprintln(w, "All declared plugins are already installed.") + }) + } + return nil +} + +// pluginResolveVersion calls the batch_get API to resolve download info. +// Returns resolved version, download URL, download approach ("inner"|"public"). +func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion, downloadURL, downloadApproach string, err error) { + item := map[string]interface{}{"plugin_key": key} + if version != "" { + item["version"] = version + } + body := map[string]interface{}{ + "items": []interface{}{item}, + } + + data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugins/-/versions/batch_get", nil, body) + if err != nil { + return "", "", "", withAppsHint(err, "check plugin key spelling and network") + } + + versions := pluginExtractVersionInfo(data, key) + if len(versions) == 0 { + return "", "", "", appsValidationError("no version found for plugin %q", key). + WithHint("check plugin key and version") + } + + first := versions[0] + rv, _ := first["version"].(string) + dl, _ := first["downloadURL"].(string) + approach, _ := first["downloadApproach"].(string) + if rv == "" { + return "", "", "", appsValidationError("incomplete version info for plugin %q", key). + WithHint("API returned version info without version; contact plugin maintainer") + } + return rv, dl, approach, nil +} + +// pluginExtractVersionInfo extracts the version list for a key from the +// batch_get response. Handles both field names: "pluginVersions" (fullstack-cli +// inner API) and "pluginKeyToVersions" (OpenAPI design). +func pluginExtractVersionInfo(data map[string]interface{}, key string) []map[string]interface{} { + var raw interface{} + for _, field := range []string{"pluginVersions", "pluginKeyToVersions", "plugin_key_to_versions"} { + if v, ok := data[field]; ok { + raw = v + break + } + } + m, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + arr, ok := m[key].([]interface{}) + if !ok { + return nil + } + out := make([]map[string]interface{}, 0, len(arr)) + for _, v := range arr { + if vm, ok := v.(map[string]interface{}); ok { + out = append(out, vm) + } + } + return out +} + +// pluginDownloadPackage downloads a plugin .tgz using the approach indicated by +// the batch_get API: "inner" uses an authenticated API call to the plugin +// package endpoint; "public" does a plain HTTP GET to the download URL. +// When approach is empty, it infers from the URL shape. +func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version, downloadURL, approach string) ([]byte, error) { + switch approach { + case "inner": + apiPath := pluginBuildInnerDownloadPath(key, version) + return pluginDownloadViaAPI(ctx, rctx, apiPath) + case "public": + if downloadURL == "" { + return nil, appsValidationError("public download requires a downloadURL for %s@%s", key, version) + } + return pluginDownloadDirect(downloadURL) + default: + if downloadURL != "" && strings.HasPrefix(downloadURL, "http") { + return pluginDownloadDirect(downloadURL) + } + apiPath := pluginBuildInnerDownloadPath(key, version) + return pluginDownloadViaAPI(ctx, rctx, apiPath) + } +} + +// pluginBuildInnerDownloadPath constructs the API path for downloading a plugin +// package. For key "@scope/name", the path segments are scope and name. +func pluginBuildInnerDownloadPath(key, version string) string { + scope, name := pluginSplitKey(key) + return fmt.Sprintf("%s/plugins/%s/%s/versions/%s/package", apiBasePath, scope, name, version) +} + +// pluginSplitKey splits "@scope/name" into ("@scope", "name"). +func pluginSplitKey(key string) (string, string) { + if idx := strings.Index(key, "/"); idx > 0 { + return key[:idx], key[idx+1:] + } + return key, "" +} + +func pluginDownloadViaAPI(ctx context.Context, rctx *common.RuntimeContext, apiPath string) ([]byte, error) { + resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: apiPath, + }) + if err != nil { + return nil, appsFileIOError(err, "download failed: %s", apiPath) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed: %s", apiPath) + } + return io.ReadAll(resp.Body) +} + +func pluginDownloadDirect(url string) ([]byte, error) { + resp, err := http.Get(url) //nolint:gosec,noctx // download URL from trusted API response + if err != nil { + return nil, appsFileIOError(err, "download failed: %s", common.TruncateStr(url, 120)) + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed") + } + return io.ReadAll(resp.Body) +} diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go new file mode 100644 index 000000000..f7ddf79ac --- /dev/null +++ b/shortcuts/apps/plugin_install_test.go @@ -0,0 +1,201 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestPluginInstall_SinglePlugin(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + + factory, stdout, reg := newAppsExecuteFactory(t) + + // Mock batch_get API + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/plugins/-/versions/batch_get", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "pluginKeyToVersions": map[string]interface{}{ + "@test/my-plugin": []interface{}{ + map[string]interface{}{ + "version": "1.0.0", + "downloadURL": "/open-apis/spark/v1/plugins/test/versions/1.0.0/package", + }, + }, + }, + }, + }, + }) + + // Mock download API (return a valid tgz with manifest.json + package.json) + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/plugins/@test/my-plugin/versions/1.0.0/package", + RawBody: tgzData, + ContentType: "application/octet-stream", + }) + + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin@1.0.0", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file extracted + manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo + t.Fatalf("manifest.json not extracted: %v", err) + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if v := ap["@test/my-plugin"]; v != "1.0.0" { + t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v) + } + + // Verify output + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "installed" { + t.Errorf("status = %v, want installed", data["status"]) + } +} + +func TestPluginInstall_AlreadyInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Create an existing installed plugin with package.json containing version + pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin@1.0.0", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "already_installed" { + t.Errorf("status = %v, want already_installed", data["status"]) + } +} + +// --- tgz helpers --- + +func TestPluginExtractTGZ(t *testing.T) { + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "README.md": "# Hello", + }) + + destDir := t.TempDir() + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + t.Fatalf("extract error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo + if err != nil { + t.Fatalf("manifest.json not extracted: %v", err) + } + if string(data) != `{"actions":[]}` { + t.Errorf("manifest.json content = %q", string(data)) + } +} + +func TestPluginExtractTGZ_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{ + Name: "package/../../../etc/passwd", + Size: 5, + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte("evil!")) + tw.Close() + gz.Close() + + destDir := t.TempDir() + if err := pluginExtractTGZ(&buf, destDir); err != nil { + t.Fatalf("extract should not error, but skip bad entries: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo + t.Error("path traversal should have been blocked") + } +} + +func TestPluginParseInstallTarget(t *testing.T) { + tests := []struct { + input string + wantKey string + wantVersion string + }{ + {"@scope/name@1.0.0", "@scope/name", "1.0.0"}, + {"@scope/name@latest", "@scope/name", "latest"}, + {"@scope/name", "@scope/name", ""}, + {"simple@2.0.0", "simple", "2.0.0"}, + {"simple", "simple", ""}, + {"", "", ""}, + } + for _, tt := range tests { + key, ver := pluginParseInstallTarget(tt.input) + if key != tt.wantKey || ver != tt.wantVersion { + t.Errorf("pluginParseInstallTarget(%q) = (%q, %q), want (%q, %q)", + tt.input, key, ver, tt.wantKey, tt.wantVersion) + } + } +} + +// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix. +func buildTestTGZ(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range files { + tw.WriteHeader(&tar.Header{ + Name: "package/" + name, + Size: int64(len(content)), + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte(content)) + } + + tw.Close() + gz.Close() + return buf.Bytes() +} diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go new file mode 100644 index 000000000..62efb2100 --- /dev/null +++ b/shortcuts/apps/plugin_instance_create.go @@ -0,0 +1,168 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceCreate creates a plugin instance by writing a capability +// JSON file into the resolved capabilities directory. +var AppsPluginInstanceCreate = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-create", + Description: "Create a plugin instance (write capability JSON)", + Risk: "write", + Flags: []common.Flag{ + {Name: "id", Desc: "semantic instance id (lowercase + hyphens); auto-derived from plugin key if omitted"}, + {Name: "plugin", Desc: "plugin key@version (e.g. @official-plugins/ai-text-generate@1.0.0)", Required: true}, + {Name: "name", Desc: "display name for the instance", Required: true}, + {Name: "description", Desc: "instance description"}, + {Name: "form-value", Desc: "formValue JSON object", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "params-schema", Desc: "paramsSchema JSON object (optional)", Input: []string{common.File, common.Stdin}}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + {Name: "force", Type: "bool", Desc: "overwrite existing instance with same id"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + pluginKey, pluginVersion, _ := pluginParseKeyVersion(rctx.Str("plugin")) + id := strings.TrimSpace(rctx.Str("id")) + if id == "" { + id = pluginDeriveID(pluginKey) + } + return common.NewDryRunAPI(). + Desc("Create plugin instance (write capability JSON)"). + Set("action", "create"). + Set("id", id). + Set("plugin", pluginKey+"@"+pluginVersion). + Set("target", fmt.Sprintf("/%s.json", id)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, _, err := pluginParseKeyVersion(rctx.Str("plugin")); err != nil { + return err + } + if id := strings.TrimSpace(rctx.Str("id")); id != "" { + if err := pluginValidateID(id); err != nil { + return err + } + } + if err := pluginValidateJSONFlag("--form-value", rctx.Str("form-value")); err != nil { + return err + } + if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { + if err := pluginValidateJSONFlag("--params-schema", ps); err != nil { + return err + } + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + pluginKey, pluginVersion, err := pluginParseKeyVersion(rctx.Str("plugin")) + if err != nil { + return err + } + + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + warnings, err := pluginCheckInstalledVersion(projectPath, pluginKey, pluginVersion) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + return err + } + if err := os.MkdirAll(capDir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; auto-create capabilities dir. + return appsFileIOError(err, "cannot create capabilities directory %s", capDir) + } + + id := strings.TrimSpace(rctx.Str("id")) + if id == "" { + id = pluginDeriveID(pluginKey) + } + + capPath := filepath.Join(capDir, id+".json") + if !rctx.Bool("force") { + if _, err := os.Stat(capPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; existence check before create. + return appsValidationError("instance %q already exists at %s", id, pluginCapRelPath(projectPath, capPath)). + WithHint("use --force to overwrite, or choose a different --id") + } + } + + var formValue interface{} + if err := json.Unmarshal([]byte(rctx.Str("form-value")), &formValue); err != nil { + return appsValidationParamError("--form-value", "invalid JSON: %v", err).WithCause(err) + } + + now := time.Now().UnixMilli() + cap := map[string]interface{}{ + "id": id, + "pluginKey": pluginKey, + "pluginVersion": pluginVersion, + "name": strings.TrimSpace(rctx.Str("name")), + "description": strings.TrimSpace(rctx.Str("description")), + "formValue": formValue, + "createdAt": now, + "updatedAt": now, + "createdBy": 0, + } + + var paramsSchema interface{} + if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { + if err := json.Unmarshal([]byte(ps), ¶msSchema); err != nil { + return appsValidationParamError("--params-schema", "invalid JSON: %v", err).WithCause(err) + } + cap["paramsSchema"] = paramsSchema + } + + // Validate formValue against paramsSchema (feida-ai 5-rule check) + if violations := pluginValidateFormValue(formValue, paramsSchema); len(violations) > 0 { + hint := strings.Join(violations, "\n- ") + return appsValidationError("formValue validation failed:\n- %s", hint). + WithHint("fix the issues above and retry") + } + + if err := pluginWriteCapJSON(capPath, cap); err != nil { + return appsFileIOError(err, "cannot write %s", capPath) + } + + relPath := pluginCapRelPath(projectPath, capPath) + result := map[string]interface{}{ + "id": id, + "pluginKey": pluginKey, + "pluginVersion": pluginVersion, + "name": cap["name"], + "path": relPath, + } + if len(warnings) > 0 { + result["warnings"] = warnings + } + rctx.OutFormat(result, nil, func(w io.Writer) { + for _, w2 := range warnings { + fmt.Fprintf(w, "⚠ %s\n", w2) + } + fmt.Fprintf(w, "✓ Plugin instance created: %s\n", id) + fmt.Fprintf(w, " Plugin: %s@%s\n", pluginKey, pluginVersion) + fmt.Fprintf(w, " Path: %s\n", relPath) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_instance_create_test.go b/shortcuts/apps/plugin_instance_create_test.go new file mode 100644 index 000000000..d33fe05ff --- /dev/null +++ b/shortcuts/apps/plugin_instance_create_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginInstanceCreate_Basic(t *testing.T) { + dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "@test/my-plugin@1.0.0", + "--name", "My Instance", + "--form-value", `{"prompt":"hello"}`, + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + data, _ := env["data"].(map[string]interface{}) + if data["id"] != "test-my-plugin" { + t.Errorf("id = %v, want test-my-plugin (auto-derived)", data["id"]) + } + if data["pluginKey"] != "@test/my-plugin" { + t.Errorf("pluginKey = %v, want @test/my-plugin", data["pluginKey"]) + } + + // Verify file was written + capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") + capData, err := os.ReadFile(capPath) //nolint:forbidigo + if err != nil { + t.Fatalf("capability file not created: %v", err) + } + var cap map[string]interface{} + if err := json.Unmarshal(capData, &cap); err != nil { + t.Fatalf("invalid capability JSON: %v", err) + } + if cap["name"] != "My Instance" { + t.Errorf("cap.name = %v, want My Instance", cap["name"]) + } +} + +func TestPluginInstanceCreate_CustomID(t *testing.T) { + dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--id", "custom-summary", + "--plugin", "@test/my-plugin@2.0.0", + "--name", "Custom", + "--form-value", `{"key":"val"}`, + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + capPath := filepath.Join(dir, "server", "capabilities", "custom-summary.json") + if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo + t.Fatalf("capability file not created at custom id path: %v", err) + } +} + +func TestPluginInstanceCreate_WithParamsSchema(t *testing.T) { + dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "@test/my-plugin@1.0.0", + "--name", "WithSchema", + "--form-value", `{"prompt":"{{input.text}}"}`, + "--params-schema", `{"type":"object","properties":{"text":{"type":"string","description":"user input text"}}}`, + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") + capData, err := os.ReadFile(capPath) //nolint:forbidigo + if err != nil { + t.Fatal(err) + } + var cap map[string]interface{} + if err := json.Unmarshal(capData, &cap); err != nil { + t.Fatal(err) + } + if _, ok := cap["paramsSchema"]; !ok { + t.Error("paramsSchema should be present in capability") + } +} + +func TestPluginInstanceCreate_DuplicateID(t *testing.T) { + dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing"}) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--id", "existing", + "--plugin", "@test/my-plugin@1.0.0", + "--name", "Dup", + "--form-value", `{}`, + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error for duplicate id") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", p.Subtype) + } +} + +func TestPluginInstanceCreate_ForceOverwrite(t *testing.T) { + dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing", "name": "Old"}) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--id", "existing", + "--plugin", "@test/my-plugin@1.0.0", + "--name", "New", + "--form-value", `{}`, + "--force", + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error with --force: %v", err) + } + + capData, _ := os.ReadFile(filepath.Join(capDir, "existing.json")) //nolint:forbidigo + var cap map[string]interface{} + json.Unmarshal(capData, &cap) + if cap["name"] != "New" { + t.Errorf("name = %v, want New (overwritten)", cap["name"]) + } +} + +func TestPluginInstanceCreate_PluginNotInstalled(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "@test/not-installed@1.0.0", + "--name", "Fail", + "--form-value", `{}`, + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when plugin not installed") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +func TestPluginInstanceCreate_InvalidPluginFormat(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + dir := setupPluginTestProject(t, "server") + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "no-version", + "--name", "Fail", + "--form-value", `{}`, + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error for invalid plugin format") + } +} + +func TestPluginInstanceCreate_InvalidJSON(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + dir := setupPluginTestProject(t, "server") + + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "@test/p@1.0.0", + "--name", "Fail", + "--form-value", `not json`, + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestPluginInstanceCreate_AutoCreateCapDir(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + pluginKey := "@test/my-plugin" + manifestDir := filepath.Join(dir, "node_modules", pluginKey) + if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{}`), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ + "+plugin-instance-create", + "--plugin", "@test/my-plugin@1.0.0", + "--name", "AutoDir", + "--form-value", `{}`, + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("should auto-create capabilities dir: %v", err) + } + + capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") + if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo + t.Fatalf("capability file not created: %v", err) + } +} + +// --- helpers --- + +// setupPluginTestProjectWithManifest creates a project dir with package.json, +// capabilities dir, and a minimal manifest.json for the given plugin key. +func setupPluginTestProjectWithManifest(t *testing.T, appType, pluginKey string) string { + t.Helper() + dir := setupPluginTestProject(t, appType) + manifestDir := filepath.Join(dir, "node_modules", pluginKey) + if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{"actions":[]}`), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + return dir +} diff --git a/shortcuts/apps/plugin_instance_delete.go b/shortcuts/apps/plugin_instance_delete.go new file mode 100644 index 000000000..dce0a48dc --- /dev/null +++ b/shortcuts/apps/plugin_instance_delete.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceDelete deletes a plugin instance (capability JSON file). +// The operation is idempotent: deleting a non-existent instance is not an error. +var AppsPluginInstanceDelete = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-delete", + Description: "Delete a plugin instance", + Risk: "write", + Flags: []common.Flag{ + {Name: "id", Desc: "instance id", Required: true}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + id := strings.TrimSpace(rctx.Str("id")) + return common.NewDryRunAPI(). + Desc("Delete plugin instance (remove capability JSON)"). + Set("action", "delete"). + Set("id", id). + Set("target", fmt.Sprintf("/%s.json", id)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("id")) == "" { + return appsValidationParamError("--id", "--id is required") + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + return err + } + + capPath := filepath.Join(capDir, id+".json") + if err := os.Remove(capPath); err != nil && !os.IsNotExist(err) { //nolint:forbidigo // shortcuts cannot import internal/vfs; local file delete. + return appsFileIOError(err, "cannot delete %s", capPath) + } + + result := map[string]interface{}{ + "id": id, + "deleted": true, + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Plugin instance deleted: %s\n", id) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_instance_delete_test.go b/shortcuts/apps/plugin_instance_delete_test.go new file mode 100644 index 000000000..cbe14c09a --- /dev/null +++ b/shortcuts/apps/plugin_instance_delete_test.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPluginInstanceDelete_Basic(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{"id": "my-inst"}) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ + "+plugin-instance-delete", + "--id", "my-inst", + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + data, _ := env["data"].(map[string]interface{}) + if data["deleted"] != true { + t.Errorf("deleted = %v, want true", data["deleted"]) + } + + if _, err := os.Stat(filepath.Join(capDir, "my-inst.json")); !os.IsNotExist(err) { //nolint:forbidigo + t.Error("capability file should have been deleted") + } +} + +func TestPluginInstanceDelete_Idempotent(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ + "+plugin-instance-delete", + "--id", "nonexistent", + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("delete of nonexistent instance should be idempotent, got: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + data, _ := env["data"].(map[string]interface{}) + if data["deleted"] != true { + t.Errorf("deleted = %v, want true", data["deleted"]) + } +} + +func TestPluginInstanceDelete_MissingID(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ + "+plugin-instance-delete", + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when --id is missing") + } +} diff --git a/shortcuts/apps/plugin_instance_get.go b/shortcuts/apps/plugin_instance_get.go new file mode 100644 index 000000000..4e0cb5a6c --- /dev/null +++ b/shortcuts/apps/plugin_instance_get.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceGet reads a single plugin instance (capability JSON) by id. +var AppsPluginInstanceGet = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-get", + Description: "Get a plugin instance by id", + Risk: "read", + Flags: []common.Flag{ + {Name: "id", Desc: "instance id (filename without .json in capabilities/)", Required: true}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + id := strings.TrimSpace(rctx.Str("id")) + return common.NewDryRunAPI(). + Desc("Get plugin instance (read capability JSON)"). + Set("action", "get"). + Set("id", id). + Set("source", fmt.Sprintf("/%s.json", id)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + if id == "" { + return appsValidationParamError("--id", "--id is required") + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + return err + } + + cap, err := pluginGetCapability(capDir, id) + if err != nil { + return err + } + + rctx.OutFormat(cap, nil, func(w io.Writer) { + pluginPrintInstance(w, cap) + }) + return nil + }, +} + +func pluginPrintInstance(w io.Writer, cap map[string]interface{}) { + fmt.Fprintf(w, "ID: %v\n", cap["id"]) + fmt.Fprintf(w, "Plugin: %v\n", cap["pluginKey"]) + fmt.Fprintf(w, "Version: %v\n", cap["pluginVersion"]) + fmt.Fprintf(w, "Name: %v\n", cap["name"]) + + if ts := common.FormatTime(cap["createdAt"]); ts != "" { + fmt.Fprintf(w, "Created: %s\n", ts) + } + if ts := common.FormatTime(cap["updatedAt"]); ts != "" { + fmt.Fprintf(w, "Updated: %s\n", ts) + } + + if ps, ok := cap["paramsSchema"]; ok && ps != nil { + b, _ := json.MarshalIndent(ps, " ", " ") + fmt.Fprintf(w, "ParamsSchema: %s\n", b) + } + if fv, ok := cap["formValue"]; ok && fv != nil { + b, _ := json.MarshalIndent(fv, " ", " ") + fmt.Fprintf(w, "FormValue: %s\n", b) + } +} diff --git a/shortcuts/apps/plugin_instance_get_test.go b/shortcuts/apps/plugin_instance_get_test.go new file mode 100644 index 000000000..126302f5a --- /dev/null +++ b/shortcuts/apps/plugin_instance_get_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginInstanceGet_Basic(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ + "id": "my-inst", "pluginKey": "@test/plugin", "pluginVersion": "1.0.0", + "name": "My Instance", "createdAt": 1718500000000, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceGet, + []string{"+plugin-instance-get", "--id", "my-inst", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := stdout.String() + if !strings.Contains(got, "my-inst") { + t.Errorf("output missing instance id: %s", got) + } + if !strings.Contains(got, "@test/plugin") { + t.Errorf("output missing pluginKey: %s", got) + } +} + +func TestPluginInstanceGet_JSON(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ + "id": "my-inst", "pluginKey": "@test/plugin", "pluginVersion": "1.0.0", "name": "Test", + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceGet, + []string{"+plugin-instance-get", "--id", "my-inst", "--project-path", dir, "--format", "json", "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) + } + data, _ := env["data"].(map[string]interface{}) + if data["id"] != "my-inst" { + t.Errorf("id = %v, want my-inst", data["id"]) + } +} + +func TestPluginInstanceGet_NotFound(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceGet, + []string{"+plugin-instance-get", "--id", "nonexistent", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", p.Subtype) + } +} + +func TestPluginInstanceGet_MissingID(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceGet, + []string{"+plugin-instance-get", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatal("expected error when --id is missing") + } +} diff --git a/shortcuts/apps/plugin_instance_list.go b/shortcuts/apps/plugin_instance_list.go new file mode 100644 index 000000000..5c54f51b0 --- /dev/null +++ b/shortcuts/apps/plugin_instance_list.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceList lists all plugin instances (capability JSON files) +// in the resolved capabilities directory. +var AppsPluginInstanceList = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-list", + Description: "List all plugin instances in the project", + Risk: "read", + Flags: []common.Flag{ + {Name: "summary", Type: "bool", Desc: "show only id and name"}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List plugin instances (scan capabilities directory)"). + Set("action", "list"). + Set("summary", fmt.Sprintf("%v", rctx.Bool("summary"))) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + // Cannot determine capabilities dir → no instances exist yet. + rctx.OutFormat( + map[string]interface{}{"instances": []interface{}{}}, + &output.Meta{Count: 0}, + func(w io.Writer) { fmt.Fprintln(w, "No plugin instances found.") }, + ) + return nil + } + + caps, err := pluginListCapabilities(capDir) + if err != nil { + return err + } + + summary := rctx.Bool("summary") + instances := make([]interface{}, 0, len(caps)) + for _, cap := range caps { + if summary { + instances = append(instances, map[string]interface{}{ + "id": cap["id"], + "name": cap["name"], + }) + } else { + instances = append(instances, cap) + } + } + + data := map[string]interface{}{"instances": instances} + rctx.OutFormat(data, &output.Meta{Count: len(instances)}, func(w io.Writer) { + if len(instances) == 0 { + fmt.Fprintln(w, "No plugin instances found.") + return + } + rows := make([]map[string]interface{}, 0, len(caps)) + for _, cap := range caps { + rows = append(rows, map[string]interface{}{ + "id": cap["id"], + "pluginKey": cap["pluginKey"], + "pluginVersion": cap["pluginVersion"], + "name": cap["name"], + }) + } + output.PrintTable(w, rows) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_instance_list_test.go b/shortcuts/apps/plugin_instance_list_test.go new file mode 100644 index 000000000..5adf94d74 --- /dev/null +++ b/shortcuts/apps/plugin_instance_list_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginInstanceList_Empty(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceList, + []string{"+plugin-instance-list", "--project-path", dir, "--format", "json", "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) + } + data, _ := env["data"].(map[string]interface{}) + instances, _ := data["instances"].([]interface{}) + if len(instances) != 0 { + t.Errorf("expected 0 instances, got %d", len(instances)) + } +} + +func TestPluginInstanceList_WithInstances(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "inst-a.json", map[string]interface{}{ + "id": "inst-a", "pluginKey": "@test/plugin-a", "pluginVersion": "1.0.0", "name": "Instance A", + }) + writeTestCapJSON(t, capDir, "inst-b.json", map[string]interface{}{ + "id": "inst-b", "pluginKey": "@test/plugin-b", "pluginVersion": "2.0.0", "name": "Instance B", + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceList, + []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := stdout.String() + if !strings.Contains(got, "inst-a") || !strings.Contains(got, "inst-b") { + t.Errorf("output missing instances: %s", got) + } +} + +func TestPluginInstanceList_Summary(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "inst-a.json", map[string]interface{}{ + "id": "inst-a", "pluginKey": "@test/plugin-a", "pluginVersion": "1.0.0", + "name": "Instance A", "paramsSchema": map[string]interface{}{"type": "object"}, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceList, + []string{"+plugin-instance-list", "--summary", "--project-path", dir, "--format", "json", "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) + } + data, _ := env["data"].(map[string]interface{}) + instances, _ := data["instances"].([]interface{}) + if len(instances) != 1 { + t.Fatalf("got %d instances, want 1", len(instances)) + } + inst := instances[0].(map[string]interface{}) + if _, has := inst["paramsSchema"]; has { + t.Error("summary should not include paramsSchema") + } + if inst["id"] != "inst-a" { + t.Errorf("id = %v, want inst-a", inst["id"]) + } +} + +func TestPluginInstanceList_NoPackageJSON(t *testing.T) { + dir := t.TempDir() + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceList, + []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatal("expected error when package.json missing") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +func TestPluginInstanceList_CapDirNotExist(t *testing.T) { + dir := setupPluginTestProjectNoCapDir(t) + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceList, + []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("should not error when capabilities dir not found, got: %v", err) + } +} + +// --- helpers --- + +// setupPluginTestProject creates a temp dir with package.json and a capabilities dir. +// appType is "server" or "shared". +func setupPluginTestProject(t *testing.T, appType string) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + capDir := filepath.Join(dir, appType, "capabilities") + if err := os.MkdirAll(capDir, 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + return dir +} + +// setupPluginTestProjectNoCapDir creates a temp dir with package.json but no capabilities dir. +func setupPluginTestProjectNoCapDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + return dir +} diff --git a/shortcuts/apps/plugin_instance_update.go b/shortcuts/apps/plugin_instance_update.go new file mode 100644 index 000000000..f1e8a7e45 --- /dev/null +++ b/shortcuts/apps/plugin_instance_update.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceUpdate updates an existing plugin instance's mutable fields +// (name, formValue, paramsSchema) while preserving immutable fields. +var AppsPluginInstanceUpdate = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-update", + Description: "Update a plugin instance (modify capability JSON)", + Risk: "write", + Flags: []common.Flag{ + {Name: "id", Desc: "instance id", Required: true}, + {Name: "name", Desc: "new display name"}, + {Name: "form-value", Desc: "new formValue JSON object", Input: []string{common.File, common.Stdin}}, + {Name: "params-schema", Desc: "new paramsSchema JSON object", Input: []string{common.File, common.Stdin}}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + id := strings.TrimSpace(rctx.Str("id")) + return common.NewDryRunAPI(). + Desc("Update plugin instance (modify capability JSON)"). + Set("action", "update"). + Set("id", id). + Set("target", fmt.Sprintf("/%s.json", id)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + if id == "" { + return appsValidationParamError("--id", "--id is required") + } + hasUpdate := false + if rctx.Changed("name") { + hasUpdate = true + } + if fv := strings.TrimSpace(rctx.Str("form-value")); fv != "" { + if err := pluginValidateJSONFlag("--form-value", fv); err != nil { + return err + } + hasUpdate = true + } + if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { + if err := pluginValidateJSONFlag("--params-schema", ps); err != nil { + return err + } + hasUpdate = true + } + if !hasUpdate { + return appsValidationError("at least one of --name, --form-value, or --params-schema must be provided") + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + return err + } + + cap, err := pluginGetCapability(capDir, id) + if err != nil { + return err + } + + if rctx.Changed("name") { + cap["name"] = strings.TrimSpace(rctx.Str("name")) + } + if fv := strings.TrimSpace(rctx.Str("form-value")); fv != "" { + var formValue interface{} + if err := json.Unmarshal([]byte(fv), &formValue); err != nil { + return appsValidationParamError("--form-value", "invalid JSON: %v", err).WithCause(err) + } + cap["formValue"] = formValue + } + if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { + var paramsSchema interface{} + if err := json.Unmarshal([]byte(ps), ¶msSchema); err != nil { + return appsValidationParamError("--params-schema", "invalid JSON: %v", err).WithCause(err) + } + cap["paramsSchema"] = paramsSchema + } + + // Validate formValue against paramsSchema after merge + if violations := pluginValidateFormValue(cap["formValue"], cap["paramsSchema"]); len(violations) > 0 { + hint := strings.Join(violations, "\n- ") + return appsValidationError("formValue validation failed:\n- %s", hint). + WithHint("fix the issues above and retry") + } + + cap["updatedAt"] = time.Now().UnixMilli() + + capPath := filepath.Join(capDir, id+".json") + if err := pluginWriteCapJSON(capPath, cap); err != nil { + return appsFileIOError(err, "cannot write %s", capPath) + } + + result := map[string]interface{}{ + "id": id, + "pluginKey": cap["pluginKey"], + "name": cap["name"], + "updatedAt": cap["updatedAt"], + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Plugin instance updated: %s\n", id) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_instance_update_test.go b/shortcuts/apps/plugin_instance_update_test.go new file mode 100644 index 000000000..b1674abba --- /dev/null +++ b/shortcuts/apps/plugin_instance_update_test.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginInstanceUpdate_Name(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ + "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", + "name": "Old Name", "formValue": map[string]interface{}{"k": "v"}, + "createdAt": 1000, "createdBy": 0, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ + "+plugin-instance-update", + "--id", "my-inst", + "--name", "New Name", + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo + var cap map[string]interface{} + json.Unmarshal(capData, &cap) + if cap["name"] != "New Name" { + t.Errorf("name = %v, want New Name", cap["name"]) + } + if cap["pluginKey"] != "@test/p" { + t.Errorf("pluginKey should be preserved, got %v", cap["pluginKey"]) + } + if cap["createdBy"] != float64(0) { + t.Errorf("createdBy should be preserved, got %v", cap["createdBy"]) + } +} + +func TestPluginInstanceUpdate_FormValue(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ + "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", + "name": "Inst", "formValue": map[string]interface{}{"old": true}, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ + "+plugin-instance-update", + "--id", "my-inst", + "--form-value", `{"new":"value"}`, + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo + var cap map[string]interface{} + json.Unmarshal(capData, &cap) + fv, ok := cap["formValue"].(map[string]interface{}) + if !ok { + t.Fatalf("formValue is not a map: %T", cap["formValue"]) + } + if fv["new"] != "value" { + t.Errorf("formValue.new = %v, want value", fv["new"]) + } +} + +func TestPluginInstanceUpdate_NotFound(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ + "+plugin-instance-update", + "--id", "nonexistent", + "--name", "X", + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error for nonexistent instance") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want invalid_argument", p.Subtype) + } +} + +func TestPluginInstanceUpdate_NoFieldProvided(t *testing.T) { + dir := setupPluginTestProject(t, "server") + factory, stdout, _ := newAppsExecuteFactory(t) + + err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ + "+plugin-instance-update", + "--id", "my-inst", + "--project-path", dir, + "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when no update fields provided") + } +} + +func TestPluginInstanceUpdate_PreservesImmutableFields(t *testing.T) { + dir := setupPluginTestProject(t, "server") + capDir := filepath.Join(dir, "server", "capabilities") + writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ + "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", + "name": "Old", "formValue": map[string]interface{}{}, + "createdAt": float64(1000000), "createdBy": float64(0), + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ + "+plugin-instance-update", + "--id", "my-inst", + "--name", "Updated", + "--project-path", dir, + "--format", "json", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo + var cap map[string]interface{} + json.Unmarshal(capData, &cap) + + if cap["id"] != "my-inst" { + t.Errorf("id should be preserved, got %v", cap["id"]) + } + if cap["pluginKey"] != "@test/p" { + t.Errorf("pluginKey should be preserved, got %v", cap["pluginKey"]) + } + if cap["pluginVersion"] != "1.0.0" { + t.Errorf("pluginVersion should be preserved, got %v", cap["pluginVersion"]) + } + if cap["createdAt"] != float64(1000000) { + t.Errorf("createdAt should be preserved, got %v", cap["createdAt"]) + } + updatedAt, ok := cap["updatedAt"].(float64) + if !ok || updatedAt <= 1000000 { + t.Errorf("updatedAt should be updated to a recent timestamp, got %v", cap["updatedAt"]) + } +} diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/plugin_list.go new file mode 100644 index 000000000..bdd60660c --- /dev/null +++ b/shortcuts/apps/plugin_list.go @@ -0,0 +1,78 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginList lists plugin packages declared in package.json actionPlugins, +// cross-referencing with node_modules to report installation status. +var AppsPluginList = common.Shortcut{ + Service: appsService, + Command: "+plugin-list", + Description: "List declared plugin packages and their installation status", + Risk: "read", + Flags: []common.Flag{ + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List declared plugin packages and installation status"). + Set("action", "list"). + Set("source", "package.json actionPlugins + node_modules") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + + declared := pluginGetActionPlugins(pkg) + plugins := make([]interface{}, 0, len(declared)) + for key, version := range declared { + installed := pluginInstalledVersion(projectPath, key) + status := "declared_not_installed" + if installed != "" { + status = "installed" + } + plugins = append(plugins, map[string]interface{}{ + "key": key, + "version": version, + "status": status, + }) + } + + data := map[string]interface{}{"plugins": plugins} + rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) { + if len(plugins) == 0 { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + return + } + rows := make([]map[string]interface{}, 0, len(plugins)) + for _, p := range plugins { + rows = append(rows, p.(map[string]interface{})) + } + output.PrintTable(w, rows) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_list_test.go b/shortcuts/apps/plugin_list_test.go new file mode 100644 index 000000000..8bea3828b --- /dev/null +++ b/shortcuts/apps/plugin_list_test.go @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPluginList_Empty(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginList_Installed(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "installed" { + t.Errorf("status = %v, want installed", p["status"]) + } +} + +func TestPluginList_DeclaredNotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/missing": "1.0.0", + }, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "declared_not_installed" { + t.Errorf("status = %v, want declared_not_installed", p["status"]) + } +} + +// --- helpers --- + +func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { + t.Helper() + data, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } +} diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go new file mode 100644 index 000000000..dc503af82 --- /dev/null +++ b/shortcuts/apps/plugin_uninstall.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginUninstall removes a plugin package from node_modules and its +// entry from package.json actionPlugins. +var AppsPluginUninstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-uninstall", + Description: "Uninstall a plugin package (remove from node_modules and package.json)", + Risk: "write", + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + key := strings.TrimSpace(rctx.Str("name")) + return common.NewDryRunAPI(). + Desc("Uninstall plugin package (remove from node_modules and package.json)"). + Set("action", "uninstall"). + Set("plugin_key", key). + Set("remove_dir", fmt.Sprintf("node_modules/%s", key)). + Set("update_file", "package.json actionPlugins") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name is required") + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + key := strings.TrimSpace(rctx.Str("name")) + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + pkgDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory. + return appsFileIOError(err, "cannot remove %s", pkgDir) + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginRemoveActionPlugin(pkg, key) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, + "removed": true, + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/plugin_uninstall_test.go new file mode 100644 index 000000000..2921a4e7e --- /dev/null +++ b/shortcuts/apps/plugin_uninstall_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPluginUninstall_Basic(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify node_modules removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + t.Error("node_modules plugin dir should be removed") + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/my-plugin"]; ok { + t.Error("actionPlugins should no longer contain @test/my-plugin") + } +} + +func TestPluginUninstall_NotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/not-here", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstalling non-existent plugin should succeed: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["removed"] != true { + t.Errorf("removed = %v, want true", data["removed"]) + } +} + +func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "name": "my-app", + "actionPlugins": map[string]interface{}{ + "@test/remove-me": "1.0.0", + "@test/keep-me": "2.0.0", + }, + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/remove-me", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/remove-me"]; ok { + t.Error("@test/remove-me should be removed from actionPlugins") + } + if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" { + t.Errorf("@test/keep-me should be preserved, got %q", v) + } + if name, _ := pkg["name"].(string); name != "my-app" { + t.Errorf("other fields should be preserved, name = %q", name) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index e15489fa1..39eb5d47e 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -32,5 +32,13 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + AppsPluginInstall, + AppsPluginUninstall, + AppsPluginList, + AppsPluginInstanceList, + AppsPluginInstanceGet, + AppsPluginInstanceCreate, + AppsPluginInstanceUpdate, + AppsPluginInstanceDelete, } } From 9dc032ca7345e287812eb974786773a35f8ed4d5 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 18 Jun 2026 16:18:20 +0800 Subject: [PATCH 02/40] fix: close install gaps aligned with fullstack-cli - latest version: re-check installed version after API resolves, skip download when already up to date - actionPlugins sync: ensure package.json record is updated even when install is skipped (already_installed path) - peerDependencies: warn about missing peer deps after extraction instead of silently ignoring them --- shortcuts/apps/plugin_common.go | 41 ++++++++++++++++++++++++++++++++ shortcuts/apps/plugin_install.go | 25 ++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 9fd1cfd66..300950711 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -506,6 +506,47 @@ func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) { delete(m, key) } +// pluginSyncActionPlugins ensures the actionPlugins record in package.json +// matches the actually installed version, even when install is skipped. +func pluginSyncActionPlugins(projectPath, key, version string) { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return + } + ap := pluginGetActionPlugins(pkg) + if ap[key] == version { + return + } + pluginSetActionPlugin(pkg, key, version) + _ = pluginWritePackageJSON(projectPath, pkg) +} + +// pluginCheckPeerDeps reads peerDependencies from the installed plugin's +// package.json and returns the names of any that are missing from node_modules. +func pluginCheckPeerDeps(projectPath, pluginKey string) []string { + pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return nil + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + peerDeps, ok := pkg["peerDependencies"].(map[string]interface{}) + if !ok || len(peerDeps) == 0 { + return nil + } + var missing []string + for dep := range peerDeps { + depDir := filepath.Join(projectPath, "node_modules", dep) + if !pluginDirExists(depDir) { + missing = append(missing, dep) + } + } + return missing +} + // pluginParseInstallTarget parses "key[@version]" where version is optional. // For scoped packages like "@scope/name@1.0.0", the split is at the last "@". func pluginParseInstallTarget(s string) (key string, version string) { diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index f3738b617..9676d4b7d 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -77,9 +77,10 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP return appsValidationParamError("--name", "invalid plugin name %q", name) } - // Check if already installed with same version + // Check if already installed with same version (pre-API fast path) if version != "" && version != "latest" { if installed := pluginInstalledVersion(projectPath, key); installed == version { + pluginSyncActionPlugins(projectPath, key, version) result := map[string]interface{}{ "key": key, "version": version, "status": "already_installed", } @@ -96,6 +97,18 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP return err } + // Post-API check: latest may resolve to the already-installed version + if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion { + pluginSyncActionPlugins(projectPath, key, resolvedVersion) + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion) + }) + return nil + } + // Download tgz tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion, downloadURL, approach) if err != nil { @@ -114,6 +127,9 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP return appsFileIOError(err, "cannot extract plugin package for %s", key) } + // Check peer dependencies + missingPeers := pluginCheckPeerDeps(projectPath, key) + // Update package.json pkg, err := pluginReadPackageJSON(projectPath) if err != nil { @@ -127,8 +143,15 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP result := map[string]interface{}{ "key": key, "version": resolvedVersion, "status": "installed", } + if len(missingPeers) > 0 { + result["missing_peer_dependencies"] = missingPeers + } rctx.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion) + if len(missingPeers) > 0 { + fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", ")) + fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.") + } }) return nil } From b8f45c96d7a44003e7befc851f9a6ad2f61cb8f3 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 18 Jun 2026 17:18:18 +0800 Subject: [PATCH 03/40] feat: add +plugin-instance-types command and auto-generate on create/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generate TypeScript interface definitions from plugin instance's paramsSchema and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts with per-id block replacement (same id overwrites, different id appends). Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic: - toPascalCase for type name prefixes (handles digit-prefixed segments) - JSON Schema → TypeScript recursive conversion - Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ---- - Auto-invoked after +plugin-instance-create and +plugin-instance-update - Also available as standalone +plugin-instance-types --id --- shortcuts/apps/plugin_common.go | 252 +++++++++++++++++++++++ shortcuts/apps/plugin_instance_create.go | 10 + shortcuts/apps/plugin_instance_types.go | 81 ++++++++ shortcuts/apps/plugin_instance_update.go | 10 + shortcuts/apps/shortcuts.go | 1 + 5 files changed, 354 insertions(+) create mode 100644 shortcuts/apps/plugin_instance_types.go diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 300950711..d65c8fcbe 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -645,3 +645,255 @@ func pluginStripFirstComponent(name string) string { } return "" } + +// ── TypeScript type generation ── + +const pluginTypesFile = "shared/plugin-types.ts" +const pluginBlockStartPrefix = "// ---- plugin:" +const pluginBlockEndPrefix = "// ---- end:" + +// pluginGenerateAndPersistTypes reads manifest + capability, generates TypeScript +// interfaces, and writes them to shared/plugin-types.ts with per-id block replacement. +// Returns (outputPath, typeNames, error). +func pluginGenerateAndPersistTypes(projectPath string, cap map[string]interface{}) (string, []string, error) { + pluginKey, _ := cap["pluginKey"].(string) + id, _ := cap["id"].(string) + name, _ := cap["name"].(string) + if pluginKey == "" || id == "" { + return "", nil, fmt.Errorf("capability missing pluginKey or id") + } + + manifest, err := pluginReadManifest(projectPath, pluginKey) + if err != nil { + return "", nil, fmt.Errorf("cannot read manifest for %s: %w", pluginKey, err) + } + + actions, _ := manifest["actions"].([]interface{}) + if len(actions) == 0 { + return "", nil, fmt.Errorf("plugin %s has no actions defined", pluginKey) + } + + prefix := pluginToPascalCase(id) + var typeNames []string + var parts []string + parts = append(parts, + "// ============================================================", + fmt.Sprintf("// 插件 %s (%s) 的类型定义", id, name), + "// 由 lark-cli +plugin-instance-types 自动生成", + "// ============================================================", + ) + + paramsSchema, _ := cap["paramsSchema"].(map[string]interface{}) + + for i, rawAction := range actions { + action, ok := rawAction.(map[string]interface{}) + if !ok { + continue + } + actionKey, _ := action["key"].(string) + actionSuffix := "" + if len(actions) > 1 { + actionSuffix = pluginToPascalCase(actionKey) + } + inputName := prefix + actionSuffix + "Input" + outputName := prefix + actionSuffix + "Output" + + // inputSchema: first action uses paramsSchema if available + var inputSchema map[string]interface{} + if i == 0 && paramsSchema != nil && len(paramsSchema) > 0 { + inputSchema = paramsSchema + } else { + inputSchema, _ = action["inputSchema"].(map[string]interface{}) + } + + if inputSchema != nil { + if iface := pluginGenerateInterface(inputName, inputSchema); iface != "" { + parts = append(parts, "", iface) + typeNames = append(typeNames, inputName) + } + } + + outputSchema, _ := action["outputSchema"].(map[string]interface{}) + if outputSchema != nil { + if props, ok := outputSchema["properties"].(map[string]interface{}); ok && len(props) > 0 { + keys := make([]string, 0, 3) + for k := range props { + if len(keys) < 3 { + keys = append(keys, k) + } + } + parts = append(parts, "", + "/**", + fmt.Sprintf(" * capabilityClient.load('%s').call<%s>('%s', input)", id, outputName, actionKey), + fmt.Sprintf(" * const { %s } = result;", strings.Join(keys, ", ")), + " */", + ) + } + if iface := pluginGenerateInterface(outputName, outputSchema); iface != "" { + parts = append(parts, iface) + typeNames = append(typeNames, outputName) + } + } + } + + typesCode := strings.Join(parts, "\n") + outputPath := filepath.Join(projectPath, pluginTypesFile) + + if err := pluginPersistTypesBlock(outputPath, id, typesCode); err != nil { + return "", nil, err + } + + return pluginTypesFile, typeNames, nil +} + +// pluginToPascalCase converts "task-text-summary" → "TaskTextSummary". +// Handles digit-prefixed segments: "4s-store" → "FourSStore". +func pluginToPascalCase(id string) string { + digitWords := map[byte]string{ + '0': "Zero", '1': "One", '2': "Two", '3': "Three", '4': "Four", + '5': "Five", '6': "Six", '7': "Seven", '8': "Eight", '9': "Nine", + } + parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' }) + var result strings.Builder + for _, part := range parts { + if part == "" { + continue + } + if part[0] >= '0' && part[0] <= '9' { + i := 0 + for i < len(part) && part[i] >= '0' && part[i] <= '9' { + if w, ok := digitWords[part[i]]; ok { + result.WriteString(w) + } + i++ + } + if i < len(part) { + result.WriteByte(part[i] - 32) // uppercase + result.WriteString(strings.ToLower(part[i+1:])) + } + } else { + result.WriteByte(part[0] &^ 0x20) // uppercase first char + result.WriteString(strings.ToLower(part[1:])) + } + } + return result.String() +} + +// pluginGenerateInterface generates "export interface Name { ... }" from a JSON Schema. +func pluginGenerateInterface(name string, schema map[string]interface{}) string { + props, ok := schema["properties"].(map[string]interface{}) + if !ok || len(props) == 0 { + return "" + } + requiredSet := make(map[string]bool) + if req, ok := schema["required"].([]interface{}); ok { + for _, r := range req { + if s, ok := r.(string); ok { + requiredSet[s] = true + } + } + } + var lines []string + for key, val := range props { + propMap, _ := val.(map[string]interface{}) + optional := "" + if !requiredSet[key] { + optional = "?" + } + tsType := pluginSchemaToTS(propMap, " ") + desc, _ := propMap["description"].(string) + if desc != "" { + lines = append(lines, fmt.Sprintf(" /** %s */", desc)) + } + safeKey := pluginQuoteKey(key) + lines = append(lines, fmt.Sprintf(" %s%s: %s;", safeKey, optional, tsType)) + } + return fmt.Sprintf("export interface %s {\n%s\n}", name, strings.Join(lines, "\n")) +} + +// pluginSchemaToTS converts a JSON Schema property to a TypeScript type string. +func pluginSchemaToTS(prop map[string]interface{}, indent string) string { + if prop == nil { + return "unknown" + } + t, _ := prop["type"].(string) + switch t { + case "string": + return "string" + case "number", "integer": + return "number" + case "boolean": + return "boolean" + case "array": + if items, ok := prop["items"].(map[string]interface{}); ok { + return pluginSchemaToTS(items, indent) + "[]" + } + return "unknown[]" + case "object": + if innerProps, ok := prop["properties"].(map[string]interface{}); ok && len(innerProps) > 0 { + inner := indent + " " + var fields []string + for k, v := range innerProps { + vm, _ := v.(map[string]interface{}) + fields = append(fields, fmt.Sprintf("%s%s: %s;", inner, pluginQuoteKey(k), pluginSchemaToTS(vm, inner))) + } + return fmt.Sprintf("{\n%s\n%s}", strings.Join(fields, "\n"), indent) + } + return "Record" + } + // No explicit type: infer from structure + if _, ok := prop["properties"]; ok { + return pluginSchemaToTS(map[string]interface{}{"type": "object", "properties": prop["properties"]}, indent) + } + if _, ok := prop["items"]; ok { + return pluginSchemaToTS(map[string]interface{}{"type": "array", "items": prop["items"]}, indent) + } + return "unknown" +} + +// pluginQuoteKey returns the key as-is if it's a valid JS identifier, else quoted. +func pluginQuoteKey(key string) string { + clean := strings.Map(func(r rune) rune { + if r == '\n' || r == '\r' || r == '\t' { + return ' ' + } + return r + }, strings.TrimSpace(key)) + if regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$]*$`).MatchString(clean) { + return clean + } + return "'" + strings.ReplaceAll(clean, "'", "\\'") + "'" +} + +// pluginPersistTypesBlock writes a type block to the types file, replacing existing +// blocks for the same id or appending if new. +func pluginPersistTypesBlock(outputPath, id, typesCode string) error { + blockStart := pluginBlockStartPrefix + id + " ----" + blockEnd := pluginBlockEndPrefix + id + " ----" + newBlock := blockStart + "\n" + typesCode + "\n" + blockEnd + + existing, err := os.ReadFile(outputPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local types file read. + if err != nil && !os.IsNotExist(err) { + return appsFileIOError(err, "cannot read %s", outputPath) + } + content := string(existing) + + var updated string + if startIdx := strings.Index(content, blockStart); startIdx >= 0 { + endIdx := strings.Index(content, blockEnd) + if endIdx >= 0 { + updated = content[:startIdx] + newBlock + content[endIdx+len(blockEnd):] + } else { + updated = content + "\n\n" + newBlock + } + } else if content != "" { + updated = content + "\n\n" + newBlock + } else { + updated = newBlock + "\n" + } + + if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create directory for %s", outputPath) + } + return validate.AtomicWrite(outputPath, []byte(updated), 0o644) +} diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go index 62efb2100..159f9bf6e 100644 --- a/shortcuts/apps/plugin_instance_create.go +++ b/shortcuts/apps/plugin_instance_create.go @@ -144,6 +144,9 @@ var AppsPluginInstanceCreate = common.Shortcut{ return appsFileIOError(err, "cannot write %s", capPath) } + // Auto-generate TypeScript types + typesPath, typeNames, typesErr := pluginGenerateAndPersistTypes(projectPath, cap) + relPath := pluginCapRelPath(projectPath, capPath) result := map[string]interface{}{ "id": id, @@ -155,6 +158,10 @@ var AppsPluginInstanceCreate = common.Shortcut{ if len(warnings) > 0 { result["warnings"] = warnings } + if typesErr == nil { + result["typesPath"] = typesPath + result["types"] = typeNames + } rctx.OutFormat(result, nil, func(w io.Writer) { for _, w2 := range warnings { fmt.Fprintf(w, "⚠ %s\n", w2) @@ -162,6 +169,9 @@ var AppsPluginInstanceCreate = common.Shortcut{ fmt.Fprintf(w, "✓ Plugin instance created: %s\n", id) fmt.Fprintf(w, " Plugin: %s@%s\n", pluginKey, pluginVersion) fmt.Fprintf(w, " Path: %s\n", relPath) + if typesErr == nil { + fmt.Fprintf(w, " Types: %s → %s\n", strings.Join(typeNames, ", "), typesPath) + } }) return nil }, diff --git a/shortcuts/apps/plugin_instance_types.go b/shortcuts/apps/plugin_instance_types.go new file mode 100644 index 000000000..73c859767 --- /dev/null +++ b/shortcuts/apps/plugin_instance_types.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstanceTypes generates TypeScript type definitions from a plugin +// instance's paramsSchema and the plugin manifest's actions, and writes them +// to shared/plugin-types.ts with per-id block replacement. +var AppsPluginInstanceTypes = common.Shortcut{ + Service: appsService, + Command: "+plugin-instance-types", + Description: "Generate TypeScript types for a plugin instance", + Risk: "write", + Flags: []common.Flag{ + {Name: "id", Desc: "instance id", Required: true}, + {Name: "project-path", Desc: "project root path (defaults to current directory)"}, + {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + id := strings.TrimSpace(rctx.Str("id")) + return common.NewDryRunAPI(). + Desc("Generate TypeScript types for plugin instance"). + Set("action", "types"). + Set("id", id). + Set("output", pluginTypesFile) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("id")) == "" { + return appsValidationParamError("--id", "--id is required") + } + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("id")) + projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + if err != nil { + return err + } + + capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) + if err != nil { + return err + } + + cap, err := pluginGetCapability(capDir, id) + if err != nil { + return err + } + + outputPath, typeNames, err := pluginGenerateAndPersistTypes(projectPath, cap) + if err != nil { + return appsFileIOError(err, "failed to generate types for %s", id) + } + + result := map[string]interface{}{ + "instanceId": id, + "outputPath": outputPath, + "types": typeNames, + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Types generated for %s → %s\n", id, outputPath) + for _, t := range typeNames { + fmt.Fprintf(w, " %s\n", t) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_instance_update.go b/shortcuts/apps/plugin_instance_update.go index f1e8a7e45..7769b2623 100644 --- a/shortcuts/apps/plugin_instance_update.go +++ b/shortcuts/apps/plugin_instance_update.go @@ -117,14 +117,24 @@ var AppsPluginInstanceUpdate = common.Shortcut{ return appsFileIOError(err, "cannot write %s", capPath) } + // Auto-regenerate TypeScript types + typesPath, typeNames, typesErr := pluginGenerateAndPersistTypes(projectPath, cap) + result := map[string]interface{}{ "id": id, "pluginKey": cap["pluginKey"], "name": cap["name"], "updatedAt": cap["updatedAt"], } + if typesErr == nil { + result["typesPath"] = typesPath + result["types"] = typeNames + } rctx.OutFormat(result, nil, func(w io.Writer) { fmt.Fprintf(w, "✓ Plugin instance updated: %s\n", id) + if typesErr == nil { + fmt.Fprintf(w, " Types: %s → %s\n", strings.Join(typeNames, ", "), typesPath) + } }) return nil }, diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 39eb5d47e..6f95a597e 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -40,5 +40,6 @@ func Shortcuts() []common.Shortcut { AppsPluginInstanceCreate, AppsPluginInstanceUpdate, AppsPluginInstanceDelete, + AppsPluginInstanceTypes, } } From d7820f7c1f46ea56e2bc33cecac8e93aa23b4ed1 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 18 Jun 2026 17:19:22 +0800 Subject: [PATCH 04/40] fix: hide +plugin-instance-types from agent (auto-invoked by create/update) --- shortcuts/apps/plugin_instance_types.go | 1 + 1 file changed, 1 insertion(+) diff --git a/shortcuts/apps/plugin_instance_types.go b/shortcuts/apps/plugin_instance_types.go index 73c859767..8e934cf61 100644 --- a/shortcuts/apps/plugin_instance_types.go +++ b/shortcuts/apps/plugin_instance_types.go @@ -20,6 +20,7 @@ var AppsPluginInstanceTypes = common.Shortcut{ Command: "+plugin-instance-types", Description: "Generate TypeScript types for a plugin instance", Risk: "write", + Hidden: true, Flags: []common.Flag{ {Name: "id", Desc: "instance id", Required: true}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, From 1d9f102b3675ac50d97e83b3c8d4996ee576f1af Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 18 Jun 2026 17:22:04 +0800 Subject: [PATCH 05/40] feat: add plugin skill files for agent workflow guidance - lark-apps-plugin.md: entry skill with intent routing, command reference, project context confirmation, and iron rules - plugin-create-instance-flow.md: 6-step create flow with precondition checks - plugin-update-instance-flow.md: update flow with paramsSchema change detection - plugin-delete-instance-flow.md: delete flow with code reference scanning - plugin-get-instance-flow.md: query routing for list/get/manifest reads - plugin-instance-schema.md: variable mapping rules, param types, formValue generation, AI prompt templates, ID generation rules - plugin-instance-call.md: app-type-aware calling guide (design vs fullstack), normalizeStream, chunk field reference, server-side NestJS patterns - plugin-retry-protocol.md: validation failure retry protocol (max 3) - SKILL.md: add plugin intent route with trigger keywords --- skills/lark-apps/SKILL.md | 1 + .../lark-apps/references/lark-apps-plugin.md | 72 +++++ .../references/plugin-create-instance-flow.md | 103 +++++++ .../references/plugin-delete-instance-flow.md | 57 ++++ .../references/plugin-get-instance-flow.md | 67 +++++ .../references/plugin-instance-call.md | 262 ++++++++++++++++++ .../references/plugin-instance-schema.md | 167 +++++++++++ .../references/plugin-retry-protocol.md | 54 ++++ .../references/plugin-update-instance-flow.md | 68 +++++ 9 files changed, 851 insertions(+) create mode 100644 skills/lark-apps/references/lark-apps-plugin.md create mode 100644 skills/lark-apps/references/plugin-create-instance-flow.md create mode 100644 skills/lark-apps/references/plugin-delete-instance-flow.md create mode 100644 skills/lark-apps/references/plugin-get-instance-flow.md create mode 100644 skills/lark-apps/references/plugin-instance-call.md create mode 100644 skills/lark-apps/references/plugin-instance-schema.md create mode 100644 skills/lark-apps/references/plugin-retry-protocol.md create mode 100644 skills/lark-apps/references/plugin-update-instance-flow.md diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index ef9da3746..8eabb07d2 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -29,6 +29,7 @@ metadata: | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | +| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | 先读插件入口 Skill 确认项目上下文,再按意图路由到 CRUD 链路 | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/lark-apps-plugin.md b/skills/lark-apps/references/lark-apps-plugin.md new file mode 100644 index 000000000..3c9df0bbe --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin.md @@ -0,0 +1,72 @@ +# lark-apps 插件管理 + +妙搭应用的插件(Plugin)体系:插件包安装、插件实例 CRUD、调用代码生成。 + +**触发关键词**:用户要实现 AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON 等能力时,或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例时加载本 Skill。 + +## 核心概念 + +- **插件包(Plugin Package)**:npm 格式的功能包,安装到 `node_modules/`,含 `manifest.json` 描述 actions 和 form.schema。 +- **插件实例(Plugin Instance / Capability)**:基于插件包创建的业务配置,存储在 `capabilities/{id}.json`,定义 `paramsSchema`(业务入参)和 `formValue`(表单映射,通过 `{{input.xxx}}` 引用 paramsSchema 参数)。 +- **变量映射**:`调用方传值 → paramsSchema 定义变量 → formValue 消费变量 {{input.xxx}} → Plugin form.schema 接收`。 + +## 确认项目上下文 + +所有本地 plugin 命令需要 `--project-path`。按以下顺序确认: + +1. cwd 有 `.spark/meta.json` → 直接用 cwd +2. 用户给了 app_id → `grep -rl "app_id值" --include="meta.json" .` 搜索工作区 +3. 用户给了应用名称 → `find . -maxdepth 2 -type d -name "名称"` 定位 +4. 都没有 → 询问用户要操作哪个应用 +5. 找不到 → 提示先 `lark-cli apps +create` + `apps +init` + +确认后,所有后续命令统一传 `--project-path <路径>`。 + +## 命令速查 + +### 插件包管理 + +| 命令 | 功能 | 鉴权 | +|------|------|------| +| `+plugin-install --name ` | 下载 tgz → 解压到 node_modules → 更新 package.json | user token | +| `+plugin-install`(无 --name) | 批量安装 package.json actionPlugins 中声明的所有插件 | user token | +| `+plugin-uninstall --name ` | 删除 node_modules/{key} + 移除 actionPlugins 条目 | 无 | +| `+plugin-list` | 列出已声明插件及安装状态(installed / declared_not_installed) | 无 | + +### 插件实例 CRUD + +| 命令 | 功能 | 鉴权 | +|------|------|------| +| `+plugin-instance-create --plugin --name --form-value ` | 校验 + 写 capability JSON | 无 | +| `+plugin-instance-update --id [--name] [--form-value]` | 更新实例可变字段 | 无 | +| `+plugin-instance-delete --id ` | 删除实例(幂等) | 无 | +| `+plugin-instance-get --id ` | 读取单个实例 | 无 | +| `+plugin-instance-list [--summary]` | 列出所有实例 | 无 | + +所有本地命令支持 `--project-path`、`--capabilities-dir`、`--format json`、`--dry-run`。 + +## 意图路由 + +根据用户意图选择对应链路,**必须读取对应的 flow 文件后再执行**: + +| 用户意图 | 路由到 | 必读 | +|---------|--------|------| +| 新增插件能力("加个 AI 翻译""接入文本生成") | **Create 链路** | [`plugin-create-instance-flow.md`](plugin-create-instance-flow.md) | +| 修改已有实例配置("改一下 prompt""换个模型") | **Update 链路** | [`plugin-update-instance-flow.md`](plugin-update-instance-flow.md) | +| 删除实例("去掉这个能力""不需要了") | **Delete 链路** | [`plugin-delete-instance-flow.md`](plugin-delete-instance-flow.md) | +| 查看实例详情 / 列出已有实例 / 查已装插件 | **Get 链路** | [`plugin-get-instance-flow.md`](plugin-get-instance-flow.md) | +| 写插件调用代码(Create/Update 完成后的下一步) | 读 call 指南 | [`plugin-instance-call.md`](plugin-instance-call.md) | + +## 本期支持的插件(17 个) + +ai-text-generate / ai-text-summary / ai-text-to-json / ai-translate / ai-search-summary / ai-text-to-image / ai-background-replace / ai-image-compare / ai-image-matting / ai-image-to-image / ai-image-to-json / ai-image-understanding / ai-speech-synthesis / ai-speech-to-text / ai-categorization / ai-doc-parser / web-crawler + +**不支持**(需用户通过 GUI 手动配置):飞书发消息、飞书创建群组、飞书多维表格、飞书审批、飞书 aPaaS。 + +## 铁律 + +1. **只能通过 CLI 命令修改 capability JSON 文件** — 禁止 Agent 直接用文件编辑工具写 `capabilities/*.json`,必须通过 `+plugin-instance-create` / `+plugin-instance-update` / `+plugin-instance-delete` 操作,确保校验和格式一致性。 +2. **先装包再建实例** — `+plugin-instance-create` 前必须确保插件包已安装(`+plugin-install`),否则校验会因读不到 manifest 而失败。 +3. **校验失败走重试协议** — Create / Update 返回校验错误时,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理:解析 hint → 修正 → 重试(max 3 次)。 +4. **写代码前读源码** — Create 完成后,Agent 应读取 `node_modules/{pluginKey}/manifest.json` 和 `capabilities/{id}.json` 理解插件能力,再按 [`plugin-instance-call.md`](plugin-instance-call.md) 生成调用代码。禁止凭记忆猜测 actionKey / inputSchema / outputMode。 +5. **不要在 formValue 中使用 Handlebars 控制语法** — 仅允许 `{{input.xxx}}`,严禁 `{{#if}}` / `{{#each}}` / `{{else}}` 等。 diff --git a/skills/lark-apps/references/plugin-create-instance-flow.md b/skills/lark-apps/references/plugin-create-instance-flow.md new file mode 100644 index 000000000..50893f889 --- /dev/null +++ b/skills/lark-apps/references/plugin-create-instance-flow.md @@ -0,0 +1,103 @@ +# Create 链路 — 新增插件实例 + +从用户需求到插件可调用的完整流程。本链路最严格,每一步都有前置门禁。 + +## 流程 + +``` +Step 1: +plugin-install --name +Step 2: 读 plugin-instance-schema.md + 设计 paramsSchema / formValue +Step 3: +plugin-instance-create --plugin --name --form-value @file [--params-schema @file] +Step 4: 校验通过? → 否:走 plugin-retry-protocol.md(max 3 次) +Step 5: 读 node_modules/{pluginKey}/manifest.json + capabilities/{id}.json +Step 6: 读 plugin-instance-call.md → 生成调用代码 +``` + +## Step 1 — 安装插件包 + +```bash +lark-cli apps +plugin-install --name @official-plugins/ai-text-generate@1.0.0 --project-path +``` + +- 鉴权:需要 user token(先 `lark-cli auth login`) +- 已安装同版本会跳过(status=already_installed) +- 失败时 hint 会指示原因(网络/版本不存在/package.json 缺失) + +## Step 2 — 设计 paramsSchema 和 formValue + +**必读**:[`plugin-instance-schema.md`](plugin-instance-schema.md) — 变量映射规则、参数类型约束、formValue 生成规则。 + +设计前必须先读插件的 form.schema: +```bash +cat /node_modules//manifest.json +``` + +根据 form.schema 的字段和用户业务意图,设计: +1. **paramsSchema** — 对外暴露的业务入参(变量定义) +2. **formValue** — 将变量映射到 form.schema 字段(变量消费) +3. **语义化 ID** — 如 `task-text-summary`,小写+短横线,描述业务用途 + +## Step 3 — 创建实例 + +```bash +lark-cli apps +plugin-instance-create \ + --id task-text-summary \ + --plugin @official-plugins/ai-text-generate@1.0.0 \ + --name "任务摘要生成" \ + --description "根据任务详情生成摘要" \ + --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ + --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ + --project-path \ + --format json +``` + +大 JSON 场景用 `@file` 传入:先写临时文件,再 `--form-value @form.json --params-schema @schema.json`。 + +### 前置检查(CLI 自动执行) + +| 检查项 | 失败时 hint | +|--------|-----------| +| package.json 存在 | `run 'lark-cli apps +init'` | +| capabilities 路径可解析 | `use --capabilities-dir or check .env.local` | +| 插件包已安装 | `run '+plugin-install ...' first` | +| 版本匹配 | warning(非 error):`installed X differs from Y` | +| ID 唯一 | `use --force to overwrite, or choose a different --id` | +| formValue 校验(5 规则) | 逐条列出违规项 | + +## Step 4 — 校验失败处理 + +CLI 返回 `ok: false` + `error.hint` 逐条列出问题时,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理: + +1. 解析 hint 中的每条违规 +2. 修正 formValue / paramsSchema +3. 重新调用 `+plugin-instance-create`(或 `--force` 覆盖) +4. 最多重试 3 次,3 次仍失败则上报用户 + +## Step 5 — 读取插件源码 + +创建成功后,读取以下文件获取完整信息: + +```bash +# 插件 manifest(actions / inputSchema / outputSchema / outputMode) +cat /node_modules//manifest.json + +# 创建的实例配置(paramsSchema / formValue) +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +## Step 6 — 生成调用代码 + +**必读**:[`plugin-instance-call.md`](plugin-instance-call.md) — Client/Server 决策、outputMode 处理、normalizeStream。 + +根据 manifest 中的 `actions[].outputMode` 选择调用方式: +- `unary` → `capabilityClient.load(id).call(actionKey, input)` +- `stream` → `capabilityClient.load(id).callStream(actionKey, input)` + normalizeStream + +## Red Flags + +| 念头 | 反驳 | +|------|------| +| "我记得这个插件的 schema,不用读 manifest" | manifest 可能更新过,必须每次读 | +| "create 完直接写代码" | 没读 manifest 就写代码 = 猜 actionKey/params | +| "install 之前先 create" | 没装包 manifest 读不到,校验会失败 | +| "formValue 校验报错,我直接编辑 JSON 文件" | 铁律:只能通过 CLI 命令修改 capability JSON | diff --git a/skills/lark-apps/references/plugin-delete-instance-flow.md b/skills/lark-apps/references/plugin-delete-instance-flow.md new file mode 100644 index 000000000..28f33be4f --- /dev/null +++ b/skills/lark-apps/references/plugin-delete-instance-flow.md @@ -0,0 +1,57 @@ +# Delete 链路 — 删除插件实例 + +删除实例前必须先清理代码引用,避免运行时报错。 + +## 流程 + +``` +Step 1: +plugin-instance-get --id → 确认实例存在 +Step 2: 扫描代码引用 +Step 3: 有引用? + ├── 有 → 读 plugin-instance-call.md → 清理调用代码 + └── 无 → 直接删除 +Step 4: +plugin-instance-delete --id +Step 5: 确认清理完成 +``` + +## Step 1 — 确认实例存在 + +```bash +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +实例不存在 → 无需删除,直接告知用户。 + +## Step 2 — 扫描代码引用 + +```bash +grep -rn "load('${id}')\|load(\"${id}\")" /client/ /server/ /shared/ +``` + +查找所有使用 `capabilityClient.load('')` 或 `capabilityService.load('')` 的位置。 + +## Step 3 — 清理代码引用 + +如果有引用: +1. 移除或替换调用代码(视业务逻辑决定是删除功能还是换用其他实例) +2. 清理相关的 import、类型定义、状态变量 +3. 如果该实例的结果被持久化到数据库字段,考虑是否需要清理字段或保留历史数据 + +## Step 4 — 删除实例 + +```bash +lark-cli apps +plugin-instance-delete --id --project-path --format json +``` + +删除是幂等的:文件不存在也返回 `deleted: true`,不报错。 + +## Step 5 — 确认清理 + +```bash +# 确认文件已删除 +lark-cli apps +plugin-instance-get --id --project-path +# 应返回 "instance not found" + +# 确认代码无残留引用 +grep -rn "${id}" /client/ /server/ /shared/ +``` diff --git a/skills/lark-apps/references/plugin-get-instance-flow.md b/skills/lark-apps/references/plugin-get-instance-flow.md new file mode 100644 index 000000000..d3de5fadf --- /dev/null +++ b/skills/lark-apps/references/plugin-get-instance-flow.md @@ -0,0 +1,67 @@ +# Get 链路 — 查询插件信息 + +查询操作,无副作用。根据查什么路由到不同命令。 + +## 路由 + +| 查什么 | 命令 | 示例 | +|--------|------|------| +| 已声明的插件包及安装状态 | `+plugin-list` | `lark-cli apps +plugin-list --project-path ` | +| 所有已建的实例(概览) | `+plugin-instance-list` | `lark-cli apps +plugin-instance-list --project-path ` | +| 所有已建的实例(仅 id+name) | `+plugin-instance-list --summary` | 同上加 `--summary` | +| 某个实例的完整配置 | `+plugin-instance-get --id ` | `lark-cli apps +plugin-instance-get --id --project-path ` | +| 插件的 actions / schema | 直接读 manifest | `cat /node_modules//manifest.json` | + +## +plugin-list + +列出 package.json `actionPlugins` 中声明的插件包,交叉检查 node_modules 报告安装状态。 + +```bash +lark-cli apps +plugin-list --project-path --format json +``` + +返回: +```json +{ + "ok": true, + "data": { + "plugins": [ + {"key": "@official-plugins/ai-text-generate", "version": "1.0.0", "status": "installed"}, + {"key": "@official-plugins/ai-translate", "version": "1.0.0", "status": "declared_not_installed"} + ] + } +} +``` + +`declared_not_installed` → 需要 `+plugin-install` 安装。 + +## +plugin-instance-list + +扫描 capabilities 目录下所有 `*.json` 文件。 + +```bash +lark-cli apps +plugin-instance-list --project-path --format json +``` + +capabilities 目录不存在时返回空列表(不报错)。`--summary` 只返回 id 和 name。 + +## +plugin-instance-get + +读取单个实例的完整配置(id、pluginKey、pluginVersion、name、description、paramsSchema、formValue、createdAt、updatedAt)。 + +```bash +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +实例不存在 → 返回错误 + hint `list instances with '+plugin-instance-list'`。 + +## 读取插件源码(写代码前必做) + +Agent 需要写调用代码时,不要只靠 instance-get 的输出,还要读插件的 manifest 获取 actions 详情: + +```bash +# manifest 包含 actions[].key / inputSchema / outputSchema / outputMode +cat /node_modules//manifest.json +``` + +然后按 [`plugin-instance-call.md`](plugin-instance-call.md) 生成调用代码。 diff --git a/skills/lark-apps/references/plugin-instance-call.md b/skills/lark-apps/references/plugin-instance-call.md new file mode 100644 index 000000000..dbc376735 --- /dev/null +++ b/skills/lark-apps/references/plugin-instance-call.md @@ -0,0 +1,262 @@ +# 插件实例调用代码编写指南 + +创建/更新插件实例后,根据本文件生成调用代码。 + +## 调用前获取权威依据 + +**必须**先读取以下文件获取 actions 信息: + +```bash +# 插件 manifest — actions / outputMode / inputSchema / outputSchema +cat /node_modules//manifest.json + +# 实例配置 — paramsSchema / formValue +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +**编码前闸门**:先列出 Schema 摘录,确认后再写代码。 + +``` +pluginInstanceId: xxx +actionKey: xxx +outputMode: unary | stream +input.required: [...] +output.fields: [...] +调用侧: Client | Server(仅全栈应用) +``` + +若摘录字段缺失,不得进入实现阶段。 + +## call / callStream 函数签名 + +```typescript +.call(actionKey: string, input: object) // 非流式,返回 Promise +.callStream(actionKey: string, input: object) // 流式,返回 AsyncIterable +``` + +```typescript +// ❌ 错误:把参数 JSON.stringify 后当 actionKey +plugin.call(JSON.stringify({ text: '...' })); +// ❌ 错误:漏掉 actionKey,直接传参数 +plugin.call({ text: '...' }); +// ✅ 正确:第一个参数是 actionKey 字符串,第二个是 input 对象 +plugin.call('textGenerate', { text: '...' }); +``` + +**唯一导入方式**(严禁其他路径): +```typescript +// ❌ 严禁 +import { capabilityClient } from '@lark-apaas/client-capability'; +// ✅ 唯一指定 +import { capabilityClient } from '@lark-apaas/client-toolkit'; +``` + +--- + +## 按应用类型选择调用方式 + +### Design / Modern 应用(appType=3/6,纯前端) + +只有 Client 侧调用,无 Server 侧。 + +| outputMode | 调用方式 | +|------------|---------| +| `unary` | `capabilityClient.load(id).call(actionKey, input)` | +| `stream` | `capabilityClient.load(id).callStream(actionKey, input)` | + +### 全栈应用(appType=2,NestJS + React) + +有 Client 侧和 Server 侧两条路。 + +| 优先级 | 场景 | 调用方式 | +|--------|------|---------| +| **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` | +| **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` | +| **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` | + +**何时用 Server 侧**: +1. 涉及敏感凭证(token/secret 不能暴露给前端) +2. 多步骤强事务编排(需要原子性) +3. 触发器/定时任务(无前端上下文) +4. 插件结果需持久化到数据库(调用+落库在同一方法中完成) + +> 不涉及持久化,仅即时展示(流式渲染、发消息)→ Client 侧。 + +--- + +## Client 侧调用 + +### 非流式调用(outputMode = "unary") + +```typescript +import { capabilityClient } from '@lark-apaas/client-toolkit'; +import { logger } from "@lark-apaas/client-toolkit/logger"; + +const result = await capabilityClient + .load('task_text_summary') + .call('textGenerate', { task_content: '...' }); + +logger.info(result); +``` + +### 流式调用(outputMode = "stream") + +```typescript +const streamResult = capabilityClient + .load('task_text_summary') + .callStream('textGenerate', { task_content: '...' }); + +const stream = normalizeStream(streamResult); +let fullContent = ''; +for await (const chunk of stream) { + const delta = readFirstStringField(chunk as Record, ['content']); + if (delta) { + fullContent += delta; + setContent(fullContent); + } +} +``` + +### normalizeStream(必须) + +`callStream()` 可能返回 `AsyncIterable` 或 `{ output: AsyncIterable }`,必须归一化: + +```typescript +type AnyRecord = Record; + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return !!value && typeof (value as AnyRecord)[Symbol.asyncIterator] === 'function'; +} + +function normalizeStream(resultOrStream: unknown): AsyncIterable { + if (isAsyncIterable(resultOrStream)) return resultOrStream; + if ( + resultOrStream && typeof resultOrStream === 'object' && + 'output' in (resultOrStream as AnyRecord) && + isAsyncIterable((resultOrStream as AnyRecord).output) + ) { + return (resultOrStream as AnyRecord).output as AsyncIterable; + } + throw new Error('Invalid callStream result: cannot find AsyncIterable stream'); +} + +function readFirstStringField(chunk: AnyRecord, keys: string[]): string { + for (const key of keys) { + const value = chunk[key]; + if (typeof value === 'string') return value; + } + return ''; +} +``` + +### 流式 chunk 字段速查 + +chunk 是扁平对象,字段名与 outputSchema 一致。**禁止** `chunk.data?.text`、`chunk.choices[0]` 等非 capabilityClient 格式。 + +| 插件 | chunk 字段 | 正确写法 | 错误写法 | +|------|-----------|---------|---------| +| ai-text-generate | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | +| ai-translate | `translation` | `chunk.translation` | ~~`chunk.content`~~ | +| ai-text-summary | `summary` | `chunk.summary` | ~~`chunk.content`~~ | +| ai-image-understanding | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | +| ai-search-summary | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | + +> 同一应用多页面调用同一插件时,所有页面必须使用一致的 chunk 字段名。 + +### 多插件并行流式(推荐) + +需求涉及多种独立输出(标题、正文、图片等)时,拆分为多个插件并行调用: + +```tsx +const handleGenerate = async (keywords: string) => { + // 封面图(非流式,异步不阻塞) + capabilityClient.load('cover_generator') + .call<{ images: string[] }>('textToImage', { keywords }) + .then(res => res?.images?.[0] && setCoverUrl(res.images[0])) + .catch(err => logger.warn('封面生成失败', err)); + + // 标题(非流式) + const titleResult = await capabilityClient + .load('title_generator') + .call<{ content: string }>('textGenerate', { keywords }); + setTitle(titleResult?.content || ''); + + // 正文(流式) + const contentStream = capabilityClient + .load('content_generator') + .callStream<{ content: string }>('textGenerate', { keywords }); + const stream = normalizeStream(contentStream); + let fullContent = ''; + for await (const chunk of stream) { + const delta = readFirstStringField(chunk as Record, ['content']); + if (delta) { fullContent += delta; setContent(fullContent); } + } +}; +``` + +--- + +## Server 侧调用(仅全栈应用) + +### NestJS 注入 + +```typescript +import { Injectable, Inject, Logger } from '@nestjs/common'; +import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core'; + +@Injectable() +export class XxxService { + private readonly logger = new Logger(XxxService.name); + constructor(@Inject() private readonly capabilityService: CapabilityService) {} + + async callPlugin(input: Record) { + try { + return await this.capabilityService + .load('') + .call('', input); + } catch (error) { + this.logger.error('pluginInstance call failed', { + pluginInstanceId: '', + actionKey: '', + error: error instanceof Error ? error.message : 'Unknown error', + }); + throw error; + } + } +} +``` + +### Server 侧编排原则 + +- PluginInstance 调用属于外部依赖 / side-effect +- 除非业务要求强一致性,默认不阻塞主业务流程 +- 推荐异步触发 + catch 兜底: + +```typescript +this.somePluginSideEffect(input).catch(error => { + this.logger.warn('PluginInstance side-effect failed, ignored', { + error: error instanceof Error ? error.message : 'Unknown error' + }); +}); +``` + +--- + +## 持久化决策 + +以下任一条件成立时,插件结果**必须**保存到数据库: +1. 结果会在其他页面展示 +2. 结果供后续功能消费 +3. 用户再次访问时需要看到结果 +4. 结果对应数据库中已有字段 + +仅一次性即时展示时可不持久化。 + +- 前端调用后需持久化 → 流式结束后调已有 CRUD 接口保存 +- 复用已有 create/update 接口,不要为插件结果单独建 API + +## 失败日志最小集 + +```typescript +{ pluginInstanceId, actionKey, outputMode, inputKeys, error } +``` diff --git a/skills/lark-apps/references/plugin-instance-schema.md b/skills/lark-apps/references/plugin-instance-schema.md new file mode 100644 index 000000000..38f1f6771 --- /dev/null +++ b/skills/lark-apps/references/plugin-instance-schema.md @@ -0,0 +1,167 @@ +# 插件实例 Schema 规则 + +生成 paramsSchema 和 formValue 前必读本文件。 + +## 变量三层映射 + +``` +调用方传值 paramsSchema 定义变量 formValue 消费变量 Plugin form.schema 接收 +(resume_text="...") → (定义: resume_text) → ("prompt": "...{{input.resume_text}}...") → (prompt 字段) +(article="...") → (定义: article) → ("content": "{{input.article}}") → (content 字段) +``` + +**关键区分**: +- formValue 的 **key** = Plugin form.schema 的字段名(如 `prompt`、`content`、`fileUrl`) +- formValue 的 **value** 中通过 `{{input.xxx}}` 引用 paramsSchema 定义的变量 +- 变量名(paramsSchema)与 form 字段名(form.schema)分属不同层,通常名称不同 + +## paramsSchema 生成规则 + +### 支持的参数类型(仅 4 种) + +**文本**: +```json +{ "type": "string", "description": "文本参数描述" } +``` + +**数组**: +```json +{ "type": "array", "description": "描述", "items": { "type": "string", "description": "元素描述" } } +``` + +**图片**: +```json +{ "type": "array", "format": "plugin-image-url", "description": "描述", "items": { "type": "string" } } +``` + +**文件**: +```json +{ "type": "array", "format": "plugin-file-url", "description": "描述", "items": { "type": "string" } } +``` + +### 约束 + +- 只允许 string 和 array 两种 type(图片/文件是 array + format) +- 每个参数**必须**有 type 和 description +- array 类型**必须**有 items 字段 +- format 只允许 `plugin-image-url` 或 `plugin-file-url` +- 参考 form.schema 字段的 type 进行定义,保持类型一致(不能给图片/文件类型定义为 string) +- 若 form.schema 字段描述写"不允许使用参数",则不生成对应 paramsSchema +- 参数设计应体现"收敛输入,扩展能力":用语义明确的参数名(如 `keywords`、`article_text`),避免过于开放的参数(如直接暴露 `prompt`) + +## formValue 生成规则 + +- **key 必须**对应 form.schema 中定义的字段 +- **value** 可以是常量,或 `{{input.xxx}}` 引用 paramsSchema 参数 +- **类型一致性**: + - form.schema type=string → `"字段名": "{{input.param}}"` 或常量字符串 + - form.schema type=array + paramsSchema type=array → **透传**:`"字段名": "{{input.param}}"`(禁止再包数组) + - form.schema type=array + paramsSchema type=string → **包装**:`"字段名": ["{{input.param}}"]` +- **禁止双层包装**:paramsSchema 已经是 array 时,`["{{input.param}}"]` 会导致运行时 `[url]` → `[[url]]` +- 无法明确赋值的字段留空字符串 `""`,不要硬编码 +- **业务枚举参数的动静态判断**: + - 用户指定单一固定值(如"翻译成英文")→ formValue 直接填常量 + - 用户列举多个值或暗示可选(如"翻译成中英日韩")→ 必须生成 paramsSchema 参数 +- 若 form.schema 字段描述写"固定填默认值 xxx"→ 直接填固定值,不引用参数 + +## 插件字段映射表 + +不同插件的"内容入口"字段各不相同,必须先看 manifest 的 form.schema: + +| 插件 | 内容入口字段 | 映射方式 | +|------|------------|---------| +| ai-text-generate | `prompt` | 用户输入嵌入 prompt 字符串 | +| ai-text-to-json | `prompt` | 文本嵌入 prompt,无独立 text 字段 | +| ai-text-summary | `content` | 直接赋值 `"content": "{{input.xxx}}"` | +| ai-translate | `content` | 直接赋值 | +| ai-categorization | `textToBeCategorized` | 直接赋值 | +| ai-speech-synthesis | `text` | 直接赋值 | +| ai-image-understanding | `prompt` + `images` | 图片传 images,指令嵌入 prompt | +| ai-doc-parser | `fileUrl` | array 类型透传 `"fileUrl": "{{input.xxx}}"` | + +## AI Prompt 编写规则 + +当插件涉及 AI 能力时,formValue 的 prompt 字段**应包含完整的高质量提示词**,而非简单透传。 + +### 禁止的做法 + +```json +// ❌ 直接透传,无任何预设指令 +"prompt": "{{input.prompt}}" +// ❌ 过于简单 +"prompt": "根据关键词生成文案:{{input.keywords}}" +// ❌ 文生图/图生图一次调用仅支持一张图 +"prompt": "请根据以下要求,生成3张配图" +``` + +### Prompt 编写要素 + +1. **角色设定**:明确 AI 扮演的角色或专业背景 +2. **任务描述**:清晰说明要完成的具体任务 +3. **输入说明**:标明用户输入将被插入的位置及其含义 +4. **输出要求**:明确输出的格式、结构、长度等 +5. **风格约束**:指定语气、风格、受众等 +6. **质量标准**:设定内容质量的具体标准 + +### 各场景 Prompt 模板参考 + +#### 文本生成类 +```json +"prompt": "你是一位资深的[平台名]内容创作专家,擅长撰写高互动率的内容。\n\n请根据以下关键词生成一篇文案:\n关键词:{{input.keywords}}\n\n内容要求:\n1. 标题(15-25字):使用数字、疑问句或悬念式开头,包含1-2个emoji\n2. 正文(300-500字):口语化表达,分3-5段,适当使用emoji\n3. 结尾:设置互动问题,包含3-5个话题标签" +``` + +#### 图片理解类 +```json +"prompt": "你是一位专业的图像分析专家。\n\n请对提供的图片进行深度分析:\n1. 基础信息:图片类型、主体内容、场景环境\n2. 细节描述:颜色、构图、关键元素、文字信息\n3. 语义理解:图片传达的含义、情感、潜在用途\n\n{{input.additional_requirements}}\n\n输出要求:描述准确客观,不确定的内容明确标注" +``` + +#### 文生图类 +```json +"prompt": "请生成一张高质量图片:\n\n主题内容:{{input.subject}}\n\n画面要求:\n1. 风格:[根据需求填写]\n2. 构图:[居中/三分法/对称等]\n3. 光线:[自然光/柔和光等]\n4. 色调:[明亮温暖/冷色调等]\n\n质量要求:画面清晰、主体突出、色彩和谐、无变形失真" +``` + +#### 数据提取/分析类 +```json +"prompt": "你是一位数据分析专家,擅长从非结构化内容中提取关键信息。\n\n请从以下内容中提取信息:\n{{input.content}}\n\n提取要求:\n1. 识别关键实体(人名、地点、组织、日期、金额等)\n2. 提取核心事实和数据点\n3. 归纳主要观点或结论" +``` + +#### AI 对话/问答类 +```json +"prompt": "你是一位[专业领域]专家。请以专业、友好的态度回答用户问题。\n\n用户问题:{{input.question}}\n\n回答要求:准确、完整、通俗易懂、结构化、提供可操作建议" +``` + +### 动态参数与预设内容结合 + +- **用户输入作为"素材"**:prompt 主体应是详细的任务指令,`{{input.xxx}}` 作为素材嵌入 +- **避免空泛透传**:即使需要用户自定义 prompt,也应提供默认模板 +- **预留扩展性**:可设可选参数(如 `{{input.additional_requirements}}`)供补充需求 + +## 模板语法限制 + +- **仅允许** `{{input.参数名}}` 一种语法 +- **严禁** `{{#if}}`、`{{#each}}`、`{{#unless}}`、`{{/if}}`、`{{/each}}`、`{{else}}` +- 需要条件逻辑时用自然语言表述 + +## 一致性铁律 + +1. **定义的变量必须被引用** — paramsSchema 中定义了 `xxx`,formValue 中至少有一处 `{{input.xxx}}` +2. **引用的变量必须被定义** — formValue 中出现 `{{input.xxx}}`,paramsSchema.properties 中必须有 `xxx` +3. **paramsSchema 允许为空** — 当 formValue 所有字段都是常量时可以是 `{}` +4. **paramsSchema/formValue 不一致 → 后端 actions 为空 → 插件无法调用** — 这是常见致命错误 + +## ID 生成规则 + +1. 基于插件实例的名称和描述,设计有业务语义的 ID +2. 格式:小写字母 + 数字 + 短横线(如 `task-text-summary`) +3. 长度不超过 128 字符 +4. 必须在当前项目内唯一 +5. 与已存在的 ID 冲突时重新生成 +6. 未命名时用 `unnamed-plugin-N` + +### 示例 + +``` +名称"数据分析",描述"分析数据发现趋势",已存在["data-analysis-1"] → data-analysis-trend-2 +名称"智能图片理解",描述"(测试)" → test-image-understanding-1 +名称"未命名插件" → unnamed-plugin-1 +``` diff --git a/skills/lark-apps/references/plugin-retry-protocol.md b/skills/lark-apps/references/plugin-retry-protocol.md new file mode 100644 index 000000000..569d822f4 --- /dev/null +++ b/skills/lark-apps/references/plugin-retry-protocol.md @@ -0,0 +1,54 @@ +# 插件实例校验失败重试协议 + +`+plugin-instance-create` 或 `+plugin-instance-update` 返回 `ok: false` 且 error.type 为 `validation` 时,按本协议处理。 + +## 触发条件 + +CLI 返回格式: +```json +{ + "ok": false, + "error": { + "type": "validation", + "subtype": "invalid_argument", + "message": "formValue validation failed:\n- ...\n- ...", + "hint": "fix the issues above and retry" + } +} +``` + +## 重试流程 + +``` +校验失败 + ↓ +Step 1: 解析 error.message 中的每条违规(以 "- " 开头的行) + ↓ +Step 2: 逐条修正 formValue / paramsSchema + ↓ +Step 3: 重新调用 +plugin-instance-create(加 --force)或 +plugin-instance-update + ↓ +校验通过? + ├── 是 → 继续后续流程 + └── 否 → 回到 Step 1(累计 ≤ 3 次) + └── 3 次仍失败 → 上报用户,附带最后一次的完整错误信息 +``` + +## 常见违规及修正方式 + +| 违规信息 | 原因 | 修正 | +|---------|------|------| +| `forbidden Handlebars syntax at formValue.xxx: {{#if` | formValue 中使用了控制语法 | 改为纯 `{{input.xxx}}` 或自然语言描述 | +| `paramsSchema property "x" type "number" is invalid` | 参数类型不在 string/array 范围 | 改为 `"type": "string"` 或 `"type": "array"` | +| `paramsSchema property "x" is array but missing items` | array 类型缺少 items 定义 | 补上 `"items": {"type": "string"}` | +| `paramsSchema property "x" missing description` | 参数缺少描述 | 补上 `"description": "..."` | +| `{{input.xxx}} at formValue.yyy is not defined in paramsSchema` | formValue 引用了未定义的变量 | 在 paramsSchema.properties 中补充定义,或修正拼写 | +| `paramsSchema property "x" is never referenced` | 定义了变量但 formValue 中没有引用 | 在 formValue 中补充 `{{input.x}}`,或从 paramsSchema 移除 | + +## 修正要点 + +1. **不要直接编辑 capability JSON 文件** — 必须通过 CLI 命令重新提交 +2. **Create 重试用 `--force`** — 覆盖上一次失败写入的文件 +3. **Update 直接重新调用** — 会覆盖现有配置 +4. **保持 paramsSchema 和 formValue 的一致性** — 修一个通常要同步改另一个 +5. **3 次失败后不要继续猜** — 上报用户并附带完整错误,让用户决策 diff --git a/skills/lark-apps/references/plugin-update-instance-flow.md b/skills/lark-apps/references/plugin-update-instance-flow.md new file mode 100644 index 000000000..3c11d2bee --- /dev/null +++ b/skills/lark-apps/references/plugin-update-instance-flow.md @@ -0,0 +1,68 @@ +# Update 链路 — 修改插件实例 + +修改已有实例的 name、formValue、paramsSchema。关键点:改 schema 可能影响已有调用代码。 + +## 流程 + +``` +Step 1: +plugin-instance-get --id → 查看现状 +Step 2: 读 plugin-instance-schema.md + 设计修改方案 +Step 3: +plugin-instance-update --id --form-value @file [--params-schema @file] [--name ] +Step 4: 校验通过? → 否:走 plugin-retry-protocol.md(max 3 次) +Step 5: paramsSchema 变化? + ├── 不变 → 完成 + └── 变化 → 扫描代码引用 → 读 plugin-instance-call.md → 改代码 +``` + +## Step 1 — 查看现状 + +```bash +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +确认当前的 pluginKey、paramsSchema、formValue,理解要改什么。 + +## Step 2 — 设计修改方案 + +**必读**:[`plugin-instance-schema.md`](plugin-instance-schema.md) + +同时读取插件 manifest 确认 form.schema 约束: +```bash +cat /node_modules//manifest.json +``` + +## Step 3 — 执行更新 + +```bash +# 只改名 +lark-cli apps +plugin-instance-update --id --name "新名称" --project-path + +# 改 formValue +lark-cli apps +plugin-instance-update --id --form-value '{"prompt":"新 prompt {{input.text}}"}' --project-path + +# 同时改 formValue + paramsSchema +lark-cli apps +plugin-instance-update --id \ + --form-value @form.json --params-schema @schema.json --project-path +``` + +CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt),只更新你传入的字段 + updatedAt。 + +## Step 4 — 校验失败处理 + +同 Create 链路,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理。 + +## Step 5 — paramsSchema 变化时更新代码 + +判断 paramsSchema 是否变化(加/改/删了 properties): + +**加/改字段** → 调用方需要传新参数: +1. 读 manifest + 更新后的 capability JSON +2. `grep -rn "load('${id}')" /` 找到代码引用 +3. 按 [`plugin-instance-call.md`](plugin-instance-call.md) 更新调用代码中的 input 参数 + +**删字段** → 调用方不再需要传该参数: +1. 同上找到引用 +2. 移除已删除参数的传入 +3. 检查是否有上游依赖该参数的逻辑需要清理 + +**未变化** → 无需改代码,直接完成。 From 0a999171be5ca68277fc5f193455ab64c406a545 Mon Sep 17 00:00:00 2001 From: anguohui Date: Mon, 22 Jun 2026 11:48:28 +0800 Subject: [PATCH 06/40] feat: add --local flag to +plugin-install for local tgz installation Supports installing plugin packages from local .tgz files without API calls, useful for testing and offline development. Reads plugin key and version from the extracted package.json inside the tgz. Also moved Scopes to ConditionalScopes so --local path skips auth. --- shortcuts/apps/plugin_install.go | 86 ++++++++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index 9676d4b7d..ba5daba39 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -6,6 +6,7 @@ package apps import ( "bytes" "context" + "encoding/json" "fmt" "io" "net/http" @@ -27,11 +28,12 @@ var AppsPluginInstall = common.Shortcut{ Service: appsService, Command: "+plugin-install", Description: "Install a plugin package (download, extract, update package.json)", - Risk: "write", - Scopes: []string{"spark:plugin:readonly"}, - AuthTypes: []string{"user"}, + Risk: "write", + ConditionalScopes: []string{"spark:plugin:readonly"}, + AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "name", Desc: "plugin key[@version] (e.g. @official-plugins/ai-text-generate@1.0.0); omit to install all declared plugins"}, + {Name: "local", Desc: "install from a local .tgz file instead of downloading from registry (e.g. --local ./plugin.tgz)"}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -62,6 +64,10 @@ var AppsPluginInstall = common.Shortcut{ return err } + if localTgz := strings.TrimSpace(rctx.Str("local")); localTgz != "" { + return pluginInstallLocal(rctx, projectPath, localTgz) + } + name := strings.TrimSpace(rctx.Str("name")) if name == "" { return pluginInstallAll(ctx, rctx, projectPath) @@ -191,6 +197,80 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP return nil } +// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls. +// Reads plugin key and version from the extracted package.json inside the tgz. +func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error { + tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read. + if err != nil { + return appsValidationParamError("--local", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err) + } + + // Extract to a temp dir first to read package.json + tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo + if err != nil { + return appsFileIOError(err, "cannot create temp dir") + } + defer os.RemoveAll(tmpDir) //nolint:forbidigo + + if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil { + return appsFileIOError(err, "cannot extract tgz") + } + + // Read key and version from extracted package.json + pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo + if err != nil { + return appsFileIOError(err, "tgz does not contain package.json") + } + var pkgMeta map[string]interface{} + if err := json.Unmarshal(pkgData, &pkgMeta); err != nil { + return appsFileIOError(err, "invalid package.json in tgz") + } + key, _ := pkgMeta["name"].(string) + version, _ := pkgMeta["version"].(string) + if key == "" { + return appsValidationParamError("--local", "package.json in tgz missing 'name' field") + } + if version == "" { + version = "0.0.0" + } + + // Move to node_modules + destDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create parent dir for %s", destDir) + } + if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo + // rename may fail across filesystems; fall back to re-extract + if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo + return appsFileIOError(err2, "cannot create %s", destDir) + } + if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil { + return appsFileIOError(err2, "cannot extract plugin to %s", destDir) + } + } + + // Update package.json actionPlugins + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, version) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": version, "status": "installed", "source": "local", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath) + }) + return nil +} + // pluginResolveVersion calls the batch_get API to resolve download info. // Returns resolved version, download URL, download approach ("inner"|"public"). func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion, downloadURL, downloadApproach string, err error) { From d80636d7da6324d588ad271126d88e77794c2b14 Mon Sep 17 00:00:00 2001 From: anguohui Date: Mon, 22 Jun 2026 20:12:01 +0800 Subject: [PATCH 07/40] fix: improve error messages for plugin install and check - pluginCheckInstalled: distinguish "directory not exist" (not installed) vs "directory exists but manifest.json missing" (not built correctly), with specific hints for each case - pluginResolveVersion: detect non-JSON API response (typically HTML 404 from unregistered endpoint) and give clear "API not available" message instead of misleading "check plugin key spelling" - Hide --local flag from help (dev/test only, not for agents) --- shortcuts/apps/plugin_common.go | 16 +++++++++++++--- shortcuts/apps/plugin_install.go | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index d65c8fcbe..99d6f8da4 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -252,13 +252,23 @@ func pluginValidateJSONFlag(flagName, value string) error { return nil } -// pluginCheckInstalled verifies that the plugin package is installed in node_modules. +// pluginCheckInstalled verifies that the plugin package is installed in node_modules +// with a valid manifest.json. Distinguishes three failure cases: +// - plugin directory does not exist → "not installed" +// - plugin directory exists but manifest.json missing → "not built" +// - other I/O error func pluginCheckInstalled(projectPath, pluginKey string) error { - manifestPath := filepath.Join(projectPath, "node_modules", pluginKey, "manifest.json") + pluginDir := filepath.Join(projectPath, "node_modules", pluginKey) + manifestPath := filepath.Join(pluginDir, "manifest.json") if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. if os.IsNotExist(err) { + if pluginDirExists(pluginDir) { + return appsFailedPreconditionError( + "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, + ).WithHint("reinstall with a properly built .tgz (ap build + npm pack), or run 'lark-cli apps +plugin-install --local ' to replace it") + } return appsFailedPreconditionError("plugin %q is not installed", pluginKey). - WithHint("run 'lark-cli apps +plugin-install %s' first", pluginKey) + WithHint("run 'lark-cli apps +plugin-install --local ' or 'lark-cli apps +plugin-install --name %s' to install", pluginKey) } return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) } diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index ba5daba39..7d34c5591 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -16,6 +16,7 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -33,7 +34,7 @@ var AppsPluginInstall = common.Shortcut{ AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "name", Desc: "plugin key[@version] (e.g. @official-plugins/ai-text-generate@1.0.0); omit to install all declared plugins"}, - {Name: "local", Desc: "install from a local .tgz file instead of downloading from registry (e.g. --local ./plugin.tgz)"}, + {Name: "local", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -284,7 +285,13 @@ func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugins/-/versions/batch_get", nil, body) if err != nil { - return "", "", "", withAppsHint(err, "check plugin key spelling and network") + p, ok := errs.ProblemOf(err) + if ok && p.Subtype == errs.SubtypeInvalidResponse { + p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key) + p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team" + return "", "", "", err + } + return "", "", "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key)) } versions := pluginExtractVersionInfo(data, key) From a91f2cdd85da666675c0d437fe2c107a557b2ee7 Mon Sep 17 00:00:00 2001 From: anguohui Date: Mon, 22 Jun 2026 22:36:31 +0800 Subject: [PATCH 08/40] refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Merge plugin-instance-schema, create/update/delete/get flows, and retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry) - Merge plugin-catalog into lark-apps-plugin.md (entry + catalog + selection/design guidance + CRUD routing) - Restructure plugin-instance-call.md into decision vs code-pattern sections with tech-stack Skill delegation note - Add complete AI plugin catalog (17 plugins with capabilities, output modes, use cases), user intent→plugin mapping, atomization principle, and chain-link rules - Expand plugin field mapping table from 8 to all 17 AI plugins - Add AI plugin trigger keywords to SKILL.md description for host agent skill matching - Rename files to lark-apps-plugin-* prefix for consistency --- skills/lark-apps/SKILL.md | 4 +- ...tance-call.md => lark-apps-plugin-call.md} | 131 +++--- .../references/lark-apps-plugin-crud.md | 442 ++++++++++++++++++ .../lark-apps/references/lark-apps-plugin.md | 129 ++++- .../references/plugin-create-instance-flow.md | 103 ---- .../references/plugin-delete-instance-flow.md | 57 --- .../references/plugin-get-instance-flow.md | 67 --- .../references/plugin-instance-schema.md | 167 ------- .../references/plugin-retry-protocol.md | 54 --- .../references/plugin-update-instance-flow.md | 68 --- 10 files changed, 623 insertions(+), 599 deletions(-) rename skills/lark-apps/references/{plugin-instance-call.md => lark-apps-plugin-call.md} (79%) create mode 100644 skills/lark-apps/references/lark-apps-plugin-crud.md delete mode 100644 skills/lark-apps/references/plugin-create-instance-flow.md delete mode 100644 skills/lark-apps/references/plugin-delete-instance-flow.md delete mode 100644 skills/lark-apps/references/plugin-get-instance-flow.md delete mode 100644 skills/lark-apps/references/plugin-instance-schema.md delete mode 100644 skills/lark-apps/references/plugin-retry-protocol.md delete mode 100644 skills/lark-apps/references/plugin-update-instance-flow.md diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 8eabb07d2..e6f83c760 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI插件集成。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。当用户要接入AI能力时也使用:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要,或提到Plugin/PluginInstance/Capability/插件安装/插件实例。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -29,7 +29,7 @@ metadata: | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | 先读插件入口 Skill 确认项目上下文,再按意图路由到 CRUD 链路 | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | +| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | 读 [`lark-apps-plugin.md`](references/lark-apps-plugin.md)(插件选择 + CRUD 路由),按需读 [`lark-apps-plugin-crud.md`](references/lark-apps-plugin-crud.md)(Schema + 链路)和 [`lark-apps-plugin-call.md`](references/lark-apps-plugin-call.md)(调用代码) | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/plugin-instance-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md similarity index 79% rename from skills/lark-apps/references/plugin-instance-call.md rename to skills/lark-apps/references/lark-apps-plugin-call.md index dbc376735..c6e444370 100644 --- a/skills/lark-apps/references/plugin-instance-call.md +++ b/skills/lark-apps/references/lark-apps-plugin-call.md @@ -2,7 +2,13 @@ 创建/更新插件实例后,根据本文件生成调用代码。 -## 调用前获取权威依据 +本文件分两部分:**调用决策**(选择调用侧、是否持久化)和**代码模式**(具体写法)。 + +--- + +## 第一部分:调用决策 + +### 调用前获取权威依据 **必须**先读取以下文件获取 actions 信息: @@ -23,11 +29,57 @@ outputMode: unary | stream input.required: [...] output.fields: [...] 调用侧: Client | Server(仅全栈应用) +持久化: 是 | 否(及方式) ``` 若摘录字段缺失,不得进入实现阶段。 -## call / callStream 函数签名 +### Client vs Server 决策 + +| 应用类型 | 可选调用侧 | +|---------|-----------| +| Design / Modern(appType=3/6,纯前端) | 只有 Client 侧 | +| 全栈应用(appType=2,NestJS + React) | Client 侧(首选)或 Server 侧 | + +**Server 侧仅在以下场景使用**: +1. 涉及敏感凭证(token/secret 不能暴露给前端) +2. 多步骤强事务编排(需要原子性) +3. 触发器/定时任务(无前端上下文) +4. 插件结果需持久化到数据库(调用+落库在同一方法中完成) + +> 不涉及上述场景,仅即时展示(流式渲染、一次性展示)→ Client 侧。 + +### 持久化决策 + +**设计阶段就要判断**,不要等到写代码时才想。 + +以下任一条件成立时,插件结果**必须**保存到数据库: +1. 结果会在其他页面展示 +2. 结果供后续功能消费 +3. 用户再次访问时需要看到结果 +4. 结果对应数据库中已有字段 + +仅一次性即时展示(聊天对话、临时预览)时可不持久化。 + +**持久化方式优先级**: +- **推荐(A)**:Server 侧 Service 调用插件 + 同一方法落库 +- **备选(B)**:Client 侧调用插件 → 流式结束后调已有 CRUD 接口保存 + +复用已有 create/update 接口,不要为插件结果单独建 API。 + +### 失败日志最小集 + +```typescript +{ pluginInstanceId, actionKey, outputMode, inputKeys, error } +``` + +--- + +## 第二部分:代码模式 + +> 以下内容与妙搭技术栈(`@lark-apaas/client-toolkit`、NestJS)绑定。如仓库本地有技术栈 Skill(如 `.agent/skills/plugin-coding-guide/SKILL.md`),优先读仓库本地版本。 + +### call / callStream 函数签名 ```typescript .call(actionKey: string, input: object) // 非流式,返回 Promise @@ -51,42 +103,9 @@ import { capabilityClient } from '@lark-apaas/client-capability'; import { capabilityClient } from '@lark-apaas/client-toolkit'; ``` ---- - -## 按应用类型选择调用方式 +### Client 侧调用 -### Design / Modern 应用(appType=3/6,纯前端) - -只有 Client 侧调用,无 Server 侧。 - -| outputMode | 调用方式 | -|------------|---------| -| `unary` | `capabilityClient.load(id).call(actionKey, input)` | -| `stream` | `capabilityClient.load(id).callStream(actionKey, input)` | - -### 全栈应用(appType=2,NestJS + React) - -有 Client 侧和 Server 侧两条路。 - -| 优先级 | 场景 | 调用方式 | -|--------|------|---------| -| **首选** | 绝大多数场景 | `capabilityClient.load(id).call()` | -| **首选** | 流式输出场景 | `capabilityClient.load(id).callStream()` | -| **兜底** | Client 侧无法满足时 | `CapabilityService.load(id).call()` | - -**何时用 Server 侧**: -1. 涉及敏感凭证(token/secret 不能暴露给前端) -2. 多步骤强事务编排(需要原子性) -3. 触发器/定时任务(无前端上下文) -4. 插件结果需持久化到数据库(调用+落库在同一方法中完成) - -> 不涉及持久化,仅即时展示(流式渲染、发消息)→ Client 侧。 - ---- - -## Client 侧调用 - -### 非流式调用(outputMode = "unary") +#### 非流式(outputMode = "unary") ```typescript import { capabilityClient } from '@lark-apaas/client-toolkit'; @@ -99,7 +118,7 @@ const result = await capabilityClient logger.info(result); ``` -### 流式调用(outputMode = "stream") +#### 流式(outputMode = "stream") ```typescript const streamResult = capabilityClient @@ -117,7 +136,7 @@ for await (const chunk of stream) { } ``` -### normalizeStream(必须) +#### normalizeStream(必须) `callStream()` 可能返回 `AsyncIterable` 或 `{ output: AsyncIterable }`,必须归一化: @@ -149,7 +168,7 @@ function readFirstStringField(chunk: AnyRecord, keys: string[]): string { } ``` -### 流式 chunk 字段速查 +#### 流式 chunk 字段速查 chunk 是扁平对象,字段名与 outputSchema 一致。**禁止** `chunk.data?.text`、`chunk.choices[0]` 等非 capabilityClient 格式。 @@ -159,11 +178,12 @@ chunk 是扁平对象,字段名与 outputSchema 一致。**禁止** `chunk.dat | ai-translate | `translation` | `chunk.translation` | ~~`chunk.content`~~ | | ai-text-summary | `summary` | `chunk.summary` | ~~`chunk.content`~~ | | ai-image-understanding | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | +| ai-image-compare | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | | ai-search-summary | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | > 同一应用多页面调用同一插件时,所有页面必须使用一致的 chunk 字段名。 -### 多插件并行流式(推荐) +#### 多插件并行流式(推荐) 需求涉及多种独立输出(标题、正文、图片等)时,拆分为多个插件并行调用: @@ -194,11 +214,9 @@ const handleGenerate = async (keywords: string) => { }; ``` ---- - -## Server 侧调用(仅全栈应用) +### Server 侧调用(仅全栈应用) -### NestJS 注入 +#### NestJS 注入 ```typescript import { Injectable, Inject, Logger } from '@nestjs/common'; @@ -226,7 +244,7 @@ export class XxxService { } ``` -### Server 侧编排原则 +#### Server 侧编排原则 - PluginInstance 调用属于外部依赖 / side-effect - 除非业务要求强一致性,默认不阻塞主业务流程 @@ -239,24 +257,3 @@ this.somePluginSideEffect(input).catch(error => { }); }); ``` - ---- - -## 持久化决策 - -以下任一条件成立时,插件结果**必须**保存到数据库: -1. 结果会在其他页面展示 -2. 结果供后续功能消费 -3. 用户再次访问时需要看到结果 -4. 结果对应数据库中已有字段 - -仅一次性即时展示时可不持久化。 - -- 前端调用后需持久化 → 流式结束后调已有 CRUD 接口保存 -- 复用已有 create/update 接口,不要为插件结果单独建 API - -## 失败日志最小集 - -```typescript -{ pluginInstanceId, actionKey, outputMode, inputKeys, error } -``` diff --git a/skills/lark-apps/references/lark-apps-plugin-crud.md b/skills/lark-apps/references/lark-apps-plugin-crud.md new file mode 100644 index 000000000..75e13d77e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-crud.md @@ -0,0 +1,442 @@ +# 插件实例 CRUD + +Schema 规则 + Create / Update / Delete / Get 四条链路 + 校验重试协议。 + +--- + +## Schema 规则 + +生成 paramsSchema 和 formValue 前必读本章节。 + +### 变量三层映射 + +``` +调用方传值 paramsSchema 定义变量 formValue 消费变量 Plugin form.schema 接收 +(resume_text="...") → (定义: resume_text) → ("prompt": "...{{input.resume_text}}...") → (prompt 字段) +(article="...") → (定义: article) → ("content": "{{input.article}}") → (content 字段) +``` + +**关键区分**: +- formValue 的 **key** = Plugin form.schema 的字段名(如 `prompt`、`content`、`fileUrl`) +- formValue 的 **value** 中通过 `{{input.xxx}}` 引用 paramsSchema 定义的变量 +- 变量名(paramsSchema)与 form 字段名(form.schema)分属不同层,通常名称不同 + +### paramsSchema 生成规则 + +#### 支持的参数类型(仅 4 种) + +**文本**: +```json +{ "type": "string", "description": "文本参数描述" } +``` + +**数组**: +```json +{ "type": "array", "description": "描述", "items": { "type": "string", "description": "元素描述" } } +``` + +**图片**: +```json +{ "type": "array", "format": "plugin-image-url", "description": "描述", "items": { "type": "string" } } +``` + +**文件**: +```json +{ "type": "array", "format": "plugin-file-url", "description": "描述", "items": { "type": "string" } } +``` + +#### 约束 + +- 只允许 string 和 array 两种 type(图片/文件是 array + format) +- 每个参数**必须**有 type 和 description +- array 类型**必须**有 items 字段 +- format 只允许 `plugin-image-url` 或 `plugin-file-url` +- 参考 form.schema 字段的 type 进行定义,保持类型一致(不能给图片/文件类型定义为 string) +- 若 form.schema 字段描述写"不允许使用参数",则不生成对应 paramsSchema +- 参数设计应体现"收敛输入,扩展能力":用语义明确的参数名(如 `keywords`、`article_text`),避免过于开放的参数(如直接暴露 `prompt`) + +### formValue 生成规则 + +- **key 必须**对应 form.schema 中定义的字段 +- **value** 可以是常量,或 `{{input.xxx}}` 引用 paramsSchema 参数 +- **类型一致性**: + - form.schema type=string → `"字段名": "{{input.param}}"` 或常量字符串 + - form.schema type=array + paramsSchema type=array → **透传**:`"字段名": "{{input.param}}"`(禁止再包数组) + - form.schema type=array + paramsSchema type=string → **包装**:`"字段名": ["{{input.param}}"]` +- **禁止双层包装**:paramsSchema 已经是 array 时,`["{{input.param}}"]` 会导致运行时 `[url]` → `[[url]]` +- 无法明确赋值的字段留空字符串 `""`,不要硬编码 +- **业务枚举参数的动静态判断**: + - 用户指定单一固定值(如"翻译成英文")→ formValue 直接填常量 + - 用户列举多个值或暗示可选(如"翻译成中英日韩")→ 必须生成 paramsSchema 参数 +- 若 form.schema 字段描述写"固定填默认值 xxx"→ 直接填固定值,不引用参数 + +### 插件字段映射表 + +不同插件的"内容入口"字段各不相同,必须先看 manifest 的 form.schema。下表覆盖全部 AI 插件: + +#### 文本类 + +| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | +|------|------------|---------|------------| +| ai-text-generate | `prompt` | 用户输入嵌入 prompt 字符串 | `modelID`、`modelParams`(固定值,不引用参数) | +| ai-text-summary | `content` | 直接赋值 `"content": "{{input.xxx}}"` | `requirement`(摘要要求,可常量或参数) | +| ai-translate | `content` | 直接赋值 | `targetLanguage`(单一语言写常量,多语言生成参数) | +| ai-categorization | `textToBeCategorized` | 直接赋值 | `categories`(分类列表,array 类型) | +| ai-text-to-json | `prompt` | 文本嵌入 prompt,无独立 text 字段 | `jsonStructure`(固定结构定义,不引用参数)、`modelID`、`modelParams` | +| ai-search-summary | `prompt` | 用户查询嵌入 prompt | `modelID`、`modelParams`(固定值) | + +#### 图片类 + +| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | +|------|------------|---------|------------| +| ai-text-to-image | `prompt` | 图片描述嵌入 prompt | `ratio`(宽高比,单一写常量)、`style`(风格,可常量或参数) | +| ai-image-to-image | `prompt` + `images` | 指令嵌入 prompt,图片传 images(image 类型透传) | `strength`(编辑强度,通常常量) | +| ai-image-understanding | `prompt` + `images` | 指令嵌入 prompt,图片传 images(image 类型透传) | `modelID`、`modelParams`(固定值) | +| ai-image-to-json | `prompt` + `images` | 文本嵌入 prompt,图片传 images | `jsonStructure`(固定结构定义)、`modelID`、`modelParams` | +| ai-image-compare | `prompt` + `images` | 对比指令嵌入 prompt,两张图片传 images | — | +| ai-image-matting | `images` | 图片直接传入(image 类型透传) | — | +| ai-background-replace | `images` + `prompt` | 原图传 images,新背景描述嵌入 prompt | — | + +#### 文档/语音/其他 + +| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | +|------|------------|---------|------------| +| ai-doc-parser | `fileUrl` | file 类型:paramsSchema 为 array → 透传 `"fileUrl": "{{input.xxx}}"`;paramsSchema 为 string → 包装 `"fileUrl": ["{{input.xxx}}"]` | — | +| ai-speech-to-text | `fileUrl` | 同 ai-doc-parser | — | +| ai-speech-synthesis | `text` | 直接赋值 `"text": "{{input.xxx}}"` | `voice`(语音角色,通常常量) | +| web-crawler | `url` | 直接赋值 `"url": "{{input.xxx}}"` | — | + +### AI Prompt 编写规则 + +当插件涉及 AI 能力时,formValue 的 prompt 字段**应包含完整的高质量提示词**,而非简单透传。 + +#### 禁止的做法 + +```json +// ❌ 直接透传,无任何预设指令 +"prompt": "{{input.prompt}}" +// ❌ 过于简单 +"prompt": "根据关键词生成文案:{{input.keywords}}" +// ❌ 文生图/图生图一次调用仅支持一张图 +"prompt": "请根据以下要求,生成3张配图" +``` + +#### Prompt 编写要素 + +1. **角色设定**:明确 AI 扮演的角色或专业背景 +2. **任务描述**:清晰说明要完成的具体任务 +3. **输入说明**:标明用户输入将被插入的位置及其含义 +4. **输出要求**:明确输出的格式、结构、长度等 +5. **风格约束**:指定语气、风格、受众等 +6. **质量标准**:设定内容质量的具体标准 + +#### 各场景 Prompt 模板参考 + +**文本生成类**: +```json +"prompt": "你是一位资深的[平台名]内容创作专家,擅长撰写高互动率的内容。\n\n请根据以下关键词生成一篇文案:\n关键词:{{input.keywords}}\n\n内容要求:\n1. 标题(15-25字):使用数字、疑问句或悬念式开头,包含1-2个emoji\n2. 正文(300-500字):口语化表达,分3-5段,适当使用emoji\n3. 结尾:设置互动问题,包含3-5个话题标签" +``` + +**图片理解类**: +```json +"prompt": "你是一位专业的图像分析专家。\n\n请对提供的图片进行深度分析:\n1. 基础信息:图片类型、主体内容、场景环境\n2. 细节描述:颜色、构图、关键元素、文字信息\n3. 语义理解:图片传达的含义、情感、潜在用途\n\n{{input.additional_requirements}}\n\n输出要求:描述准确客观,不确定的内容明确标注" +``` + +**文生图类**: +```json +"prompt": "请生成一张高质量图片:\n\n主题内容:{{input.subject}}\n\n画面要求:\n1. 风格:[根据需求填写]\n2. 构图:[居中/三分法/对称等]\n3. 光线:[自然光/柔和光等]\n4. 色调:[明亮温暖/冷色调等]\n\n质量要求:画面清晰、主体突出、色彩和谐、无变形失真" +``` + +**数据提取/分析类**: +```json +"prompt": "你是一位数据分析专家,擅长从非结构化内容中提取关键信息。\n\n请从以下内容中提取信息:\n{{input.content}}\n\n提取要求:\n1. 识别关键实体(人名、地点、组织、日期、金额等)\n2. 提取核心事实和数据点\n3. 归纳主要观点或结论" +``` + +**AI 对话/问答类**: +```json +"prompt": "你是一位[专业领域]专家。请以专业、友好的态度回答用户问题。\n\n用户问题:{{input.question}}\n\n回答要求:准确、完整、通俗易懂、结构化、提供可操作建议" +``` + +#### 动态参数与预设内容结合 + +- **用户输入作为"素材"**:prompt 主体应是详细的任务指令,`{{input.xxx}}` 作为素材嵌入 +- **避免空泛透传**:即使需要用户自定义 prompt,也应提供默认模板 +- **预留扩展性**:可设可选参数(如 `{{input.additional_requirements}}`)供补充需求 + +### 模板语法限制 + +- **仅允许** `{{input.参数名}}` 一种语法 +- **严禁** `{{#if}}`、`{{#each}}`、`{{#unless}}`、`{{/if}}`、`{{/each}}`、`{{else}}` +- 需要条件逻辑时用自然语言表述 + +### 一致性铁律 + +1. **定义的变量必须被引用** — paramsSchema 中定义了 `xxx`,formValue 中至少有一处 `{{input.xxx}}` +2. **引用的变量必须被定义** — formValue 中出现 `{{input.xxx}}`,paramsSchema.properties 中必须有 `xxx` +3. **paramsSchema 允许为空** — 当 formValue 所有字段都是常量时可以是 `{}` +4. **paramsSchema/formValue 不一致 → 后端 actions 为空 → 插件无法调用** — 这是常见致命错误 + +### ID 生成规则 + +1. 基于插件实例的名称和描述,设计有业务语义的 ID +2. 格式:小写字母 + 数字 + 短横线(如 `task-text-summary`) +3. 长度不超过 128 字符 +4. 必须在当前项目内唯一 +5. 与已存在的 ID 冲突时重新生成 +6. 未命名时用 `unnamed-plugin-N` + +``` +名称"数据分析",描述"分析数据发现趋势",已存在["data-analysis-1"] → data-analysis-trend-2 +名称"智能图片理解",描述"(测试)" → test-image-understanding-1 +名称"未命名插件" → unnamed-plugin-1 +``` + +--- + +## Create 链路 + +从用户需求到插件可调用的完整流程。 + +``` +Step 1: +plugin-install --name +Step 2: 设计 paramsSchema / formValue(读上方 Schema 规则) +Step 3: +plugin-instance-create +Step 4: 校验通过? → 否:走下方「校验失败重试」(max 3 次) +Step 5: 读 manifest + capability JSON +Step 6: 读 lark-apps-plugin-call.md → 生成调用代码 +``` + +### Step 1 — 安装插件包 + +```bash +lark-cli apps +plugin-install --name @official-plugins/ai-text-generate@1.0.0 --project-path +``` + +- 鉴权:需要 user token(先 `lark-cli auth login`) +- 已安装同版本会跳过(status=already_installed) +- 失败时 hint 会指示原因(网络/版本不存在/package.json 缺失) + +### Step 2 — 设计 paramsSchema 和 formValue + +设计前必须先读插件的 form.schema: +```bash +cat /node_modules//manifest.json +``` + +根据 form.schema 的字段和用户业务意图,设计: +1. **paramsSchema** — 对外暴露的业务入参(变量定义) +2. **formValue** — 将变量映射到 form.schema 字段(变量消费) +3. **语义化 ID** — 如 `task-text-summary`,小写+短横线,描述业务用途 + +### Step 3 — 创建实例 + +```bash +lark-cli apps +plugin-instance-create \ + --id task-text-summary \ + --plugin @official-plugins/ai-text-generate@1.0.0 \ + --name "任务摘要生成" \ + --description "根据任务详情生成摘要" \ + --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ + --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ + --project-path \ + --format json +``` + +大 JSON 场景用 `@file` 传入:先写临时文件,再 `--form-value @form.json --params-schema @schema.json`。 + +#### 前置检查(CLI 自动执行) + +| 检查项 | 失败时 hint | +|--------|-----------| +| package.json 存在 | `run 'lark-cli apps +init'` | +| capabilities 路径可解析 | `use --capabilities-dir or check .env.local` | +| 插件包已安装 | `run '+plugin-install ...' first` | +| 版本匹配 | warning(非 error):`installed X differs from Y` | +| ID 唯一 | `use --force to overwrite, or choose a different --id` | +| formValue 校验(5 规则) | 逐条列出违规项 | + +### Step 4 — 校验失败处理 + +CLI 返回 `ok: false` + `error.hint` 时,按下方「校验失败重试」处理。 + +### Step 5 — 读取插件源码 + +> 创建成功时 CLI 会自动生成 TypeScript 类型文件(`shared/plugin-types.ts`),无需手动调用。 + +```bash +cat /node_modules//manifest.json +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +### Step 6 — 生成调用代码 + +**必读**:[`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) + +### Red Flags + +| 念头 | 反驳 | +|------|------| +| "我记得这个插件的 schema,不用读 manifest" | manifest 可能更新过,必须每次读 | +| "create 完直接写代码" | 没读 manifest 就写代码 = 猜 actionKey/params | +| "install 之前先 create" | 没装包 manifest 读不到,校验会失败 | +| "formValue 校验报错,我直接编辑 JSON 文件" | 铁律:只能通过 CLI 命令修改 capability JSON | + +--- + +## Update 链路 + +修改已有实例的 name、formValue、paramsSchema。关键点:改 schema 可能影响已有调用代码。 + +``` +Step 1: +plugin-instance-get --id → 查看现状 +Step 2: 设计修改方案(读上方 Schema 规则) +Step 3: +plugin-instance-update +Step 4: 校验通过? → 否:走下方「校验失败重试」(max 3 次) +Step 5: paramsSchema 变化? → 变化则扫描代码引用并更新 +``` + +### Step 1 — 查看现状 + +```bash +lark-cli apps +plugin-instance-get --id --project-path --format json +``` + +确认当前的 pluginKey、paramsSchema、formValue,理解要改什么。 + +### Step 2 — 设计修改方案 + +同时读取插件 manifest 确认 form.schema 约束: +```bash +cat /node_modules//manifest.json +``` + +### Step 3 — 执行更新 + +```bash +# 只改名 +lark-cli apps +plugin-instance-update --id --name "新名称" --project-path + +# 改 formValue +lark-cli apps +plugin-instance-update --id --form-value '{"prompt":"新 prompt {{input.text}}"}' --project-path + +# 同时改 formValue + paramsSchema +lark-cli apps +plugin-instance-update --id \ + --form-value @form.json --params-schema @schema.json --project-path +``` + +CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt),只更新你传入的字段 + updatedAt。 + +### Step 5 — paramsSchema 变化时更新代码 + +**加/改字段** → 调用方需要传新参数: +1. 读 manifest + 更新后的 capability JSON +2. `grep -rn "load('${id}')" /` 找到代码引用 +3. 按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 更新调用代码中的 input 参数 + +**删字段** → 调用方不再需要传该参数:同上找到引用,移除已删除参数的传入。 + +**未变化** → 无需改代码,直接完成。 + +--- + +## Delete 链路 + +删除实例前必须先清理代码引用,避免运行时报错。 + +``` +Step 1: +plugin-instance-get --id → 确认实例存在 +Step 2: 扫描代码引用 → 有引用则先清理 +Step 3: +plugin-instance-delete --id +Step 4: 确认清理完成 +``` + +### 扫描代码引用 + +```bash +grep -rn "load('${id}')\|load(\"${id}\")" /client/ /server/ /shared/ +``` + +如果有引用: +1. 移除或替换调用代码(视业务逻辑决定是删除功能还是换用其他实例) +2. 清理相关的 import、类型定义、状态变量 +3. 如果该实例的结果被持久化到数据库字段,考虑是否需要清理字段或保留历史数据 + +### 执行删除 + +```bash +lark-cli apps +plugin-instance-delete --id --project-path --format json +``` + +删除是幂等的:文件不存在也返回 `deleted: true`,不报错。 + +### 确认清理 + +```bash +lark-cli apps +plugin-instance-get --id --project-path +# 应返回 "instance not found" + +grep -rn "${id}" /client/ /server/ /shared/ +``` + +--- + +## Get 链路 + +查询操作,无副作用。根据查什么路由到不同命令。 + +| 查什么 | 命令 | 示例 | +|--------|------|------| +| 已声明的插件包及安装状态 | `+plugin-list` | `lark-cli apps +plugin-list --project-path ` | +| 所有已建的实例(概览) | `+plugin-instance-list` | `lark-cli apps +plugin-instance-list --project-path ` | +| 所有已建的实例(仅 id+name) | `+plugin-instance-list --summary` | 同上加 `--summary` | +| 某个实例的完整配置 | `+plugin-instance-get --id ` | `lark-cli apps +plugin-instance-get --id --project-path ` | +| 插件的 actions / schema | 直接读 manifest | `cat /node_modules//manifest.json` | + +`+plugin-list` 返回示例: +```json +{ + "ok": true, + "data": { + "plugins": [ + {"key": "@official-plugins/ai-text-generate", "version": "1.0.0", "status": "installed"}, + {"key": "@official-plugins/ai-translate", "version": "1.0.0", "status": "declared_not_installed"} + ] + } +} +``` + +`declared_not_installed` → 需要 `+plugin-install` 安装。 + +**写代码前必做**:不要只靠 instance-get 的输出,还要读插件的 manifest 获取 actions 详情,再按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 生成调用代码。 + +--- + +## 校验失败重试 + +`+plugin-instance-create` 或 `+plugin-instance-update` 返回 `ok: false` 且 error.type 为 `validation` 时: + +``` +校验失败 → 解析 error.message 中的每条违规(以 "- " 开头的行) + → 逐条修正 formValue / paramsSchema + → 重新调用(create 加 --force / update 直接重调) + → 最多 3 次,3 次仍失败 → 上报用户 +``` + +### 常见违规及修正方式 + +| 违规信息 | 原因 | 修正 | +|---------|------|------| +| `forbidden Handlebars syntax at formValue.xxx: {{#if` | formValue 中使用了控制语法 | 改为纯 `{{input.xxx}}` 或自然语言描述 | +| `paramsSchema property "x" type "number" is invalid` | 参数类型不在 string/array 范围 | 改为 `"type": "string"` 或 `"type": "array"` | +| `paramsSchema property "x" is array but missing items` | array 类型缺少 items 定义 | 补上 `"items": {"type": "string"}` | +| `paramsSchema property "x" missing description` | 参数缺少描述 | 补上 `"description": "..."` | +| `{{input.xxx}} at formValue.yyy is not defined in paramsSchema` | formValue 引用了未定义的变量 | 在 paramsSchema.properties 中补充定义,或修正拼写 | +| `paramsSchema property "x" is never referenced` | 定义了变量但 formValue 中没有引用 | 在 formValue 中补充 `{{input.x}}`,或从 paramsSchema 移除 | + +### 修正要点 + +1. **不要直接编辑 capability JSON 文件** — 必须通过 CLI 命令重新提交 +2. **Create 重试用 `--force`** — 覆盖上一次失败写入的文件 +3. **Update 直接重新调用** — 会覆盖现有配置 +4. **保持 paramsSchema 和 formValue 的一致性** — 修一个通常要同步改另一个 +5. **3 次失败后不要继续猜** — 上报用户并附带完整错误,让用户决策 diff --git a/skills/lark-apps/references/lark-apps-plugin.md b/skills/lark-apps/references/lark-apps-plugin.md index 3c9df0bbe..aeb644de0 100644 --- a/skills/lark-apps/references/lark-apps-plugin.md +++ b/skills/lark-apps/references/lark-apps-plugin.md @@ -2,7 +2,7 @@ 妙搭应用的插件(Plugin)体系:插件包安装、插件实例 CRUD、调用代码生成。 -**触发关键词**:用户要实现 AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON 等能力时,或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例时加载本 Skill。 +**触发关键词**:用户要实现 AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要 等能力时,或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例时加载本 Skill。 ## 核心概念 @@ -45,28 +45,129 @@ 所有本地命令支持 `--project-path`、`--capabilities-dir`、`--format json`、`--dry-run`。 -## 意图路由 +--- + +## AI 插件目录(17 个) + +### 插件能力速查 + +#### 文本类 -根据用户意图选择对应链路,**必须读取对应的 flow 文件后再执行**: +| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | +|---------|------|---------|---------|---------| +| `ai-text-generate` | 文本生成 | stream | 流式文本 `content` | 文案、报告、对话、问答 | +| `ai-text-summary` | 文本摘要 | stream | 流式文本 `summary` | 长文本摘要、要点提取 | +| `ai-translate` | 多语言翻译 | stream | 流式文本 `translation` | 中英日韩等多语言互译 | +| `ai-categorization` | 文本分类 | unary | `{categories: string[]}` | 打标签、情感分析、内容分类 | +| `ai-text-to-json` | 文本→结构化 JSON | unary | `{字段名: 值}` | 信息提取、表单自动填充(最多 20 字段) | +| `ai-search-summary` | 搜索摘要 | stream | 流式文本 `content` | 联网搜索 + 摘要生成 | -| 用户意图 | 路由到 | 必读 | -|---------|--------|------| -| 新增插件能力("加个 AI 翻译""接入文本生成") | **Create 链路** | [`plugin-create-instance-flow.md`](plugin-create-instance-flow.md) | -| 修改已有实例配置("改一下 prompt""换个模型") | **Update 链路** | [`plugin-update-instance-flow.md`](plugin-update-instance-flow.md) | -| 删除实例("去掉这个能力""不需要了") | **Delete 链路** | [`plugin-delete-instance-flow.md`](plugin-delete-instance-flow.md) | -| 查看实例详情 / 列出已有实例 / 查已装插件 | **Get 链路** | [`plugin-get-instance-flow.md`](plugin-get-instance-flow.md) | -| 写插件调用代码(Create/Update 完成后的下一步) | 读 call 指南 | [`plugin-instance-call.md`](plugin-instance-call.md) | +#### 图片类 -## 本期支持的插件(17 个) +| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | +|---------|------|---------|---------|---------| +| `ai-text-to-image` | 文生图 | unary | `{images: string[]}` | 根据文本描述生成图片 | +| `ai-image-to-image` | 图生图 | unary | `{images: string[]}` | 图片编辑、风格转换 | +| `ai-image-understanding` | 图片理解 | stream | 流式文本 `content` | 图片描述、问答、OCR | +| `ai-image-to-json` | 图片→结构化 JSON | unary | `{字段名: 值}` | 图片信息提取(单步直达) | +| `ai-image-compare` | 图片对比 | stream | 流式文本 `content` | 两张图片差异分析 | +| `ai-image-matting` | 抠图 | unary | `{images: string[]}` | 去背景、主体提取 | +| `ai-background-replace` | 换背景 | unary | `{images: string[]}` | 替换图片背景 | -ai-text-generate / ai-text-summary / ai-text-to-json / ai-translate / ai-search-summary / ai-text-to-image / ai-background-replace / ai-image-compare / ai-image-matting / ai-image-to-image / ai-image-to-json / ai-image-understanding / ai-speech-synthesis / ai-speech-to-text / ai-categorization / ai-doc-parser / web-crawler +#### 文档/语音/其他 + +| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | +|---------|------|---------|---------|---------| +| `ai-doc-parser` | 文档解析 | unary | **纯文本 string** | PDF/Word/Excel 文本提取 | +| `ai-speech-to-text` | 语音识别 | unary | **纯文本 string** | 音频转文字 | +| `ai-speech-synthesis` | 语音合成 | unary | 音频 URL string | 文字转语音 | +| `web-crawler` | 网页抓取 | unary | 网页内容 string | 抓取指定 URL 的页面内容 | + +> 所有插件 key 使用时需加 `@official-plugins/` 前缀,如 `@official-plugins/ai-text-generate@1.0.0`。 **不支持**(需用户通过 GUI 手动配置):飞书发消息、飞书创建群组、飞书多维表格、飞书审批、飞书 aPaaS。 +### 用户意图 → 插件选择 + +当用户表达需求但没指定插件时,按此表选择: + +| 用户表述 | 对应插件 | 类型 | +|---------|---------|------| +| "AI 写文案 / 生成文本 / 帮我写" | `ai-text-generate` | 流式生成 | +| "总结 / 摘要 / 提取要点" | `ai-text-summary` | 流式生成 | +| "翻译成XX / 多语言" | `ai-translate` | 流式生成 | +| "分类 / 打标签 / 情感分析" | `ai-categorization` | 结构化 | +| "从文本提取字段 / 文本转结构化" | `ai-text-to-json` | 结构化 | +| "搜索并总结 / 联网查询" | `ai-search-summary` | 流式生成 | +| "AI 生图 / 文生图 / 生成图片" | `ai-text-to-image` | 图片 | +| "图片编辑 / 风格转换 / 图生图" | `ai-image-to-image` | 图片 | +| "识别图片 / 图片问答 / 看图说话" | `ai-image-understanding` | 流式生成 | +| "从图片提取信息 / 图片转结构化" | `ai-image-to-json` | 结构化 | +| "对比两张图 / 图片差异" | `ai-image-compare` | 流式生成 | +| "抠图 / 去背景" | `ai-image-matting` | 图片 | +| "换背景 / 替换背景" | `ai-background-replace` | 图片 | +| "解析文档 / 读 PDF / 读 Word" | `ai-doc-parser` | 文本提取 | +| "语音合成 / 文字转语音 / 朗读" | `ai-speech-synthesis` | 音频 | +| "语音识别 / 音频转文字" | `ai-speech-to-text` | 文本提取 | +| "抓取网页 / 爬取页面" | `web-crawler` | 文本提取 | + +### 设计原则 + +#### 原子化 + +**一个插件实例只做一件事**。不同输出类型、不同业务语义必须创建独立的插件实例。 + +``` +✅ 正确:需要生成标题 + 生成正文 + → 创建两个 ai-text-generate 实例:title-generator、content-generator + → 各自有独立的 prompt 和 paramsSchema + +❌ 错误:把标题和正文塞进同一个实例的 prompt + → 输出混在一起,无法分别渲染 +``` + +同一个官方插件可以创建多个实例,每个实例服务不同的业务场景。 + +#### 链式调用 + +部分插件输出是纯文本,不能直接产出结构化数据。需要链式组合时: + +``` +文档 → 结构化:ai-doc-parser → ai-text-to-json(两步) +图片 → 结构化:ai-image-to-json(单步直达,优先用这个) +语音 → 结构化:ai-speech-to-text → ai-text-to-json(两步) +``` + +| 上游插件 | 上游输出 | 需要结构化时 | 下游插件 | +|---------|---------|------------|---------| +| `ai-doc-parser` | 纯文本 | 必须接下游 | `ai-text-to-json` | +| `ai-speech-to-text` | 纯文本 | 必须接下游 | `ai-text-to-json` | +| `ai-image-understanding` | 流式文本 | 优先用 `ai-image-to-json` 单步完成 | `ai-text-to-json` | + +代码中的链式传递:上游插件输出在代码中作为下游插件实例的入参传入,每个实例的 paramsSchema 是独立的接口契约。 + +#### 流式标注 + +使用 stream 输出模式的插件,功能设计中需注明涉及流式渲染,代码中使用 `callStream` + `normalizeStream`。 + +--- + +## 意图路由 + +根据用户意图选择对应操作,**必须读取对应文件后再执行**: + +| 用户意图 | 必读 | +|---------|------| +| 新增插件能力("加个 AI 翻译""接入文本生成") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Create | +| 修改已有实例配置("改一下 prompt""换个模型") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Update | +| 删除实例("去掉这个能力""不需要了") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Delete | +| 查看实例详情 / 列出已有实例 / 查已装插件 | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Get | +| 写插件调用代码(Create/Update 完成后的下一步) | [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) | + ## 铁律 1. **只能通过 CLI 命令修改 capability JSON 文件** — 禁止 Agent 直接用文件编辑工具写 `capabilities/*.json`,必须通过 `+plugin-instance-create` / `+plugin-instance-update` / `+plugin-instance-delete` 操作,确保校验和格式一致性。 2. **先装包再建实例** — `+plugin-instance-create` 前必须确保插件包已安装(`+plugin-install`),否则校验会因读不到 manifest 而失败。 -3. **校验失败走重试协议** — Create / Update 返回校验错误时,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理:解析 hint → 修正 → 重试(max 3 次)。 -4. **写代码前读源码** — Create 完成后,Agent 应读取 `node_modules/{pluginKey}/manifest.json` 和 `capabilities/{id}.json` 理解插件能力,再按 [`plugin-instance-call.md`](plugin-instance-call.md) 生成调用代码。禁止凭记忆猜测 actionKey / inputSchema / outputMode。 +3. **校验失败走重试协议** — Create / Update 返回校验错误时,按 [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § 校验失败重试 处理:解析 hint → 修正 → 重试(max 3 次)。 +4. **写代码前读源码** — Create 完成后,Agent 应读取 `node_modules/{pluginKey}/manifest.json` 和 `capabilities/{id}.json` 理解插件能力,再按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 生成调用代码。禁止凭记忆猜测 actionKey / inputSchema / outputMode。 5. **不要在 formValue 中使用 Handlebars 控制语法** — 仅允许 `{{input.xxx}}`,严禁 `{{#if}}` / `{{#each}}` / `{{else}}` 等。 diff --git a/skills/lark-apps/references/plugin-create-instance-flow.md b/skills/lark-apps/references/plugin-create-instance-flow.md deleted file mode 100644 index 50893f889..000000000 --- a/skills/lark-apps/references/plugin-create-instance-flow.md +++ /dev/null @@ -1,103 +0,0 @@ -# Create 链路 — 新增插件实例 - -从用户需求到插件可调用的完整流程。本链路最严格,每一步都有前置门禁。 - -## 流程 - -``` -Step 1: +plugin-install --name -Step 2: 读 plugin-instance-schema.md + 设计 paramsSchema / formValue -Step 3: +plugin-instance-create --plugin --name --form-value @file [--params-schema @file] -Step 4: 校验通过? → 否:走 plugin-retry-protocol.md(max 3 次) -Step 5: 读 node_modules/{pluginKey}/manifest.json + capabilities/{id}.json -Step 6: 读 plugin-instance-call.md → 生成调用代码 -``` - -## Step 1 — 安装插件包 - -```bash -lark-cli apps +plugin-install --name @official-plugins/ai-text-generate@1.0.0 --project-path -``` - -- 鉴权:需要 user token(先 `lark-cli auth login`) -- 已安装同版本会跳过(status=already_installed) -- 失败时 hint 会指示原因(网络/版本不存在/package.json 缺失) - -## Step 2 — 设计 paramsSchema 和 formValue - -**必读**:[`plugin-instance-schema.md`](plugin-instance-schema.md) — 变量映射规则、参数类型约束、formValue 生成规则。 - -设计前必须先读插件的 form.schema: -```bash -cat /node_modules//manifest.json -``` - -根据 form.schema 的字段和用户业务意图,设计: -1. **paramsSchema** — 对外暴露的业务入参(变量定义) -2. **formValue** — 将变量映射到 form.schema 字段(变量消费) -3. **语义化 ID** — 如 `task-text-summary`,小写+短横线,描述业务用途 - -## Step 3 — 创建实例 - -```bash -lark-cli apps +plugin-instance-create \ - --id task-text-summary \ - --plugin @official-plugins/ai-text-generate@1.0.0 \ - --name "任务摘要生成" \ - --description "根据任务详情生成摘要" \ - --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ - --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ - --project-path \ - --format json -``` - -大 JSON 场景用 `@file` 传入:先写临时文件,再 `--form-value @form.json --params-schema @schema.json`。 - -### 前置检查(CLI 自动执行) - -| 检查项 | 失败时 hint | -|--------|-----------| -| package.json 存在 | `run 'lark-cli apps +init'` | -| capabilities 路径可解析 | `use --capabilities-dir or check .env.local` | -| 插件包已安装 | `run '+plugin-install ...' first` | -| 版本匹配 | warning(非 error):`installed X differs from Y` | -| ID 唯一 | `use --force to overwrite, or choose a different --id` | -| formValue 校验(5 规则) | 逐条列出违规项 | - -## Step 4 — 校验失败处理 - -CLI 返回 `ok: false` + `error.hint` 逐条列出问题时,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理: - -1. 解析 hint 中的每条违规 -2. 修正 formValue / paramsSchema -3. 重新调用 `+plugin-instance-create`(或 `--force` 覆盖) -4. 最多重试 3 次,3 次仍失败则上报用户 - -## Step 5 — 读取插件源码 - -创建成功后,读取以下文件获取完整信息: - -```bash -# 插件 manifest(actions / inputSchema / outputSchema / outputMode) -cat /node_modules//manifest.json - -# 创建的实例配置(paramsSchema / formValue) -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -## Step 6 — 生成调用代码 - -**必读**:[`plugin-instance-call.md`](plugin-instance-call.md) — Client/Server 决策、outputMode 处理、normalizeStream。 - -根据 manifest 中的 `actions[].outputMode` 选择调用方式: -- `unary` → `capabilityClient.load(id).call(actionKey, input)` -- `stream` → `capabilityClient.load(id).callStream(actionKey, input)` + normalizeStream - -## Red Flags - -| 念头 | 反驳 | -|------|------| -| "我记得这个插件的 schema,不用读 manifest" | manifest 可能更新过,必须每次读 | -| "create 完直接写代码" | 没读 manifest 就写代码 = 猜 actionKey/params | -| "install 之前先 create" | 没装包 manifest 读不到,校验会失败 | -| "formValue 校验报错,我直接编辑 JSON 文件" | 铁律:只能通过 CLI 命令修改 capability JSON | diff --git a/skills/lark-apps/references/plugin-delete-instance-flow.md b/skills/lark-apps/references/plugin-delete-instance-flow.md deleted file mode 100644 index 28f33be4f..000000000 --- a/skills/lark-apps/references/plugin-delete-instance-flow.md +++ /dev/null @@ -1,57 +0,0 @@ -# Delete 链路 — 删除插件实例 - -删除实例前必须先清理代码引用,避免运行时报错。 - -## 流程 - -``` -Step 1: +plugin-instance-get --id → 确认实例存在 -Step 2: 扫描代码引用 -Step 3: 有引用? - ├── 有 → 读 plugin-instance-call.md → 清理调用代码 - └── 无 → 直接删除 -Step 4: +plugin-instance-delete --id -Step 5: 确认清理完成 -``` - -## Step 1 — 确认实例存在 - -```bash -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -实例不存在 → 无需删除,直接告知用户。 - -## Step 2 — 扫描代码引用 - -```bash -grep -rn "load('${id}')\|load(\"${id}\")" /client/ /server/ /shared/ -``` - -查找所有使用 `capabilityClient.load('')` 或 `capabilityService.load('')` 的位置。 - -## Step 3 — 清理代码引用 - -如果有引用: -1. 移除或替换调用代码(视业务逻辑决定是删除功能还是换用其他实例) -2. 清理相关的 import、类型定义、状态变量 -3. 如果该实例的结果被持久化到数据库字段,考虑是否需要清理字段或保留历史数据 - -## Step 4 — 删除实例 - -```bash -lark-cli apps +plugin-instance-delete --id --project-path --format json -``` - -删除是幂等的:文件不存在也返回 `deleted: true`,不报错。 - -## Step 5 — 确认清理 - -```bash -# 确认文件已删除 -lark-cli apps +plugin-instance-get --id --project-path -# 应返回 "instance not found" - -# 确认代码无残留引用 -grep -rn "${id}" /client/ /server/ /shared/ -``` diff --git a/skills/lark-apps/references/plugin-get-instance-flow.md b/skills/lark-apps/references/plugin-get-instance-flow.md deleted file mode 100644 index d3de5fadf..000000000 --- a/skills/lark-apps/references/plugin-get-instance-flow.md +++ /dev/null @@ -1,67 +0,0 @@ -# Get 链路 — 查询插件信息 - -查询操作,无副作用。根据查什么路由到不同命令。 - -## 路由 - -| 查什么 | 命令 | 示例 | -|--------|------|------| -| 已声明的插件包及安装状态 | `+plugin-list` | `lark-cli apps +plugin-list --project-path ` | -| 所有已建的实例(概览) | `+plugin-instance-list` | `lark-cli apps +plugin-instance-list --project-path ` | -| 所有已建的实例(仅 id+name) | `+plugin-instance-list --summary` | 同上加 `--summary` | -| 某个实例的完整配置 | `+plugin-instance-get --id ` | `lark-cli apps +plugin-instance-get --id --project-path ` | -| 插件的 actions / schema | 直接读 manifest | `cat /node_modules//manifest.json` | - -## +plugin-list - -列出 package.json `actionPlugins` 中声明的插件包,交叉检查 node_modules 报告安装状态。 - -```bash -lark-cli apps +plugin-list --project-path --format json -``` - -返回: -```json -{ - "ok": true, - "data": { - "plugins": [ - {"key": "@official-plugins/ai-text-generate", "version": "1.0.0", "status": "installed"}, - {"key": "@official-plugins/ai-translate", "version": "1.0.0", "status": "declared_not_installed"} - ] - } -} -``` - -`declared_not_installed` → 需要 `+plugin-install` 安装。 - -## +plugin-instance-list - -扫描 capabilities 目录下所有 `*.json` 文件。 - -```bash -lark-cli apps +plugin-instance-list --project-path --format json -``` - -capabilities 目录不存在时返回空列表(不报错)。`--summary` 只返回 id 和 name。 - -## +plugin-instance-get - -读取单个实例的完整配置(id、pluginKey、pluginVersion、name、description、paramsSchema、formValue、createdAt、updatedAt)。 - -```bash -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -实例不存在 → 返回错误 + hint `list instances with '+plugin-instance-list'`。 - -## 读取插件源码(写代码前必做) - -Agent 需要写调用代码时,不要只靠 instance-get 的输出,还要读插件的 manifest 获取 actions 详情: - -```bash -# manifest 包含 actions[].key / inputSchema / outputSchema / outputMode -cat /node_modules//manifest.json -``` - -然后按 [`plugin-instance-call.md`](plugin-instance-call.md) 生成调用代码。 diff --git a/skills/lark-apps/references/plugin-instance-schema.md b/skills/lark-apps/references/plugin-instance-schema.md deleted file mode 100644 index 38f1f6771..000000000 --- a/skills/lark-apps/references/plugin-instance-schema.md +++ /dev/null @@ -1,167 +0,0 @@ -# 插件实例 Schema 规则 - -生成 paramsSchema 和 formValue 前必读本文件。 - -## 变量三层映射 - -``` -调用方传值 paramsSchema 定义变量 formValue 消费变量 Plugin form.schema 接收 -(resume_text="...") → (定义: resume_text) → ("prompt": "...{{input.resume_text}}...") → (prompt 字段) -(article="...") → (定义: article) → ("content": "{{input.article}}") → (content 字段) -``` - -**关键区分**: -- formValue 的 **key** = Plugin form.schema 的字段名(如 `prompt`、`content`、`fileUrl`) -- formValue 的 **value** 中通过 `{{input.xxx}}` 引用 paramsSchema 定义的变量 -- 变量名(paramsSchema)与 form 字段名(form.schema)分属不同层,通常名称不同 - -## paramsSchema 生成规则 - -### 支持的参数类型(仅 4 种) - -**文本**: -```json -{ "type": "string", "description": "文本参数描述" } -``` - -**数组**: -```json -{ "type": "array", "description": "描述", "items": { "type": "string", "description": "元素描述" } } -``` - -**图片**: -```json -{ "type": "array", "format": "plugin-image-url", "description": "描述", "items": { "type": "string" } } -``` - -**文件**: -```json -{ "type": "array", "format": "plugin-file-url", "description": "描述", "items": { "type": "string" } } -``` - -### 约束 - -- 只允许 string 和 array 两种 type(图片/文件是 array + format) -- 每个参数**必须**有 type 和 description -- array 类型**必须**有 items 字段 -- format 只允许 `plugin-image-url` 或 `plugin-file-url` -- 参考 form.schema 字段的 type 进行定义,保持类型一致(不能给图片/文件类型定义为 string) -- 若 form.schema 字段描述写"不允许使用参数",则不生成对应 paramsSchema -- 参数设计应体现"收敛输入,扩展能力":用语义明确的参数名(如 `keywords`、`article_text`),避免过于开放的参数(如直接暴露 `prompt`) - -## formValue 生成规则 - -- **key 必须**对应 form.schema 中定义的字段 -- **value** 可以是常量,或 `{{input.xxx}}` 引用 paramsSchema 参数 -- **类型一致性**: - - form.schema type=string → `"字段名": "{{input.param}}"` 或常量字符串 - - form.schema type=array + paramsSchema type=array → **透传**:`"字段名": "{{input.param}}"`(禁止再包数组) - - form.schema type=array + paramsSchema type=string → **包装**:`"字段名": ["{{input.param}}"]` -- **禁止双层包装**:paramsSchema 已经是 array 时,`["{{input.param}}"]` 会导致运行时 `[url]` → `[[url]]` -- 无法明确赋值的字段留空字符串 `""`,不要硬编码 -- **业务枚举参数的动静态判断**: - - 用户指定单一固定值(如"翻译成英文")→ formValue 直接填常量 - - 用户列举多个值或暗示可选(如"翻译成中英日韩")→ 必须生成 paramsSchema 参数 -- 若 form.schema 字段描述写"固定填默认值 xxx"→ 直接填固定值,不引用参数 - -## 插件字段映射表 - -不同插件的"内容入口"字段各不相同,必须先看 manifest 的 form.schema: - -| 插件 | 内容入口字段 | 映射方式 | -|------|------------|---------| -| ai-text-generate | `prompt` | 用户输入嵌入 prompt 字符串 | -| ai-text-to-json | `prompt` | 文本嵌入 prompt,无独立 text 字段 | -| ai-text-summary | `content` | 直接赋值 `"content": "{{input.xxx}}"` | -| ai-translate | `content` | 直接赋值 | -| ai-categorization | `textToBeCategorized` | 直接赋值 | -| ai-speech-synthesis | `text` | 直接赋值 | -| ai-image-understanding | `prompt` + `images` | 图片传 images,指令嵌入 prompt | -| ai-doc-parser | `fileUrl` | array 类型透传 `"fileUrl": "{{input.xxx}}"` | - -## AI Prompt 编写规则 - -当插件涉及 AI 能力时,formValue 的 prompt 字段**应包含完整的高质量提示词**,而非简单透传。 - -### 禁止的做法 - -```json -// ❌ 直接透传,无任何预设指令 -"prompt": "{{input.prompt}}" -// ❌ 过于简单 -"prompt": "根据关键词生成文案:{{input.keywords}}" -// ❌ 文生图/图生图一次调用仅支持一张图 -"prompt": "请根据以下要求,生成3张配图" -``` - -### Prompt 编写要素 - -1. **角色设定**:明确 AI 扮演的角色或专业背景 -2. **任务描述**:清晰说明要完成的具体任务 -3. **输入说明**:标明用户输入将被插入的位置及其含义 -4. **输出要求**:明确输出的格式、结构、长度等 -5. **风格约束**:指定语气、风格、受众等 -6. **质量标准**:设定内容质量的具体标准 - -### 各场景 Prompt 模板参考 - -#### 文本生成类 -```json -"prompt": "你是一位资深的[平台名]内容创作专家,擅长撰写高互动率的内容。\n\n请根据以下关键词生成一篇文案:\n关键词:{{input.keywords}}\n\n内容要求:\n1. 标题(15-25字):使用数字、疑问句或悬念式开头,包含1-2个emoji\n2. 正文(300-500字):口语化表达,分3-5段,适当使用emoji\n3. 结尾:设置互动问题,包含3-5个话题标签" -``` - -#### 图片理解类 -```json -"prompt": "你是一位专业的图像分析专家。\n\n请对提供的图片进行深度分析:\n1. 基础信息:图片类型、主体内容、场景环境\n2. 细节描述:颜色、构图、关键元素、文字信息\n3. 语义理解:图片传达的含义、情感、潜在用途\n\n{{input.additional_requirements}}\n\n输出要求:描述准确客观,不确定的内容明确标注" -``` - -#### 文生图类 -```json -"prompt": "请生成一张高质量图片:\n\n主题内容:{{input.subject}}\n\n画面要求:\n1. 风格:[根据需求填写]\n2. 构图:[居中/三分法/对称等]\n3. 光线:[自然光/柔和光等]\n4. 色调:[明亮温暖/冷色调等]\n\n质量要求:画面清晰、主体突出、色彩和谐、无变形失真" -``` - -#### 数据提取/分析类 -```json -"prompt": "你是一位数据分析专家,擅长从非结构化内容中提取关键信息。\n\n请从以下内容中提取信息:\n{{input.content}}\n\n提取要求:\n1. 识别关键实体(人名、地点、组织、日期、金额等)\n2. 提取核心事实和数据点\n3. 归纳主要观点或结论" -``` - -#### AI 对话/问答类 -```json -"prompt": "你是一位[专业领域]专家。请以专业、友好的态度回答用户问题。\n\n用户问题:{{input.question}}\n\n回答要求:准确、完整、通俗易懂、结构化、提供可操作建议" -``` - -### 动态参数与预设内容结合 - -- **用户输入作为"素材"**:prompt 主体应是详细的任务指令,`{{input.xxx}}` 作为素材嵌入 -- **避免空泛透传**:即使需要用户自定义 prompt,也应提供默认模板 -- **预留扩展性**:可设可选参数(如 `{{input.additional_requirements}}`)供补充需求 - -## 模板语法限制 - -- **仅允许** `{{input.参数名}}` 一种语法 -- **严禁** `{{#if}}`、`{{#each}}`、`{{#unless}}`、`{{/if}}`、`{{/each}}`、`{{else}}` -- 需要条件逻辑时用自然语言表述 - -## 一致性铁律 - -1. **定义的变量必须被引用** — paramsSchema 中定义了 `xxx`,formValue 中至少有一处 `{{input.xxx}}` -2. **引用的变量必须被定义** — formValue 中出现 `{{input.xxx}}`,paramsSchema.properties 中必须有 `xxx` -3. **paramsSchema 允许为空** — 当 formValue 所有字段都是常量时可以是 `{}` -4. **paramsSchema/formValue 不一致 → 后端 actions 为空 → 插件无法调用** — 这是常见致命错误 - -## ID 生成规则 - -1. 基于插件实例的名称和描述,设计有业务语义的 ID -2. 格式:小写字母 + 数字 + 短横线(如 `task-text-summary`) -3. 长度不超过 128 字符 -4. 必须在当前项目内唯一 -5. 与已存在的 ID 冲突时重新生成 -6. 未命名时用 `unnamed-plugin-N` - -### 示例 - -``` -名称"数据分析",描述"分析数据发现趋势",已存在["data-analysis-1"] → data-analysis-trend-2 -名称"智能图片理解",描述"(测试)" → test-image-understanding-1 -名称"未命名插件" → unnamed-plugin-1 -``` diff --git a/skills/lark-apps/references/plugin-retry-protocol.md b/skills/lark-apps/references/plugin-retry-protocol.md deleted file mode 100644 index 569d822f4..000000000 --- a/skills/lark-apps/references/plugin-retry-protocol.md +++ /dev/null @@ -1,54 +0,0 @@ -# 插件实例校验失败重试协议 - -`+plugin-instance-create` 或 `+plugin-instance-update` 返回 `ok: false` 且 error.type 为 `validation` 时,按本协议处理。 - -## 触发条件 - -CLI 返回格式: -```json -{ - "ok": false, - "error": { - "type": "validation", - "subtype": "invalid_argument", - "message": "formValue validation failed:\n- ...\n- ...", - "hint": "fix the issues above and retry" - } -} -``` - -## 重试流程 - -``` -校验失败 - ↓ -Step 1: 解析 error.message 中的每条违规(以 "- " 开头的行) - ↓ -Step 2: 逐条修正 formValue / paramsSchema - ↓ -Step 3: 重新调用 +plugin-instance-create(加 --force)或 +plugin-instance-update - ↓ -校验通过? - ├── 是 → 继续后续流程 - └── 否 → 回到 Step 1(累计 ≤ 3 次) - └── 3 次仍失败 → 上报用户,附带最后一次的完整错误信息 -``` - -## 常见违规及修正方式 - -| 违规信息 | 原因 | 修正 | -|---------|------|------| -| `forbidden Handlebars syntax at formValue.xxx: {{#if` | formValue 中使用了控制语法 | 改为纯 `{{input.xxx}}` 或自然语言描述 | -| `paramsSchema property "x" type "number" is invalid` | 参数类型不在 string/array 范围 | 改为 `"type": "string"` 或 `"type": "array"` | -| `paramsSchema property "x" is array but missing items` | array 类型缺少 items 定义 | 补上 `"items": {"type": "string"}` | -| `paramsSchema property "x" missing description` | 参数缺少描述 | 补上 `"description": "..."` | -| `{{input.xxx}} at formValue.yyy is not defined in paramsSchema` | formValue 引用了未定义的变量 | 在 paramsSchema.properties 中补充定义,或修正拼写 | -| `paramsSchema property "x" is never referenced` | 定义了变量但 formValue 中没有引用 | 在 formValue 中补充 `{{input.x}}`,或从 paramsSchema 移除 | - -## 修正要点 - -1. **不要直接编辑 capability JSON 文件** — 必须通过 CLI 命令重新提交 -2. **Create 重试用 `--force`** — 覆盖上一次失败写入的文件 -3. **Update 直接重新调用** — 会覆盖现有配置 -4. **保持 paramsSchema 和 formValue 的一致性** — 修一个通常要同步改另一个 -5. **3 次失败后不要继续猜** — 上报用户并附带完整错误,让用户决策 diff --git a/skills/lark-apps/references/plugin-update-instance-flow.md b/skills/lark-apps/references/plugin-update-instance-flow.md deleted file mode 100644 index 3c11d2bee..000000000 --- a/skills/lark-apps/references/plugin-update-instance-flow.md +++ /dev/null @@ -1,68 +0,0 @@ -# Update 链路 — 修改插件实例 - -修改已有实例的 name、formValue、paramsSchema。关键点:改 schema 可能影响已有调用代码。 - -## 流程 - -``` -Step 1: +plugin-instance-get --id → 查看现状 -Step 2: 读 plugin-instance-schema.md + 设计修改方案 -Step 3: +plugin-instance-update --id --form-value @file [--params-schema @file] [--name ] -Step 4: 校验通过? → 否:走 plugin-retry-protocol.md(max 3 次) -Step 5: paramsSchema 变化? - ├── 不变 → 完成 - └── 变化 → 扫描代码引用 → 读 plugin-instance-call.md → 改代码 -``` - -## Step 1 — 查看现状 - -```bash -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -确认当前的 pluginKey、paramsSchema、formValue,理解要改什么。 - -## Step 2 — 设计修改方案 - -**必读**:[`plugin-instance-schema.md`](plugin-instance-schema.md) - -同时读取插件 manifest 确认 form.schema 约束: -```bash -cat /node_modules//manifest.json -``` - -## Step 3 — 执行更新 - -```bash -# 只改名 -lark-cli apps +plugin-instance-update --id --name "新名称" --project-path - -# 改 formValue -lark-cli apps +plugin-instance-update --id --form-value '{"prompt":"新 prompt {{input.text}}"}' --project-path - -# 同时改 formValue + paramsSchema -lark-cli apps +plugin-instance-update --id \ - --form-value @form.json --params-schema @schema.json --project-path -``` - -CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt),只更新你传入的字段 + updatedAt。 - -## Step 4 — 校验失败处理 - -同 Create 链路,按 [`plugin-retry-protocol.md`](plugin-retry-protocol.md) 处理。 - -## Step 5 — paramsSchema 变化时更新代码 - -判断 paramsSchema 是否变化(加/改/删了 properties): - -**加/改字段** → 调用方需要传新参数: -1. 读 manifest + 更新后的 capability JSON -2. `grep -rn "load('${id}')" /` 找到代码引用 -3. 按 [`plugin-instance-call.md`](plugin-instance-call.md) 更新调用代码中的 input 参数 - -**删字段** → 调用方不再需要传该参数: -1. 同上找到引用 -2. 移除已删除参数的传入 -3. 检查是否有上游依赖该参数的逻辑需要清理 - -**未变化** → 无需改代码,直接完成。 From 999ac4e7d6e130d35bb43c55b9343aef3d2e54a2 Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 11:46:13 +0800 Subject: [PATCH 09/40] refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill Remove all code pattern content (capabilityClient imports, normalizeStream, NestJS injection, streaming examples, chunk field table) from lark-apps-plugin-call.md. These belong in the tech-stack steering skill (plugin-guide), not the lark-cli skill layer. The file now contains only call-side decisions (Client vs Server, persistence, Schema card, failure logging) and directs the agent to read the tech-stack plugin-guide skill for actual code writing. --- .../references/lark-apps-plugin-call.md | 201 ++---------------- 1 file changed, 14 insertions(+), 187 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md index c6e444370..2287f8b62 100644 --- a/skills/lark-apps/references/lark-apps-plugin-call.md +++ b/skills/lark-apps/references/lark-apps-plugin-call.md @@ -1,14 +1,8 @@ -# 插件实例调用代码编写指南 +# 插件实例调用指南 -创建/更新插件实例后,根据本文件生成调用代码。 +创建/更新插件实例后,根据本文件做调用决策,再读技术栈 Skill 写代码。 -本文件分两部分:**调用决策**(选择调用侧、是否持久化)和**代码模式**(具体写法)。 - ---- - -## 第一部分:调用决策 - -### 调用前获取权威依据 +## 调用前获取权威依据 **必须**先读取以下文件获取 actions 信息: @@ -75,185 +69,18 @@ output.fields: [...] --- -## 第二部分:代码模式 - -> 以下内容与妙搭技术栈(`@lark-apaas/client-toolkit`、NestJS)绑定。如仓库本地有技术栈 Skill(如 `.agent/skills/plugin-coding-guide/SKILL.md`),优先读仓库本地版本。 - -### call / callStream 函数签名 - -```typescript -.call(actionKey: string, input: object) // 非流式,返回 Promise -.callStream(actionKey: string, input: object) // 流式,返回 AsyncIterable -``` +## 生成调用代码 -```typescript -// ❌ 错误:把参数 JSON.stringify 后当 actionKey -plugin.call(JSON.stringify({ text: '...' })); -// ❌ 错误:漏掉 actionKey,直接传参数 -plugin.call({ text: '...' }); -// ✅ 正确:第一个参数是 actionKey 字符串,第二个是 input 对象 -plugin.call('textGenerate', { text: '...' }); -``` - -**唯一导入方式**(严禁其他路径): -```typescript -// ❌ 严禁 -import { capabilityClient } from '@lark-apaas/client-capability'; -// ✅ 唯一指定 -import { capabilityClient } from '@lark-apaas/client-toolkit'; -``` +完成上述决策后,**读取项目的技术栈 Skill** 获取具体代码模式(import 路径、call/callStream 写法、normalizeStream、NestJS 注入等)。 -### Client 侧调用 +技术栈 Skill 按项目类型不同: +- **Design / Modern 应用**(纯前端)→ 读 `plugin-guide` Skill(仅 `capabilityClient`) +- **全栈应用**(NestJS + React)→ 读 `plugin-guide` Skill(`capabilityClient` + `CapabilityService`) -#### 非流式(outputMode = "unary") +技术栈 Skill 的位置取决于运行环境: +- **妙搭平台 Agent**:自动加载 steering skill(`steering-topic: plugin_guide`) +- **本地 Code Agent**:读仓库内 `.agent/skills/plugin-guide/SKILL.md`(如存在) -```typescript -import { capabilityClient } from '@lark-apaas/client-toolkit'; -import { logger } from "@lark-apaas/client-toolkit/logger"; - -const result = await capabilityClient - .load('task_text_summary') - .call('textGenerate', { task_content: '...' }); - -logger.info(result); -``` - -#### 流式(outputMode = "stream") - -```typescript -const streamResult = capabilityClient - .load('task_text_summary') - .callStream('textGenerate', { task_content: '...' }); - -const stream = normalizeStream(streamResult); -let fullContent = ''; -for await (const chunk of stream) { - const delta = readFirstStringField(chunk as Record, ['content']); - if (delta) { - fullContent += delta; - setContent(fullContent); - } -} -``` - -#### normalizeStream(必须) - -`callStream()` 可能返回 `AsyncIterable` 或 `{ output: AsyncIterable }`,必须归一化: - -```typescript -type AnyRecord = Record; - -function isAsyncIterable(value: unknown): value is AsyncIterable { - return !!value && typeof (value as AnyRecord)[Symbol.asyncIterator] === 'function'; -} - -function normalizeStream(resultOrStream: unknown): AsyncIterable { - if (isAsyncIterable(resultOrStream)) return resultOrStream; - if ( - resultOrStream && typeof resultOrStream === 'object' && - 'output' in (resultOrStream as AnyRecord) && - isAsyncIterable((resultOrStream as AnyRecord).output) - ) { - return (resultOrStream as AnyRecord).output as AsyncIterable; - } - throw new Error('Invalid callStream result: cannot find AsyncIterable stream'); -} - -function readFirstStringField(chunk: AnyRecord, keys: string[]): string { - for (const key of keys) { - const value = chunk[key]; - if (typeof value === 'string') return value; - } - return ''; -} -``` - -#### 流式 chunk 字段速查 - -chunk 是扁平对象,字段名与 outputSchema 一致。**禁止** `chunk.data?.text`、`chunk.choices[0]` 等非 capabilityClient 格式。 - -| 插件 | chunk 字段 | 正确写法 | 错误写法 | -|------|-----------|---------|---------| -| ai-text-generate | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | -| ai-translate | `translation` | `chunk.translation` | ~~`chunk.content`~~ | -| ai-text-summary | `summary` | `chunk.summary` | ~~`chunk.content`~~ | -| ai-image-understanding | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | -| ai-image-compare | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | -| ai-search-summary | `content` | `chunk.content` | ~~`chunk.data?.text`~~ | - -> 同一应用多页面调用同一插件时,所有页面必须使用一致的 chunk 字段名。 - -#### 多插件并行流式(推荐) - -需求涉及多种独立输出(标题、正文、图片等)时,拆分为多个插件并行调用: - -```tsx -const handleGenerate = async (keywords: string) => { - // 封面图(非流式,异步不阻塞) - capabilityClient.load('cover_generator') - .call<{ images: string[] }>('textToImage', { keywords }) - .then(res => res?.images?.[0] && setCoverUrl(res.images[0])) - .catch(err => logger.warn('封面生成失败', err)); - - // 标题(非流式) - const titleResult = await capabilityClient - .load('title_generator') - .call<{ content: string }>('textGenerate', { keywords }); - setTitle(titleResult?.content || ''); - - // 正文(流式) - const contentStream = capabilityClient - .load('content_generator') - .callStream<{ content: string }>('textGenerate', { keywords }); - const stream = normalizeStream(contentStream); - let fullContent = ''; - for await (const chunk of stream) { - const delta = readFirstStringField(chunk as Record, ['content']); - if (delta) { fullContent += delta; setContent(fullContent); } - } -}; -``` - -### Server 侧调用(仅全栈应用) - -#### NestJS 注入 - -```typescript -import { Injectable, Inject, Logger } from '@nestjs/common'; -import { CapabilityService } from '@lark-apaas/fullstack-nestjs-core'; - -@Injectable() -export class XxxService { - private readonly logger = new Logger(XxxService.name); - constructor(@Inject() private readonly capabilityService: CapabilityService) {} - - async callPlugin(input: Record) { - try { - return await this.capabilityService - .load('') - .call('', input); - } catch (error) { - this.logger.error('pluginInstance call failed', { - pluginInstanceId: '', - actionKey: '', - error: error instanceof Error ? error.message : 'Unknown error', - }); - throw error; - } - } -} -``` - -#### Server 侧编排原则 - -- PluginInstance 调用属于外部依赖 / side-effect -- 除非业务要求强一致性,默认不阻塞主业务流程 -- 推荐异步触发 + catch 兜底: - -```typescript -this.somePluginSideEffect(input).catch(error => { - this.logger.warn('PluginInstance side-effect failed, ignored', { - error: error instanceof Error ? error.message : 'Unknown error' - }); -}); -``` +根据技术栈 Skill 中的指引,基于 manifest 的 `actions[].outputMode` 选择调用方式: +- `unary` → `capabilityClient.load(id).call(actionKey, input)` +- `stream` → `capabilityClient.load(id).callStream(actionKey, input)` + normalizeStream From d6c37232e6b2eb4843d3f7d1544ac0805e70b2e5 Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 12:02:12 +0800 Subject: [PATCH 10/40] fix: use absolute project-path for tech-stack skill location in plugin-call Replace relative .agent/skills path with prefix anchored to the project root determined in the earlier context confirmation step. Add fallback path and minimal call rules when skill file doesn't exist. --- .../references/lark-apps-plugin-call.md | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md index 2287f8b62..551c0d7cc 100644 --- a/skills/lark-apps/references/lark-apps-plugin-call.md +++ b/skills/lark-apps/references/lark-apps-plugin-call.md @@ -73,14 +73,17 @@ output.fields: [...] 完成上述决策后,**读取项目的技术栈 Skill** 获取具体代码模式(import 路径、call/callStream 写法、normalizeStream、NestJS 注入等)。 -技术栈 Skill 按项目类型不同: -- **Design / Modern 应用**(纯前端)→ 读 `plugin-guide` Skill(仅 `capabilityClient`) -- **全栈应用**(NestJS + React)→ 读 `plugin-guide` Skill(`capabilityClient` + `CapabilityService`) +技术栈 Skill 的位置(`` 即 `lark-apps-plugin.md` § 确认项目上下文 中已确定的应用根目录): -技术栈 Skill 的位置取决于运行环境: -- **妙搭平台 Agent**:自动加载 steering skill(`steering-topic: plugin_guide`) -- **本地 Code Agent**:读仓库内 `.agent/skills/plugin-guide/SKILL.md`(如存在) +``` +/.agent/skills/plugin-guide/SKILL.md +``` -根据技术栈 Skill 中的指引,基于 manifest 的 `actions[].outputMode` 选择调用方式: -- `unary` → `capabilityClient.load(id).call(actionKey, input)` -- `stream` → `capabilityClient.load(id).callStream(actionKey, input)` + normalizeStream +如该文件不存在,检查 `/skills/plugin-guide/SKILL.md`。都不存在时,按以下最小规则写代码: +- `import { capabilityClient } from '@lark-apaas/client-toolkit'` +- `outputMode = unary` → `capabilityClient.load(id).call(actionKey, input)` +- `outputMode = stream` → `capabilityClient.load(id).callStream(actionKey, input)` + +技术栈 Skill 按项目类型不同: +- **Design / Modern 应用**(纯前端)→ Skill 中仅 `capabilityClient`,代码放 `/client/` +- **全栈应用**(NestJS + React)→ Skill 中含 `capabilityClient` + `CapabilityService`,Client 代码放 `/client/`,Server 代码放 `/server/` From a5386f6053f6f11c0dcb72e388dae17e531a812e Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 12:03:49 +0800 Subject: [PATCH 11/40] fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill --- skills/lark-apps/references/lark-apps-plugin-call.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md index 551c0d7cc..8a4925b86 100644 --- a/skills/lark-apps/references/lark-apps-plugin-call.md +++ b/skills/lark-apps/references/lark-apps-plugin-call.md @@ -79,11 +79,6 @@ output.fields: [...] /.agent/skills/plugin-guide/SKILL.md ``` -如该文件不存在,检查 `/skills/plugin-guide/SKILL.md`。都不存在时,按以下最小规则写代码: -- `import { capabilityClient } from '@lark-apaas/client-toolkit'` -- `outputMode = unary` → `capabilityClient.load(id).call(actionKey, input)` -- `outputMode = stream` → `capabilityClient.load(id).callStream(actionKey, input)` - 技术栈 Skill 按项目类型不同: - **Design / Modern 应用**(纯前端)→ Skill 中仅 `capabilityClient`,代码放 `/client/` - **全栈应用**(NestJS + React)→ Skill 中含 `capabilityClient` + `CapabilityService`,Client 代码放 `/client/`,Server 代码放 `/server/` From 3b9ee1af670343557c86f4e2f9be0d9322349a4c Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 17:26:48 +0800 Subject: [PATCH 12/40] fix: require reading project plugin-guide skill before writing call code --- .../references/lark-apps-plugin-call.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md index 8a4925b86..b2ccdbe16 100644 --- a/skills/lark-apps/references/lark-apps-plugin-call.md +++ b/skills/lark-apps/references/lark-apps-plugin-call.md @@ -69,16 +69,20 @@ output.fields: [...] --- -## 生成调用代码 +## 生成调用代码(必须读取项目 Skill) -完成上述决策后,**读取项目的技术栈 Skill** 获取具体代码模式(import 路径、call/callStream 写法、normalizeStream、NestJS 注入等)。 +完成上述决策后,**必须先读取项目中的插件调用 Skill**,获取具体代码模式(import 路径、call/callStream 写法、normalizeStream、NestJS 注入等)。**禁止凭记忆或本文件的摘要直接写调用代码。** -技术栈 Skill 的位置(`` 即 `lark-apps-plugin.md` § 确认项目上下文 中已确定的应用根目录): +Skill 文件位于(`` 即应用根目录): ``` -/.agent/skills/plugin-guide/SKILL.md +/.agents/skills/plugin-guide/SKILL.md +``` +或 +``` +/.claude/skills/plugin-guide/SKILL.md ``` -技术栈 Skill 按项目类型不同: -- **Design / Modern 应用**(纯前端)→ Skill 中仅 `capabilityClient`,代码放 `/client/` -- **全栈应用**(NestJS + React)→ Skill 中含 `capabilityClient` + `CapabilityService`,Client 代码放 `/client/`,Server 代码放 `/server/` +**必须读取该文件后再写代码**,其中包含项目实际的 import 路径、调用写法、流式处理规范等。不同项目类型的 Skill 内容不同: +- **Design / Modern 应用**(纯前端)→ 仅 `capabilityClient`,代码放 `client/` +- **全栈应用**(NestJS + React)→ 含 `capabilityClient` + `CapabilityService`,Client 放 `client/`,Server 放 `server/` From 112183f44706a47cfe43d85914803c09bb5329b4 Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 20:38:26 +0800 Subject: [PATCH 13/40] fix: improve plugin error hints for AI agent friendliness - Version mismatch warning now includes the exact +plugin-install command to update - Batch install (+plugin-install without --name) now re-installs when declared version differs from installed version - Remove --local flag from user-facing error hints (internal-only) --- shortcuts/apps/plugin_common.go | 8 +++++--- shortcuts/apps/plugin_install.go | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 99d6f8da4..4650f18c9 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -265,10 +265,10 @@ func pluginCheckInstalled(projectPath, pluginKey string) error { if pluginDirExists(pluginDir) { return appsFailedPreconditionError( "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, - ).WithHint("reinstall with a properly built .tgz (ap build + npm pack), or run 'lark-cli apps +plugin-install --local ' to replace it") + ).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey) } return appsFailedPreconditionError("plugin %q is not installed", pluginKey). - WithHint("run 'lark-cli apps +plugin-install --local ' or 'lark-cli apps +plugin-install --name %s' to install", pluginKey) + WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey) } return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) } @@ -283,7 +283,9 @@ func pluginCheckInstalledVersion(projectPath, pluginKey, declaredVersion string) } var warnings []string if installed := pluginInstalledVersion(projectPath, pluginKey); installed != "" && installed != declaredVersion { - warnings = append(warnings, fmt.Sprintf("installed %s differs from declared %s", installed, declaredVersion)) + warnings = append(warnings, fmt.Sprintf( + "installed version %s differs from declared %s; run 'lark-cli apps +plugin-install --name %s@%s' to update, or continue with the installed version", + installed, declaredVersion, pluginKey, declaredVersion)) } return warnings, nil } diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index 7d34c5591..00226d207 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -180,7 +180,8 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP var installed int for key, version := range declared { - if existing := pluginInstalledVersion(projectPath, key); existing != "" { + existing := pluginInstalledVersion(projectPath, key) + if existing != "" && existing == version { continue } target := key + "@" + version From 2beb1105233e6842e41bc7d39be09647c9cdfff9 Mon Sep 17 00:00:00 2001 From: zhangli Date: Tue, 23 Jun 2026 20:53:45 +0800 Subject: [PATCH 14/40] =?UTF-8?q?docs:=20add=20plugin=20package=20?= =?UTF-8?q?=E2=89=A0=20npm=20package=20distinction=20to=20skill=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a comparison table and iron law #6 to prevent agents from confusing +plugin-install with npm install, which was a recurring failure in multi-model evaluation. --- skills/lark-apps/references/lark-apps-plugin-crud.md | 2 ++ skills/lark-apps/references/lark-apps-plugin.md | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/skills/lark-apps/references/lark-apps-plugin-crud.md b/skills/lark-apps/references/lark-apps-plugin-crud.md index 75e13d77e..6d6764310 100644 --- a/skills/lark-apps/references/lark-apps-plugin-crud.md +++ b/skills/lark-apps/references/lark-apps-plugin-crud.md @@ -216,6 +216,8 @@ lark-cli apps +plugin-install --name @official-plugins/ai-text-generate@1.0.0 -- - 已安装同版本会跳过(status=already_installed) - 失败时 hint 会指示原因(网络/版本不存在/package.json 缺失) +> **❌ 禁止用 `npm install ` 代替 `+plugin-install`** — npm install 写入 `dependencies`,插件安装必须写入 `actionPlugins`,两者互不兼容。API 不可用时用 `--local` 模式或 `npx fullstack-cli action-plugin init`。 + ### Step 2 — 设计 paramsSchema 和 formValue 设计前必须先读插件的 form.schema: diff --git a/skills/lark-apps/references/lark-apps-plugin.md b/skills/lark-apps/references/lark-apps-plugin.md index aeb644de0..43e39e504 100644 --- a/skills/lark-apps/references/lark-apps-plugin.md +++ b/skills/lark-apps/references/lark-apps-plugin.md @@ -10,6 +10,17 @@ - **插件实例(Plugin Instance / Capability)**:基于插件包创建的业务配置,存储在 `capabilities/{id}.json`,定义 `paramsSchema`(业务入参)和 `formValue`(表单映射,通过 `{{input.xxx}}` 引用 paramsSchema 参数)。 - **变量映射**:`调用方传值 → paramsSchema 定义变量 → formValue 消费变量 {{input.xxx}} → Plugin form.schema 接收`。 +### ⚠️ 插件包 ≠ npm 包(必读) + +| | 插件包 | npm 依赖 | +|------|------|------| +| 安装命令 | `lark-cli apps +plugin-install` | `npm install` | +| 写入字段 | `package.json` → **`actionPlugins`** | `package.json` → `dependencies` / `devDependencies` | +| 用途 | 妙搭平台 AI 能力 | 项目依赖库 | +| **禁止** | ❌ 不能用 `npm install` 装插件包 | ❌ 不能用 `+plugin-install` 装普通依赖 | + +两套机制完全独立。插件包虽然放在 `node_modules/`,但由 `actionPlugins` 字段管理,**与 npm dependencies 无关**。混淆会导致运行时找不到插件。 + ## 确认项目上下文 所有本地 plugin 命令需要 `--project-path`。按以下顺序确认: @@ -171,3 +182,4 @@ 3. **校验失败走重试协议** — Create / Update 返回校验错误时,按 [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § 校验失败重试 处理:解析 hint → 修正 → 重试(max 3 次)。 4. **写代码前读源码** — Create 完成后,Agent 应读取 `node_modules/{pluginKey}/manifest.json` 和 `capabilities/{id}.json` 理解插件能力,再按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 生成调用代码。禁止凭记忆猜测 actionKey / inputSchema / outputMode。 5. **不要在 formValue 中使用 Handlebars 控制语法** — 仅允许 `{{input.xxx}}`,严禁 `{{#if}}` / `{{#each}}` / `{{else}}` 等。 +6. **禁止用 `npm install` 代替 `+plugin-install`** — 插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。混用会导致插件无法被识别。 From dbc1c93b719d2ba1652839bbf612bea8b7118eda Mon Sep 17 00:00:00 2001 From: zhangli Date: Tue, 23 Jun 2026 22:03:29 +0800 Subject: [PATCH 15/40] fix: block plugin uninstall when instances still reference the package Add pluginCheckDependentInstances to scan capabilities/ for instances that reference the plugin being uninstalled. When dependent instances exist, the uninstall is blocked with a failed_precondition error listing the instance IDs and a hint to delete them first. --- shortcuts/apps/plugin_common.go | 30 +++++++++ shortcuts/apps/plugin_uninstall.go | 5 ++ shortcuts/apps/plugin_uninstall_test.go | 85 +++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 4650f18c9..396d6d506 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -275,6 +275,36 @@ func pluginCheckInstalled(projectPath, pluginKey string) error { return nil } +// pluginCheckDependentInstances scans the capabilities directory for instances +// that reference the given pluginKey. Returns nil if none found, an error with +// the list of dependent instance ids if any exist, or the underlying I/O error. +func pluginCheckDependentInstances(projectPath, pluginKey, capDirFlag string) error { + capDir, err := pluginResolveCapDir(projectPath, capDirFlag) + if err != nil { + // No capabilities directory → no instances can exist → no conflict. + return nil + } + caps, err := pluginListCapabilities(capDir) + if err != nil { + // Cannot scan → best-effort, don't block. + return nil + } + var deps []string + for _, cap := range caps { + if pk, _ := cap["pluginKey"].(string); pk == pluginKey { + if id, _ := cap["id"].(string); id != "" { + deps = append(deps, id) + } + } + } + if len(deps) == 0 { + return nil + } + return appsFailedPreconditionError( + "plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "), + ).WithHint("delete these instances first (lark-cli apps +plugin-instance-delete --id for each), clean up calling code and types, then retry uninstall") +} + // pluginCheckInstalledVersion checks that the plugin is installed and warns if // the installed version differs from the declared version. Returns (warnings, error). func pluginCheckInstalledVersion(projectPath, pluginKey, declaredVersion string) ([]string, error) { diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go index dc503af82..a60b9b240 100644 --- a/shortcuts/apps/plugin_uninstall.go +++ b/shortcuts/apps/plugin_uninstall.go @@ -51,6 +51,11 @@ var AppsPluginUninstall = common.Shortcut{ return err } + // Block uninstall if any instances still reference this plugin package. + if err := pluginCheckDependentInstances(projectPath, key, rctx.Str("capabilities-dir")); err != nil { + return err + } + pkgDir := filepath.Join(projectPath, "node_modules", key) if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory. return appsFileIOError(err, "cannot remove %s", pkgDir) diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/plugin_uninstall_test.go index 2921a4e7e..628d2d612 100644 --- a/shortcuts/apps/plugin_uninstall_test.go +++ b/shortcuts/apps/plugin_uninstall_test.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "testing" + + "github.com/larksuite/cli/errs" ) func TestPluginUninstall_Basic(t *testing.T) { @@ -64,6 +66,89 @@ func TestPluginUninstall_NotInstalled(t *testing.T) { } } +func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Install plugin + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + + // Create a capability that references this plugin + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) //nolint:forbidigo + writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{ + "id": "my-instance", + "pluginKey": "@test/my-plugin", + "name": "My Instance", + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil") + } + + // Verify plugin directory still exists (blocked) + if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo + t.Errorf("plugin directory should still exist after blocked uninstall: %v", err) + } + + // Verify error mentions the dependent instance + prob, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed error, got %v", err) + } + if prob.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition) + } + if prob.Hint == "" { + t.Error("hint should be non-empty") + } +} + +func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + + // Create a capability that references a DIFFERENT plugin — should not block + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) //nolint:forbidigo + writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{ + "id": "other-instance", + "pluginKey": "@test/other-plugin", + "name": "Other Instance", + }) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--project-path", dir, "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err) + } + + // Verify plugin was removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + t.Error("plugin directory should be removed") + } +} + func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) { dir := t.TempDir() writeTestPkgJSON(t, dir, map[string]interface{}{ From 8037bd80370bf742aac3e7e18b14d857c913354f Mon Sep 17 00:00:00 2001 From: anguohui Date: Tue, 23 Jun 2026 21:22:36 +0800 Subject: [PATCH 16/40] fix: update plugin API paths to match new OpenAPI gateway routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get - download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version= --- shortcuts/apps/plugin_install.go | 27 +++++++++------------------ shortcuts/apps/plugin_install_test.go | 4 ++-- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index 00226d207..3c4d999b3 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -41,13 +41,13 @@ var AppsPluginInstall = common.Shortcut{ name := strings.TrimSpace(rctx.Str("name")) if name == "" { return common.NewDryRunAPI(). - POST(apiBasePath+"/plugins/-/versions/batch_get"). + POST(apiBasePath+"/plugin/versions/batch_get"). Desc("Batch-install all declared plugins from package.json actionPlugins"). Set("mode", "batch") } key, version := pluginParseInstallTarget(name) return common.NewDryRunAPI(). - POST(apiBasePath+"/plugins/-/versions/batch_get"). + POST(apiBasePath+"/plugin/versions/batch_get"). Desc("Fetch plugin version metadata, then download .tgz package"). Set("plugin_key", key). Set("version", version) @@ -284,7 +284,7 @@ func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, "items": []interface{}{item}, } - data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugins/-/versions/batch_get", nil, body) + data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_get", nil, body) if err != nil { p, ok := errs.ProblemOf(err) if ok && p.Subtype == errs.SubtypeInvalidResponse { @@ -347,7 +347,7 @@ func pluginExtractVersionInfo(data map[string]interface{}, key string) []map[str func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version, downloadURL, approach string) ([]byte, error) { switch approach { case "inner": - apiPath := pluginBuildInnerDownloadPath(key, version) + apiPath := pluginBuildDownloadPath(key, version) return pluginDownloadViaAPI(ctx, rctx, apiPath) case "public": if downloadURL == "" { @@ -358,24 +358,15 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key if downloadURL != "" && strings.HasPrefix(downloadURL, "http") { return pluginDownloadDirect(downloadURL) } - apiPath := pluginBuildInnerDownloadPath(key, version) + apiPath := pluginBuildDownloadPath(key, version) return pluginDownloadViaAPI(ctx, rctx, apiPath) } } -// pluginBuildInnerDownloadPath constructs the API path for downloading a plugin -// package. For key "@scope/name", the path segments are scope and name. -func pluginBuildInnerDownloadPath(key, version string) string { - scope, name := pluginSplitKey(key) - return fmt.Sprintf("%s/plugins/%s/%s/versions/%s/package", apiBasePath, scope, name, version) -} - -// pluginSplitKey splits "@scope/name" into ("@scope", "name"). -func pluginSplitKey(key string) (string, string) { - if idx := strings.Index(key, "/"); idx > 0 { - return key[:idx], key[idx+1:] - } - return key, "" +// pluginBuildDownloadPath constructs the API path for downloading a plugin +// package. plugin_key and version are passed as query parameters. +func pluginBuildDownloadPath(key, version string) string { + return fmt.Sprintf("%s/plugin/versions/download_package?plugin_key=%s&version=%s", apiBasePath, key, version) } func pluginDownloadViaAPI(ctx context.Context, rctx *common.RuntimeContext, apiPath string) ([]byte, error) { diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go index f7ddf79ac..79013775d 100644 --- a/shortcuts/apps/plugin_install_test.go +++ b/shortcuts/apps/plugin_install_test.go @@ -24,7 +24,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { // Mock batch_get API reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/spark/v1/plugins/-/versions/batch_get", + URL: "/open-apis/spark/v1/plugin/versions/batch_get", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -47,7 +47,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { }) reg.Register(&httpmock.Stub{ Method: "GET", - URL: "/open-apis/spark/v1/plugins/@test/my-plugin/versions/1.0.0/package", + URL: "/open-apis/spark/v1/plugin/versions/download_package", RawBody: tgzData, ContentType: "application/octet-stream", }) From 5365cb97ab1db42d3c9fec516c263ebc1979b8c7 Mon Sep 17 00:00:00 2001 From: anguohui Date: Wed, 24 Jun 2026 11:07:48 +0800 Subject: [PATCH 17/40] fix: update plugin install to match final OpenAPI gateway protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - batch_query: URL /plugin/versions/batch_query, request uses plugin_keys array + latest_only boolean, response uses flat data.items list with plugin_key/plugin_version fields - download: changed from GET+query to POST+JSON body {plugin_key, plugin_version}, response is binary tgz stream (supportFileDownload) - scope: spark:plugin:readonly → spark:app:read --- shortcuts/apps/plugin_install.go | 137 ++++++++++---------------- shortcuts/apps/plugin_install_test.go | 18 ++-- 2 files changed, 59 insertions(+), 96 deletions(-) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index 3c4d999b3..baff59aad 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -30,7 +30,7 @@ var AppsPluginInstall = common.Shortcut{ Command: "+plugin-install", Description: "Install a plugin package (download, extract, update package.json)", Risk: "write", - ConditionalScopes: []string{"spark:plugin:readonly"}, + ConditionalScopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ {Name: "name", Desc: "plugin key[@version] (e.g. @official-plugins/ai-text-generate@1.0.0); omit to install all declared plugins"}, @@ -41,13 +41,13 @@ var AppsPluginInstall = common.Shortcut{ name := strings.TrimSpace(rctx.Str("name")) if name == "" { return common.NewDryRunAPI(). - POST(apiBasePath+"/plugin/versions/batch_get"). + POST(apiBasePath+"/plugin/versions/batch_query"). Desc("Batch-install all declared plugins from package.json actionPlugins"). Set("mode", "batch") } key, version := pluginParseInstallTarget(name) return common.NewDryRunAPI(). - POST(apiBasePath+"/plugin/versions/batch_get"). + POST(apiBasePath+"/plugin/versions/batch_query"). Desc("Fetch plugin version metadata, then download .tgz package"). Set("plugin_key", key). Set("version", version) @@ -99,7 +99,7 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP } // Resolve version via API - resolvedVersion, downloadURL, approach, err := pluginResolveVersion(ctx, rctx, key, version) + resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version) if err != nil { return err } @@ -117,7 +117,7 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP } // Download tgz - tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion, downloadURL, approach) + tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion) if err != nil { return err } @@ -273,125 +273,90 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string return nil } -// pluginResolveVersion calls the batch_get API to resolve download info. -// Returns resolved version, download URL, download approach ("inner"|"public"). -func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion, downloadURL, downloadApproach string, err error) { - item := map[string]interface{}{"plugin_key": key} - if version != "" { - item["version"] = version - } +// pluginResolveVersion calls the batch_query API to resolve version info. +func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) { + isLatest := version == "" || version == "latest" body := map[string]interface{}{ - "items": []interface{}{item}, + "plugin_keys": []interface{}{key}, + "latest_only": isLatest, } - data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_get", nil, body) + data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body) if err != nil { p, ok := errs.ProblemOf(err) if ok && p.Subtype == errs.SubtypeInvalidResponse { p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key) p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team" - return "", "", "", err + return "", err } - return "", "", "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key)) + return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key)) } - versions := pluginExtractVersionInfo(data, key) - if len(versions) == 0 { - return "", "", "", appsValidationError("no version found for plugin %q", key). + // Response: data.items is a flat list of plugin_version objects + match := pluginFindVersionInItems(data, key, version) + if match == nil { + return "", appsValidationError("no version found for plugin %q", key). WithHint("check plugin key and version") } - - first := versions[0] - rv, _ := first["version"].(string) - dl, _ := first["downloadURL"].(string) - approach, _ := first["downloadApproach"].(string) + rv, _ := match["plugin_version"].(string) if rv == "" { - return "", "", "", appsValidationError("incomplete version info for plugin %q", key). - WithHint("API returned version info without version; contact plugin maintainer") + return "", appsValidationError("incomplete version info for plugin %q", key). + WithHint("API returned version info without plugin_version; contact plugin maintainer") } - return rv, dl, approach, nil + return rv, nil } -// pluginExtractVersionInfo extracts the version list for a key from the -// batch_get response. Handles both field names: "pluginVersions" (fullstack-cli -// inner API) and "pluginKeyToVersions" (OpenAPI design). -func pluginExtractVersionInfo(data map[string]interface{}, key string) []map[string]interface{} { - var raw interface{} - for _, field := range []string{"pluginVersions", "pluginKeyToVersions", "plugin_key_to_versions"} { - if v, ok := data[field]; ok { - raw = v - break - } - } - m, ok := raw.(map[string]interface{}) +// pluginFindVersionInItems extracts data.items and finds a matching version. +func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} { + raw, ok := data["items"] if !ok { return nil } - arr, ok := m[key].([]interface{}) + arr, ok := raw.([]interface{}) if !ok { return nil } - out := make([]map[string]interface{}, 0, len(arr)) + isLatest := version == "" || version == "latest" for _, v := range arr { - if vm, ok := v.(map[string]interface{}); ok { - out = append(out, vm) + item, ok := v.(map[string]interface{}) + if !ok { + continue } - } - return out -} - -// pluginDownloadPackage downloads a plugin .tgz using the approach indicated by -// the batch_get API: "inner" uses an authenticated API call to the plugin -// package endpoint; "public" does a plain HTTP GET to the download URL. -// When approach is empty, it infers from the URL shape. -func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version, downloadURL, approach string) ([]byte, error) { - switch approach { - case "inner": - apiPath := pluginBuildDownloadPath(key, version) - return pluginDownloadViaAPI(ctx, rctx, apiPath) - case "public": - if downloadURL == "" { - return nil, appsValidationError("public download requires a downloadURL for %s@%s", key, version) + pk, _ := item["plugin_key"].(string) + if pk != key { + continue } - return pluginDownloadDirect(downloadURL) - default: - if downloadURL != "" && strings.HasPrefix(downloadURL, "http") { - return pluginDownloadDirect(downloadURL) + if isLatest { + return item + } + pv, _ := item["plugin_version"].(string) + if pv == version { + return item } - apiPath := pluginBuildDownloadPath(key, version) - return pluginDownloadViaAPI(ctx, rctx, apiPath) } + return nil } -// pluginBuildDownloadPath constructs the API path for downloading a plugin -// package. plugin_key and version are passed as query parameters. -func pluginBuildDownloadPath(key, version string) string { - return fmt.Sprintf("%s/plugin/versions/download_package?plugin_key=%s&version=%s", apiBasePath, key, version) -} +// pluginDownloadPackage downloads a plugin .tgz via the download_package API. +// The endpoint is POST with JSON body {plugin_key, plugin_version}. +func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) { + apiPath := apiBasePath + "/plugin/versions/download_package" + body, _ := json.Marshal(map[string]string{ + "plugin_key": key, + "plugin_version": version, + }) -func pluginDownloadViaAPI(ctx context.Context, rctx *common.RuntimeContext, apiPath string) ([]byte, error) { resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ - HttpMethod: http.MethodGet, + HttpMethod: http.MethodPost, ApiPath: apiPath, + Body: bytes.NewReader(body), }) if err != nil { - return nil, appsFileIOError(err, "download failed: %s", apiPath) - } - defer resp.Body.Close() - if resp.StatusCode >= 400 { - return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed: %s", apiPath) - } - return io.ReadAll(resp.Body) -} - -func pluginDownloadDirect(url string) ([]byte, error) { - resp, err := http.Get(url) //nolint:gosec,noctx // download URL from trusted API response - if err != nil { - return nil, appsFileIOError(err, "download failed: %s", common.TruncateStr(url, 120)) + return nil, appsFileIOError(err, "download failed for %s@%s", key, version) } defer resp.Body.Close() if resp.StatusCode >= 400 { - return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed") + return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed for %s@%s", key, version) } return io.ReadAll(resp.Body) } diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go index 79013775d..454df01ec 100644 --- a/shortcuts/apps/plugin_install_test.go +++ b/shortcuts/apps/plugin_install_test.go @@ -21,32 +21,30 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) - // Mock batch_get API + // Mock batch_query API (new protocol: plugin_keys array, response data.items flat list) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/spark/v1/plugin/versions/batch_get", + URL: "/open-apis/spark/v1/plugin/versions/batch_query", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "pluginKeyToVersions": map[string]interface{}{ - "@test/my-plugin": []interface{}{ - map[string]interface{}{ - "version": "1.0.0", - "downloadURL": "/open-apis/spark/v1/plugins/test/versions/1.0.0/package", - }, + "items": []interface{}{ + map[string]interface{}{ + "plugin_key": "@test/my-plugin", + "plugin_version": "1.0.0", }, }, }, }, }) - // Mock download API (return a valid tgz with manifest.json + package.json) + // Mock download API (POST with JSON body, returns binary tgz) tgzData := buildTestTGZ(t, map[string]string{ "manifest.json": `{"actions":[]}`, "package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`, }) reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/plugin/versions/download_package", RawBody: tgzData, ContentType: "application/octet-stream", From d5f65d1aa4609cb731674bab84ba3e7b1e0fbb1c Mon Sep 17 00:00:00 2001 From: anguohui Date: Wed, 24 Jun 2026 12:21:24 +0800 Subject: [PATCH 18/40] fix: align dry-run output with new batch_query + download_package request format --- shortcuts/apps/plugin_install.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index baff59aad..aa537571d 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -43,14 +43,15 @@ var AppsPluginInstall = common.Shortcut{ return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). Desc("Batch-install all declared plugins from package.json actionPlugins"). - Set("mode", "batch") + Set("request_body", `{"plugin_keys": [], "latest_only": false}`) } key, version := pluginParseInstallTarget(name) + isLatest := version == "" || version == "latest" return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). - Desc("Fetch plugin version metadata, then download .tgz package"). - Set("plugin_key", key). - Set("version", version) + Desc("Query plugin version, then POST /plugin/versions/download_package to download .tgz"). + Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)). + Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version)) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) From bb891e0c50c637ab967ffac00844051548d5f330 Mon Sep 17 00:00:00 2001 From: anguohui Date: Wed, 24 Jun 2026 15:18:35 +0800 Subject: [PATCH 19/40] fix: match actual API response field names (key/version instead of plugin_key/plugin_version) --- shortcuts/apps/plugin_install.go | 10 ++++++---- shortcuts/apps/plugin_install_test.go | 6 ++++-- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index aa537571d..7e37b9564 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -299,10 +299,11 @@ func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, return "", appsValidationError("no version found for plugin %q", key). WithHint("check plugin key and version") } - rv, _ := match["plugin_version"].(string) + // API returns "version" (not "plugin_version") + rv, _ := match["version"].(string) if rv == "" { return "", appsValidationError("incomplete version info for plugin %q", key). - WithHint("API returned version info without plugin_version; contact plugin maintainer") + WithHint("API returned version info without version field; contact plugin maintainer") } return rv, nil } @@ -323,14 +324,15 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string) if !ok { continue } - pk, _ := item["plugin_key"].(string) + // API returns "key" (not "plugin_key") + pk, _ := item["key"].(string) if pk != key { continue } if isLatest { return item } - pv, _ := item["plugin_version"].(string) + pv, _ := item["version"].(string) if pv == version { return item } diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go index 454df01ec..31f53cb64 100644 --- a/shortcuts/apps/plugin_install_test.go +++ b/shortcuts/apps/plugin_install_test.go @@ -30,8 +30,10 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { "data": map[string]interface{}{ "items": []interface{}{ map[string]interface{}{ - "plugin_key": "@test/my-plugin", - "plugin_version": "1.0.0", + "key": "@test/my-plugin", + "version": "1.0.0", + "download_approach": "inner", + "status": "active", }, }, }, From a99dc33195308554fd378df4537f4f0fcec3d0a8 Mon Sep 17 00:00:00 2001 From: zhangli Date: Wed, 24 Jun 2026 18:35:52 +0800 Subject: [PATCH 20/40] docs: strengthen plugin reference reading rules from advisory to mandatory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change lark-apps-plugin.md from implicit to explicit required reading for any plugin work. Replace soft '按需读' with bold '必读' for all three plugin reference files. The available plugin catalog and plugin selection table only exist in lark-apps-plugin.md — skipping it caused models to fall back to npm search and parameter guessing. --- skills/lark-apps/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index e6f83c760..48105e6b8 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -29,7 +29,7 @@ metadata: | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | 读 [`lark-apps-plugin.md`](references/lark-apps-plugin.md)(插件选择 + CRUD 路由),按需读 [`lark-apps-plugin-crud.md`](references/lark-apps-plugin-crud.md)(Schema + 链路)和 [`lark-apps-plugin-call.md`](references/lark-apps-plugin-call.md)(调用代码) | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | +| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | **⚠️ 涉及插件则 [`lark-apps-plugin.md`](references/lark-apps-plugin.md) 必读**(含可用插件目录 17 个 + 用户意图→插件选择表 + 命令速查 + 铁律),不读则不知道哪些能力可用。创建/更新实例时必读 [`lark-apps-plugin-crud.md`](references/lark-apps-plugin-crud.md)(Schema 规则 + 链路),写调用代码时必读 [`lark-apps-plugin-call.md`](references/lark-apps-plugin-call.md)(Client/Server 决策 + call/callStream 写法) | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | ## 选择开发路径(进意图路由前先判这步) From 08340bf3aae4ed37e99eaf6b2975011ed63a82d1 Mon Sep 17 00:00:00 2001 From: anguohui Date: Wed, 24 Jun 2026 19:21:46 +0800 Subject: [PATCH 21/40] fix: remove call example annotation from types, add skill reference instead --- shortcuts/apps/plugin_common.go | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 396d6d506..94dd66770 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -722,6 +722,7 @@ func pluginGenerateAndPersistTypes(projectPath string, cap map[string]interface{ "// ============================================================", fmt.Sprintf("// 插件 %s (%s) 的类型定义", id, name), "// 由 lark-cli +plugin-instance-types 自动生成", + "// 调用方式请参考项目 Skill: .agents/skills/plugin-guide/SKILL.md", "// ============================================================", ) @@ -757,20 +758,6 @@ func pluginGenerateAndPersistTypes(projectPath string, cap map[string]interface{ outputSchema, _ := action["outputSchema"].(map[string]interface{}) if outputSchema != nil { - if props, ok := outputSchema["properties"].(map[string]interface{}); ok && len(props) > 0 { - keys := make([]string, 0, 3) - for k := range props { - if len(keys) < 3 { - keys = append(keys, k) - } - } - parts = append(parts, "", - "/**", - fmt.Sprintf(" * capabilityClient.load('%s').call<%s>('%s', input)", id, outputName, actionKey), - fmt.Sprintf(" * const { %s } = result;", strings.Join(keys, ", ")), - " */", - ) - } if iface := pluginGenerateInterface(outputName, outputSchema); iface != "" { parts = append(parts, iface) typeNames = append(typeNames, outputName) From 911f584ab0e2b8be185c60646d9e3ba78a5c0728 Mon Sep 17 00:00:00 2001 From: anguohui Date: Wed, 24 Jun 2026 23:57:47 +0800 Subject: [PATCH 22/40] refactor: streamline plugin skill files --- .../references/lark-apps-plugin-crud.md | 448 ++---------------- .../lark-apps/references/lark-apps-plugin.md | 137 +----- 2 files changed, 60 insertions(+), 525 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-crud.md b/skills/lark-apps/references/lark-apps-plugin-crud.md index 6d6764310..57f9b9ebd 100644 --- a/skills/lark-apps/references/lark-apps-plugin-crud.md +++ b/skills/lark-apps/references/lark-apps-plugin-crud.md @@ -1,444 +1,76 @@ # 插件实例 CRUD -Schema 规则 + Create / Update / Delete / Get 四条链路 + 校验重试协议。 +插件实例的创建、更新、删除、查询命令参考。详细的 Schema 规则、CRUD 流程指引、校验重试协议请读取仓库 Skill:`/.agents/skills/plugin-guide/SKILL.md`。 --- -## Schema 规则 - -生成 paramsSchema 和 formValue 前必读本章节。 - -### 变量三层映射 - -``` -调用方传值 paramsSchema 定义变量 formValue 消费变量 Plugin form.schema 接收 -(resume_text="...") → (定义: resume_text) → ("prompt": "...{{input.resume_text}}...") → (prompt 字段) -(article="...") → (定义: article) → ("content": "{{input.article}}") → (content 字段) -``` - -**关键区分**: -- formValue 的 **key** = Plugin form.schema 的字段名(如 `prompt`、`content`、`fileUrl`) -- formValue 的 **value** 中通过 `{{input.xxx}}` 引用 paramsSchema 定义的变量 -- 变量名(paramsSchema)与 form 字段名(form.schema)分属不同层,通常名称不同 - -### paramsSchema 生成规则 - -#### 支持的参数类型(仅 4 种) - -**文本**: -```json -{ "type": "string", "description": "文本参数描述" } -``` - -**数组**: -```json -{ "type": "array", "description": "描述", "items": { "type": "string", "description": "元素描述" } } -``` - -**图片**: -```json -{ "type": "array", "format": "plugin-image-url", "description": "描述", "items": { "type": "string" } } -``` - -**文件**: -```json -{ "type": "array", "format": "plugin-file-url", "description": "描述", "items": { "type": "string" } } -``` - -#### 约束 - -- 只允许 string 和 array 两种 type(图片/文件是 array + format) -- 每个参数**必须**有 type 和 description -- array 类型**必须**有 items 字段 -- format 只允许 `plugin-image-url` 或 `plugin-file-url` -- 参考 form.schema 字段的 type 进行定义,保持类型一致(不能给图片/文件类型定义为 string) -- 若 form.schema 字段描述写"不允许使用参数",则不生成对应 paramsSchema -- 参数设计应体现"收敛输入,扩展能力":用语义明确的参数名(如 `keywords`、`article_text`),避免过于开放的参数(如直接暴露 `prompt`) - -### formValue 生成规则 - -- **key 必须**对应 form.schema 中定义的字段 -- **value** 可以是常量,或 `{{input.xxx}}` 引用 paramsSchema 参数 -- **类型一致性**: - - form.schema type=string → `"字段名": "{{input.param}}"` 或常量字符串 - - form.schema type=array + paramsSchema type=array → **透传**:`"字段名": "{{input.param}}"`(禁止再包数组) - - form.schema type=array + paramsSchema type=string → **包装**:`"字段名": ["{{input.param}}"]` -- **禁止双层包装**:paramsSchema 已经是 array 时,`["{{input.param}}"]` 会导致运行时 `[url]` → `[[url]]` -- 无法明确赋值的字段留空字符串 `""`,不要硬编码 -- **业务枚举参数的动静态判断**: - - 用户指定单一固定值(如"翻译成英文")→ formValue 直接填常量 - - 用户列举多个值或暗示可选(如"翻译成中英日韩")→ 必须生成 paramsSchema 参数 -- 若 form.schema 字段描述写"固定填默认值 xxx"→ 直接填固定值,不引用参数 - -### 插件字段映射表 - -不同插件的"内容入口"字段各不相同,必须先看 manifest 的 form.schema。下表覆盖全部 AI 插件: - -#### 文本类 - -| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | -|------|------------|---------|------------| -| ai-text-generate | `prompt` | 用户输入嵌入 prompt 字符串 | `modelID`、`modelParams`(固定值,不引用参数) | -| ai-text-summary | `content` | 直接赋值 `"content": "{{input.xxx}}"` | `requirement`(摘要要求,可常量或参数) | -| ai-translate | `content` | 直接赋值 | `targetLanguage`(单一语言写常量,多语言生成参数) | -| ai-categorization | `textToBeCategorized` | 直接赋值 | `categories`(分类列表,array 类型) | -| ai-text-to-json | `prompt` | 文本嵌入 prompt,无独立 text 字段 | `jsonStructure`(固定结构定义,不引用参数)、`modelID`、`modelParams` | -| ai-search-summary | `prompt` | 用户查询嵌入 prompt | `modelID`、`modelParams`(固定值) | - -#### 图片类 - -| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | -|------|------------|---------|------------| -| ai-text-to-image | `prompt` | 图片描述嵌入 prompt | `ratio`(宽高比,单一写常量)、`style`(风格,可常量或参数) | -| ai-image-to-image | `prompt` + `images` | 指令嵌入 prompt,图片传 images(image 类型透传) | `strength`(编辑强度,通常常量) | -| ai-image-understanding | `prompt` + `images` | 指令嵌入 prompt,图片传 images(image 类型透传) | `modelID`、`modelParams`(固定值) | -| ai-image-to-json | `prompt` + `images` | 文本嵌入 prompt,图片传 images | `jsonStructure`(固定结构定义)、`modelID`、`modelParams` | -| ai-image-compare | `prompt` + `images` | 对比指令嵌入 prompt,两张图片传 images | — | -| ai-image-matting | `images` | 图片直接传入(image 类型透传) | — | -| ai-background-replace | `images` + `prompt` | 原图传 images,新背景描述嵌入 prompt | — | - -#### 文档/语音/其他 - -| 插件 | 内容入口字段 | 映射方式 | 其他常用字段 | -|------|------------|---------|------------| -| ai-doc-parser | `fileUrl` | file 类型:paramsSchema 为 array → 透传 `"fileUrl": "{{input.xxx}}"`;paramsSchema 为 string → 包装 `"fileUrl": ["{{input.xxx}}"]` | — | -| ai-speech-to-text | `fileUrl` | 同 ai-doc-parser | — | -| ai-speech-synthesis | `text` | 直接赋值 `"text": "{{input.xxx}}"` | `voice`(语音角色,通常常量) | -| web-crawler | `url` | 直接赋值 `"url": "{{input.xxx}}"` | — | - -### AI Prompt 编写规则 - -当插件涉及 AI 能力时,formValue 的 prompt 字段**应包含完整的高质量提示词**,而非简单透传。 - -#### 禁止的做法 - -```json -// ❌ 直接透传,无任何预设指令 -"prompt": "{{input.prompt}}" -// ❌ 过于简单 -"prompt": "根据关键词生成文案:{{input.keywords}}" -// ❌ 文生图/图生图一次调用仅支持一张图 -"prompt": "请根据以下要求,生成3张配图" -``` - -#### Prompt 编写要素 - -1. **角色设定**:明确 AI 扮演的角色或专业背景 -2. **任务描述**:清晰说明要完成的具体任务 -3. **输入说明**:标明用户输入将被插入的位置及其含义 -4. **输出要求**:明确输出的格式、结构、长度等 -5. **风格约束**:指定语气、风格、受众等 -6. **质量标准**:设定内容质量的具体标准 - -#### 各场景 Prompt 模板参考 - -**文本生成类**: -```json -"prompt": "你是一位资深的[平台名]内容创作专家,擅长撰写高互动率的内容。\n\n请根据以下关键词生成一篇文案:\n关键词:{{input.keywords}}\n\n内容要求:\n1. 标题(15-25字):使用数字、疑问句或悬念式开头,包含1-2个emoji\n2. 正文(300-500字):口语化表达,分3-5段,适当使用emoji\n3. 结尾:设置互动问题,包含3-5个话题标签" -``` - -**图片理解类**: -```json -"prompt": "你是一位专业的图像分析专家。\n\n请对提供的图片进行深度分析:\n1. 基础信息:图片类型、主体内容、场景环境\n2. 细节描述:颜色、构图、关键元素、文字信息\n3. 语义理解:图片传达的含义、情感、潜在用途\n\n{{input.additional_requirements}}\n\n输出要求:描述准确客观,不确定的内容明确标注" -``` - -**文生图类**: -```json -"prompt": "请生成一张高质量图片:\n\n主题内容:{{input.subject}}\n\n画面要求:\n1. 风格:[根据需求填写]\n2. 构图:[居中/三分法/对称等]\n3. 光线:[自然光/柔和光等]\n4. 色调:[明亮温暖/冷色调等]\n\n质量要求:画面清晰、主体突出、色彩和谐、无变形失真" -``` - -**数据提取/分析类**: -```json -"prompt": "你是一位数据分析专家,擅长从非结构化内容中提取关键信息。\n\n请从以下内容中提取信息:\n{{input.content}}\n\n提取要求:\n1. 识别关键实体(人名、地点、组织、日期、金额等)\n2. 提取核心事实和数据点\n3. 归纳主要观点或结论" -``` - -**AI 对话/问答类**: -```json -"prompt": "你是一位[专业领域]专家。请以专业、友好的态度回答用户问题。\n\n用户问题:{{input.question}}\n\n回答要求:准确、完整、通俗易懂、结构化、提供可操作建议" -``` - -#### 动态参数与预设内容结合 - -- **用户输入作为"素材"**:prompt 主体应是详细的任务指令,`{{input.xxx}}` 作为素材嵌入 -- **避免空泛透传**:即使需要用户自定义 prompt,也应提供默认模板 -- **预留扩展性**:可设可选参数(如 `{{input.additional_requirements}}`)供补充需求 - -### 模板语法限制 - -- **仅允许** `{{input.参数名}}` 一种语法 -- **严禁** `{{#if}}`、`{{#each}}`、`{{#unless}}`、`{{/if}}`、`{{/each}}`、`{{else}}` -- 需要条件逻辑时用自然语言表述 - -### 一致性铁律 - -1. **定义的变量必须被引用** — paramsSchema 中定义了 `xxx`,formValue 中至少有一处 `{{input.xxx}}` -2. **引用的变量必须被定义** — formValue 中出现 `{{input.xxx}}`,paramsSchema.properties 中必须有 `xxx` -3. **paramsSchema 允许为空** — 当 formValue 所有字段都是常量时可以是 `{}` -4. **paramsSchema/formValue 不一致 → 后端 actions 为空 → 插件无法调用** — 这是常见致命错误 - -### ID 生成规则 - -1. 基于插件实例的名称和描述,设计有业务语义的 ID -2. 格式:小写字母 + 数字 + 短横线(如 `task-text-summary`) -3. 长度不超过 128 字符 -4. 必须在当前项目内唯一 -5. 与已存在的 ID 冲突时重新生成 -6. 未命名时用 `unnamed-plugin-N` - -``` -名称"数据分析",描述"分析数据发现趋势",已存在["data-analysis-1"] → data-analysis-trend-2 -名称"智能图片理解",描述"(测试)" → test-image-understanding-1 -名称"未命名插件" → unnamed-plugin-1 -``` - ---- - -## Create 链路 - -从用户需求到插件可调用的完整流程。 - -``` -Step 1: +plugin-install --name -Step 2: 设计 paramsSchema / formValue(读上方 Schema 规则) -Step 3: +plugin-instance-create -Step 4: 校验通过? → 否:走下方「校验失败重试」(max 3 次) -Step 5: 读 manifest + capability JSON -Step 6: 读 lark-apps-plugin-call.md → 生成调用代码 -``` - -### Step 1 — 安装插件包 - -```bash -lark-cli apps +plugin-install --name @official-plugins/ai-text-generate@1.0.0 --project-path -``` - -- 鉴权:需要 user token(先 `lark-cli auth login`) -- 已安装同版本会跳过(status=already_installed) -- 失败时 hint 会指示原因(网络/版本不存在/package.json 缺失) - -> **❌ 禁止用 `npm install ` 代替 `+plugin-install`** — npm install 写入 `dependencies`,插件安装必须写入 `actionPlugins`,两者互不兼容。API 不可用时用 `--local` 模式或 `npx fullstack-cli action-plugin init`。 - -### Step 2 — 设计 paramsSchema 和 formValue - -设计前必须先读插件的 form.schema: -```bash -cat /node_modules//manifest.json -``` - -根据 form.schema 的字段和用户业务意图,设计: -1. **paramsSchema** — 对外暴露的业务入参(变量定义) -2. **formValue** — 将变量映射到 form.schema 字段(变量消费) -3. **语义化 ID** — 如 `task-text-summary`,小写+短横线,描述业务用途 - -### Step 3 — 创建实例 +## Create — 创建插件实例 ```bash lark-cli apps +plugin-instance-create \ - --id task-text-summary \ - --plugin @official-plugins/ai-text-generate@1.0.0 \ - --name "任务摘要生成" \ - --description "根据任务详情生成摘要" \ - --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ - --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ + --id <语义化ID> \ + --plugin \ + --name <名称> \ + --description <描述> \ + --form-value \ + --params-schema \ --project-path \ --format json ``` -大 JSON 场景用 `@file` 传入:先写临时文件,再 `--form-value @form.json --params-schema @schema.json`。 - -#### 前置检查(CLI 自动执行) - -| 检查项 | 失败时 hint | -|--------|-----------| -| package.json 存在 | `run 'lark-cli apps +init'` | -| capabilities 路径可解析 | `use --capabilities-dir or check .env.local` | -| 插件包已安装 | `run '+plugin-install ...' first` | -| 版本匹配 | warning(非 error):`installed X differs from Y` | -| ID 唯一 | `use --force to overwrite, or choose a different --id` | -| formValue 校验(5 规则) | 逐条列出违规项 | - -### Step 4 — 校验失败处理 - -CLI 返回 `ok: false` + `error.hint` 时,按下方「校验失败重试」处理。 - -### Step 5 — 读取插件源码 - -> 创建成功时 CLI 会自动生成 TypeScript 类型文件(`shared/plugin-types.ts`),无需手动调用。 - -```bash -cat /node_modules//manifest.json -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -### Step 6 — 生成调用代码 +| 参数 | 必填 | 说明 | +|------|------|------| +| `--id` | 是 | 语义化 ID,小写+短横线,如 `task-text-summary` | +| `--plugin` | 是 | 插件包 key@version,如 `@official-plugins/ai-text-generate@1.0.0` | +| `--name` | 是 | 实例显示名称 | +| `--description` | 否 | 实例描述 | +| `--form-value` | 是 | formValue JSON,或 `@file.json` 从文件读取 | +| `--params-schema` | 是 | paramsSchema JSON,或 `@file.json` 从文件读取 | +| `--project-path` | 是 | 妙搭应用根目录 | +| `--force` | 否 | 覆盖已存在的同 ID 实例 | -**必读**:[`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) - -### Red Flags - -| 念头 | 反驳 | -|------|------| -| "我记得这个插件的 schema,不用读 manifest" | manifest 可能更新过,必须每次读 | -| "create 完直接写代码" | 没读 manifest 就写代码 = 猜 actionKey/params | -| "install 之前先 create" | 没装包 manifest 读不到,校验会失败 | -| "formValue 校验报错,我直接编辑 JSON 文件" | 铁律:只能通过 CLI 命令修改 capability JSON | +校验失败时返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理(max 3 次)。 --- -## Update 链路 - -修改已有实例的 name、formValue、paramsSchema。关键点:改 schema 可能影响已有调用代码。 - -``` -Step 1: +plugin-instance-get --id → 查看现状 -Step 2: 设计修改方案(读上方 Schema 规则) -Step 3: +plugin-instance-update -Step 4: 校验通过? → 否:走下方「校验失败重试」(max 3 次) -Step 5: paramsSchema 变化? → 变化则扫描代码引用并更新 -``` - -### Step 1 — 查看现状 +## Update — 更新插件实例 ```bash -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -确认当前的 pluginKey、paramsSchema、formValue,理解要改什么。 - -### Step 2 — 设计修改方案 - -同时读取插件 manifest 确认 form.schema 约束: -```bash -cat /node_modules//manifest.json -``` - -### Step 3 — 执行更新 - -```bash -# 只改名 -lark-cli apps +plugin-instance-update --id --name "新名称" --project-path - -# 改 formValue -lark-cli apps +plugin-instance-update --id --form-value '{"prompt":"新 prompt {{input.text}}"}' --project-path - -# 同时改 formValue + paramsSchema -lark-cli apps +plugin-instance-update --id \ - --form-value @form.json --params-schema @schema.json --project-path +lark-cli apps +plugin-instance-update \ + --id \ + [--name <新名称>] \ + [--form-value ] \ + [--params-schema ] \ + --project-path \ + --format json ``` -CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt),只更新你传入的字段 + updatedAt。 - -### Step 5 — paramsSchema 变化时更新代码 - -**加/改字段** → 调用方需要传新参数: -1. 读 manifest + 更新后的 capability JSON -2. `grep -rn "load('${id}')" /` 找到代码引用 -3. 按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 更新调用代码中的 input 参数 - -**删字段** → 调用方不再需要传该参数:同上找到引用,移除已删除参数的传入。 - -**未变化** → 无需改代码,直接完成。 +只传需要修改的字段,CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt)。 --- -## Delete 链路 - -删除实例前必须先清理代码引用,避免运行时报错。 - -``` -Step 1: +plugin-instance-get --id → 确认实例存在 -Step 2: 扫描代码引用 → 有引用则先清理 -Step 3: +plugin-instance-delete --id -Step 4: 确认清理完成 -``` - -### 扫描代码引用 - -```bash -grep -rn "load('${id}')\|load(\"${id}\")" /client/ /server/ /shared/ -``` - -如果有引用: -1. 移除或替换调用代码(视业务逻辑决定是删除功能还是换用其他实例) -2. 清理相关的 import、类型定义、状态变量 -3. 如果该实例的结果被持久化到数据库字段,考虑是否需要清理字段或保留历史数据 - -### 执行删除 +## Delete — 删除插件实例 ```bash lark-cli apps +plugin-instance-delete --id --project-path --format json ``` -删除是幂等的:文件不存在也返回 `deleted: true`,不报错。 - -### 确认清理 +幂等操作,文件不存在也返回 `deleted: true`。删除前建议先扫描代码引用: ```bash -lark-cli apps +plugin-instance-get --id --project-path -# 应返回 "instance not found" - -grep -rn "${id}" /client/ /server/ /shared/ +grep -rn "load('${id}')" /client/ /server/ ``` --- -## Get 链路 - -查询操作,无副作用。根据查什么路由到不同命令。 - -| 查什么 | 命令 | 示例 | -|--------|------|------| -| 已声明的插件包及安装状态 | `+plugin-list` | `lark-cli apps +plugin-list --project-path ` | -| 所有已建的实例(概览) | `+plugin-instance-list` | `lark-cli apps +plugin-instance-list --project-path ` | -| 所有已建的实例(仅 id+name) | `+plugin-instance-list --summary` | 同上加 `--summary` | -| 某个实例的完整配置 | `+plugin-instance-get --id ` | `lark-cli apps +plugin-instance-get --id --project-path ` | -| 插件的 actions / schema | 直接读 manifest | `cat /node_modules//manifest.json` | - -`+plugin-list` 返回示例: -```json -{ - "ok": true, - "data": { - "plugins": [ - {"key": "@official-plugins/ai-text-generate", "version": "1.0.0", "status": "installed"}, - {"key": "@official-plugins/ai-translate", "version": "1.0.0", "status": "declared_not_installed"} - ] - } -} -``` - -`declared_not_installed` → 需要 `+plugin-install` 安装。 - -**写代码前必做**:不要只靠 instance-get 的输出,还要读插件的 manifest 获取 actions 详情,再按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 生成调用代码。 - ---- - -## 校验失败重试 - -`+plugin-instance-create` 或 `+plugin-instance-update` 返回 `ok: false` 且 error.type 为 `validation` 时: - -``` -校验失败 → 解析 error.message 中的每条违规(以 "- " 开头的行) - → 逐条修正 formValue / paramsSchema - → 重新调用(create 加 --force / update 直接重调) - → 最多 3 次,3 次仍失败 → 上报用户 -``` - -### 常见违规及修正方式 - -| 违规信息 | 原因 | 修正 | -|---------|------|------| -| `forbidden Handlebars syntax at formValue.xxx: {{#if` | formValue 中使用了控制语法 | 改为纯 `{{input.xxx}}` 或自然语言描述 | -| `paramsSchema property "x" type "number" is invalid` | 参数类型不在 string/array 范围 | 改为 `"type": "string"` 或 `"type": "array"` | -| `paramsSchema property "x" is array but missing items` | array 类型缺少 items 定义 | 补上 `"items": {"type": "string"}` | -| `paramsSchema property "x" missing description` | 参数缺少描述 | 补上 `"description": "..."` | -| `{{input.xxx}} at formValue.yyy is not defined in paramsSchema` | formValue 引用了未定义的变量 | 在 paramsSchema.properties 中补充定义,或修正拼写 | -| `paramsSchema property "x" is never referenced` | 定义了变量但 formValue 中没有引用 | 在 formValue 中补充 `{{input.x}}`,或从 paramsSchema 移除 | +## Get — 查询 -### 修正要点 +| 查什么 | 命令 | +|--------|------| +| 已声明的插件包及安装状态 | `+plugin-list --project-path ` | +| 所有实例概览 | `+plugin-instance-list --project-path ` | +| 所有实例(仅 id+name) | `+plugin-instance-list --summary --project-path ` | +| 单个实例完整配置 | `+plugin-instance-get --id --project-path ` | +| 插件的 actions/schema | `cat /node_modules//manifest.json` | -1. **不要直接编辑 capability JSON 文件** — 必须通过 CLI 命令重新提交 -2. **Create 重试用 `--force`** — 覆盖上一次失败写入的文件 -3. **Update 直接重新调用** — 会覆盖现有配置 -4. **保持 paramsSchema 和 formValue 的一致性** — 修一个通常要同步改另一个 -5. **3 次失败后不要继续猜** — 上报用户并附带完整错误,让用户决策 +所有命令支持 `--format json` 和 `--dry-run`。 diff --git a/skills/lark-apps/references/lark-apps-plugin.md b/skills/lark-apps/references/lark-apps-plugin.md index 43e39e504..bc5d3921d 100644 --- a/skills/lark-apps/references/lark-apps-plugin.md +++ b/skills/lark-apps/references/lark-apps-plugin.md @@ -58,128 +58,31 @@ --- -## AI 插件目录(17 个) - -### 插件能力速查 - -#### 文本类 - -| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | -|---------|------|---------|---------|---------| -| `ai-text-generate` | 文本生成 | stream | 流式文本 `content` | 文案、报告、对话、问答 | -| `ai-text-summary` | 文本摘要 | stream | 流式文本 `summary` | 长文本摘要、要点提取 | -| `ai-translate` | 多语言翻译 | stream | 流式文本 `translation` | 中英日韩等多语言互译 | -| `ai-categorization` | 文本分类 | unary | `{categories: string[]}` | 打标签、情感分析、内容分类 | -| `ai-text-to-json` | 文本→结构化 JSON | unary | `{字段名: 值}` | 信息提取、表单自动填充(最多 20 字段) | -| `ai-search-summary` | 搜索摘要 | stream | 流式文本 `content` | 联网搜索 + 摘要生成 | - -#### 图片类 - -| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | -|---------|------|---------|---------|---------| -| `ai-text-to-image` | 文生图 | unary | `{images: string[]}` | 根据文本描述生成图片 | -| `ai-image-to-image` | 图生图 | unary | `{images: string[]}` | 图片编辑、风格转换 | -| `ai-image-understanding` | 图片理解 | stream | 流式文本 `content` | 图片描述、问答、OCR | -| `ai-image-to-json` | 图片→结构化 JSON | unary | `{字段名: 值}` | 图片信息提取(单步直达) | -| `ai-image-compare` | 图片对比 | stream | 流式文本 `content` | 两张图片差异分析 | -| `ai-image-matting` | 抠图 | unary | `{images: string[]}` | 去背景、主体提取 | -| `ai-background-replace` | 换背景 | unary | `{images: string[]}` | 替换图片背景 | - -#### 文档/语音/其他 - -| 插件 key | 能力 | 输出模式 | 输出类型 | 适用场景 | -|---------|------|---------|---------|---------| -| `ai-doc-parser` | 文档解析 | unary | **纯文本 string** | PDF/Word/Excel 文本提取 | -| `ai-speech-to-text` | 语音识别 | unary | **纯文本 string** | 音频转文字 | -| `ai-speech-synthesis` | 语音合成 | unary | 音频 URL string | 文字转语音 | -| `web-crawler` | 网页抓取 | unary | 网页内容 string | 抓取指定 URL 的页面内容 | - -> 所有插件 key 使用时需加 `@official-plugins/` 前缀,如 `@official-plugins/ai-text-generate@1.0.0`。 - -**不支持**(需用户通过 GUI 手动配置):飞书发消息、飞书创建群组、飞书多维表格、飞书审批、飞书 aPaaS。 - -### 用户意图 → 插件选择 - -当用户表达需求但没指定插件时,按此表选择: - -| 用户表述 | 对应插件 | 类型 | -|---------|---------|------| -| "AI 写文案 / 生成文本 / 帮我写" | `ai-text-generate` | 流式生成 | -| "总结 / 摘要 / 提取要点" | `ai-text-summary` | 流式生成 | -| "翻译成XX / 多语言" | `ai-translate` | 流式生成 | -| "分类 / 打标签 / 情感分析" | `ai-categorization` | 结构化 | -| "从文本提取字段 / 文本转结构化" | `ai-text-to-json` | 结构化 | -| "搜索并总结 / 联网查询" | `ai-search-summary` | 流式生成 | -| "AI 生图 / 文生图 / 生成图片" | `ai-text-to-image` | 图片 | -| "图片编辑 / 风格转换 / 图生图" | `ai-image-to-image` | 图片 | -| "识别图片 / 图片问答 / 看图说话" | `ai-image-understanding` | 流式生成 | -| "从图片提取信息 / 图片转结构化" | `ai-image-to-json` | 结构化 | -| "对比两张图 / 图片差异" | `ai-image-compare` | 流式生成 | -| "抠图 / 去背景" | `ai-image-matting` | 图片 | -| "换背景 / 替换背景" | `ai-background-replace` | 图片 | -| "解析文档 / 读 PDF / 读 Word" | `ai-doc-parser` | 文本提取 | -| "语音合成 / 文字转语音 / 朗读" | `ai-speech-synthesis` | 音频 | -| "语音识别 / 音频转文字" | `ai-speech-to-text` | 文本提取 | -| "抓取网页 / 爬取页面" | `web-crawler` | 文本提取 | - -### 设计原则 - -#### 原子化 - -**一个插件实例只做一件事**。不同输出类型、不同业务语义必须创建独立的插件实例。 - -``` -✅ 正确:需要生成标题 + 生成正文 - → 创建两个 ai-text-generate 实例:title-generator、content-generator - → 各自有独立的 prompt 和 paramsSchema +## 铁律 -❌ 错误:把标题和正文塞进同一个实例的 prompt - → 输出混在一起,无法分别渲染 -``` +1. **只能通过 CLI 命令修改 capability JSON** — 禁止 Agent 直接编辑 `capabilities/*.json`,必须通过 `+plugin-instance-create` / `+plugin-instance-update` / `+plugin-instance-delete` 操作。 +2. **先装包再建实例** — `+plugin-instance-create` 前必须确保插件包已安装(`+plugin-install`)。 +3. **禁止用 `npm install` 代替 `+plugin-install`** — 插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。 +4. **操作前必读仓库 Skill** — 执行任何插件 CRUD 或生成调用代码前,必须先读取项目中的插件 Skill。 -同一个官方插件可以创建多个实例,每个实例服务不同的业务场景。 +## 详细指引(读取仓库 Skill) -#### 链式调用 +插件目录、用户意图映射、Schema 规则、CRUD 详细流程、AI Prompt 编写指引、校验重试协议等内容已下沉到应用仓库 Skill 中。 -部分插件输出是纯文本,不能直接产出结构化数据。需要链式组合时: +**执行任何插件操作前,必须先读取项目中的插件 Skill 文件**: ``` -文档 → 结构化:ai-doc-parser → ai-text-to-json(两步) -图片 → 结构化:ai-image-to-json(单步直达,优先用这个) -语音 → 结构化:ai-speech-to-text → ai-text-to-json(两步) +/.agents/skills/plugin-guide/SKILL.md ``` -| 上游插件 | 上游输出 | 需要结构化时 | 下游插件 | -|---------|---------|------------|---------| -| `ai-doc-parser` | 纯文本 | 必须接下游 | `ai-text-to-json` | -| `ai-speech-to-text` | 纯文本 | 必须接下游 | `ai-text-to-json` | -| `ai-image-understanding` | 流式文本 | 优先用 `ai-image-to-json` 单步完成 | `ai-text-to-json` | - -代码中的链式传递:上游插件输出在代码中作为下游插件实例的入参传入,每个实例的 paramsSchema 是独立的接口契约。 - -#### 流式标注 - -使用 stream 输出模式的插件,功能设计中需注明涉及流式渲染,代码中使用 `callStream` + `normalizeStream`。 - ---- - -## 意图路由 - -根据用户意图选择对应操作,**必须读取对应文件后再执行**: - -| 用户意图 | 必读 | -|---------|------| -| 新增插件能力("加个 AI 翻译""接入文本生成") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Create | -| 修改已有实例配置("改一下 prompt""换个模型") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Update | -| 删除实例("去掉这个能力""不需要了") | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Delete | -| 查看实例详情 / 列出已有实例 / 查已装插件 | [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § Get | -| 写插件调用代码(Create/Update 完成后的下一步) | [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) | - -## 铁律 - -1. **只能通过 CLI 命令修改 capability JSON 文件** — 禁止 Agent 直接用文件编辑工具写 `capabilities/*.json`,必须通过 `+plugin-instance-create` / `+plugin-instance-update` / `+plugin-instance-delete` 操作,确保校验和格式一致性。 -2. **先装包再建实例** — `+plugin-instance-create` 前必须确保插件包已安装(`+plugin-install`),否则校验会因读不到 manifest 而失败。 -3. **校验失败走重试协议** — Create / Update 返回校验错误时,按 [`lark-apps-plugin-crud.md`](lark-apps-plugin-crud.md) § 校验失败重试 处理:解析 hint → 修正 → 重试(max 3 次)。 -4. **写代码前读源码** — Create 完成后,Agent 应读取 `node_modules/{pluginKey}/manifest.json` 和 `capabilities/{id}.json` 理解插件能力,再按 [`lark-apps-plugin-call.md`](lark-apps-plugin-call.md) 生成调用代码。禁止凭记忆猜测 actionKey / inputSchema / outputMode。 -5. **不要在 formValue 中使用 Handlebars 控制语法** — 仅允许 `{{input.xxx}}`,严禁 `{{#if}}` / `{{#each}}` / `{{else}}` 等。 -6. **禁止用 `npm install` 代替 `+plugin-install`** — 插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。混用会导致插件无法被识别。 +其中 `` 为当前妙搭应用的根目录(含 `.spark/meta.json`)。 + +该文件包含: +- 17 个 AI 插件的完整目录与能力速查 +- 用户意图 → 插件选择映射表 +- paramsSchema / formValue 的 Schema 规则与字段映射 +- AI Prompt 编写规则与各场景模板 +- Create / Update / Delete / Get 详细流程 +- 校验失败重试协议 +- 插件设计原则(原子化、链式、流式) +- 调用代码生成指引 From de5de57ced468a4ae7993b3ae722ce4ba2689198 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 25 Jun 2026 11:42:44 +0800 Subject: [PATCH 23/40] =?UTF-8?q?refactor:=20=E6=8F=92=E4=BB=B6=20PE=20?= =?UTF-8?q?=E4=B8=8B=E6=B2=89=E5=88=B0=E4=BB=93=E5=BA=93=EF=BC=8Clark-cli?= =?UTF-8?q?=20=E4=BE=A7=E7=B2=BE=E7=AE=80=E4=B8=BA=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=8F=82=E8=80=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md), 其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到 应用仓库 .agents/skills/plugin-guide/SKILL.md - 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致: plugin-install / plugin-uninstall / plugin-list / plugin-instance-create / update / delete / get / list - 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力), 意图路由引导先读仓库 Skill 再看 CLI 命令参考 --- skills/lark-apps/SKILL.md | 4 +- .../references/lark-apps-plugin-call.md | 88 ------------------- .../references/lark-apps-plugin-crud.md | 76 ---------------- .../references/lark-apps-plugin-install.md | 28 ++++++ .../lark-apps-plugin-instance-create.md | 49 +++++++++++ .../lark-apps-plugin-instance-delete.md | 26 ++++++ .../lark-apps-plugin-instance-get.md | 23 +++++ .../lark-apps-plugin-instance-list.md | 25 ++++++ .../lark-apps-plugin-instance-update.md | 38 ++++++++ .../references/lark-apps-plugin-list.md | 21 +++++ .../references/lark-apps-plugin-uninstall.md | 22 +++++ .../lark-apps/references/lark-apps-plugin.md | 88 ------------------- 12 files changed, 234 insertions(+), 254 deletions(-) delete mode 100644 skills/lark-apps/references/lark-apps-plugin-call.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin-crud.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-install.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-create.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-delete.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-get.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-list.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-update.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-list.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-uninstall.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin.md diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 48105e6b8..36263b3a7 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI插件集成。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。当用户要接入AI能力时也使用:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要,或提到Plugin/PluginInstance/Capability/插件安装/插件实例。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、外部能力(插件)集成。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。当用户需要接入外部能力(AI模型服务、飞书平台能力等)或提到插件/Plugin/Capability时也使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -29,7 +29,7 @@ metadata: | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 插件集成 — 用户要实现以下能力时必须走插件链路:AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要;或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例 | **⚠️ 涉及插件则 [`lark-apps-plugin.md`](references/lark-apps-plugin.md) 必读**(含可用插件目录 17 个 + 用户意图→插件选择表 + 命令速查 + 铁律),不读则不知道哪些能力可用。创建/更新实例时必读 [`lark-apps-plugin-crud.md`](references/lark-apps-plugin-crud.md)(Schema 规则 + 链路),写调用代码时必读 [`lark-apps-plugin-call.md`](references/lark-apps-plugin-call.md)(Client/Server 决策 + call/callStream 写法) | [`lark-apps-plugin.md`](references/lark-apps-plugin.md) | +| 插件集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、CRUD 流程、调用代码生成等完整指引)。CLI 命令参考见各 `lark-apps-plugin-*.md` reference | 仓库 Skill | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/lark-apps-plugin-call.md b/skills/lark-apps/references/lark-apps-plugin-call.md deleted file mode 100644 index b2ccdbe16..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-call.md +++ /dev/null @@ -1,88 +0,0 @@ -# 插件实例调用指南 - -创建/更新插件实例后,根据本文件做调用决策,再读技术栈 Skill 写代码。 - -## 调用前获取权威依据 - -**必须**先读取以下文件获取 actions 信息: - -```bash -# 插件 manifest — actions / outputMode / inputSchema / outputSchema -cat /node_modules//manifest.json - -# 实例配置 — paramsSchema / formValue -lark-cli apps +plugin-instance-get --id --project-path --format json -``` - -**编码前闸门**:先列出 Schema 摘录,确认后再写代码。 - -``` -pluginInstanceId: xxx -actionKey: xxx -outputMode: unary | stream -input.required: [...] -output.fields: [...] -调用侧: Client | Server(仅全栈应用) -持久化: 是 | 否(及方式) -``` - -若摘录字段缺失,不得进入实现阶段。 - -### Client vs Server 决策 - -| 应用类型 | 可选调用侧 | -|---------|-----------| -| Design / Modern(appType=3/6,纯前端) | 只有 Client 侧 | -| 全栈应用(appType=2,NestJS + React) | Client 侧(首选)或 Server 侧 | - -**Server 侧仅在以下场景使用**: -1. 涉及敏感凭证(token/secret 不能暴露给前端) -2. 多步骤强事务编排(需要原子性) -3. 触发器/定时任务(无前端上下文) -4. 插件结果需持久化到数据库(调用+落库在同一方法中完成) - -> 不涉及上述场景,仅即时展示(流式渲染、一次性展示)→ Client 侧。 - -### 持久化决策 - -**设计阶段就要判断**,不要等到写代码时才想。 - -以下任一条件成立时,插件结果**必须**保存到数据库: -1. 结果会在其他页面展示 -2. 结果供后续功能消费 -3. 用户再次访问时需要看到结果 -4. 结果对应数据库中已有字段 - -仅一次性即时展示(聊天对话、临时预览)时可不持久化。 - -**持久化方式优先级**: -- **推荐(A)**:Server 侧 Service 调用插件 + 同一方法落库 -- **备选(B)**:Client 侧调用插件 → 流式结束后调已有 CRUD 接口保存 - -复用已有 create/update 接口,不要为插件结果单独建 API。 - -### 失败日志最小集 - -```typescript -{ pluginInstanceId, actionKey, outputMode, inputKeys, error } -``` - ---- - -## 生成调用代码(必须读取项目 Skill) - -完成上述决策后,**必须先读取项目中的插件调用 Skill**,获取具体代码模式(import 路径、call/callStream 写法、normalizeStream、NestJS 注入等)。**禁止凭记忆或本文件的摘要直接写调用代码。** - -Skill 文件位于(`` 即应用根目录): - -``` -/.agents/skills/plugin-guide/SKILL.md -``` -或 -``` -/.claude/skills/plugin-guide/SKILL.md -``` - -**必须读取该文件后再写代码**,其中包含项目实际的 import 路径、调用写法、流式处理规范等。不同项目类型的 Skill 内容不同: -- **Design / Modern 应用**(纯前端)→ 仅 `capabilityClient`,代码放 `client/` -- **全栈应用**(NestJS + React)→ 含 `capabilityClient` + `CapabilityService`,Client 放 `client/`,Server 放 `server/` diff --git a/skills/lark-apps/references/lark-apps-plugin-crud.md b/skills/lark-apps/references/lark-apps-plugin-crud.md deleted file mode 100644 index 57f9b9ebd..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-crud.md +++ /dev/null @@ -1,76 +0,0 @@ -# 插件实例 CRUD - -插件实例的创建、更新、删除、查询命令参考。详细的 Schema 规则、CRUD 流程指引、校验重试协议请读取仓库 Skill:`/.agents/skills/plugin-guide/SKILL.md`。 - ---- - -## Create — 创建插件实例 - -```bash -lark-cli apps +plugin-instance-create \ - --id <语义化ID> \ - --plugin \ - --name <名称> \ - --description <描述> \ - --form-value \ - --params-schema \ - --project-path \ - --format json -``` - -| 参数 | 必填 | 说明 | -|------|------|------| -| `--id` | 是 | 语义化 ID,小写+短横线,如 `task-text-summary` | -| `--plugin` | 是 | 插件包 key@version,如 `@official-plugins/ai-text-generate@1.0.0` | -| `--name` | 是 | 实例显示名称 | -| `--description` | 否 | 实例描述 | -| `--form-value` | 是 | formValue JSON,或 `@file.json` 从文件读取 | -| `--params-schema` | 是 | paramsSchema JSON,或 `@file.json` 从文件读取 | -| `--project-path` | 是 | 妙搭应用根目录 | -| `--force` | 否 | 覆盖已存在的同 ID 实例 | - -校验失败时返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理(max 3 次)。 - ---- - -## Update — 更新插件实例 - -```bash -lark-cli apps +plugin-instance-update \ - --id \ - [--name <新名称>] \ - [--form-value ] \ - [--params-schema ] \ - --project-path \ - --format json -``` - -只传需要修改的字段,CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt)。 - ---- - -## Delete — 删除插件实例 - -```bash -lark-cli apps +plugin-instance-delete --id --project-path --format json -``` - -幂等操作,文件不存在也返回 `deleted: true`。删除前建议先扫描代码引用: - -```bash -grep -rn "load('${id}')" /client/ /server/ -``` - ---- - -## Get — 查询 - -| 查什么 | 命令 | -|--------|------| -| 已声明的插件包及安装状态 | `+plugin-list --project-path ` | -| 所有实例概览 | `+plugin-instance-list --project-path ` | -| 所有实例(仅 id+name) | `+plugin-instance-list --summary --project-path ` | -| 单个实例完整配置 | `+plugin-instance-get --id --project-path ` | -| 插件的 actions/schema | `cat /node_modules//manifest.json` | - -所有命令支持 `--format json` 和 `--dry-run`。 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md new file mode 100644 index 000000000..307b3eed0 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -0,0 +1,28 @@ +# apps +plugin-install + +安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。 + +## 何时用 + +用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取仓库 Skill:`/.agents/skills/plugin-guide/SKILL.md`。 + +**插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。 + +## 命令骨架 + +- `--name `:插件包 key,如 `@official-plugins/ai-text-generate`。不传则批量安装 `actionPlugins` 中声明的所有插件。 +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --project-path ./my-app + +# 批量安装已声明的所有插件 +lark-cli apps +plugin-install --project-path ./my-app +``` + +## 输出契约 + +- 已安装同版本会跳过(status=already_installed)。 +- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-create.md b/skills/lark-apps/references/lark-apps-plugin-instance-create.md new file mode 100644 index 000000000..6562f7249 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-instance-create.md @@ -0,0 +1,49 @@ +# apps +plugin-instance-create + +创建插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-create --help` 为准。 + +## 何时用 + +用户要接入某个 AI 能力或飞书平台能力,插件包已安装后,创建对应的插件实例。创建前必须读取仓库 Skill 了解 Schema 规则:`/.agents/skills/plugin-guide/SKILL.md`。 + +## 命令骨架 + +- `--id`:语义化 ID,小写+短横线,如 `task-text-summary`。 +- `--plugin`:插件包 key,如 `@official-plugins/ai-text-generate`。 +- `--name`:实例显示名称。 +- `--description`:实例描述(可选)。 +- `--form-value`:formValue JSON,或 `@file.json` 从文件读取。 +- `--params-schema`:paramsSchema JSON,或 `@file.json` 从文件读取。 +- `--project-path`:妙搭应用根目录。 +- `--force`:覆盖已存在的同 ID 实例(可选)。 + +## 示例 + +```bash +lark-cli apps +plugin-instance-create \ + --id task-text-summary \ + --plugin @official-plugins/ai-text-generate \ + --name "任务摘要生成" \ + --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ + --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ + --project-path ./my-app --format json + +# 大 JSON 场景用 @file 传入 +lark-cli apps +plugin-instance-create \ + --id task-text-summary \ + --plugin @official-plugins/ai-text-generate \ + --name "任务摘要生成" \ + --form-value @form.json --params-schema @schema.json \ + --project-path ./my-app --format json +``` + +## 输出契约 + +- 成功返回 `ok: true` + 创建的实例配置。 +- 校验失败返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理(max 3 次,create 重试加 `--force`)。 + +## Agent 规则 + +- 创建前必须先 `+plugin-install` 安装插件包。 +- formValue / paramsSchema 的设计规则在仓库 Skill 中,不要凭记忆猜测。 +- 创建成功后,读 manifest + capability JSON,再按仓库 Skill 生成调用代码。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-delete.md b/skills/lark-apps/references/lark-apps-plugin-instance-delete.md new file mode 100644 index 000000000..fcb134101 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-instance-delete.md @@ -0,0 +1,26 @@ +# apps +plugin-instance-delete + +删除插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-delete --help` 为准。 + +## 何时用 + +用户不再需要某个插件实例时删除。删除前应先清理代码中对该实例的引用。 + +## 命令骨架 + +- `--id`:要删除的实例 ID。 +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +# 先扫描代码引用 +grep -rn "load('task-text-summary')" ./my-app/client/ ./my-app/server/ + +# 确认无引用或已清理后删除 +lark-cli apps +plugin-instance-delete --id task-text-summary --project-path ./my-app --format json +``` + +## 输出契约 + +- 幂等操作,文件不存在也返回 `deleted: true`。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-get.md b/skills/lark-apps/references/lark-apps-plugin-instance-get.md new file mode 100644 index 000000000..1361dbda8 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-instance-get.md @@ -0,0 +1,23 @@ +# apps +plugin-instance-get + +查询单个插件实例的完整配置。运行时命令事实以 `lark-cli apps +plugin-instance-get --help` 为准。 + +## 何时用 + +需要查看某个插件实例的当前 paramsSchema、formValue 等配置时。也用于生成调用代码前获取实例信息。 + +## 命令骨架 + +- `--id`:实例 ID。 +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +lark-cli apps +plugin-instance-get --id task-text-summary --project-path ./my-app --format json +``` + +## 输出契约 + +- 返回实例的完整 JSON 配置(id、pluginKey、pluginVersion、name、description、paramsSchema、formValue 等)。 +- 实例不存在时返回 `instance not found`。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-list.md b/skills/lark-apps/references/lark-apps-plugin-instance-list.md new file mode 100644 index 000000000..3839cac67 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-instance-list.md @@ -0,0 +1,25 @@ +# apps +plugin-instance-list + +列出当前项目的所有插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-list --help` 为准。 + +## 何时用 + +查看项目中已创建了哪些插件实例,判断是否需要新建或可以复用已有实例。 + +## 命令骨架 + +- `--summary`:仅输出 id + name(可选)。 +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +lark-cli apps +plugin-instance-list --project-path ./my-app --format json + +# 仅看 id 和 name +lark-cli apps +plugin-instance-list --summary --project-path ./my-app +``` + +## 输出契约 + +- 返回所有实例的配置列表。`--summary` 时仅返回 id 和 name。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-update.md b/skills/lark-apps/references/lark-apps-plugin-instance-update.md new file mode 100644 index 000000000..28b57b18a --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-instance-update.md @@ -0,0 +1,38 @@ +# apps +plugin-instance-update + +更新已有插件实例的配置。运行时命令事实以 `lark-cli apps +plugin-instance-update --help` 为准。 + +## 何时用 + +用户要修改已有插件实例的 name、formValue 或 paramsSchema(如改 prompt、换参数)。 + +## 命令骨架 + +- `--id`:要更新的实例 ID。 +- `--name`:新名称(可选)。 +- `--form-value`:新 formValue JSON 或 `@file.json`(可选)。 +- `--params-schema`:新 paramsSchema JSON 或 `@file.json`(可选)。 +- `--project-path`:妙搭应用根目录。 + +只传需要修改的字段,CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt)。 + +## 示例 + +```bash +# 只改名 +lark-cli apps +plugin-instance-update --id task-text-summary --name "新名称" --project-path ./my-app + +# 改 formValue + paramsSchema +lark-cli apps +plugin-instance-update --id task-text-summary \ + --form-value @form.json --params-schema @schema.json \ + --project-path ./my-app --format json +``` + +## 输出契约 + +- 成功返回 `ok: true` + 更新后的实例配置。 +- 校验失败返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理。 + +## Agent 规则 + +- paramsSchema 变化时,需扫描代码引用(`grep -rn "load('${id}')" /`)并更新调用代码。 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md new file mode 100644 index 000000000..53ecaf2d1 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -0,0 +1,21 @@ +# apps +plugin-list + +列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。 + +## 何时用 + +查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。 + +## 命令骨架 + +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +lark-cli apps +plugin-list --project-path ./my-app --format json +``` + +## 输出契约 + +- `data.plugins[]` 包含 `key`、`version`、`status`(`installed` / `declared_not_installed`)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md new file mode 100644 index 000000000..6b582e118 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -0,0 +1,22 @@ +# apps +plugin-uninstall + +卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 + +## 何时用 + +用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。 + +## 命令骨架 + +- `--name `:要卸载的插件包 key。 +- `--project-path`:妙搭应用根目录。 + +## 示例 + +```bash +lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate --project-path ./my-app +``` + +## 输出契约 + +- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。 diff --git a/skills/lark-apps/references/lark-apps-plugin.md b/skills/lark-apps/references/lark-apps-plugin.md deleted file mode 100644 index bc5d3921d..000000000 --- a/skills/lark-apps/references/lark-apps-plugin.md +++ /dev/null @@ -1,88 +0,0 @@ -# lark-apps 插件管理 - -妙搭应用的插件(Plugin)体系:插件包安装、插件实例 CRUD、调用代码生成。 - -**触发关键词**:用户要实现 AI生文/AI生图/AI翻译/AI摘要/AI分类/图片理解/图片识别/图片抠图/图片对比/图生图/语音识别/语音合成/文档解析/网页抓取/文本转JSON/搜索摘要 等能力时,或提到 Plugin/PluginInstance/Capability/插件安装/卸载/创建实例时加载本 Skill。 - -## 核心概念 - -- **插件包(Plugin Package)**:npm 格式的功能包,安装到 `node_modules/`,含 `manifest.json` 描述 actions 和 form.schema。 -- **插件实例(Plugin Instance / Capability)**:基于插件包创建的业务配置,存储在 `capabilities/{id}.json`,定义 `paramsSchema`(业务入参)和 `formValue`(表单映射,通过 `{{input.xxx}}` 引用 paramsSchema 参数)。 -- **变量映射**:`调用方传值 → paramsSchema 定义变量 → formValue 消费变量 {{input.xxx}} → Plugin form.schema 接收`。 - -### ⚠️ 插件包 ≠ npm 包(必读) - -| | 插件包 | npm 依赖 | -|------|------|------| -| 安装命令 | `lark-cli apps +plugin-install` | `npm install` | -| 写入字段 | `package.json` → **`actionPlugins`** | `package.json` → `dependencies` / `devDependencies` | -| 用途 | 妙搭平台 AI 能力 | 项目依赖库 | -| **禁止** | ❌ 不能用 `npm install` 装插件包 | ❌ 不能用 `+plugin-install` 装普通依赖 | - -两套机制完全独立。插件包虽然放在 `node_modules/`,但由 `actionPlugins` 字段管理,**与 npm dependencies 无关**。混淆会导致运行时找不到插件。 - -## 确认项目上下文 - -所有本地 plugin 命令需要 `--project-path`。按以下顺序确认: - -1. cwd 有 `.spark/meta.json` → 直接用 cwd -2. 用户给了 app_id → `grep -rl "app_id值" --include="meta.json" .` 搜索工作区 -3. 用户给了应用名称 → `find . -maxdepth 2 -type d -name "名称"` 定位 -4. 都没有 → 询问用户要操作哪个应用 -5. 找不到 → 提示先 `lark-cli apps +create` + `apps +init` - -确认后,所有后续命令统一传 `--project-path <路径>`。 - -## 命令速查 - -### 插件包管理 - -| 命令 | 功能 | 鉴权 | -|------|------|------| -| `+plugin-install --name ` | 下载 tgz → 解压到 node_modules → 更新 package.json | user token | -| `+plugin-install`(无 --name) | 批量安装 package.json actionPlugins 中声明的所有插件 | user token | -| `+plugin-uninstall --name ` | 删除 node_modules/{key} + 移除 actionPlugins 条目 | 无 | -| `+plugin-list` | 列出已声明插件及安装状态(installed / declared_not_installed) | 无 | - -### 插件实例 CRUD - -| 命令 | 功能 | 鉴权 | -|------|------|------| -| `+plugin-instance-create --plugin --name --form-value ` | 校验 + 写 capability JSON | 无 | -| `+plugin-instance-update --id [--name] [--form-value]` | 更新实例可变字段 | 无 | -| `+plugin-instance-delete --id ` | 删除实例(幂等) | 无 | -| `+plugin-instance-get --id ` | 读取单个实例 | 无 | -| `+plugin-instance-list [--summary]` | 列出所有实例 | 无 | - -所有本地命令支持 `--project-path`、`--capabilities-dir`、`--format json`、`--dry-run`。 - ---- - -## 铁律 - -1. **只能通过 CLI 命令修改 capability JSON** — 禁止 Agent 直接编辑 `capabilities/*.json`,必须通过 `+plugin-instance-create` / `+plugin-instance-update` / `+plugin-instance-delete` 操作。 -2. **先装包再建实例** — `+plugin-instance-create` 前必须确保插件包已安装(`+plugin-install`)。 -3. **禁止用 `npm install` 代替 `+plugin-install`** — 插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。 -4. **操作前必读仓库 Skill** — 执行任何插件 CRUD 或生成调用代码前,必须先读取项目中的插件 Skill。 - -## 详细指引(读取仓库 Skill) - -插件目录、用户意图映射、Schema 规则、CRUD 详细流程、AI Prompt 编写指引、校验重试协议等内容已下沉到应用仓库 Skill 中。 - -**执行任何插件操作前,必须先读取项目中的插件 Skill 文件**: - -``` -/.agents/skills/plugin-guide/SKILL.md -``` - -其中 `` 为当前妙搭应用的根目录(含 `.spark/meta.json`)。 - -该文件包含: -- 17 个 AI 插件的完整目录与能力速查 -- 用户意图 → 插件选择映射表 -- paramsSchema / formValue 的 Schema 规则与字段映射 -- AI Prompt 编写规则与各场景模板 -- Create / Update / Delete / Get 详细流程 -- 校验失败重试协议 -- 插件设计原则(原子化、链式、流式) -- 调用代码生成指引 From 09984fa92a1a731261c261b2b8be309b22e8458b Mon Sep 17 00:00:00 2001 From: zhangli Date: Thu, 25 Jun 2026 00:01:19 +0800 Subject: [PATCH 24/40] fix(plugin):simplify skill docs and resolve plugin version from actionPlugins Remove redundant skill documentation (pre-check table, validation error examples, JSON return samples, fullstack-cli references) that duplicate CLI error hints. Make --plugin version optional and resolve from package.json actionPlugins. Drop unused createdBy field. --- shortcuts/apps/plugin_common.go | 12 +++++++ shortcuts/apps/plugin_instance_create.go | 31 +++++++++++++------ shortcuts/apps/plugin_instance_create_test.go | 11 +++++++ shortcuts/apps/plugin_instance_update_test.go | 7 ++--- 4 files changed, 47 insertions(+), 14 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 94dd66770..9809c40d7 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -529,6 +529,18 @@ func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string { return out } +// pluginActionPluginVersion returns the installed version of a plugin from +// actionPlugins. Returns ("", false) if the key is not declared. +func pluginActionPluginVersion(projectPath, key string) (string, bool) { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return "", false + } + declared := pluginGetActionPlugins(pkg) + v, ok := declared[key] + return v, ok +} + // pluginSetActionPlugin adds or updates a plugin entry in actionPlugins. func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) { m, ok := pkg["actionPlugins"].(map[string]interface{}) diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go index 159f9bf6e..263aabe46 100644 --- a/shortcuts/apps/plugin_instance_create.go +++ b/shortcuts/apps/plugin_instance_create.go @@ -25,7 +25,7 @@ var AppsPluginInstanceCreate = common.Shortcut{ Risk: "write", Flags: []common.Flag{ {Name: "id", Desc: "semantic instance id (lowercase + hyphens); auto-derived from plugin key if omitted"}, - {Name: "plugin", Desc: "plugin key@version (e.g. @official-plugins/ai-text-generate@1.0.0)", Required: true}, + {Name: "plugin", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); version is resolved from package.json actionPlugins", Required: true}, {Name: "name", Desc: "display name for the instance", Required: true}, {Name: "description", Desc: "instance description"}, {Name: "form-value", Desc: "formValue JSON object", Required: true, Input: []string{common.File, common.Stdin}}, @@ -35,21 +35,25 @@ var AppsPluginInstanceCreate = common.Shortcut{ {Name: "force", Type: "bool", Desc: "overwrite existing instance with same id"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - pluginKey, pluginVersion, _ := pluginParseKeyVersion(rctx.Str("plugin")) + pluginKey, pluginVersion := pluginParseInstallTarget(rctx.Str("plugin")) id := strings.TrimSpace(rctx.Str("id")) if id == "" { id = pluginDeriveID(pluginKey) } + pluginRef := pluginKey + if pluginVersion != "" { + pluginRef += "@" + pluginVersion + } return common.NewDryRunAPI(). Desc("Create plugin instance (write capability JSON)"). Set("action", "create"). Set("id", id). - Set("plugin", pluginKey+"@"+pluginVersion). + Set("plugin", pluginRef). Set("target", fmt.Sprintf("/%s.json", id)) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if _, _, err := pluginParseKeyVersion(rctx.Str("plugin")); err != nil { - return err + if strings.TrimSpace(rctx.Str("plugin")) == "" { + return appsValidationParamError("--plugin", "--plugin is required") } if id := strings.TrimSpace(rctx.Str("id")); id != "" { if err := pluginValidateID(id); err != nil { @@ -71,9 +75,9 @@ var AppsPluginInstanceCreate = common.Shortcut{ return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - pluginKey, pluginVersion, err := pluginParseKeyVersion(rctx.Str("plugin")) - if err != nil { - return err + pluginKey, pluginVersion := pluginParseInstallTarget(rctx.Str("plugin")) + if pluginKey == "" { + return appsValidationParamError("--plugin", "--plugin is required") } projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) @@ -81,6 +85,16 @@ var AppsPluginInstanceCreate = common.Shortcut{ return err } + // Check that the plugin is declared in actionPlugins + declaredVersion, ok := pluginActionPluginVersion(projectPath, pluginKey) + if !ok { + return appsFailedPreconditionError("plugin %q is not installed; no entry in package.json actionPlugins", pluginKey). + WithHint("run 'lark-cli apps +plugin-install --name %s' to install first", pluginKey) + } + if pluginVersion == "" { + pluginVersion = declaredVersion + } + warnings, err := pluginCheckInstalledVersion(projectPath, pluginKey, pluginVersion) if err != nil { return err @@ -122,7 +136,6 @@ var AppsPluginInstanceCreate = common.Shortcut{ "formValue": formValue, "createdAt": now, "updatedAt": now, - "createdBy": 0, } var paramsSchema interface{} diff --git a/shortcuts/apps/plugin_instance_create_test.go b/shortcuts/apps/plugin_instance_create_test.go index d33fe05ff..86bfd723c 100644 --- a/shortcuts/apps/plugin_instance_create_test.go +++ b/shortcuts/apps/plugin_instance_create_test.go @@ -242,6 +242,11 @@ func TestPluginInstanceCreate_AutoCreateCapDir(t *testing.T) { if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{}`), 0o644); err != nil { //nolint:forbidigo t.Fatal(err) } + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + pluginKey: "1.0.0", + }, + }) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ @@ -277,5 +282,11 @@ func setupPluginTestProjectWithManifest(t *testing.T, appType, pluginKey string) if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{"actions":[]}`), 0o644); err != nil { //nolint:forbidigo t.Fatal(err) } + // Register the plugin in actionPlugins so create's actionPlugins check passes + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + pluginKey: "1.0.0", + }, + }) return dir } diff --git a/shortcuts/apps/plugin_instance_update_test.go b/shortcuts/apps/plugin_instance_update_test.go index b1674abba..7f2398f01 100644 --- a/shortcuts/apps/plugin_instance_update_test.go +++ b/shortcuts/apps/plugin_instance_update_test.go @@ -18,7 +18,7 @@ func TestPluginInstanceUpdate_Name(t *testing.T) { writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", "name": "Old Name", "formValue": map[string]interface{}{"k": "v"}, - "createdAt": 1000, "createdBy": 0, + "createdAt": 1000, }) factory, stdout, _ := newAppsExecuteFactory(t) @@ -43,9 +43,6 @@ func TestPluginInstanceUpdate_Name(t *testing.T) { if cap["pluginKey"] != "@test/p" { t.Errorf("pluginKey should be preserved, got %v", cap["pluginKey"]) } - if cap["createdBy"] != float64(0) { - t.Errorf("createdBy should be preserved, got %v", cap["createdBy"]) - } } func TestPluginInstanceUpdate_FormValue(t *testing.T) { @@ -125,7 +122,7 @@ func TestPluginInstanceUpdate_PreservesImmutableFields(t *testing.T) { writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", "name": "Old", "formValue": map[string]interface{}{}, - "createdAt": float64(1000000), "createdBy": float64(0), + "createdAt": float64(1000000), }) factory, stdout, _ := newAppsExecuteFactory(t) From 41aefd63f065b822928814e4da93a21782b2ce58 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 25 Jun 2026 12:06:46 +0800 Subject: [PATCH 25/40] =?UTF-8?q?fix:=20=E5=8E=BB=E6=8E=89=20reference=20?= =?UTF-8?q?=E4=B8=AD=E7=9A=84=E5=85=B7=E4=BD=93=E6=8F=92=E4=BB=B6=E5=90=8D?= =?UTF-8?q?=E5=92=8C=E5=8F=82=E6=95=B0=E7=A4=BA=E4=BE=8B=EF=BC=8C=E5=BC=BA?= =?UTF-8?q?=E5=88=B6=20agent=20=E8=AF=BB=E4=BB=93=E5=BA=93=20Skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取 - instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误 - 防止 agent 跳过仓库 Skill 凭示例猜测插件名 --- .../references/lark-apps-plugin-install.md | 7 +++-- .../lark-apps-plugin-instance-create.md | 31 +++++++++++-------- .../lark-apps-plugin-instance-update.md | 10 ++++-- .../references/lark-apps-plugin-uninstall.md | 2 +- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md index 307b3eed0..0bc758309 100644 --- a/skills/lark-apps/references/lark-apps-plugin-install.md +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -10,16 +10,17 @@ ## 命令骨架 -- `--name `:插件包 key,如 `@official-plugins/ai-text-generate`。不传则批量安装 `actionPlugins` 中声明的所有插件。 +- `--name `:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。 - `--project-path`:妙搭应用根目录。 ## 示例 ```bash -lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --project-path ./my-app +# plugin-key 从仓库 Skill 的「AI 插件目录」获取 +lark-cli apps +plugin-install --name --project-path # 批量安装已声明的所有插件 -lark-cli apps +plugin-install --project-path ./my-app +lark-cli apps +plugin-install --project-path ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-create.md b/skills/lark-apps/references/lark-apps-plugin-instance-create.md index 6562f7249..a16e3090c 100644 --- a/skills/lark-apps/references/lark-apps-plugin-instance-create.md +++ b/skills/lark-apps/references/lark-apps-plugin-instance-create.md @@ -2,14 +2,18 @@ 创建插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-create --help` 为准。 +## 前置条件 + +本命令的 `--plugin`、`--form-value`、`--params-schema` 参数取值均依赖仓库 Skill 中的规则。未读 `/.agents/skills/plugin-guide/SKILL.md` 直接执行会导致参数错误。 + ## 何时用 -用户要接入某个 AI 能力或飞书平台能力,插件包已安装后,创建对应的插件实例。创建前必须读取仓库 Skill 了解 Schema 规则:`/.agents/skills/plugin-guide/SKILL.md`。 +用户要接入某个 AI 能力或飞书平台能力,插件包已安装后,创建对应的插件实例。 ## 命令骨架 -- `--id`:语义化 ID,小写+短横线,如 `task-text-summary`。 -- `--plugin`:插件包 key,如 `@official-plugins/ai-text-generate`。 +- `--id`:语义化 ID,小写+短横线。 +- `--plugin`:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。 - `--name`:实例显示名称。 - `--description`:实例描述(可选)。 - `--form-value`:formValue JSON,或 `@file.json` 从文件读取。 @@ -20,21 +24,22 @@ ## 示例 ```bash +# plugin-key、formValue、paramsSchema 的设计规则见仓库 Skill lark-cli apps +plugin-instance-create \ - --id task-text-summary \ - --plugin @official-plugins/ai-text-generate \ - --name "任务摘要生成" \ - --form-value '{"prompt":"请总结以下任务内容:\n{{input.task_content}}"}' \ - --params-schema '{"type":"object","properties":{"task_content":{"type":"string","description":"任务详情文本"}},"required":["task_content"]}' \ - --project-path ./my-app --format json + --id <语义化ID> \ + --plugin \ + --name <名称> \ + --form-value \ + --params-schema \ + --project-path --format json # 大 JSON 场景用 @file 传入 lark-cli apps +plugin-instance-create \ - --id task-text-summary \ - --plugin @official-plugins/ai-text-generate \ - --name "任务摘要生成" \ + --id <语义化ID> \ + --plugin \ + --name <名称> \ --form-value @form.json --params-schema @schema.json \ - --project-path ./my-app --format json + --project-path --format json ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-update.md b/skills/lark-apps/references/lark-apps-plugin-instance-update.md index 28b57b18a..9662687d1 100644 --- a/skills/lark-apps/references/lark-apps-plugin-instance-update.md +++ b/skills/lark-apps/references/lark-apps-plugin-instance-update.md @@ -2,6 +2,10 @@ 更新已有插件实例的配置。运行时命令事实以 `lark-cli apps +plugin-instance-update --help` 为准。 +## 前置条件 + +`--form-value`、`--params-schema` 的设计规则在仓库 Skill 中。未读 `/.agents/skills/plugin-guide/SKILL.md` 直接修改会导致参数错误。 + ## 何时用 用户要修改已有插件实例的 name、formValue 或 paramsSchema(如改 prompt、换参数)。 @@ -20,12 +24,12 @@ ```bash # 只改名 -lark-cli apps +plugin-instance-update --id task-text-summary --name "新名称" --project-path ./my-app +lark-cli apps +plugin-instance-update --id --name <新名称> --project-path # 改 formValue + paramsSchema -lark-cli apps +plugin-instance-update --id task-text-summary \ +lark-cli apps +plugin-instance-update --id \ --form-value @form.json --params-schema @schema.json \ - --project-path ./my-app --format json + --project-path --format json ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md index 6b582e118..faeb39e3a 100644 --- a/skills/lark-apps/references/lark-apps-plugin-uninstall.md +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -14,7 +14,7 @@ ## 示例 ```bash -lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate --project-path ./my-app +lark-cli apps +plugin-uninstall --name --project-path ``` ## 输出契约 From 0ff2957c6ef05d3735cb044df4c292be4fd06648 Mon Sep 17 00:00:00 2001 From: zhangli Date: Thu, 25 Jun 2026 16:20:58 +0800 Subject: [PATCH 26/40] fix(plugin): resolve real paths in dry-run output for instance commands Replace placeholders with resolved paths so models can see actual file locations before execution. Add version_source, types_output, and scan_dir fields to describe implicit behaviors. --- shortcuts/apps/plugin_instance_create.go | 10 ++++++++-- shortcuts/apps/plugin_instance_delete.go | 6 ++++-- shortcuts/apps/plugin_instance_get.go | 5 ++++- shortcuts/apps/plugin_instance_list.go | 3 +++ shortcuts/apps/plugin_instance_update.go | 8 ++++++-- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go index 263aabe46..f40cdb8b5 100644 --- a/shortcuts/apps/plugin_instance_create.go +++ b/shortcuts/apps/plugin_instance_create.go @@ -44,12 +44,18 @@ var AppsPluginInstanceCreate = common.Shortcut{ if pluginVersion != "" { pluginRef += "@" + pluginVersion } + // Resolve output paths for preview (read-only, safe in dry-run) + projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) + capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) return common.NewDryRunAPI(). - Desc("Create plugin instance (write capability JSON)"). + Desc("Create plugin instance: validate formValue, write capability JSON, auto-generate TypeScript types"). Set("action", "create"). Set("id", id). Set("plugin", pluginRef). - Set("target", fmt.Sprintf("/%s.json", id)) + Set("target", fmt.Sprintf("/%s.json", id)). + Set("version_source", "resolved from package.json actionPlugins"). + Set("output", filepath.Join(capDir, id+".json")). + Set("types_output", filepath.Join(projectPath, "shared", "plugin-types.ts")) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("plugin")) == "" { diff --git a/shortcuts/apps/plugin_instance_delete.go b/shortcuts/apps/plugin_instance_delete.go index dce0a48dc..4bc428140 100644 --- a/shortcuts/apps/plugin_instance_delete.go +++ b/shortcuts/apps/plugin_instance_delete.go @@ -28,11 +28,13 @@ var AppsPluginInstanceDelete = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { id := strings.TrimSpace(rctx.Str("id")) + projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) + capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) return common.NewDryRunAPI(). - Desc("Delete plugin instance (remove capability JSON)"). + Desc("Delete plugin instance (remove capability JSON, idempotent)"). Set("action", "delete"). Set("id", id). - Set("target", fmt.Sprintf("/%s.json", id)) + Set("target", filepath.Join(capDir, id+".json")) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("id")) == "" { diff --git a/shortcuts/apps/plugin_instance_get.go b/shortcuts/apps/plugin_instance_get.go index 4e0cb5a6c..64b0d3186 100644 --- a/shortcuts/apps/plugin_instance_get.go +++ b/shortcuts/apps/plugin_instance_get.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -26,11 +27,13 @@ var AppsPluginInstanceGet = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { id := strings.TrimSpace(rctx.Str("id")) + projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) + capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) return common.NewDryRunAPI(). Desc("Get plugin instance (read capability JSON)"). Set("action", "get"). Set("id", id). - Set("source", fmt.Sprintf("/%s.json", id)) + Set("source", filepath.Join(capDir, id+".json")) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { id := strings.TrimSpace(rctx.Str("id")) diff --git a/shortcuts/apps/plugin_instance_list.go b/shortcuts/apps/plugin_instance_list.go index 5c54f51b0..c298a5209 100644 --- a/shortcuts/apps/plugin_instance_list.go +++ b/shortcuts/apps/plugin_instance_list.go @@ -25,9 +25,12 @@ var AppsPluginInstanceList = common.Shortcut{ {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) + capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) return common.NewDryRunAPI(). Desc("List plugin instances (scan capabilities directory)"). Set("action", "list"). + Set("scan_dir", capDir). Set("summary", fmt.Sprintf("%v", rctx.Bool("summary"))) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { diff --git a/shortcuts/apps/plugin_instance_update.go b/shortcuts/apps/plugin_instance_update.go index 7769b2623..3b0723da0 100644 --- a/shortcuts/apps/plugin_instance_update.go +++ b/shortcuts/apps/plugin_instance_update.go @@ -32,11 +32,15 @@ var AppsPluginInstanceUpdate = common.Shortcut{ }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { id := strings.TrimSpace(rctx.Str("id")) + projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) + capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) return common.NewDryRunAPI(). - Desc("Update plugin instance (modify capability JSON)"). + Desc("Update plugin instance: merge partial updates to existing capability, validate formValue, write back, auto-regenerate TypeScript types"). Set("action", "update"). Set("id", id). - Set("target", fmt.Sprintf("/%s.json", id)) + Set("target", fmt.Sprintf("/%s.json", id)). + Set("output", filepath.Join(capDir, id+".json")). + Set("types_output", filepath.Join(projectPath, "shared", "plugin-types.ts")) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { id := strings.TrimSpace(rctx.Str("id")) From 4e2abab504d46fc53e562afca07cb659a45a5466 Mon Sep 17 00:00:00 2001 From: zhangli Date: Thu, 25 Jun 2026 21:42:50 +0800 Subject: [PATCH 27/40] refactor(plugin): hide instance commands, delegate to repo Skill Hide +plugin-instance-create/update/delete/get/list from CLI help. Remove instance reference files from lark-apps skill. Route instance CRUD and call code generation to project repo plugin-guide skill. Go instance code preserved, just hidden. --- shortcuts/apps/plugin_instance_create.go | 2 +- shortcuts/apps/plugin_instance_delete.go | 2 +- shortcuts/apps/plugin_instance_get.go | 2 +- shortcuts/apps/plugin_instance_list.go | 2 +- shortcuts/apps/plugin_instance_update.go | 2 +- skills/lark-apps/SKILL.md | 2 +- .../lark-apps-plugin-instance-create.md | 54 ------------------- .../lark-apps-plugin-instance-delete.md | 26 --------- .../lark-apps-plugin-instance-get.md | 23 -------- .../lark-apps-plugin-instance-list.md | 25 --------- .../lark-apps-plugin-instance-update.md | 42 --------------- 11 files changed, 6 insertions(+), 176 deletions(-) delete mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-create.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-delete.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-get.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-list.md delete mode 100644 skills/lark-apps/references/lark-apps-plugin-instance-update.md diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go index f40cdb8b5..29c75b30a 100644 --- a/shortcuts/apps/plugin_instance_create.go +++ b/shortcuts/apps/plugin_instance_create.go @@ -22,7 +22,7 @@ var AppsPluginInstanceCreate = common.Shortcut{ Service: appsService, Command: "+plugin-instance-create", Description: "Create a plugin instance (write capability JSON)", - Risk: "write", + Risk: "write", Hidden: true, Flags: []common.Flag{ {Name: "id", Desc: "semantic instance id (lowercase + hyphens); auto-derived from plugin key if omitted"}, {Name: "plugin", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); version is resolved from package.json actionPlugins", Required: true}, diff --git a/shortcuts/apps/plugin_instance_delete.go b/shortcuts/apps/plugin_instance_delete.go index 4bc428140..88ee07151 100644 --- a/shortcuts/apps/plugin_instance_delete.go +++ b/shortcuts/apps/plugin_instance_delete.go @@ -20,7 +20,7 @@ var AppsPluginInstanceDelete = common.Shortcut{ Service: appsService, Command: "+plugin-instance-delete", Description: "Delete a plugin instance", - Risk: "write", + Risk: "write", Hidden: true, Flags: []common.Flag{ {Name: "id", Desc: "instance id", Required: true}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, diff --git a/shortcuts/apps/plugin_instance_get.go b/shortcuts/apps/plugin_instance_get.go index 64b0d3186..27eeff3df 100644 --- a/shortcuts/apps/plugin_instance_get.go +++ b/shortcuts/apps/plugin_instance_get.go @@ -19,7 +19,7 @@ var AppsPluginInstanceGet = common.Shortcut{ Service: appsService, Command: "+plugin-instance-get", Description: "Get a plugin instance by id", - Risk: "read", + Risk: "read", Hidden: true, Flags: []common.Flag{ {Name: "id", Desc: "instance id (filename without .json in capabilities/)", Required: true}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, diff --git a/shortcuts/apps/plugin_instance_list.go b/shortcuts/apps/plugin_instance_list.go index c298a5209..4c1d8d3eb 100644 --- a/shortcuts/apps/plugin_instance_list.go +++ b/shortcuts/apps/plugin_instance_list.go @@ -18,7 +18,7 @@ var AppsPluginInstanceList = common.Shortcut{ Service: appsService, Command: "+plugin-instance-list", Description: "List all plugin instances in the project", - Risk: "read", + Risk: "read", Hidden: true, Flags: []common.Flag{ {Name: "summary", Type: "bool", Desc: "show only id and name"}, {Name: "project-path", Desc: "project root path (defaults to current directory)"}, diff --git a/shortcuts/apps/plugin_instance_update.go b/shortcuts/apps/plugin_instance_update.go index 3b0723da0..9a4cf667e 100644 --- a/shortcuts/apps/plugin_instance_update.go +++ b/shortcuts/apps/plugin_instance_update.go @@ -21,7 +21,7 @@ var AppsPluginInstanceUpdate = common.Shortcut{ Service: appsService, Command: "+plugin-instance-update", Description: "Update a plugin instance (modify capability JSON)", - Risk: "write", + Risk: "write", Hidden: true, Flags: []common.Flag{ {Name: "id", Desc: "instance id", Required: true}, {Name: "name", Desc: "new display name"}, diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 36263b3a7..8970b2147 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -29,7 +29,7 @@ metadata: | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 插件集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、CRUD 流程、调用代码生成等完整指引)。CLI 命令参考见各 `lark-apps-plugin-*.md` reference | 仓库 Skill | +| 插件集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引)。插件包管理(安装/卸载/查看)参考 [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | 仓库 Skill | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-create.md b/skills/lark-apps/references/lark-apps-plugin-instance-create.md deleted file mode 100644 index a16e3090c..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-instance-create.md +++ /dev/null @@ -1,54 +0,0 @@ -# apps +plugin-instance-create - -创建插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-create --help` 为准。 - -## 前置条件 - -本命令的 `--plugin`、`--form-value`、`--params-schema` 参数取值均依赖仓库 Skill 中的规则。未读 `/.agents/skills/plugin-guide/SKILL.md` 直接执行会导致参数错误。 - -## 何时用 - -用户要接入某个 AI 能力或飞书平台能力,插件包已安装后,创建对应的插件实例。 - -## 命令骨架 - -- `--id`:语义化 ID,小写+短横线。 -- `--plugin`:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。 -- `--name`:实例显示名称。 -- `--description`:实例描述(可选)。 -- `--form-value`:formValue JSON,或 `@file.json` 从文件读取。 -- `--params-schema`:paramsSchema JSON,或 `@file.json` 从文件读取。 -- `--project-path`:妙搭应用根目录。 -- `--force`:覆盖已存在的同 ID 实例(可选)。 - -## 示例 - -```bash -# plugin-key、formValue、paramsSchema 的设计规则见仓库 Skill -lark-cli apps +plugin-instance-create \ - --id <语义化ID> \ - --plugin \ - --name <名称> \ - --form-value \ - --params-schema \ - --project-path --format json - -# 大 JSON 场景用 @file 传入 -lark-cli apps +plugin-instance-create \ - --id <语义化ID> \ - --plugin \ - --name <名称> \ - --form-value @form.json --params-schema @schema.json \ - --project-path --format json -``` - -## 输出契约 - -- 成功返回 `ok: true` + 创建的实例配置。 -- 校验失败返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理(max 3 次,create 重试加 `--force`)。 - -## Agent 规则 - -- 创建前必须先 `+plugin-install` 安装插件包。 -- formValue / paramsSchema 的设计规则在仓库 Skill 中,不要凭记忆猜测。 -- 创建成功后,读 manifest + capability JSON,再按仓库 Skill 生成调用代码。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-delete.md b/skills/lark-apps/references/lark-apps-plugin-instance-delete.md deleted file mode 100644 index fcb134101..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-instance-delete.md +++ /dev/null @@ -1,26 +0,0 @@ -# apps +plugin-instance-delete - -删除插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-delete --help` 为准。 - -## 何时用 - -用户不再需要某个插件实例时删除。删除前应先清理代码中对该实例的引用。 - -## 命令骨架 - -- `--id`:要删除的实例 ID。 -- `--project-path`:妙搭应用根目录。 - -## 示例 - -```bash -# 先扫描代码引用 -grep -rn "load('task-text-summary')" ./my-app/client/ ./my-app/server/ - -# 确认无引用或已清理后删除 -lark-cli apps +plugin-instance-delete --id task-text-summary --project-path ./my-app --format json -``` - -## 输出契约 - -- 幂等操作,文件不存在也返回 `deleted: true`。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-get.md b/skills/lark-apps/references/lark-apps-plugin-instance-get.md deleted file mode 100644 index 1361dbda8..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-instance-get.md +++ /dev/null @@ -1,23 +0,0 @@ -# apps +plugin-instance-get - -查询单个插件实例的完整配置。运行时命令事实以 `lark-cli apps +plugin-instance-get --help` 为准。 - -## 何时用 - -需要查看某个插件实例的当前 paramsSchema、formValue 等配置时。也用于生成调用代码前获取实例信息。 - -## 命令骨架 - -- `--id`:实例 ID。 -- `--project-path`:妙搭应用根目录。 - -## 示例 - -```bash -lark-cli apps +plugin-instance-get --id task-text-summary --project-path ./my-app --format json -``` - -## 输出契约 - -- 返回实例的完整 JSON 配置(id、pluginKey、pluginVersion、name、description、paramsSchema、formValue 等)。 -- 实例不存在时返回 `instance not found`。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-list.md b/skills/lark-apps/references/lark-apps-plugin-instance-list.md deleted file mode 100644 index 3839cac67..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-instance-list.md +++ /dev/null @@ -1,25 +0,0 @@ -# apps +plugin-instance-list - -列出当前项目的所有插件实例。运行时命令事实以 `lark-cli apps +plugin-instance-list --help` 为准。 - -## 何时用 - -查看项目中已创建了哪些插件实例,判断是否需要新建或可以复用已有实例。 - -## 命令骨架 - -- `--summary`:仅输出 id + name(可选)。 -- `--project-path`:妙搭应用根目录。 - -## 示例 - -```bash -lark-cli apps +plugin-instance-list --project-path ./my-app --format json - -# 仅看 id 和 name -lark-cli apps +plugin-instance-list --summary --project-path ./my-app -``` - -## 输出契约 - -- 返回所有实例的配置列表。`--summary` 时仅返回 id 和 name。 diff --git a/skills/lark-apps/references/lark-apps-plugin-instance-update.md b/skills/lark-apps/references/lark-apps-plugin-instance-update.md deleted file mode 100644 index 9662687d1..000000000 --- a/skills/lark-apps/references/lark-apps-plugin-instance-update.md +++ /dev/null @@ -1,42 +0,0 @@ -# apps +plugin-instance-update - -更新已有插件实例的配置。运行时命令事实以 `lark-cli apps +plugin-instance-update --help` 为准。 - -## 前置条件 - -`--form-value`、`--params-schema` 的设计规则在仓库 Skill 中。未读 `/.agents/skills/plugin-guide/SKILL.md` 直接修改会导致参数错误。 - -## 何时用 - -用户要修改已有插件实例的 name、formValue 或 paramsSchema(如改 prompt、换参数)。 - -## 命令骨架 - -- `--id`:要更新的实例 ID。 -- `--name`:新名称(可选)。 -- `--form-value`:新 formValue JSON 或 `@file.json`(可选)。 -- `--params-schema`:新 paramsSchema JSON 或 `@file.json`(可选)。 -- `--project-path`:妙搭应用根目录。 - -只传需要修改的字段,CLI 自动保留不可变字段(id / pluginKey / pluginVersion / createdAt)。 - -## 示例 - -```bash -# 只改名 -lark-cli apps +plugin-instance-update --id --name <新名称> --project-path - -# 改 formValue + paramsSchema -lark-cli apps +plugin-instance-update --id \ - --form-value @form.json --params-schema @schema.json \ - --project-path --format json -``` - -## 输出契约 - -- 成功返回 `ok: true` + 更新后的实例配置。 -- 校验失败返回 `ok: false` + `error.hint`,按仓库 Skill 中的重试协议处理。 - -## Agent 规则 - -- paramsSchema 变化时,需扫描代码引用(`grep -rn "load('${id}')" /`)并更新调用代码。 From 490006ee7b5920e55ffad7649d696d347d28a850 Mon Sep 17 00:00:00 2001 From: anguohui Date: Thu, 25 Jun 2026 22:02:30 +0800 Subject: [PATCH 28/40] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=20plugin-i?= =?UTF-8?q?nstance=205=20=E4=B8=AA=20CLI=20=E5=91=BD=E4=BB=A4=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=B1=E4=BB=93=E5=BA=93=20Skill=20=E5=BC=95?= =?UTF-8?q?=E5=AF=BC=20agent=20=E7=9B=B4=E6=8E=A5=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件) - 删除 plugin_instance_types(TypeScript 类型生成命令) - 移除 shortcuts.go 中的 6 个注册项 - 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行): 校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等 - 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变 插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json, 验证规则写在 Skill 中由 agent 自校验。 --- shortcuts/apps/plugin_common.go | 565 +----------------- shortcuts/apps/plugin_common_test.go | 150 ----- shortcuts/apps/plugin_instance_create.go | 197 ------ shortcuts/apps/plugin_instance_create_test.go | 292 --------- shortcuts/apps/plugin_instance_delete.go | 75 --- shortcuts/apps/plugin_instance_delete_test.go | 81 --- shortcuts/apps/plugin_instance_get.go | 94 --- shortcuts/apps/plugin_instance_get_test.go | 93 --- shortcuts/apps/plugin_instance_list.go | 97 --- shortcuts/apps/plugin_instance_list_test.go | 151 ----- shortcuts/apps/plugin_instance_types.go | 82 --- shortcuts/apps/plugin_instance_update.go | 145 ----- shortcuts/apps/plugin_instance_update_test.go | 161 ----- shortcuts/apps/shortcuts.go | 6 - 14 files changed, 20 insertions(+), 2169 deletions(-) delete mode 100644 shortcuts/apps/plugin_instance_create.go delete mode 100644 shortcuts/apps/plugin_instance_create_test.go delete mode 100644 shortcuts/apps/plugin_instance_delete.go delete mode 100644 shortcuts/apps/plugin_instance_delete_test.go delete mode 100644 shortcuts/apps/plugin_instance_get.go delete mode 100644 shortcuts/apps/plugin_instance_get_test.go delete mode 100644 shortcuts/apps/plugin_instance_list.go delete mode 100644 shortcuts/apps/plugin_instance_list_test.go delete mode 100644 shortcuts/apps/plugin_instance_types.go delete mode 100644 shortcuts/apps/plugin_instance_update.go delete mode 100644 shortcuts/apps/plugin_instance_update_test.go diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 9809c40d7..45b5fd491 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -11,17 +11,12 @@ import ( "io" "os" "path/filepath" - "regexp" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" ) -// pluginIDPattern validates semantic instance ids: lowercase alphanumeric + hyphens, -// not starting or ending with a hyphen. -var pluginIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`) - // pluginResolveProjectPath resolves --project-path to an absolute path, // defaulting to cwd when empty. func pluginResolveProjectPath(raw string) (string, error) { @@ -136,19 +131,6 @@ func pluginDirExists(path string) bool { return err == nil && info.IsDir() } -// pluginReadCapJSON reads and parses a single capability JSON file. -func pluginReadCapJSON(path string) (map[string]interface{}, error) { - data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local capability file read. - if err != nil { - return nil, err - } - var cap map[string]interface{} - if err := json.Unmarshal(data, &cap); err != nil { - return nil, fmt.Errorf("invalid JSON in %s: %w", filepath.Base(path), err) - } - return cap, nil -} - // pluginListCapabilities reads all *.json files from capDir. // Returns nil (not error) if the directory does not exist. func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { @@ -165,116 +147,19 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } - cap, err := pluginReadCapJSON(filepath.Join(capDir, entry.Name())) + data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo if err != nil { continue } + var cap map[string]interface{} + if err := json.Unmarshal(data, &cap); err != nil { + continue + } caps = append(caps, cap) } return caps, nil } -// pluginGetCapability reads a single capability by id from capDir. -// The file is expected at capDir/{id}.json. -func pluginGetCapability(capDir, id string) (map[string]interface{}, error) { - path := filepath.Join(capDir, id+".json") - cap, err := pluginReadCapJSON(path) - if err != nil { - if os.IsNotExist(err) { - return nil, appsValidationError("instance %q not found", id). - WithHint("list instances with 'lark-cli apps +plugin-instance-list'") - } - return nil, appsFileIOError(err, "cannot read capability %s", path) - } - return cap, nil -} - -// pluginReadManifest reads manifest.json from node_modules for the given pluginKey. -func pluginReadManifest(projectPath, pluginKey string) (map[string]interface{}, error) { - path := filepath.Join(projectPath, "node_modules", pluginKey, "manifest.json") - data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local manifest read. - if err != nil { - return nil, err - } - var manifest map[string]interface{} - if err := json.Unmarshal(data, &manifest); err != nil { - return nil, fmt.Errorf("invalid manifest.json for %s: %w", pluginKey, err) - } - return manifest, nil -} - -// pluginParseKeyVersion splits "key@version" into (key, version). -// The key may start with "@" (scoped npm package), so the split is at the last "@". -func pluginParseKeyVersion(s string) (string, string, error) { - s = strings.TrimSpace(s) - if s == "" { - return "", "", appsValidationParamError("--plugin", "--plugin is required") - } - idx := strings.LastIndex(s, "@") - if idx <= 0 { - return "", "", appsValidationParamError("--plugin", - "invalid format %q; expected key@version (e.g. @official-plugins/ai-text-generate@1.0.0)", s) - } - key, version := s[:idx], s[idx+1:] - if key == "" || version == "" { - return "", "", appsValidationParamError("--plugin", - "invalid format %q; expected key@version", s) - } - return key, version, nil -} - -// pluginDeriveID derives an instance id from a plugin key. -// "@official-plugins/ai-text-generate" → "official-plugins-ai-text-generate" -func pluginDeriveID(pluginKey string) string { - id := strings.TrimPrefix(pluginKey, "@") - id = strings.ReplaceAll(id, "/", "-") - return id -} - -// pluginValidateID checks that id is a valid semantic instance id. -func pluginValidateID(id string) error { - if !pluginIDPattern.MatchString(id) { - return appsValidationParamError("--id", - "invalid id %q; must be lowercase alphanumeric with hyphens, not starting/ending with hyphen", id) - } - return nil -} - -// pluginValidateJSONFlag checks that value is non-empty valid JSON. -func pluginValidateJSONFlag(flagName, value string) error { - value = strings.TrimSpace(value) - if value == "" { - return appsValidationParamError(flagName, "%s value is required", flagName) - } - if !json.Valid([]byte(value)) { - return appsValidationParamError(flagName, "%s must be valid JSON", flagName) - } - return nil -} - -// pluginCheckInstalled verifies that the plugin package is installed in node_modules -// with a valid manifest.json. Distinguishes three failure cases: -// - plugin directory does not exist → "not installed" -// - plugin directory exists but manifest.json missing → "not built" -// - other I/O error -func pluginCheckInstalled(projectPath, pluginKey string) error { - pluginDir := filepath.Join(projectPath, "node_modules", pluginKey) - manifestPath := filepath.Join(pluginDir, "manifest.json") - if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. - if os.IsNotExist(err) { - if pluginDirExists(pluginDir) { - return appsFailedPreconditionError( - "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, - ).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey) - } - return appsFailedPreconditionError("plugin %q is not installed", pluginKey). - WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey) - } - return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) - } - return nil -} - // pluginCheckDependentInstances scans the capabilities directory for instances // that reference the given pluginKey. Returns nil if none found, an error with // the list of dependent instance ids if any exist, or the underlying I/O error. @@ -305,183 +190,24 @@ func pluginCheckDependentInstances(projectPath, pluginKey, capDirFlag string) er ).WithHint("delete these instances first (lark-cli apps +plugin-instance-delete --id for each), clean up calling code and types, then retry uninstall") } -// pluginCheckInstalledVersion checks that the plugin is installed and warns if -// the installed version differs from the declared version. Returns (warnings, error). -func pluginCheckInstalledVersion(projectPath, pluginKey, declaredVersion string) ([]string, error) { - if err := pluginCheckInstalled(projectPath, pluginKey); err != nil { - return nil, err - } - var warnings []string - if installed := pluginInstalledVersion(projectPath, pluginKey); installed != "" && installed != declaredVersion { - warnings = append(warnings, fmt.Sprintf( - "installed version %s differs from declared %s; run 'lark-cli apps +plugin-install --name %s@%s' to update, or continue with the installed version", - installed, declaredVersion, pluginKey, declaredVersion)) - } - return warnings, nil -} - -// ── formValue validation (aligned with feida-ai validatePluginInstance) ── - -// Forbidden Handlebars block-level helpers. -var pluginForbiddenTemplatePatterns = []*regexp.Regexp{ - regexp.MustCompile(`\{\{#if\b`), - regexp.MustCompile(`\{\{#each\b`), - regexp.MustCompile(`\{\{#unless\b`), - regexp.MustCompile(`\{\{/if\}\}`), - regexp.MustCompile(`\{\{/each\}\}`), - regexp.MustCompile(`\{\{/unless\}\}`), - regexp.MustCompile(`\{\{else\}\}`), -} - -// pluginInputRefPattern matches {{input.xxx}} template references. -var pluginInputRefPattern = regexp.MustCompile(`\{\{input\.(\w+)\}\}`) - -// pluginTemplateRefExact matches a string that is exactly one {{input.xxx}} with no surrounding text. -var pluginTemplateRefExact = regexp.MustCompile(`^\{\{input\.(\w+)\}\}$`) - -// pluginValidateFormValue validates formValue and paramsSchema following feida-ai's -// validatePluginInstance rules. Returns all violations; empty means valid. -// Also auto-fixes array double-wrapping in formValue (mutates fvMap in place). -func pluginValidateFormValue(formValue, paramsSchema interface{}) []string { - var errors []string - - fvMap, _ := formValue.(map[string]interface{}) - - // Rule 1: Forbidden Handlebars control syntax (recursive) - pluginTraverseValues(formValue, "formValue", func(s, path string) { - for _, pat := range pluginForbiddenTemplatePatterns { - if pat.MatchString(s) { - errors = append(errors, fmt.Sprintf("forbidden Handlebars syntax at %s: %s", path, pat.FindString(s))) - } - } - }) - - // If no paramsSchema provided, skip schema-dependent rules - psMap, _ := paramsSchema.(map[string]interface{}) - properties, _ := psMap["properties"].(map[string]interface{}) - definedParams := make(map[string]bool, len(properties)) - for k := range properties { - definedParams[k] = true - } - - // Rule 2: paramsSchema property type validation - allowedTypes := map[string]bool{"string": true, "array": true} - allowedFormats := map[string]bool{"plugin-image-url": true, "plugin-file-url": true} - for paramName, paramDef := range properties { - def, ok := paramDef.(map[string]interface{}) - if !ok { - continue - } - paramType, _ := def["type"].(string) - if !allowedTypes[paramType] { - errors = append(errors, fmt.Sprintf("paramsSchema property %q type %q is invalid; only string or array allowed", paramName, paramType)) - } - if paramType == "array" { - if _, hasItems := def["items"]; !hasItems { - errors = append(errors, fmt.Sprintf("paramsSchema property %q is array but missing items", paramName)) - } - } - if f, ok := def["format"].(string); ok && !allowedFormats[f] { - errors = append(errors, fmt.Sprintf("paramsSchema property %q format %q is invalid; only plugin-image-url or plugin-file-url allowed", paramName, f)) - } - if _, hasDesc := def["description"]; !hasDesc { - errors = append(errors, fmt.Sprintf("paramsSchema property %q missing description", paramName)) - } - } - - // Rule 3: {{input.xxx}} references must exist in paramsSchema - pluginTraverseValues(formValue, "formValue", func(s, path string) { - for _, match := range pluginInputRefPattern.FindAllStringSubmatch(s, -1) { - if !definedParams[match[1]] { - errors = append(errors, fmt.Sprintf("{{input.%s}} at %s is not defined in paramsSchema", match[1], path)) - } - } - }) - - // Rule 4: every paramsSchema property must be consumed by {{input.xxx}} in formValue - if len(definedParams) > 0 && fvMap != nil { - fvStr, _ := json.Marshal(fvMap) - fvJSON := string(fvStr) - for paramName := range definedParams { - ref := "{{input." + paramName + "}}" - if !strings.Contains(fvJSON, ref) { - errors = append(errors, fmt.Sprintf("paramsSchema property %q is never referenced as %s in formValue", paramName, ref)) - } - } - } - - // Rule 5: array double-wrapping auto-fix - // If paramsSchema declares a field as type:array, and formValue wraps it in - // ["{{input.xxx}}"], auto-fix to "{{input.xxx}}" to prevent runtime [[val]] nesting. - if fvMap != nil { - arrayParams := make(map[string]bool) - for paramName, paramDef := range properties { - if def, ok := paramDef.(map[string]interface{}); ok { - if t, _ := def["type"].(string); t == "array" { - arrayParams[paramName] = true - } - } - } - if len(arrayParams) > 0 { - pluginAutoFixArrayWrapping(fvMap, arrayParams) - } - } - - return errors -} - -// pluginTraverseValues recursively visits all string leaf values in a nested -// structure (object / array / string), calling visitor for each. -func pluginTraverseValues(value interface{}, path string, visitor func(s, path string)) { - switch v := value.(type) { - case string: - visitor(v, path) - case []interface{}: - for i, item := range v { - pluginTraverseValues(item, fmt.Sprintf("%s[%d]", path, i), visitor) - } - case map[string]interface{}: - for key, val := range v { - pluginTraverseValues(val, path+"."+key, visitor) - } - } -} - -// pluginAutoFixArrayWrapping fixes ["{{input.xxx}}"] → "{{input.xxx}}" for -// array-typed params to prevent runtime double-wrapping. -func pluginAutoFixArrayWrapping(obj map[string]interface{}, arrayParams map[string]bool) { - for key, value := range obj { - arr, ok := value.([]interface{}) - if ok && len(arr) == 1 { - if s, ok := arr[0].(string); ok { - if m := pluginTemplateRefExact.FindStringSubmatch(s); m != nil && arrayParams[m[1]] { - obj[key] = s - } +// pluginCheckInstalled verifies that the plugin package is installed in node_modules +// with a valid manifest.json. +func pluginCheckInstalled(projectPath, pluginKey string) error { + pluginDir := filepath.Join(projectPath, "node_modules", pluginKey) + manifestPath := filepath.Join(pluginDir, "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. + if os.IsNotExist(err) { + if pluginDirExists(pluginDir) { + return appsFailedPreconditionError( + "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, + ).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey) } + return appsFailedPreconditionError("plugin %q is not installed", pluginKey). + WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey) } - if nested, ok := value.(map[string]interface{}); ok { - pluginAutoFixArrayWrapping(nested, arrayParams) - } - } -} - -// pluginWriteCapJSON writes a capability map to capDir/{id}.json atomically. -func pluginWriteCapJSON(capPath string, cap map[string]interface{}) error { - data, err := json.MarshalIndent(cap, "", " ") - if err != nil { - return appsFileIOError(err, "cannot marshal capability JSON") - } - data = append(data, '\n') - return validate.AtomicWrite(capPath, data, 0o644) -} - -// pluginCapRelPath returns the capability file path relative to projectPath. -func pluginCapRelPath(projectPath, capPath string) string { - rel, err := filepath.Rel(projectPath, capPath) - if err != nil { - return capPath + return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) } - return rel + return nil } // ── package.json helpers ── @@ -529,18 +255,6 @@ func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string { return out } -// pluginActionPluginVersion returns the installed version of a plugin from -// actionPlugins. Returns ("", false) if the key is not declared. -func pluginActionPluginVersion(projectPath, key string) (string, bool) { - pkg, err := pluginReadPackageJSON(projectPath) - if err != nil { - return "", false - } - declared := pluginGetActionPlugins(pkg) - v, ok := declared[key] - return v, ok -} - // pluginSetActionPlugin adds or updates a plugin entry in actionPlugins. func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) { m, ok := pkg["actionPlugins"].(map[string]interface{}) @@ -699,242 +413,3 @@ func pluginStripFirstComponent(name string) string { } return "" } - -// ── TypeScript type generation ── - -const pluginTypesFile = "shared/plugin-types.ts" -const pluginBlockStartPrefix = "// ---- plugin:" -const pluginBlockEndPrefix = "// ---- end:" - -// pluginGenerateAndPersistTypes reads manifest + capability, generates TypeScript -// interfaces, and writes them to shared/plugin-types.ts with per-id block replacement. -// Returns (outputPath, typeNames, error). -func pluginGenerateAndPersistTypes(projectPath string, cap map[string]interface{}) (string, []string, error) { - pluginKey, _ := cap["pluginKey"].(string) - id, _ := cap["id"].(string) - name, _ := cap["name"].(string) - if pluginKey == "" || id == "" { - return "", nil, fmt.Errorf("capability missing pluginKey or id") - } - - manifest, err := pluginReadManifest(projectPath, pluginKey) - if err != nil { - return "", nil, fmt.Errorf("cannot read manifest for %s: %w", pluginKey, err) - } - - actions, _ := manifest["actions"].([]interface{}) - if len(actions) == 0 { - return "", nil, fmt.Errorf("plugin %s has no actions defined", pluginKey) - } - - prefix := pluginToPascalCase(id) - var typeNames []string - var parts []string - parts = append(parts, - "// ============================================================", - fmt.Sprintf("// 插件 %s (%s) 的类型定义", id, name), - "// 由 lark-cli +plugin-instance-types 自动生成", - "// 调用方式请参考项目 Skill: .agents/skills/plugin-guide/SKILL.md", - "// ============================================================", - ) - - paramsSchema, _ := cap["paramsSchema"].(map[string]interface{}) - - for i, rawAction := range actions { - action, ok := rawAction.(map[string]interface{}) - if !ok { - continue - } - actionKey, _ := action["key"].(string) - actionSuffix := "" - if len(actions) > 1 { - actionSuffix = pluginToPascalCase(actionKey) - } - inputName := prefix + actionSuffix + "Input" - outputName := prefix + actionSuffix + "Output" - - // inputSchema: first action uses paramsSchema if available - var inputSchema map[string]interface{} - if i == 0 && paramsSchema != nil && len(paramsSchema) > 0 { - inputSchema = paramsSchema - } else { - inputSchema, _ = action["inputSchema"].(map[string]interface{}) - } - - if inputSchema != nil { - if iface := pluginGenerateInterface(inputName, inputSchema); iface != "" { - parts = append(parts, "", iface) - typeNames = append(typeNames, inputName) - } - } - - outputSchema, _ := action["outputSchema"].(map[string]interface{}) - if outputSchema != nil { - if iface := pluginGenerateInterface(outputName, outputSchema); iface != "" { - parts = append(parts, iface) - typeNames = append(typeNames, outputName) - } - } - } - - typesCode := strings.Join(parts, "\n") - outputPath := filepath.Join(projectPath, pluginTypesFile) - - if err := pluginPersistTypesBlock(outputPath, id, typesCode); err != nil { - return "", nil, err - } - - return pluginTypesFile, typeNames, nil -} - -// pluginToPascalCase converts "task-text-summary" → "TaskTextSummary". -// Handles digit-prefixed segments: "4s-store" → "FourSStore". -func pluginToPascalCase(id string) string { - digitWords := map[byte]string{ - '0': "Zero", '1': "One", '2': "Two", '3': "Three", '4': "Four", - '5': "Five", '6': "Six", '7': "Seven", '8': "Eight", '9': "Nine", - } - parts := strings.FieldsFunc(id, func(r rune) bool { return r == '-' || r == '_' }) - var result strings.Builder - for _, part := range parts { - if part == "" { - continue - } - if part[0] >= '0' && part[0] <= '9' { - i := 0 - for i < len(part) && part[i] >= '0' && part[i] <= '9' { - if w, ok := digitWords[part[i]]; ok { - result.WriteString(w) - } - i++ - } - if i < len(part) { - result.WriteByte(part[i] - 32) // uppercase - result.WriteString(strings.ToLower(part[i+1:])) - } - } else { - result.WriteByte(part[0] &^ 0x20) // uppercase first char - result.WriteString(strings.ToLower(part[1:])) - } - } - return result.String() -} - -// pluginGenerateInterface generates "export interface Name { ... }" from a JSON Schema. -func pluginGenerateInterface(name string, schema map[string]interface{}) string { - props, ok := schema["properties"].(map[string]interface{}) - if !ok || len(props) == 0 { - return "" - } - requiredSet := make(map[string]bool) - if req, ok := schema["required"].([]interface{}); ok { - for _, r := range req { - if s, ok := r.(string); ok { - requiredSet[s] = true - } - } - } - var lines []string - for key, val := range props { - propMap, _ := val.(map[string]interface{}) - optional := "" - if !requiredSet[key] { - optional = "?" - } - tsType := pluginSchemaToTS(propMap, " ") - desc, _ := propMap["description"].(string) - if desc != "" { - lines = append(lines, fmt.Sprintf(" /** %s */", desc)) - } - safeKey := pluginQuoteKey(key) - lines = append(lines, fmt.Sprintf(" %s%s: %s;", safeKey, optional, tsType)) - } - return fmt.Sprintf("export interface %s {\n%s\n}", name, strings.Join(lines, "\n")) -} - -// pluginSchemaToTS converts a JSON Schema property to a TypeScript type string. -func pluginSchemaToTS(prop map[string]interface{}, indent string) string { - if prop == nil { - return "unknown" - } - t, _ := prop["type"].(string) - switch t { - case "string": - return "string" - case "number", "integer": - return "number" - case "boolean": - return "boolean" - case "array": - if items, ok := prop["items"].(map[string]interface{}); ok { - return pluginSchemaToTS(items, indent) + "[]" - } - return "unknown[]" - case "object": - if innerProps, ok := prop["properties"].(map[string]interface{}); ok && len(innerProps) > 0 { - inner := indent + " " - var fields []string - for k, v := range innerProps { - vm, _ := v.(map[string]interface{}) - fields = append(fields, fmt.Sprintf("%s%s: %s;", inner, pluginQuoteKey(k), pluginSchemaToTS(vm, inner))) - } - return fmt.Sprintf("{\n%s\n%s}", strings.Join(fields, "\n"), indent) - } - return "Record" - } - // No explicit type: infer from structure - if _, ok := prop["properties"]; ok { - return pluginSchemaToTS(map[string]interface{}{"type": "object", "properties": prop["properties"]}, indent) - } - if _, ok := prop["items"]; ok { - return pluginSchemaToTS(map[string]interface{}{"type": "array", "items": prop["items"]}, indent) - } - return "unknown" -} - -// pluginQuoteKey returns the key as-is if it's a valid JS identifier, else quoted. -func pluginQuoteKey(key string) string { - clean := strings.Map(func(r rune) rune { - if r == '\n' || r == '\r' || r == '\t' { - return ' ' - } - return r - }, strings.TrimSpace(key)) - if regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$]*$`).MatchString(clean) { - return clean - } - return "'" + strings.ReplaceAll(clean, "'", "\\'") + "'" -} - -// pluginPersistTypesBlock writes a type block to the types file, replacing existing -// blocks for the same id or appending if new. -func pluginPersistTypesBlock(outputPath, id, typesCode string) error { - blockStart := pluginBlockStartPrefix + id + " ----" - blockEnd := pluginBlockEndPrefix + id + " ----" - newBlock := blockStart + "\n" + typesCode + "\n" + blockEnd - - existing, err := os.ReadFile(outputPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local types file read. - if err != nil && !os.IsNotExist(err) { - return appsFileIOError(err, "cannot read %s", outputPath) - } - content := string(existing) - - var updated string - if startIdx := strings.Index(content, blockStart); startIdx >= 0 { - endIdx := strings.Index(content, blockEnd) - if endIdx >= 0 { - updated = content[:startIdx] + newBlock + content[endIdx+len(blockEnd):] - } else { - updated = content + "\n\n" + newBlock - } - } else if content != "" { - updated = content + "\n\n" + newBlock - } else { - updated = newBlock + "\n" - } - - if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { //nolint:forbidigo - return appsFileIOError(err, "cannot create directory for %s", outputPath) - } - return validate.AtomicWrite(outputPath, []byte(updated), 0o644) -} diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go index bc4971c00..3fb64c191 100644 --- a/shortcuts/apps/plugin_common_test.go +++ b/shortcuts/apps/plugin_common_test.go @@ -7,7 +7,6 @@ import ( "encoding/json" "os" "path/filepath" - "strings" "testing" "github.com/larksuite/cli/errs" @@ -260,155 +259,6 @@ func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { } } -// --- pluginGetCapability --- - -func TestPluginGetCapability_Found(t *testing.T) { - dir := t.TempDir() - writeTestCapJSON(t, dir, "my-instance.json", map[string]interface{}{"id": "my-instance", "name": "My Instance"}) - - cap, err := pluginGetCapability(dir, "my-instance") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cap["id"] != "my-instance" { - t.Errorf("id = %v, want my-instance", cap["id"]) - } -} - -func TestPluginGetCapability_NotFound(t *testing.T) { - dir := t.TempDir() - _, err := pluginGetCapability(dir, "nonexistent") - if err == nil { - t.Fatal("expected error") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeInvalidArgument { - t.Errorf("subtype = %q, want invalid_argument", p.Subtype) - } -} - -// --- pluginValidateFormValue --- - -func TestValidateFormValue_Valid(t *testing.T) { - fv := map[string]interface{}{"prompt": "{{input.text}}"} - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "text": map[string]interface{}{"type": "string", "description": "input text"}, - }, - } - if errs := pluginValidateFormValue(fv, ps); len(errs) > 0 { - t.Errorf("expected no errors, got %v", errs) - } -} - -func TestValidateFormValue_ForbiddenHandlebars(t *testing.T) { - fv := map[string]interface{}{"body": "{{#if x}}yes{{/if}}"} - errs := pluginValidateFormValue(fv, nil) - if len(errs) == 0 { - t.Fatal("expected forbidden Handlebars error") - } -} - -func TestValidateFormValue_UndefinedRef(t *testing.T) { - fv := map[string]interface{}{"prompt": "{{input.typo}}"} - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "text": map[string]interface{}{"type": "string", "description": "d"}, - }, - } - errs := pluginValidateFormValue(fv, ps) - found := false - for _, e := range errs { - if strings.Contains(e, "typo") && strings.Contains(e, "not defined") { - found = true - } - } - if !found { - t.Errorf("expected undefined ref error for 'typo', got %v", errs) - } -} - -func TestValidateFormValue_UnconsumedParam(t *testing.T) { - fv := map[string]interface{}{"prompt": "hello"} - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "unused": map[string]interface{}{"type": "string", "description": "d"}, - }, - } - errs := pluginValidateFormValue(fv, ps) - found := false - for _, e := range errs { - if strings.Contains(e, "unused") && strings.Contains(e, "never referenced") { - found = true - } - } - if !found { - t.Errorf("expected unconsumed param error, got %v", errs) - } -} - -func TestValidateFormValue_ParamsSchemaTypeValidation(t *testing.T) { - fv := map[string]interface{}{"f": "{{input.x}}"} - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "x": map[string]interface{}{"type": "number"}, - }, - } - errs := pluginValidateFormValue(fv, ps) - found := false - for _, e := range errs { - if strings.Contains(e, "number") && strings.Contains(e, "invalid") { - found = true - } - } - if !found { - t.Errorf("expected type validation error, got %v", errs) - } -} - -func TestValidateFormValue_ArrayAutoFix(t *testing.T) { - fv := map[string]interface{}{ - "files": []interface{}{"{{input.fileUrl}}"}, - } - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "fileUrl": map[string]interface{}{ - "type": "array", "items": map[string]interface{}{"type": "string"}, - "description": "d", - }, - }, - } - errs := pluginValidateFormValue(fv, ps) - if len(errs) != 0 { - t.Errorf("expected no errors after auto-fix, got %v", errs) - } - // Verify auto-fix: array should be unwrapped to string - if s, ok := fv["files"].(string); !ok || s != "{{input.fileUrl}}" { - t.Errorf("expected auto-fix to unwrap array, got %v (%T)", fv["files"], fv["files"]) - } -} - -func TestValidateFormValue_MissingDescription(t *testing.T) { - fv := map[string]interface{}{"f": "{{input.x}}"} - ps := map[string]interface{}{ - "properties": map[string]interface{}{ - "x": map[string]interface{}{"type": "string"}, - }, - } - errs := pluginValidateFormValue(fv, ps) - found := false - for _, e := range errs { - if strings.Contains(e, "missing description") { - found = true - } - } - if !found { - t.Errorf("expected missing description error, got %v", errs) - } -} // --- helpers --- diff --git a/shortcuts/apps/plugin_instance_create.go b/shortcuts/apps/plugin_instance_create.go deleted file mode 100644 index 29c75b30a..000000000 --- a/shortcuts/apps/plugin_instance_create.go +++ /dev/null @@ -1,197 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "path/filepath" - "strings" - "time" - - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceCreate creates a plugin instance by writing a capability -// JSON file into the resolved capabilities directory. -var AppsPluginInstanceCreate = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-create", - Description: "Create a plugin instance (write capability JSON)", - Risk: "write", Hidden: true, - Flags: []common.Flag{ - {Name: "id", Desc: "semantic instance id (lowercase + hyphens); auto-derived from plugin key if omitted"}, - {Name: "plugin", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); version is resolved from package.json actionPlugins", Required: true}, - {Name: "name", Desc: "display name for the instance", Required: true}, - {Name: "description", Desc: "instance description"}, - {Name: "form-value", Desc: "formValue JSON object", Required: true, Input: []string{common.File, common.Stdin}}, - {Name: "params-schema", Desc: "paramsSchema JSON object (optional)", Input: []string{common.File, common.Stdin}}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - {Name: "force", Type: "bool", Desc: "overwrite existing instance with same id"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - pluginKey, pluginVersion := pluginParseInstallTarget(rctx.Str("plugin")) - id := strings.TrimSpace(rctx.Str("id")) - if id == "" { - id = pluginDeriveID(pluginKey) - } - pluginRef := pluginKey - if pluginVersion != "" { - pluginRef += "@" + pluginVersion - } - // Resolve output paths for preview (read-only, safe in dry-run) - projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) - capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - return common.NewDryRunAPI(). - Desc("Create plugin instance: validate formValue, write capability JSON, auto-generate TypeScript types"). - Set("action", "create"). - Set("id", id). - Set("plugin", pluginRef). - Set("target", fmt.Sprintf("/%s.json", id)). - Set("version_source", "resolved from package.json actionPlugins"). - Set("output", filepath.Join(capDir, id+".json")). - Set("types_output", filepath.Join(projectPath, "shared", "plugin-types.ts")) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if strings.TrimSpace(rctx.Str("plugin")) == "" { - return appsValidationParamError("--plugin", "--plugin is required") - } - if id := strings.TrimSpace(rctx.Str("id")); id != "" { - if err := pluginValidateID(id); err != nil { - return err - } - } - if err := pluginValidateJSONFlag("--form-value", rctx.Str("form-value")); err != nil { - return err - } - if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { - if err := pluginValidateJSONFlag("--params-schema", ps); err != nil { - return err - } - } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - pluginKey, pluginVersion := pluginParseInstallTarget(rctx.Str("plugin")) - if pluginKey == "" { - return appsValidationParamError("--plugin", "--plugin is required") - } - - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - // Check that the plugin is declared in actionPlugins - declaredVersion, ok := pluginActionPluginVersion(projectPath, pluginKey) - if !ok { - return appsFailedPreconditionError("plugin %q is not installed; no entry in package.json actionPlugins", pluginKey). - WithHint("run 'lark-cli apps +plugin-install --name %s' to install first", pluginKey) - } - if pluginVersion == "" { - pluginVersion = declaredVersion - } - - warnings, err := pluginCheckInstalledVersion(projectPath, pluginKey, pluginVersion) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - return err - } - if err := os.MkdirAll(capDir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; auto-create capabilities dir. - return appsFileIOError(err, "cannot create capabilities directory %s", capDir) - } - - id := strings.TrimSpace(rctx.Str("id")) - if id == "" { - id = pluginDeriveID(pluginKey) - } - - capPath := filepath.Join(capDir, id+".json") - if !rctx.Bool("force") { - if _, err := os.Stat(capPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; existence check before create. - return appsValidationError("instance %q already exists at %s", id, pluginCapRelPath(projectPath, capPath)). - WithHint("use --force to overwrite, or choose a different --id") - } - } - - var formValue interface{} - if err := json.Unmarshal([]byte(rctx.Str("form-value")), &formValue); err != nil { - return appsValidationParamError("--form-value", "invalid JSON: %v", err).WithCause(err) - } - - now := time.Now().UnixMilli() - cap := map[string]interface{}{ - "id": id, - "pluginKey": pluginKey, - "pluginVersion": pluginVersion, - "name": strings.TrimSpace(rctx.Str("name")), - "description": strings.TrimSpace(rctx.Str("description")), - "formValue": formValue, - "createdAt": now, - "updatedAt": now, - } - - var paramsSchema interface{} - if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { - if err := json.Unmarshal([]byte(ps), ¶msSchema); err != nil { - return appsValidationParamError("--params-schema", "invalid JSON: %v", err).WithCause(err) - } - cap["paramsSchema"] = paramsSchema - } - - // Validate formValue against paramsSchema (feida-ai 5-rule check) - if violations := pluginValidateFormValue(formValue, paramsSchema); len(violations) > 0 { - hint := strings.Join(violations, "\n- ") - return appsValidationError("formValue validation failed:\n- %s", hint). - WithHint("fix the issues above and retry") - } - - if err := pluginWriteCapJSON(capPath, cap); err != nil { - return appsFileIOError(err, "cannot write %s", capPath) - } - - // Auto-generate TypeScript types - typesPath, typeNames, typesErr := pluginGenerateAndPersistTypes(projectPath, cap) - - relPath := pluginCapRelPath(projectPath, capPath) - result := map[string]interface{}{ - "id": id, - "pluginKey": pluginKey, - "pluginVersion": pluginVersion, - "name": cap["name"], - "path": relPath, - } - if len(warnings) > 0 { - result["warnings"] = warnings - } - if typesErr == nil { - result["typesPath"] = typesPath - result["types"] = typeNames - } - rctx.OutFormat(result, nil, func(w io.Writer) { - for _, w2 := range warnings { - fmt.Fprintf(w, "⚠ %s\n", w2) - } - fmt.Fprintf(w, "✓ Plugin instance created: %s\n", id) - fmt.Fprintf(w, " Plugin: %s@%s\n", pluginKey, pluginVersion) - fmt.Fprintf(w, " Path: %s\n", relPath) - if typesErr == nil { - fmt.Fprintf(w, " Types: %s → %s\n", strings.Join(typeNames, ", "), typesPath) - } - }) - return nil - }, -} diff --git a/shortcuts/apps/plugin_instance_create_test.go b/shortcuts/apps/plugin_instance_create_test.go deleted file mode 100644 index 86bfd723c..000000000 --- a/shortcuts/apps/plugin_instance_create_test.go +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/larksuite/cli/errs" -) - -func TestPluginInstanceCreate_Basic(t *testing.T) { - dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "@test/my-plugin@1.0.0", - "--name", "My Instance", - "--form-value", `{"prompt":"hello"}`, - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON output: %v", err) - } - data, _ := env["data"].(map[string]interface{}) - if data["id"] != "test-my-plugin" { - t.Errorf("id = %v, want test-my-plugin (auto-derived)", data["id"]) - } - if data["pluginKey"] != "@test/my-plugin" { - t.Errorf("pluginKey = %v, want @test/my-plugin", data["pluginKey"]) - } - - // Verify file was written - capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") - capData, err := os.ReadFile(capPath) //nolint:forbidigo - if err != nil { - t.Fatalf("capability file not created: %v", err) - } - var cap map[string]interface{} - if err := json.Unmarshal(capData, &cap); err != nil { - t.Fatalf("invalid capability JSON: %v", err) - } - if cap["name"] != "My Instance" { - t.Errorf("cap.name = %v, want My Instance", cap["name"]) - } -} - -func TestPluginInstanceCreate_CustomID(t *testing.T) { - dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--id", "custom-summary", - "--plugin", "@test/my-plugin@2.0.0", - "--name", "Custom", - "--form-value", `{"key":"val"}`, - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - capPath := filepath.Join(dir, "server", "capabilities", "custom-summary.json") - if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo - t.Fatalf("capability file not created at custom id path: %v", err) - } -} - -func TestPluginInstanceCreate_WithParamsSchema(t *testing.T) { - dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "@test/my-plugin@1.0.0", - "--name", "WithSchema", - "--form-value", `{"prompt":"{{input.text}}"}`, - "--params-schema", `{"type":"object","properties":{"text":{"type":"string","description":"user input text"}}}`, - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") - capData, err := os.ReadFile(capPath) //nolint:forbidigo - if err != nil { - t.Fatal(err) - } - var cap map[string]interface{} - if err := json.Unmarshal(capData, &cap); err != nil { - t.Fatal(err) - } - if _, ok := cap["paramsSchema"]; !ok { - t.Error("paramsSchema should be present in capability") - } -} - -func TestPluginInstanceCreate_DuplicateID(t *testing.T) { - dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing"}) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--id", "existing", - "--plugin", "@test/my-plugin@1.0.0", - "--name", "Dup", - "--form-value", `{}`, - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error for duplicate id") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeInvalidArgument { - t.Errorf("subtype = %q, want invalid_argument", p.Subtype) - } -} - -func TestPluginInstanceCreate_ForceOverwrite(t *testing.T) { - dir := setupPluginTestProjectWithManifest(t, "server", "@test/my-plugin") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "existing.json", map[string]interface{}{"id": "existing", "name": "Old"}) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--id", "existing", - "--plugin", "@test/my-plugin@1.0.0", - "--name", "New", - "--form-value", `{}`, - "--force", - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error with --force: %v", err) - } - - capData, _ := os.ReadFile(filepath.Join(capDir, "existing.json")) //nolint:forbidigo - var cap map[string]interface{} - json.Unmarshal(capData, &cap) - if cap["name"] != "New" { - t.Errorf("name = %v, want New (overwritten)", cap["name"]) - } -} - -func TestPluginInstanceCreate_PluginNotInstalled(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "@test/not-installed@1.0.0", - "--name", "Fail", - "--form-value", `{}`, - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error when plugin not installed") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeFailedPrecondition { - t.Errorf("subtype = %q, want failed_precondition", p.Subtype) - } -} - -func TestPluginInstanceCreate_InvalidPluginFormat(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - dir := setupPluginTestProject(t, "server") - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "no-version", - "--name", "Fail", - "--form-value", `{}`, - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error for invalid plugin format") - } -} - -func TestPluginInstanceCreate_InvalidJSON(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - dir := setupPluginTestProject(t, "server") - - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "@test/p@1.0.0", - "--name", "Fail", - "--form-value", `not json`, - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error for invalid JSON") - } -} - -func TestPluginInstanceCreate_AutoCreateCapDir(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - pluginKey := "@test/my-plugin" - manifestDir := filepath.Join(dir, "node_modules", pluginKey) - if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{}`), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - writeTestPkgJSON(t, dir, map[string]interface{}{ - "actionPlugins": map[string]interface{}{ - pluginKey: "1.0.0", - }, - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceCreate, []string{ - "+plugin-instance-create", - "--plugin", "@test/my-plugin@1.0.0", - "--name", "AutoDir", - "--form-value", `{}`, - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("should auto-create capabilities dir: %v", err) - } - - capPath := filepath.Join(dir, "server", "capabilities", "test-my-plugin.json") - if _, err := os.Stat(capPath); err != nil { //nolint:forbidigo - t.Fatalf("capability file not created: %v", err) - } -} - -// --- helpers --- - -// setupPluginTestProjectWithManifest creates a project dir with package.json, -// capabilities dir, and a minimal manifest.json for the given plugin key. -func setupPluginTestProjectWithManifest(t *testing.T, appType, pluginKey string) string { - t.Helper() - dir := setupPluginTestProject(t, appType) - manifestDir := filepath.Join(dir, "node_modules", pluginKey) - if err := os.MkdirAll(manifestDir, 0o755); err != nil { //nolint:forbidigo - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(manifestDir, "manifest.json"), []byte(`{"actions":[]}`), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - // Register the plugin in actionPlugins so create's actionPlugins check passes - writeTestPkgJSON(t, dir, map[string]interface{}{ - "actionPlugins": map[string]interface{}{ - pluginKey: "1.0.0", - }, - }) - return dir -} diff --git a/shortcuts/apps/plugin_instance_delete.go b/shortcuts/apps/plugin_instance_delete.go deleted file mode 100644 index 88ee07151..000000000 --- a/shortcuts/apps/plugin_instance_delete.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceDelete deletes a plugin instance (capability JSON file). -// The operation is idempotent: deleting a non-existent instance is not an error. -var AppsPluginInstanceDelete = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-delete", - Description: "Delete a plugin instance", - Risk: "write", Hidden: true, - Flags: []common.Flag{ - {Name: "id", Desc: "instance id", Required: true}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) - capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - return common.NewDryRunAPI(). - Desc("Delete plugin instance (remove capability JSON, idempotent)"). - Set("action", "delete"). - Set("id", id). - Set("target", filepath.Join(capDir, id+".json")) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if strings.TrimSpace(rctx.Str("id")) == "" { - return appsValidationParamError("--id", "--id is required") - } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - return err - } - - capPath := filepath.Join(capDir, id+".json") - if err := os.Remove(capPath); err != nil && !os.IsNotExist(err) { //nolint:forbidigo // shortcuts cannot import internal/vfs; local file delete. - return appsFileIOError(err, "cannot delete %s", capPath) - } - - result := map[string]interface{}{ - "id": id, - "deleted": true, - } - rctx.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "✓ Plugin instance deleted: %s\n", id) - }) - return nil - }, -} diff --git a/shortcuts/apps/plugin_instance_delete_test.go b/shortcuts/apps/plugin_instance_delete_test.go deleted file mode 100644 index cbe14c09a..000000000 --- a/shortcuts/apps/plugin_instance_delete_test.go +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestPluginInstanceDelete_Basic(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{"id": "my-inst"}) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ - "+plugin-instance-delete", - "--id", "my-inst", - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - data, _ := env["data"].(map[string]interface{}) - if data["deleted"] != true { - t.Errorf("deleted = %v, want true", data["deleted"]) - } - - if _, err := os.Stat(filepath.Join(capDir, "my-inst.json")); !os.IsNotExist(err) { //nolint:forbidigo - t.Error("capability file should have been deleted") - } -} - -func TestPluginInstanceDelete_Idempotent(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ - "+plugin-instance-delete", - "--id", "nonexistent", - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("delete of nonexistent instance should be idempotent, got: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - data, _ := env["data"].(map[string]interface{}) - if data["deleted"] != true { - t.Errorf("deleted = %v, want true", data["deleted"]) - } -} - -func TestPluginInstanceDelete_MissingID(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceDelete, []string{ - "+plugin-instance-delete", - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error when --id is missing") - } -} diff --git a/shortcuts/apps/plugin_instance_get.go b/shortcuts/apps/plugin_instance_get.go deleted file mode 100644 index 27eeff3df..000000000 --- a/shortcuts/apps/plugin_instance_get.go +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "encoding/json" - "fmt" - "io" - "path/filepath" - "strings" - - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceGet reads a single plugin instance (capability JSON) by id. -var AppsPluginInstanceGet = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-get", - Description: "Get a plugin instance by id", - Risk: "read", Hidden: true, - Flags: []common.Flag{ - {Name: "id", Desc: "instance id (filename without .json in capabilities/)", Required: true}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) - capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - return common.NewDryRunAPI(). - Desc("Get plugin instance (read capability JSON)"). - Set("action", "get"). - Set("id", id). - Set("source", filepath.Join(capDir, id+".json")) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - if id == "" { - return appsValidationParamError("--id", "--id is required") - } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - return err - } - - cap, err := pluginGetCapability(capDir, id) - if err != nil { - return err - } - - rctx.OutFormat(cap, nil, func(w io.Writer) { - pluginPrintInstance(w, cap) - }) - return nil - }, -} - -func pluginPrintInstance(w io.Writer, cap map[string]interface{}) { - fmt.Fprintf(w, "ID: %v\n", cap["id"]) - fmt.Fprintf(w, "Plugin: %v\n", cap["pluginKey"]) - fmt.Fprintf(w, "Version: %v\n", cap["pluginVersion"]) - fmt.Fprintf(w, "Name: %v\n", cap["name"]) - - if ts := common.FormatTime(cap["createdAt"]); ts != "" { - fmt.Fprintf(w, "Created: %s\n", ts) - } - if ts := common.FormatTime(cap["updatedAt"]); ts != "" { - fmt.Fprintf(w, "Updated: %s\n", ts) - } - - if ps, ok := cap["paramsSchema"]; ok && ps != nil { - b, _ := json.MarshalIndent(ps, " ", " ") - fmt.Fprintf(w, "ParamsSchema: %s\n", b) - } - if fv, ok := cap["formValue"]; ok && fv != nil { - b, _ := json.MarshalIndent(fv, " ", " ") - fmt.Fprintf(w, "FormValue: %s\n", b) - } -} diff --git a/shortcuts/apps/plugin_instance_get_test.go b/shortcuts/apps/plugin_instance_get_test.go deleted file mode 100644 index 126302f5a..000000000 --- a/shortcuts/apps/plugin_instance_get_test.go +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "encoding/json" - "path/filepath" - "strings" - "testing" - - "github.com/larksuite/cli/errs" -) - -func TestPluginInstanceGet_Basic(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ - "id": "my-inst", "pluginKey": "@test/plugin", "pluginVersion": "1.0.0", - "name": "My Instance", "createdAt": 1718500000000, - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceGet, - []string{"+plugin-instance-get", "--id", "my-inst", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - got := stdout.String() - if !strings.Contains(got, "my-inst") { - t.Errorf("output missing instance id: %s", got) - } - if !strings.Contains(got, "@test/plugin") { - t.Errorf("output missing pluginKey: %s", got) - } -} - -func TestPluginInstanceGet_JSON(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ - "id": "my-inst", "pluginKey": "@test/plugin", "pluginVersion": "1.0.0", "name": "Test", - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceGet, - []string{"+plugin-instance-get", "--id", "my-inst", "--project-path", dir, "--format", "json", "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) - } - data, _ := env["data"].(map[string]interface{}) - if data["id"] != "my-inst" { - t.Errorf("id = %v, want my-inst", data["id"]) - } -} - -func TestPluginInstanceGet_NotFound(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceGet, - []string{"+plugin-instance-get", "--id", "nonexistent", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err == nil { - t.Fatal("expected error") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeInvalidArgument { - t.Errorf("subtype = %q, want invalid_argument", p.Subtype) - } -} - -func TestPluginInstanceGet_MissingID(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceGet, - []string{"+plugin-instance-get", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err == nil { - t.Fatal("expected error when --id is missing") - } -} diff --git a/shortcuts/apps/plugin_instance_list.go b/shortcuts/apps/plugin_instance_list.go deleted file mode 100644 index 4c1d8d3eb..000000000 --- a/shortcuts/apps/plugin_instance_list.go +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "fmt" - "io" - - "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceList lists all plugin instances (capability JSON files) -// in the resolved capabilities directory. -var AppsPluginInstanceList = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-list", - Description: "List all plugin instances in the project", - Risk: "read", Hidden: true, - Flags: []common.Flag{ - {Name: "summary", Type: "bool", Desc: "show only id and name"}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) - capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - return common.NewDryRunAPI(). - Desc("List plugin instances (scan capabilities directory)"). - Set("action", "list"). - Set("scan_dir", capDir). - Set("summary", fmt.Sprintf("%v", rctx.Bool("summary"))) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - // Cannot determine capabilities dir → no instances exist yet. - rctx.OutFormat( - map[string]interface{}{"instances": []interface{}{}}, - &output.Meta{Count: 0}, - func(w io.Writer) { fmt.Fprintln(w, "No plugin instances found.") }, - ) - return nil - } - - caps, err := pluginListCapabilities(capDir) - if err != nil { - return err - } - - summary := rctx.Bool("summary") - instances := make([]interface{}, 0, len(caps)) - for _, cap := range caps { - if summary { - instances = append(instances, map[string]interface{}{ - "id": cap["id"], - "name": cap["name"], - }) - } else { - instances = append(instances, cap) - } - } - - data := map[string]interface{}{"instances": instances} - rctx.OutFormat(data, &output.Meta{Count: len(instances)}, func(w io.Writer) { - if len(instances) == 0 { - fmt.Fprintln(w, "No plugin instances found.") - return - } - rows := make([]map[string]interface{}, 0, len(caps)) - for _, cap := range caps { - rows = append(rows, map[string]interface{}{ - "id": cap["id"], - "pluginKey": cap["pluginKey"], - "pluginVersion": cap["pluginVersion"], - "name": cap["name"], - }) - } - output.PrintTable(w, rows) - }) - return nil - }, -} diff --git a/shortcuts/apps/plugin_instance_list_test.go b/shortcuts/apps/plugin_instance_list_test.go deleted file mode 100644 index 5adf94d74..000000000 --- a/shortcuts/apps/plugin_instance_list_test.go +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/larksuite/cli/errs" -) - -func TestPluginInstanceList_Empty(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceList, - []string{"+plugin-instance-list", "--project-path", dir, "--format", "json", "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) - } - data, _ := env["data"].(map[string]interface{}) - instances, _ := data["instances"].([]interface{}) - if len(instances) != 0 { - t.Errorf("expected 0 instances, got %d", len(instances)) - } -} - -func TestPluginInstanceList_WithInstances(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "inst-a.json", map[string]interface{}{ - "id": "inst-a", "pluginKey": "@test/plugin-a", "pluginVersion": "1.0.0", "name": "Instance A", - }) - writeTestCapJSON(t, capDir, "inst-b.json", map[string]interface{}{ - "id": "inst-b", "pluginKey": "@test/plugin-b", "pluginVersion": "2.0.0", "name": "Instance B", - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceList, - []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - got := stdout.String() - if !strings.Contains(got, "inst-a") || !strings.Contains(got, "inst-b") { - t.Errorf("output missing instances: %s", got) - } -} - -func TestPluginInstanceList_Summary(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "inst-a.json", map[string]interface{}{ - "id": "inst-a", "pluginKey": "@test/plugin-a", "pluginVersion": "1.0.0", - "name": "Instance A", "paramsSchema": map[string]interface{}{"type": "object"}, - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceList, - []string{"+plugin-instance-list", "--summary", "--project-path", dir, "--format", "json", "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - var env map[string]interface{} - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("invalid JSON: %v\nraw: %s", err, stdout.String()) - } - data, _ := env["data"].(map[string]interface{}) - instances, _ := data["instances"].([]interface{}) - if len(instances) != 1 { - t.Fatalf("got %d instances, want 1", len(instances)) - } - inst := instances[0].(map[string]interface{}) - if _, has := inst["paramsSchema"]; has { - t.Error("summary should not include paramsSchema") - } - if inst["id"] != "inst-a" { - t.Errorf("id = %v, want inst-a", inst["id"]) - } -} - -func TestPluginInstanceList_NoPackageJSON(t *testing.T) { - dir := t.TempDir() - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceList, - []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err == nil { - t.Fatal("expected error when package.json missing") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeFailedPrecondition { - t.Errorf("subtype = %q, want failed_precondition", p.Subtype) - } -} - -func TestPluginInstanceList_CapDirNotExist(t *testing.T) { - dir := setupPluginTestProjectNoCapDir(t) - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceList, - []string{"+plugin-instance-list", "--project-path", dir, "--as", "user"}, - factory, stdout) - if err != nil { - t.Fatalf("should not error when capabilities dir not found, got: %v", err) - } -} - -// --- helpers --- - -// setupPluginTestProject creates a temp dir with package.json and a capabilities dir. -// appType is "server" or "shared". -func setupPluginTestProject(t *testing.T, appType string) string { - t.Helper() - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - capDir := filepath.Join(dir, appType, "capabilities") - if err := os.MkdirAll(capDir, 0o755); err != nil { //nolint:forbidigo - t.Fatal(err) - } - return dir -} - -// setupPluginTestProjectNoCapDir creates a temp dir with package.json but no capabilities dir. -func setupPluginTestProjectNoCapDir(t *testing.T) string { - t.Helper() - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo - t.Fatal(err) - } - return dir -} diff --git a/shortcuts/apps/plugin_instance_types.go b/shortcuts/apps/plugin_instance_types.go deleted file mode 100644 index 8e934cf61..000000000 --- a/shortcuts/apps/plugin_instance_types.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "fmt" - "io" - "strings" - - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceTypes generates TypeScript type definitions from a plugin -// instance's paramsSchema and the plugin manifest's actions, and writes them -// to shared/plugin-types.ts with per-id block replacement. -var AppsPluginInstanceTypes = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-types", - Description: "Generate TypeScript types for a plugin instance", - Risk: "write", - Hidden: true, - Flags: []common.Flag{ - {Name: "id", Desc: "instance id", Required: true}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - id := strings.TrimSpace(rctx.Str("id")) - return common.NewDryRunAPI(). - Desc("Generate TypeScript types for plugin instance"). - Set("action", "types"). - Set("id", id). - Set("output", pluginTypesFile) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if strings.TrimSpace(rctx.Str("id")) == "" { - return appsValidationParamError("--id", "--id is required") - } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - return err - } - - cap, err := pluginGetCapability(capDir, id) - if err != nil { - return err - } - - outputPath, typeNames, err := pluginGenerateAndPersistTypes(projectPath, cap) - if err != nil { - return appsFileIOError(err, "failed to generate types for %s", id) - } - - result := map[string]interface{}{ - "instanceId": id, - "outputPath": outputPath, - "types": typeNames, - } - rctx.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "✓ Types generated for %s → %s\n", id, outputPath) - for _, t := range typeNames { - fmt.Fprintf(w, " %s\n", t) - } - }) - return nil - }, -} diff --git a/shortcuts/apps/plugin_instance_update.go b/shortcuts/apps/plugin_instance_update.go deleted file mode 100644 index 9a4cf667e..000000000 --- a/shortcuts/apps/plugin_instance_update.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "encoding/json" - "fmt" - "io" - "path/filepath" - "strings" - "time" - - "github.com/larksuite/cli/shortcuts/common" -) - -// AppsPluginInstanceUpdate updates an existing plugin instance's mutable fields -// (name, formValue, paramsSchema) while preserving immutable fields. -var AppsPluginInstanceUpdate = common.Shortcut{ - Service: appsService, - Command: "+plugin-instance-update", - Description: "Update a plugin instance (modify capability JSON)", - Risk: "write", Hidden: true, - Flags: []common.Flag{ - {Name: "id", Desc: "instance id", Required: true}, - {Name: "name", Desc: "new display name"}, - {Name: "form-value", Desc: "new formValue JSON object", Input: []string{common.File, common.Stdin}}, - {Name: "params-schema", Desc: "new paramsSchema JSON object", Input: []string{common.File, common.Stdin}}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - {Name: "capabilities-dir", Desc: "explicit capabilities directory (relative to project or absolute)"}, - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, _ := pluginResolveProjectPath(rctx.Str("project-path")) - capDir, _ := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - return common.NewDryRunAPI(). - Desc("Update plugin instance: merge partial updates to existing capability, validate formValue, write back, auto-regenerate TypeScript types"). - Set("action", "update"). - Set("id", id). - Set("target", fmt.Sprintf("/%s.json", id)). - Set("output", filepath.Join(capDir, id+".json")). - Set("types_output", filepath.Join(projectPath, "shared", "plugin-types.ts")) - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - if id == "" { - return appsValidationParamError("--id", "--id is required") - } - hasUpdate := false - if rctx.Changed("name") { - hasUpdate = true - } - if fv := strings.TrimSpace(rctx.Str("form-value")); fv != "" { - if err := pluginValidateJSONFlag("--form-value", fv); err != nil { - return err - } - hasUpdate = true - } - if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { - if err := pluginValidateJSONFlag("--params-schema", ps); err != nil { - return err - } - hasUpdate = true - } - if !hasUpdate { - return appsValidationError("at least one of --name, --form-value, or --params-schema must be provided") - } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - return pluginCheckProjectDir(projectPath) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - id := strings.TrimSpace(rctx.Str("id")) - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) - if err != nil { - return err - } - - capDir, err := pluginResolveCapDir(projectPath, rctx.Str("capabilities-dir")) - if err != nil { - return err - } - - cap, err := pluginGetCapability(capDir, id) - if err != nil { - return err - } - - if rctx.Changed("name") { - cap["name"] = strings.TrimSpace(rctx.Str("name")) - } - if fv := strings.TrimSpace(rctx.Str("form-value")); fv != "" { - var formValue interface{} - if err := json.Unmarshal([]byte(fv), &formValue); err != nil { - return appsValidationParamError("--form-value", "invalid JSON: %v", err).WithCause(err) - } - cap["formValue"] = formValue - } - if ps := strings.TrimSpace(rctx.Str("params-schema")); ps != "" { - var paramsSchema interface{} - if err := json.Unmarshal([]byte(ps), ¶msSchema); err != nil { - return appsValidationParamError("--params-schema", "invalid JSON: %v", err).WithCause(err) - } - cap["paramsSchema"] = paramsSchema - } - - // Validate formValue against paramsSchema after merge - if violations := pluginValidateFormValue(cap["formValue"], cap["paramsSchema"]); len(violations) > 0 { - hint := strings.Join(violations, "\n- ") - return appsValidationError("formValue validation failed:\n- %s", hint). - WithHint("fix the issues above and retry") - } - - cap["updatedAt"] = time.Now().UnixMilli() - - capPath := filepath.Join(capDir, id+".json") - if err := pluginWriteCapJSON(capPath, cap); err != nil { - return appsFileIOError(err, "cannot write %s", capPath) - } - - // Auto-regenerate TypeScript types - typesPath, typeNames, typesErr := pluginGenerateAndPersistTypes(projectPath, cap) - - result := map[string]interface{}{ - "id": id, - "pluginKey": cap["pluginKey"], - "name": cap["name"], - "updatedAt": cap["updatedAt"], - } - if typesErr == nil { - result["typesPath"] = typesPath - result["types"] = typeNames - } - rctx.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "✓ Plugin instance updated: %s\n", id) - if typesErr == nil { - fmt.Fprintf(w, " Types: %s → %s\n", strings.Join(typeNames, ", "), typesPath) - } - }) - return nil - }, -} diff --git a/shortcuts/apps/plugin_instance_update_test.go b/shortcuts/apps/plugin_instance_update_test.go deleted file mode 100644 index 7f2398f01..000000000 --- a/shortcuts/apps/plugin_instance_update_test.go +++ /dev/null @@ -1,161 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/larksuite/cli/errs" -) - -func TestPluginInstanceUpdate_Name(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ - "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", - "name": "Old Name", "formValue": map[string]interface{}{"k": "v"}, - "createdAt": 1000, - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ - "+plugin-instance-update", - "--id", "my-inst", - "--name", "New Name", - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo - var cap map[string]interface{} - json.Unmarshal(capData, &cap) - if cap["name"] != "New Name" { - t.Errorf("name = %v, want New Name", cap["name"]) - } - if cap["pluginKey"] != "@test/p" { - t.Errorf("pluginKey should be preserved, got %v", cap["pluginKey"]) - } -} - -func TestPluginInstanceUpdate_FormValue(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ - "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", - "name": "Inst", "formValue": map[string]interface{}{"old": true}, - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ - "+plugin-instance-update", - "--id", "my-inst", - "--form-value", `{"new":"value"}`, - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo - var cap map[string]interface{} - json.Unmarshal(capData, &cap) - fv, ok := cap["formValue"].(map[string]interface{}) - if !ok { - t.Fatalf("formValue is not a map: %T", cap["formValue"]) - } - if fv["new"] != "value" { - t.Errorf("formValue.new = %v, want value", fv["new"]) - } -} - -func TestPluginInstanceUpdate_NotFound(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ - "+plugin-instance-update", - "--id", "nonexistent", - "--name", "X", - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error for nonexistent instance") - } - p, ok := errs.ProblemOf(err) - if !ok { - t.Fatalf("expected typed error, got %T: %v", err, err) - } - if p.Subtype != errs.SubtypeInvalidArgument { - t.Errorf("subtype = %q, want invalid_argument", p.Subtype) - } -} - -func TestPluginInstanceUpdate_NoFieldProvided(t *testing.T) { - dir := setupPluginTestProject(t, "server") - factory, stdout, _ := newAppsExecuteFactory(t) - - err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ - "+plugin-instance-update", - "--id", "my-inst", - "--project-path", dir, - "--as", "user", - }, factory, stdout) - if err == nil { - t.Fatal("expected error when no update fields provided") - } -} - -func TestPluginInstanceUpdate_PreservesImmutableFields(t *testing.T) { - dir := setupPluginTestProject(t, "server") - capDir := filepath.Join(dir, "server", "capabilities") - writeTestCapJSON(t, capDir, "my-inst.json", map[string]interface{}{ - "id": "my-inst", "pluginKey": "@test/p", "pluginVersion": "1.0.0", - "name": "Old", "formValue": map[string]interface{}{}, - "createdAt": float64(1000000), - }) - - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsPluginInstanceUpdate, []string{ - "+plugin-instance-update", - "--id", "my-inst", - "--name", "Updated", - "--project-path", dir, - "--format", "json", - "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - capData, _ := os.ReadFile(filepath.Join(capDir, "my-inst.json")) //nolint:forbidigo - var cap map[string]interface{} - json.Unmarshal(capData, &cap) - - if cap["id"] != "my-inst" { - t.Errorf("id should be preserved, got %v", cap["id"]) - } - if cap["pluginKey"] != "@test/p" { - t.Errorf("pluginKey should be preserved, got %v", cap["pluginKey"]) - } - if cap["pluginVersion"] != "1.0.0" { - t.Errorf("pluginVersion should be preserved, got %v", cap["pluginVersion"]) - } - if cap["createdAt"] != float64(1000000) { - t.Errorf("createdAt should be preserved, got %v", cap["createdAt"]) - } - updatedAt, ok := cap["updatedAt"].(float64) - if !ok || updatedAt <= 1000000 { - t.Errorf("updatedAt should be updated to a recent timestamp, got %v", cap["updatedAt"]) - } -} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 6f95a597e..8a47d0770 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -35,11 +35,5 @@ func Shortcuts() []common.Shortcut { AppsPluginInstall, AppsPluginUninstall, AppsPluginList, - AppsPluginInstanceList, - AppsPluginInstanceGet, - AppsPluginInstanceCreate, - AppsPluginInstanceUpdate, - AppsPluginInstanceDelete, - AppsPluginInstanceTypes, } } From b33fe327183af2d36691943f7c2712962ede335c Mon Sep 17 00:00:00 2001 From: anguohui Date: Fri, 26 Jun 2026 15:41:42 +0800 Subject: [PATCH 29/40] refactor(plugin): remove --project-path flag and split --name into --name + --version - Remove --project-path from plugin-install/list/uninstall (use cwd like npm) - Split --name key@version into separate --name and --version flags - Remove pluginParseInstallTarget (no longer needed) - Improve DryRun desc and error hints for --version usage - Update skill docs to reflect new flag structure - Tests use chdirTest helper instead of --project-path --- shortcuts/apps/plugin_common.go | 14 ------ shortcuts/apps/plugin_install.go | 43 +++++++++++-------- shortcuts/apps/plugin_install_test.go | 32 +++----------- shortcuts/apps/plugin_list.go | 8 ++-- shortcuts/apps/plugin_list_test.go | 21 +++++++-- shortcuts/apps/plugin_uninstall.go | 5 +-- shortcuts/apps/plugin_uninstall_test.go | 15 ++++--- .../references/lark-apps-plugin-install.md | 13 ++++-- .../references/lark-apps-plugin-list.md | 4 +- .../references/lark-apps-plugin-uninstall.md | 5 ++- 10 files changed, 78 insertions(+), 82 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 45b5fd491..e0d8d92a7 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -315,20 +315,6 @@ func pluginCheckPeerDeps(projectPath, pluginKey string) []string { return missing } -// pluginParseInstallTarget parses "key[@version]" where version is optional. -// For scoped packages like "@scope/name@1.0.0", the split is at the last "@". -func pluginParseInstallTarget(s string) (key string, version string) { - s = strings.TrimSpace(s) - if s == "" { - return "", "" - } - idx := strings.LastIndex(s, "@") - if idx <= 0 { - return s, "" - } - return s[:idx], s[idx+1:] -} - // pluginInstalledVersion reads the version of an installed plugin from its // package.json in node_modules. Returns "" if not found or unreadable. func pluginInstalledVersion(projectPath, pluginKey string) string { diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index 7e37b9564..fb198a1a2 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -33,35 +33,39 @@ var AppsPluginInstall = common.Shortcut{ ConditionalScopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, Flags: []common.Flag{ - {Name: "name", Desc: "plugin key[@version] (e.g. @official-plugins/ai-text-generate@1.0.0); omit to install all declared plugins"}, + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, + {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, {Name: "local", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - name := strings.TrimSpace(rctx.Str("name")) - if name == "" { + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). Desc("Batch-install all declared plugins from package.json actionPlugins"). Set("request_body", `{"plugin_keys": [], "latest_only": false}`) } - key, version := pluginParseInstallTarget(name) + version := strings.TrimSpace(rctx.Str("version")) isLatest := version == "" || version == "latest" + desc := fmt.Sprintf("Query version for %s, then download .tgz", key) + if isLatest { + desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key) + } return common.NewDryRunAPI(). POST(apiBasePath+"/plugin/versions/batch_query"). - Desc("Query plugin version, then POST /plugin/versions/download_package to download .tgz"). + Desc(desc). Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)). Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version)) }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } @@ -70,19 +74,19 @@ var AppsPluginInstall = common.Shortcut{ return pluginInstallLocal(rctx, projectPath, localTgz) } - name := strings.TrimSpace(rctx.Str("name")) - if name == "" { + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { return pluginInstallAll(ctx, rctx, projectPath) } - return pluginInstallOne(ctx, rctx, projectPath, name) + version := strings.TrimSpace(rctx.Str("version")) + return pluginInstallOne(ctx, rctx, projectPath, key, version) }, } -// pluginInstallOne installs a single plugin by key[@version]. -func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, name string) error { - key, version := pluginParseInstallTarget(name) +// pluginInstallOne installs a single plugin by key and optional version. +func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error { if key == "" { - return appsValidationParamError("--name", "invalid plugin name %q", name) + return appsValidationParamError("--name", "--name is required") } // Check if already installed with same version (pre-API fast path) @@ -185,8 +189,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP if existing != "" && existing == version { continue } - target := key + "@" + version - if err := pluginInstallOne(ctx, rctx, projectPath, target); err != nil { + if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil { return fmt.Errorf("install %s: %w", key, err) } installed++ @@ -296,8 +299,12 @@ func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, // Response: data.items is a flat list of plugin_version objects match := pluginFindVersionInItems(data, key, version) if match == nil { + hint := "check plugin key spelling" + if !isLatest { + hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key) + } return "", appsValidationError("no version found for plugin %q", key). - WithHint("check plugin key and version") + WithHint(hint) } // API returns "version" (not "plugin_version") rv, _ := match["version"].(string) diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go index 31f53cb64..031a40232 100644 --- a/shortcuts/apps/plugin_install_test.go +++ b/shortcuts/apps/plugin_install_test.go @@ -18,6 +18,7 @@ import ( func TestPluginInstall_SinglePlugin(t *testing.T) { dir := t.TempDir() writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) factory, stdout, reg := newAppsExecuteFactory(t) @@ -53,8 +54,8 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { }) err := runAppsShortcut(t, AppsPluginInstall, []string{ - "+plugin-install", "--name", "@test/my-plugin@1.0.0", - "--project-path", dir, "--format", "json", "--as", "user", + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -93,11 +94,12 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) { pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginInstall, []string{ - "+plugin-install", "--name", "@test/my-plugin@1.0.0", - "--project-path", dir, "--format", "json", "--as", "user", + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -156,28 +158,6 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) { } } -func TestPluginParseInstallTarget(t *testing.T) { - tests := []struct { - input string - wantKey string - wantVersion string - }{ - {"@scope/name@1.0.0", "@scope/name", "1.0.0"}, - {"@scope/name@latest", "@scope/name", "latest"}, - {"@scope/name", "@scope/name", ""}, - {"simple@2.0.0", "simple", "2.0.0"}, - {"simple", "simple", ""}, - {"", "", ""}, - } - for _, tt := range tests { - key, ver := pluginParseInstallTarget(tt.input) - if key != tt.wantKey || ver != tt.wantVersion { - t.Errorf("pluginParseInstallTarget(%q) = (%q, %q), want (%q, %q)", - tt.input, key, ver, tt.wantKey, tt.wantVersion) - } - } -} - // buildTestTGZ creates a .tgz in memory with files under a "package/" prefix. func buildTestTGZ(t *testing.T, files map[string]string) []byte { t.Helper() diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/plugin_list.go index bdd60660c..ba6b7117a 100644 --- a/shortcuts/apps/plugin_list.go +++ b/shortcuts/apps/plugin_list.go @@ -19,9 +19,7 @@ var AppsPluginList = common.Shortcut{ Command: "+plugin-list", Description: "List declared plugin packages and their installation status", Risk: "read", - Flags: []common.Flag{ - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, - }, + Flags: []common.Flag{}, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). Desc("List declared plugin packages and installation status"). @@ -29,14 +27,14 @@ var AppsPluginList = common.Shortcut{ Set("source", "package.json actionPlugins + node_modules") }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } diff --git a/shortcuts/apps/plugin_list_test.go b/shortcuts/apps/plugin_list_test.go index 8bea3828b..a49bd7df7 100644 --- a/shortcuts/apps/plugin_list_test.go +++ b/shortcuts/apps/plugin_list_test.go @@ -13,10 +13,11 @@ import ( func TestPluginList_Empty(t *testing.T) { dir := t.TempDir() writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginList, []string{ - "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + "+plugin-list", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -41,10 +42,11 @@ func TestPluginList_Installed(t *testing.T) { manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginList, []string{ - "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + "+plugin-list", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -70,10 +72,11 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) { "@test/missing": "1.0.0", }, }) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginList, []string{ - "+plugin-list", "--project-path", dir, "--format", "json", "--as", "user", + "+plugin-list", "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -94,6 +97,18 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) { // --- helpers --- +func chdirTest(t *testing.T, dir string) { + t.Helper() + prev, err := os.Getwd() //nolint:forbidigo + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { //nolint:forbidigo + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck +} + func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { t.Helper() data, err := json.Marshal(pkg) diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go index a60b9b240..ce00eedb3 100644 --- a/shortcuts/apps/plugin_uninstall.go +++ b/shortcuts/apps/plugin_uninstall.go @@ -23,7 +23,6 @@ var AppsPluginUninstall = common.Shortcut{ Risk: "write", Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, - {Name: "project-path", Desc: "project root path (defaults to current directory)"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { key := strings.TrimSpace(rctx.Str("name")) @@ -38,7 +37,7 @@ var AppsPluginUninstall = common.Shortcut{ if strings.TrimSpace(rctx.Str("name")) == "" { return appsValidationParamError("--name", "--name is required") } - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } @@ -46,7 +45,7 @@ var AppsPluginUninstall = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { key := strings.TrimSpace(rctx.Str("name")) - projectPath, err := pluginResolveProjectPath(rctx.Str("project-path")) + projectPath, err := pluginResolveProjectPath("") if err != nil { return err } diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/plugin_uninstall_test.go index 628d2d612..db4584cd2 100644 --- a/shortcuts/apps/plugin_uninstall_test.go +++ b/shortcuts/apps/plugin_uninstall_test.go @@ -22,11 +22,12 @@ func TestPluginUninstall_Basic(t *testing.T) { pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginUninstall, []string{ "+plugin-uninstall", "--name", "@test/my-plugin", - "--project-path", dir, "--format", "json", "--as", "user", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -48,11 +49,12 @@ func TestPluginUninstall_Basic(t *testing.T) { func TestPluginUninstall_NotInstalled(t *testing.T) { dir := t.TempDir() writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginUninstall, []string{ "+plugin-uninstall", "--name", "@test/not-here", - "--project-path", dir, "--format", "json", "--as", "user", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("uninstalling non-existent plugin should succeed: %v", err) @@ -86,11 +88,12 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { "pluginKey": "@test/my-plugin", "name": "My Instance", }) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginUninstall, []string{ "+plugin-uninstall", "--name", "@test/my-plugin", - "--project-path", dir, "--format", "json", "--as", "user", + "--format", "json", "--as", "user", }, factory, stdout) if err == nil { t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil") @@ -133,11 +136,12 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { "pluginKey": "@test/other-plugin", "name": "Other Instance", }) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginUninstall, []string{ "+plugin-uninstall", "--name", "@test/my-plugin", - "--project-path", dir, "--format", "json", "--as", "user", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err) @@ -158,11 +162,12 @@ func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) { "@test/keep-me": "2.0.0", }, }) + chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsPluginUninstall, []string{ "+plugin-uninstall", "--name", "@test/remove-me", - "--project-path", dir, "--format", "json", "--as", "user", + "--format", "json", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("unexpected error: %v", err) diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md index 0bc758309..fc20d0557 100644 --- a/skills/lark-apps/references/lark-apps-plugin-install.md +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -11,16 +11,21 @@ ## 命令骨架 - `--name `:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。 -- `--project-path`:妙搭应用根目录。 +- `--version `:指定版本(如 `1.0.0`)。不传则安装最新版。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 ## 示例 ```bash -# plugin-key 从仓库 Skill 的「AI 插件目录」获取 -lark-cli apps +plugin-install --name --project-path +# 安装最新版 +lark-cli apps +plugin-install --name + +# 安装指定版本 +lark-cli apps +plugin-install --name --version 1.0.0 # 批量安装已声明的所有插件 -lark-cli apps +plugin-install --project-path +lark-cli apps +plugin-install ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md index 53ecaf2d1..58f49fc24 100644 --- a/skills/lark-apps/references/lark-apps-plugin-list.md +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -8,12 +8,12 @@ ## 命令骨架 -- `--project-path`:妙搭应用根目录。 +在项目根目录下运行(和 npm 一样,无需指定路径)。 ## 示例 ```bash -lark-cli apps +plugin-list --project-path ./my-app --format json +lark-cli apps +plugin-list --format json ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md index faeb39e3a..f01f6c30e 100644 --- a/skills/lark-apps/references/lark-apps-plugin-uninstall.md +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -9,12 +9,13 @@ ## 命令骨架 - `--name `:要卸载的插件包 key。 -- `--project-path`:妙搭应用根目录。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 ## 示例 ```bash -lark-cli apps +plugin-uninstall --name --project-path +lark-cli apps +plugin-uninstall --name ``` ## 输出契约 From 7810a01eba722aaccfeff4df06d3e04197518677 Mon Sep 17 00:00:00 2001 From: anguohui Date: Fri, 26 Jun 2026 16:27:50 +0800 Subject: [PATCH 30/40] feat(plugin): add Examples to --help for plugin-install/list/uninstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个 可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。 --- shortcuts/apps/plugin_install.go | 5 +++++ shortcuts/apps/plugin_list.go | 4 ++++ shortcuts/apps/plugin_uninstall.go | 3 +++ 3 files changed, 12 insertions(+) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index fb198a1a2..ff9dbba99 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -32,6 +32,11 @@ var AppsPluginInstall = common.Shortcut{ Risk: "write", ConditionalScopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, + Tips: []string{ + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate", + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0", + "Example: lark-cli apps +plugin-install (install all declared plugins in package.json)", + }, Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/plugin_list.go index ba6b7117a..e4f9ecf21 100644 --- a/shortcuts/apps/plugin_list.go +++ b/shortcuts/apps/plugin_list.go @@ -19,6 +19,10 @@ var AppsPluginList = common.Shortcut{ Command: "+plugin-list", Description: "List declared plugin packages and their installation status", Risk: "read", + Tips: []string{ + "Example: lark-cli apps +plugin-list", + "Example: lark-cli apps +plugin-list --format pretty", + }, Flags: []common.Flag{}, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go index ce00eedb3..918d77f20 100644 --- a/shortcuts/apps/plugin_uninstall.go +++ b/shortcuts/apps/plugin_uninstall.go @@ -21,6 +21,9 @@ var AppsPluginUninstall = common.Shortcut{ Command: "+plugin-uninstall", Description: "Uninstall a plugin package (remove from node_modules and package.json)", Risk: "write", + Tips: []string{ + "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", + }, Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, }, From 70aec2726b7cdcb986f184607ad9799ac19e4299 Mon Sep 17 00:00:00 2001 From: anguohui Date: Fri, 26 Jun 2026 17:14:00 +0800 Subject: [PATCH 31/40] fix(plugin): address PR #1609 review findings - Fix hint referencing non-existent +plugin-instance-delete command, point to repo plugin-guide Skill instead - Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir to env-only resolution, fix ambiguous hint to suggest env vars - Reclassify download errors from file_io to network/api with proper hints and retryable marking - Slim SKILL.md routing row, move judgment rules to plugin-install reference - Rename --local flag to --file to align with CLI conventions --- shortcuts/apps/plugin_common.go | 33 ++++++---------- shortcuts/apps/plugin_common_test.go | 38 +++++-------------- shortcuts/apps/plugin_install.go | 28 +++++++++++--- shortcuts/apps/plugin_uninstall.go | 2 +- skills/lark-apps/SKILL.md | 2 +- .../references/lark-apps-plugin-install.md | 8 +++- 6 files changed, 52 insertions(+), 59 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index e0d8d92a7..849fa7f94 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -50,20 +50,12 @@ func pluginCheckProjectDir(projectPath string) error { return nil } -// pluginResolveCapDir resolves the capabilities directory using a 4-level fallback: -// 1. capDirFlag (explicit --capabilities-dir) -// 2. MIAODA_CAPABILITIES_DIR env var -// 3. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) -// 3.5 Read .env.local for MIAODA_APP_TYPE -// 4. Detect by checking which directories exist under projectPath -func pluginResolveCapDir(projectPath, capDirFlag string) (string, error) { - if dir := strings.TrimSpace(capDirFlag); dir != "" { - if filepath.IsAbs(dir) { - return dir, nil - } - return filepath.Join(projectPath, dir), nil - } - +// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback: +// 1. MIAODA_CAPABILITIES_DIR env var +// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) +// 2.5 Read .env.local for MIAODA_APP_TYPE +// 3. Detect by checking which directories exist under projectPath +func pluginResolveCapDir(projectPath string) (string, error) { if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional. if filepath.IsAbs(dir) { return dir, nil @@ -71,7 +63,7 @@ func pluginResolveCapDir(projectPath, capDirFlag string) (string, error) { return filepath.Join(projectPath, dir), nil } - // 3. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ + // 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional. if appType == "" { appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") @@ -83,7 +75,7 @@ func pluginResolveCapDir(projectPath, capDirFlag string) (string, error) { return filepath.Join(projectPath, "server", "capabilities"), nil } - // 4. Directory detection + // 3. Directory detection serverDir := filepath.Join(projectPath, "server", "capabilities") sharedDir := filepath.Join(projectPath, "shared", "capabilities") serverOK := pluginDirExists(serverDir) @@ -93,13 +85,12 @@ func pluginResolveCapDir(projectPath, capDirFlag string) (string, error) { case serverOK && sharedOK: return "", appsFailedPreconditionError( "ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist", - ).WithHint("use --capabilities-dir to specify which capabilities directory to use") + ).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity") case serverOK: return serverDir, nil case sharedOK: return sharedDir, nil default: - // Default to server/capabilities/ (most common app type) return filepath.Join(projectPath, "server", "capabilities"), nil } } @@ -163,8 +154,8 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { // pluginCheckDependentInstances scans the capabilities directory for instances // that reference the given pluginKey. Returns nil if none found, an error with // the list of dependent instance ids if any exist, or the underlying I/O error. -func pluginCheckDependentInstances(projectPath, pluginKey, capDirFlag string) error { - capDir, err := pluginResolveCapDir(projectPath, capDirFlag) +func pluginCheckDependentInstances(projectPath, pluginKey string) error { + capDir, err := pluginResolveCapDir(projectPath) if err != nil { // No capabilities directory → no instances can exist → no conflict. return nil @@ -187,7 +178,7 @@ func pluginCheckDependentInstances(projectPath, pluginKey, capDirFlag string) er } return appsFailedPreconditionError( "plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "), - ).WithHint("delete these instances first (lark-cli apps +plugin-instance-delete --id for each), clean up calling code and types, then retry uninstall") + ).WithHint("delete these instances first (see /.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall") } // pluginCheckInstalled verifies that the plugin package is installed in node_modules diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go index 3fb64c191..263f75faf 100644 --- a/shortcuts/apps/plugin_common_test.go +++ b/shortcuts/apps/plugin_common_test.go @@ -64,29 +64,9 @@ func TestPluginCheckProjectDir_Missing(t *testing.T) { // --- pluginResolveCapDir --- -func TestPluginResolveCapDir_ExplicitFlag(t *testing.T) { - got, err := pluginResolveCapDir("/proj", "my/caps") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if want := filepath.Join("/proj", "my/caps"); got != want { - t.Errorf("got %q, want %q", got, want) - } -} - -func TestPluginResolveCapDir_ExplicitFlagAbsolute(t *testing.T) { - got, err := pluginResolveCapDir("/proj", "/absolute/caps") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "/absolute/caps" { - t.Errorf("got %q, want /absolute/caps", got) - } -} - func TestPluginResolveCapDir_EnvVar(t *testing.T) { t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps") - got, err := pluginResolveCapDir("/proj", "") + got, err := pluginResolveCapDir("/proj") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -97,7 +77,7 @@ func TestPluginResolveCapDir_EnvVar(t *testing.T) { func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) { t.Setenv("MIAODA_APP_TYPE", "2") - got, err := pluginResolveCapDir("/proj", "") + got, err := pluginResolveCapDir("/proj") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -108,7 +88,7 @@ func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) { func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) { t.Setenv("MIAODA_APP_TYPE", "6") - got, err := pluginResolveCapDir("/proj", "") + got, err := pluginResolveCapDir("/proj") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -122,7 +102,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo t.Fatal(err) } - got, err := pluginResolveCapDir(dir, "") + got, err := pluginResolveCapDir(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -136,7 +116,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) { if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo t.Fatal(err) } - got, err := pluginResolveCapDir(dir, "") + got, err := pluginResolveCapDir(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -150,7 +130,7 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) { if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo t.Fatal(err) } - got, err := pluginResolveCapDir(dir, "") + got, err := pluginResolveCapDir(dir) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -167,7 +147,7 @@ func TestPluginResolveCapDir_Ambiguous(t *testing.T) { if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo t.Fatal(err) } - _, err := pluginResolveCapDir(dir, "") + _, err := pluginResolveCapDir(dir) if err == nil { t.Fatal("expected ambiguous error") } @@ -182,7 +162,7 @@ func TestPluginResolveCapDir_Ambiguous(t *testing.T) { func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) { dir := t.TempDir() - got, err := pluginResolveCapDir(dir, "") + got, err := pluginResolveCapDir(dir) if err != nil { t.Fatalf("should default to server/capabilities, got error: %v", err) } @@ -193,7 +173,7 @@ func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) { func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) { t.Setenv("MIAODA_APP_TYPE", "3") - got, err := pluginResolveCapDir("/proj", "") + got, err := pluginResolveCapDir("/proj") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go index ff9dbba99..546c2bef7 100644 --- a/shortcuts/apps/plugin_install.go +++ b/shortcuts/apps/plugin_install.go @@ -40,7 +40,7 @@ var AppsPluginInstall = common.Shortcut{ Flags: []common.Flag{ {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, - {Name: "local", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, + {Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { key := strings.TrimSpace(rctx.Str("name")) @@ -75,7 +75,7 @@ var AppsPluginInstall = common.Shortcut{ return err } - if localTgz := strings.TrimSpace(rctx.Str("local")); localTgz != "" { + if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" { return pluginInstallLocal(rctx, projectPath, localTgz) } @@ -213,7 +213,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error { tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read. if err != nil { - return appsValidationParamError("--local", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err) + return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err) } // Extract to a temp dir first to read package.json @@ -239,7 +239,7 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string key, _ := pkgMeta["name"].(string) version, _ := pkgMeta["version"].(string) if key == "" { - return appsValidationParamError("--local", "package.json in tgz missing 'name' field") + return appsValidationParamError("--file", "package.json in tgz missing 'name' field") } if version == "" { version = "0.0.0" @@ -367,11 +367,27 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key Body: bytes.NewReader(body), }) if err != nil { - return nil, appsFileIOError(err, "download failed for %s@%s", key, version) + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err). + WithHint("check network connectivity and retry"). + WithRetryable(). + WithCause(err) } defer resp.Body.Close() + if resp.StatusCode >= 500 { + return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode). + WithHint("plugin registry returned a server error; retry after a short wait"). + WithRetryable() + } if resp.StatusCode >= 400 { - return nil, appsFileIOError(fmt.Errorf("HTTP %d", resp.StatusCode), "download failed for %s@%s", key, version) + respBody, _ := io.ReadAll(resp.Body) + hint := "check plugin key and version spelling" + if resp.StatusCode == 403 { + hint = "download token may have expired; retry the install to get a fresh token" + } else if resp.StatusCode == 404 { + hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version) + } + return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)). + WithHint(hint) } return io.ReadAll(resp.Body) } diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go index 918d77f20..0c1b5c15c 100644 --- a/shortcuts/apps/plugin_uninstall.go +++ b/shortcuts/apps/plugin_uninstall.go @@ -54,7 +54,7 @@ var AppsPluginUninstall = common.Shortcut{ } // Block uninstall if any instances still reference this plugin package. - if err := pluginCheckDependentInstances(projectPath, key, rctx.Str("capabilities-dir")); err != nil { + if err := pluginCheckDependentInstances(projectPath, key); err != nil { return err } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 038f87f48..c22e36e0a 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -34,7 +34,7 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 插件集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引)。插件包管理(安装/卸载/查看)参考 [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | 仓库 Skill | +| 接入外部能力(AI 模型服务、飞书平台能力)/ 提到插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | ## 高频路径 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md index fc20d0557..8a3a806c2 100644 --- a/skills/lark-apps/references/lark-apps-plugin-install.md +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -4,7 +4,13 @@ ## 何时用 -用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取仓库 Skill:`/.agents/skills/plugin-guide/SKILL.md`。 +用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。 + +**判断规则**: +1. AI 模型服务能力(文本生成、图片理解、语音识别等)必须通过插件接入,禁止自行编写替代实现。 +2. 飞书平台能力(操作多维表格、发送消息等)优先查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现。 + +**前置步骤**:安装前先读仓库 Skill `/.agents/skills/plugin-guide/SKILL.md`,获取插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引。 **插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。 From a552aed3bcc834ee7e7839c68d02b68db203543d Mon Sep 17 00:00:00 2001 From: anguohui Date: Fri, 26 Jun 2026 17:27:28 +0800 Subject: [PATCH 32/40] fix(skill): restore plugin routing row with judgment rules, fix markdown formatting Revert SKILL.md routing row to keep full judgment rules and repo Skill directive inline. Fix bold marker spacing and restore missing table column. Revert reference to original content without duplicated rules. --- skills/lark-apps/SKILL.md | 2 +- skills/lark-apps/references/lark-apps-plugin-install.md | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index c22e36e0a..6bbfe8802 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -34,7 +34,7 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 接入外部能力(AI 模型服务、飞书平台能力)/ 提到插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | +| 外部能力(插件)集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**必须先读创建的应用仓库 Skill** `.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引) | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | ## 高频路径 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md index 8a3a806c2..7d1648587 100644 --- a/skills/lark-apps/references/lark-apps-plugin-install.md +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -4,13 +4,7 @@ ## 何时用 -用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。 - -**判断规则**: -1. AI 模型服务能力(文本生成、图片理解、语音识别等)必须通过插件接入,禁止自行编写替代实现。 -2. 飞书平台能力(操作多维表格、发送消息等)优先查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现。 - -**前置步骤**:安装前先读仓库 Skill `/.agents/skills/plugin-guide/SKILL.md`,获取插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引。 +用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取创建的应用仓库 Skill:`.agents/skills/plugin-guide/SKILL.md`。 **插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。 From b5d3e9896ef8b9b9ac43119a05c6e70ec5fcc4a9 Mon Sep 17 00:00:00 2001 From: anguohui Date: Fri, 26 Jun 2026 18:10:28 +0800 Subject: [PATCH 33/40] fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test Restore SKILL.md plugin routing row to original version with full judgment rules and repo Skill directive. Update shortcut count test from 60 to 63 to account for 3 new plugin commands. --- shortcuts/apps/shortcuts_test.go | 9 +++++---- skills/lark-apps/SKILL.md | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 69b5b9654..fccdb62f5 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -19,11 +19,12 @@ import ( // - 7 file(list/get/sign/download/upload/delete/quota-get) // - 3 git-credential // - 5 session(create/list/get/stop/chat)+ 1 session-messages-list -// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)= 60。 -func TestAppsShortcuts_Returns60(t *testing.T) { +// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset) +// - 3 plugin(install/uninstall/list)= 63。 +func TestAppsShortcuts_Returns63(t *testing.T) { got := Shortcuts() - if len(got) != 60 { - t.Fatalf("Shortcuts() returned %d entries, want 60", len(got)) + if len(got) != 63 { + t.Fatalf("Shortcuts() returned %d entries, want 63", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 6bbfe8802..a869f3576 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、外部能力(插件)集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。当用户需要接入外部能力(AI模型服务、飞书平台能力等)或提到插件/Plugin/Capability时也使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、外部能力(AI模型能力和飞书平台能力)集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。当用户需要接入外部能力(AI模型服务、飞书平台能力等)或提到插件/Plugin/Capability时也使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -34,7 +34,7 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 外部能力(插件)集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**必须先读创建的应用仓库 Skill** `.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引) | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | +| 外部AI能力(插件)集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引)。插件包管理(安装/卸载/查看)参考 [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | 仓库 Skill | ## 高频路径 @@ -68,8 +68,8 @@ metadata: ## 能力边界 -- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 -- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 +- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 +- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 ## app_id 获取 From 2f50e3920315de9e851bbd56f3ba8bf6b02af5df Mon Sep 17 00:00:00 2001 From: zhangli Date: Fri, 26 Jun 2026 18:31:40 +0800 Subject: [PATCH 34/40] fix(plugin):fix lark-apps skill docs which is about plugin --- skills/lark-apps/SKILL.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index a869f3576..61b146a05 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、外部能力(AI模型能力和飞书平台能力)集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。当用户需要接入外部能力(AI模型服务、飞书平台能力等)或提到插件/Plugin/Capability时也使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -14,6 +14,8 @@ metadata: ## 意图路由 +**插件能力优先**:涉及 AI 模型能力(生成、识别、提取、翻译、总结、合成、分类等)或飞书平台能力等外部能力时,先读 `.agents/skills/plugin-guide/SKILL.md` 确认是否有对应插件。无插件覆盖再自行实现。 + 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): | 用户意图 | 先用 | 按需读取 | @@ -34,7 +36,7 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | -| 外部AI能力(插件)集成 — 用户需求涉及外部能力或提到插件/Plugin/Capability。**判断规则**:(1) AI 模型服务能力(文本生成、图片理解、语音识别等)**必须**通过插件接入,禁止自行编写替代实现;(2) 飞书平台能力(操作多维表格、发送消息等)**优先**查看是否有对应插件,插件不支持时再考虑自行通过飞书 OpenAPI 实现 | **⚠️ 必须先读仓库 Skill** `/.agents/skills/plugin-guide/SKILL.md`(插件目录、Schema 规则、实例 CRUD、调用代码生成等完整指引)。插件包管理(安装/卸载/查看)参考 [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md) / [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md) / [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | 仓库 Skill | +| 外部能力(AI模型能力和飞书平台能力)集成/插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | ## 高频路径 From e28a00c2fe51b9134ec3c3de1476662acb3abcf4 Mon Sep 17 00:00:00 2001 From: zhangli Date: Fri, 26 Jun 2026 18:53:56 +0800 Subject: [PATCH 35/40] fix(plugin):correct plugin skill md --- skills/lark-apps/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 61b146a05..c9ca03cfe 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -14,7 +14,7 @@ metadata: ## 意图路由 -**插件能力优先**:涉及 AI 模型能力(生成、识别、提取、翻译、总结、合成、分类等)或飞书平台能力等外部能力时,先读 `.agents/skills/plugin-guide/SKILL.md` 确认是否有对应插件。无插件覆盖再自行实现。 +**仓库 Skill 优先**:执行任何操作前,先读应用代码仓库 `.agents/skills/plugin-guide/SKILL.md` 确认对应的能力。 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): From 110107458a839b67755fb373da7a1c3d10fd1714 Mon Sep 17 00:00:00 2001 From: zhangli Date: Fri, 26 Jun 2026 19:26:24 +0800 Subject: [PATCH 36/40] fix(plugin):correct plugin md --- skills/lark-apps/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index c9ca03cfe..23dac4749 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -14,7 +14,7 @@ metadata: ## 意图路由 -**仓库 Skill 优先**:执行任何操作前,先读应用代码仓库 `.agents/skills/plugin-guide/SKILL.md` 确认对应的能力。 +**仓库 Skill 优先**:先尝试读 `.agents/skills/plugin-guide/SKILL.md` 确认对应的能力。如果文件不存在(应用尚未初始化),按下方路由表 `+init` 初始化应用,初始化完成后必须重新回到此处读取仓库 Skill。如果文件存在,禁止跳过仓库 Skill 直接手写替代方案。 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): From 22ae7ab04d855fab903bb52c6bb8f1745c62113e Mon Sep 17 00:00:00 2001 From: zhangli Date: Fri, 26 Jun 2026 20:45:02 +0800 Subject: [PATCH 37/40] fix(plugin):correct plugin and local dev skills md --- skills/lark-apps/SKILL.md | 2 +- skills/lark-apps/references/lark-apps-local-dev.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 23dac4749..0efff2cef 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -14,7 +14,7 @@ metadata: ## 意图路由 -**仓库 Skill 优先**:先尝试读 `.agents/skills/plugin-guide/SKILL.md` 确认对应的能力。如果文件不存在(应用尚未初始化),按下方路由表 `+init` 初始化应用,初始化完成后必须重新回到此处读取仓库 Skill。如果文件存在,禁止跳过仓库 Skill 直接手写替代方案。 +**仓库 Skill 优先**:每次执行任务前,先读取项目仓库 `.agents/skills/plugin-guide/SKILL.md`获取·对应能力。文件不存在(应用尚未初始化)→ 按路由表 `+init`,完成后回来读取。文件存在 → 读取内容后再继续。 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md index d404e34cf..84b01b442 100644 --- a/skills/lark-apps/references/lark-apps-local-dev.md +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -35,6 +35,7 @@ lark-cli apps +release-create --app-id app_xxx ``` `+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。 +`+init`执行完成后,你需要读应用代码仓库 `.agents/skills/plugin-guide/SKILL.md` 来了解怎样开发相关的插件能力。 ## 改完代码后部署上线 From 1864b7fae9b28df3be6b0d07aae22ff63c083de6 Mon Sep 17 00:00:00 2001 From: zhangli Date: Fri, 26 Jun 2026 21:31:56 +0800 Subject: [PATCH 38/40] fix(plugin):correct apps plugin skills md --- skills/lark-apps/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 0efff2cef..463195efd 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -14,7 +14,7 @@ metadata: ## 意图路由 -**仓库 Skill 优先**:每次执行任务前,先读取项目仓库 `.agents/skills/plugin-guide/SKILL.md`获取·对应能力。文件不存在(应用尚未初始化)→ 按路由表 `+init`,完成后回来读取。文件存在 → 读取内容后再继续。 +**仓库 Skill 优先**:必须读取 `<项目根目录>/.agents/skills/plugin-guide/SKILL.md`获取能力指引;已读取过则直接使用。文件不存在(应用尚未初始化)→ 按路由表 `+init`,完成后回来读取。文件存在 → 读取后再继续。 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): From 1d313a56b1cdebba6dee4488ae5b46f0929bc961 Mon Sep 17 00:00:00 2001 From: anguohui Date: Sat, 27 Jun 2026 14:14:30 +0800 Subject: [PATCH 39/40] fix(lark-apps): move repo skill reading hint to post-init phase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除, 改在 +init 完成后的 local-dev reference 中提示 agent 读取 仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill 不存在导致 agent 无法获取插件知识的时序问题。 --- skills/lark-apps/SKILL.md | 2 -- skills/lark-apps/references/lark-apps-local-dev.md | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 463195efd..4c61c3d6e 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -14,8 +14,6 @@ metadata: ## 意图路由 -**仓库 Skill 优先**:必须读取 `<项目根目录>/.agents/skills/plugin-guide/SKILL.md`获取能力指引;已读取过则直接使用。文件不存在(应用尚未初始化)→ 按路由表 `+init`,完成后回来读取。文件存在 → 读取后再继续。 - 按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): | 用户意图 | 先用 | 按需读取 | diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md index 84b01b442..621d22943 100644 --- a/skills/lark-apps/references/lark-apps-local-dev.md +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -11,7 +11,7 @@ ## 端到端流程(新建应用) -`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 +`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> 读仓库 Skill -> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 ```bash # 新建 full_stack 应用 @@ -35,7 +35,8 @@ lark-cli apps +release-create --app-id app_xxx ``` `+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。 -`+init`执行完成后,你需要读应用代码仓库 `.agents/skills/plugin-guide/SKILL.md` 来了解怎样开发相关的插件能力。 + +**`+init` 完成后必须执行**:`cat /.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。 ## 改完代码后部署上线 From e5f66ce22e10481e40750a82d8ff227c859b27b1 Mon Sep 17 00:00:00 2001 From: anguohui Date: Sat, 27 Jun 2026 16:22:14 +0800 Subject: [PATCH 40/40] fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读" - local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤 - post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底 --- skills/lark-apps/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 4c61c3d6e..b8bc7599e 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -22,7 +22,7 @@ metadata: | 找已有 app_id、按名字过滤应用 | `+list --keyword ` | [`lark-apps-list.md`](references/lark-apps-list.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | -| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |