Files
larksuite-cli/shortcuts/apps/plugin_common.go
zhangli 09984fa92a 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.
2026-06-25 11:59:52 +08:00

941 lines
32 KiB
Go

// 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
// 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.
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 <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
}
}
}
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
}
// 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{})
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)
}
// 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) {
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 ""
}
// ── 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<string, unknown>"
}
// 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)
}