Compare commits

...

42 Commits

Author SHA1 Message Date
zhangli
412dcba4b8 style: gofmt plugin files 2026-06-30 11:50:13 +08:00
anguohui
e5f66ce22e fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide
- SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读"
- local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤
- post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底
2026-06-27 16:23:36 +08:00
anguohui
1d313a56b1 fix(lark-apps): move repo skill reading hint to post-init phase
将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除,
改在 +init 完成后的 local-dev reference 中提示 agent 读取
仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill
不存在导致 agent 无法获取插件知识的时序问题。
2026-06-27 16:23:36 +08:00
zhangli
1864b7fae9 fix(plugin):correct apps plugin skills md 2026-06-26 21:31:56 +08:00
zhangli
22ae7ab04d fix(plugin):correct plugin and local dev skills md 2026-06-26 20:45:02 +08:00
zhangli
110107458a fix(plugin):correct plugin md 2026-06-26 19:26:24 +08:00
zhangli
e28a00c2fe fix(plugin):correct plugin skill md 2026-06-26 18:53:56 +08:00
zhangli
2f50e39203 fix(plugin):fix lark-apps skill docs which is about plugin 2026-06-26 18:31:40 +08:00
anguohui
b5d3e9896e 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.
2026-06-26 18:10:28 +08:00
anguohui
a552aed3bc 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.
2026-06-26 17:27:28 +08:00
anguohui
70aec2726b 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
2026-06-26 17:14:00 +08:00
anguohui
52894d095b merge: resolve conflicts with feat/apps-spark-capibilities
Keep both plugin commands and openapi-key commands in shortcuts registry;
merge SKILL.md descriptions to include both plugin and observability features.
2026-06-26 16:38:35 +08:00
anguohui
7810a01eba feat(plugin): add Examples to --help for plugin-install/list/uninstall
按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个
可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。
2026-06-26 16:27:50 +08:00
anguohui
b33fe32718 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
2026-06-26 15:43:07 +08:00
anguohui
490006ee7b refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件
- 删除 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 自校验。
2026-06-25 22:02:30 +08:00
zhangli
4e2abab504 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.
2026-06-25 21:42:50 +08:00
zhangli
0ff2957c6e fix(plugin): resolve real paths in dry-run output for instance commands
Replace <capabilities_dir> 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.
2026-06-25 16:21:46 +08:00
anguohui
41aefd63f0 fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill
- 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取
- instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误
- 防止 agent 跳过仓库 Skill 凭示例猜测插件名
2026-06-25 12:07:02 +08:00
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
anguohui
de5de57ced refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考
- 删除旧的 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 命令参考
2026-06-25 11:43:31 +08:00
anguohui
911f584ab0 refactor: streamline plugin skill files 2026-06-24 23:57:47 +08:00
anguohui
08340bf3aa fix: remove call example annotation from types, add skill reference instead 2026-06-24 19:21:46 +08:00
zhangli
a99dc33195 docs: strengthen plugin reference reading rules from advisory to mandatory
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.
2026-06-24 18:35:52 +08:00
anguohui
bb891e0c50 fix: match actual API response field names (key/version instead of plugin_key/plugin_version) 2026-06-24 15:18:35 +08:00
anguohui
d5f65d1aa4 fix: align dry-run output with new batch_query + download_package request format 2026-06-24 12:21:24 +08:00
anguohui
5365cb97ab fix: update plugin install to match final OpenAPI gateway protocol
- 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
2026-06-24 11:13:05 +08:00
anguohui
8037bd8037 fix: update plugin API paths to match new OpenAPI gateway routes
- batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get
- download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version=
2026-06-24 11:13:05 +08:00
zhangli
dbc1c93b71 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.
2026-06-23 22:03:29 +08:00
zhangli
2beb110523 docs: add plugin package ≠ npm package distinction to skill docs
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.
2026-06-23 20:53:45 +08:00
anguohui
112183f447 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)
2026-06-23 20:38:26 +08:00
anguohui
3b9ee1af67 fix: require reading project plugin-guide skill before writing call code 2026-06-23 17:26:48 +08:00
anguohui
a5386f6053 fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill 2026-06-23 12:03:49 +08:00
anguohui
d6c37232e6 fix: use absolute project-path for tech-stack skill location in plugin-call
Replace relative .agent/skills path with <project-path> 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.
2026-06-23 12:02:12 +08:00
anguohui
999ac4e7d6 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.
2026-06-23 11:46:13 +08:00
anguohui
a91f2cdd85 refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance
- 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
2026-06-22 22:36:31 +08:00
anguohui
d80636d7da 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)
2026-06-22 20:12:01 +08:00
anguohui
0a999171be 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.
2026-06-22 11:48:28 +08:00
anguohui
1d9f102b36 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
2026-06-18 17:22:04 +08:00
anguohui
d7820f7c1f fix: hide +plugin-instance-types from agent (auto-invoked by create/update) 2026-06-18 17:19:22 +08:00
anguohui
b8f45c96d7 feat: add +plugin-instance-types command and auto-generate on create/update
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 <id>
2026-06-18 17:18:18 +08:00
anguohui
9dc032ca73 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
2026-06-18 16:18:20 +08:00
anguohui
e9f2da086f feat: add plugin package and instance management commands for apps domain
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
2026-06-18 16:10:47 +08:00
15 changed files with 1785 additions and 9 deletions

View File

@@ -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 ""
}

View File

@@ -0,0 +1,253 @@
// 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"
)
// --- 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_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))
}
}
// --- 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)
}
}

View File

@@ -0,0 +1,393 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/errs"
"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",
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"},
{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"))
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": [<from actionPlugins>], "latest_only": false}`)
}
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(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("")
if err != nil {
return err
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
if err != nil {
return err
}
if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" {
return pluginInstallLocal(rctx, projectPath, localTgz)
}
key := strings.TrimSpace(rctx.Str("name"))
if key == "" {
return pluginInstallAll(ctx, rctx, projectPath)
}
version := strings.TrimSpace(rctx.Str("version"))
return pluginInstallOne(ctx, rctx, projectPath, key, version)
},
}
// 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", "--name is required")
}
// 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",
}
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, err := pluginResolveVersion(ctx, rctx, key, version)
if err != nil {
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)
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)
}
// Check peer dependencies
missingPeers := pluginCheckPeerDeps(projectPath, 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",
}
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
}
// 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 {
existing := pluginInstalledVersion(projectPath, key)
if existing != "" && existing == version {
continue
}
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); 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
}
// 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("--file", "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("--file", "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_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{}{
"plugin_keys": []interface{}{key},
"latest_only": isLatest,
}
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 "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", 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(hint)
}
// 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 version field; contact plugin maintainer")
}
return rv, nil
}
// 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 := raw.([]interface{})
if !ok {
return nil
}
isLatest := version == "" || version == "latest"
for _, v := range arr {
item, ok := v.(map[string]interface{})
if !ok {
continue
}
// API returns "key" (not "plugin_key")
pk, _ := item["key"].(string)
if pk != key {
continue
}
if isLatest {
return item
}
pv, _ := item["version"].(string)
if pv == version {
return item
}
}
return nil
}
// 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,
})
resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: apiPath,
Body: bytes.NewReader(body),
})
if err != nil {
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 {
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)
}

View File

@@ -0,0 +1,181 @@
// 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{}{})
chdirTest(t, dir)
factory, stdout, reg := newAppsExecuteFactory(t)
// 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_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"key": "@test/my-plugin",
"version": "1.0.0",
"download_approach": "inner",
"status": "active",
},
},
},
},
})
// 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: "POST",
URL: "/open-apis/spark/v1/plugin/versions/download_package",
RawBody: tgzData,
ContentType: "application/octet-stream",
})
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+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)
}
// 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
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginInstall, []string{
"+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)
}
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")
}
}
// 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()
}

View File

@@ -0,0 +1,80 @@
// 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",
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().
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("")
if err != nil {
return err
}
return pluginCheckProjectDir(projectPath)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
projectPath, err := pluginResolveProjectPath("")
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
},
}

View File

@@ -0,0 +1,121 @@
// 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{}{})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--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
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--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",
},
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginList, []string{
"+plugin-list", "--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 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)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
t.Fatal(err)
}
}

View File

@@ -0,0 +1,84 @@
// 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",
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},
},
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("")
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("")
if err != nil {
return err
}
// Block uninstall if any instances still reference this plugin package.
if err := pluginCheckDependentInstances(projectPath, key); 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
},
}

View File

@@ -0,0 +1,187 @@
// 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 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
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--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{}{})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/not-here",
"--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_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",
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--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",
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/my-plugin",
"--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{}{
"name": "my-app",
"actionPlugins": map[string]interface{}{
"@test/remove-me": "1.0.0",
"@test/keep-me": "2.0.0",
},
})
chdirTest(t, dir)
factory, stdout, _ := newAppsExecuteFactory(t)
err := runAppsShortcut(t, AppsPluginUninstall, []string{
"+plugin-uninstall", "--name", "@test/remove-me",
"--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)
}
}

View File

@@ -63,6 +63,9 @@ func Shortcuts() []common.Shortcut {
AppsSessionStop,
AppsSessionMessagesList,
AppsChat,
AppsPluginInstall,
AppsPluginUninstall,
AppsPluginList,
// open API key management
AppsOpenAPIKeyList,
AppsOpenAPIKeyGet,

View File

@@ -19,11 +19,12 @@ import (
// - 7 filelist/get/sign/download/upload/delete/quota-get
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset= 60。
func TestAppsShortcuts_Returns60(t *testing.T) {
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/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))
}
}

View File

@@ -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、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传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"]
@@ -22,7 +22,7 @@ metadata:
| 找已有 app_id、按名字过滤应用 | `+list --keyword <name>` | [`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) |
@@ -34,6 +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 <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) |
## 高频路径
@@ -67,8 +68,8 @@ metadata:
## 能力边界
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限/ 自动化 / 插件`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web处理。
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web处理。
## app_id 获取

View File

@@ -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 应用
@@ -36,6 +36,8 @@ lark-cli apps +release-create --app-id app_xxx
`+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init``repository_url`,再用原生 `git clone` / `git checkout sprint/default`
**`+init` 完成后必须执行**`cat <project-path>/.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。
## 改完代码后部署上线
已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。

View File

@@ -0,0 +1,34 @@
# 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>`:插件包 key从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。
- `--version <ver>`:指定版本(如 `1.0.0`)。不传则安装最新版。
在项目根目录下运行(和 npm 一样,无需指定路径)。
## 示例
```bash
# 安装最新版
lark-cli apps +plugin-install --name <plugin-key>
# 安装指定版本
lark-cli apps +plugin-install --name <plugin-key> --version 1.0.0
# 批量安装已声明的所有插件
lark-cli apps +plugin-install
```
## 输出契约
- 已安装同版本会跳过status=already_installed
- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。

View File

@@ -0,0 +1,21 @@
# apps +plugin-list
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
## 何时用
查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。
## 命令骨架
在项目根目录下运行(和 npm 一样,无需指定路径)。
## 示例
```bash
lark-cli apps +plugin-list --format json
```
## 输出契约
- `data.plugins[]` 包含 `key``version``status``installed` / `declared_not_installed`)。

View File

@@ -0,0 +1,23 @@
# apps +plugin-uninstall
卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。
## 何时用
用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。
## 命令骨架
- `--name <key>`:要卸载的插件包 key。
在项目根目录下运行(和 npm 一样,无需指定路径)。
## 示例
```bash
lark-cli apps +plugin-uninstall --name <plugin-key>
```
## 输出契约
- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。