Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
e9f2da0
feat: add plugin package and instance management commands for apps do…
anngo-nk Jun 18, 2026
9dc032c
fix: close install gaps aligned with fullstack-cli
anngo-nk Jun 18, 2026
b8f45c9
feat: add +plugin-instance-types command and auto-generate on create/…
anngo-nk Jun 18, 2026
d7820f7
fix: hide +plugin-instance-types from agent (auto-invoked by create/u…
anngo-nk Jun 18, 2026
1d9f102
feat: add plugin skill files for agent workflow guidance
anngo-nk Jun 18, 2026
0a99917
feat: add --local flag to +plugin-install for local tgz installation
anngo-nk Jun 22, 2026
d80636d
fix: improve error messages for plugin install and check
anngo-nk Jun 22, 2026
a91f2cd
refactor: consolidate plugin skill files from 9 to 3, add catalog and…
anngo-nk Jun 22, 2026
999ac4e
refactor: slim down plugin-call to decisions only, delegate code patt…
anngo-nk Jun 23, 2026
d6c3723
fix: use absolute project-path for tech-stack skill location in plugi…
anngo-nk Jun 23, 2026
a5386f6
fix: remove fallback minimal rules from plugin-call, rely on tech-sta…
anngo-nk Jun 23, 2026
3b9ee1a
fix: require reading project plugin-guide skill before writing call code
anngo-nk Jun 23, 2026
112183f
fix: improve plugin error hints for AI agent friendliness
anngo-nk Jun 23, 2026
2beb110
docs: add plugin package ≠ npm package distinction to skill docs
zhangli-268 Jun 23, 2026
dbc1c93
fix: block plugin uninstall when instances still reference the package
zhangli-268 Jun 23, 2026
8037bd8
fix: update plugin API paths to match new OpenAPI gateway routes
anngo-nk Jun 23, 2026
5365cb9
fix: update plugin install to match final OpenAPI gateway protocol
anngo-nk Jun 24, 2026
d5f65d1
fix: align dry-run output with new batch_query + download_package req…
anngo-nk Jun 24, 2026
bb891e0
fix: match actual API response field names (key/version instead of pl…
anngo-nk Jun 24, 2026
a99dc33
docs: strengthen plugin reference reading rules from advisory to mand…
zhangli-268 Jun 24, 2026
08340bf
fix: remove call example annotation from types, add skill reference i…
anngo-nk Jun 24, 2026
911f584
refactor: streamline plugin skill files
anngo-nk Jun 24, 2026
de5de57
refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考
anngo-nk Jun 25, 2026
09984fa
fix(plugin):simplify skill docs and resolve plugin version from actio…
zhangli-268 Jun 24, 2026
41aefd6
fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill
anngo-nk Jun 25, 2026
0ff2957
fix(plugin): resolve real paths in dry-run output for instance commands
zhangli-268 Jun 25, 2026
4e2abab
refactor(plugin): hide instance commands, delegate to repo Skill
zhangli-268 Jun 25, 2026
490006e
refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件
anngo-nk Jun 25, 2026
b33fe32
refactor(plugin): remove --project-path flag and split --name into --…
anngo-nk Jun 26, 2026
7810a01
feat(plugin): add Examples to --help for plugin-install/list/uninstall
anngo-nk Jun 26, 2026
52894d0
merge: resolve conflicts with feat/apps-spark-capibilities
anngo-nk Jun 26, 2026
70aec27
fix(plugin): address PR #1609 review findings
anngo-nk Jun 26, 2026
a552aed
fix(skill): restore plugin routing row with judgment rules, fix markd…
anngo-nk Jun 26, 2026
b5d3e98
fix(plugin): revert SKILL.md to pre-review version, fix shortcut coun…
anngo-nk Jun 26, 2026
2f50e39
fix(plugin):fix lark-apps skill docs which is about plugin
zhangli-268 Jun 26, 2026
e28a00c
fix(plugin):correct plugin skill md
zhangli-268 Jun 26, 2026
1101074
fix(plugin):correct plugin md
zhangli-268 Jun 26, 2026
22ae7ab
fix(plugin):correct plugin and local dev skills md
zhangli-268 Jun 26, 2026
1864b7f
fix(plugin):correct apps plugin skills md
zhangli-268 Jun 26, 2026
1d313a5
fix(lark-apps): move repo skill reading hint to post-init phase
anngo-nk Jun 27, 2026
e5f66ce
fix(lark-apps): strengthen local-dev reference reading and post-init …
anngo-nk Jun 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
392 changes: 392 additions & 0 deletions shortcuts/apps/plugin_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,392 @@
// 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"
"strings"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
)

// 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 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
}
return filepath.Join(projectPath, dir), nil
}

// 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")
}
if appType == "6" {
return filepath.Join(projectPath, "shared", "capabilities"), nil
}
if appType != "" {
return filepath.Join(projectPath, "server", "capabilities"), nil
}

// 3. 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("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:
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()
}

// 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
}
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
}

// 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 string) error {
capDir, err := pluginResolveCapDir(projectPath)
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 (see <project-path>/.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
// 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)
}
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
}
return nil
}

// ── 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)
}

// 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
}

// 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 ""
}
Loading
Loading