Compare commits

..

20 Commits

Author SHA1 Message Date
leave330
beee72f844 docs: defer standalone lark-application skill to a later iteration 2026-07-08 19:37:07 +08:00
leave330
78acceb910 fix: preserve cause chain when rewrapping name-collision error 2026-07-08 17:53:18 +08:00
leave330
cb8d09d802 test: use conventional placeholder credentials in app test config 2026-07-08 17:27:22 +08:00
leave330
42dc50f5fb test: build delete stub URL from shared base path constant 2026-07-08 17:16:02 +08:00
leave330
59ccffb09a docs: refine lark-application skill guide 2026-07-08 17:10:01 +08:00
leave330
d4f62490d0 fix: limit recommended tier to slash-command read scope 2026-07-08 16:10:18 +08:00
leave330
d8327726b0 feat: expose application domain in interactive auth login 2026-07-08 16:00:16 +08:00
leave330
3a297df5b0 docs: note interactive login picker does not yet list application domain 2026-07-08 15:38:05 +08:00
leave330
dce04f792a fix: declare shared scopes on application shortcuts for user preflight 2026-07-08 15:09:52 +08:00
leave330
fd00d43406 fix: preserve high-risk note in delete dry-run and document force i18n caveat 2026-07-08 13:28:36 +08:00
leave330
f2f49a68f4 test: add application slash command dry-run E2E coverage
Pin the dry-run request shapes for +slash-command-list/create/update/delete
(GET/POST/PATCH/DELETE paths, top-level icon key sibling to description,
description.i18n) and confirm the high-risk-write delete path exits 10 with
a confirmation_required envelope on stderr when --yes is omitted.
2026-07-08 12:38:59 +08:00
leave330
42c4b7d155 fix: classify slash command not-found as API error and preserve conflict code
- commandNotFoundError now returns errs.NewAPIError(SubtypeNotFound, ...)
  instead of a validation error: a resolution miss against the live list
  means the resource doesn't exist, not that the caller's argument shape
  was invalid.
- The no-force name-collision rewrap in +slash-command-create now carries
  over the original Code/LogID from the upstream conflict response instead
  of dropping them.
- +slash-command-delete now prints an extra stderr note that recreating the
  same command name yields a NEW command_id.
- Add a test guarding that --force only converts a name-collision response
  into an update; any other POST failure (e.g. invalid icon_key) must
  surface unchanged with no PATCH attempted.
2026-07-08 12:38:53 +08:00
leave330
96c8e638a1 docs: fix slash command trigger event reference in lark-application skill 2026-07-08 12:20:01 +08:00
leave330
ba8368504d docs: add lark-application skill for slash command management 2026-07-08 11:56:15 +08:00
leave330
c3adfc1789 feat: add slash-command-delete shortcut with high-risk confirmation 2026-07-08 11:49:00 +08:00
leave330
a290aa628f feat: add slash-command-update shortcut with by-name addressing 2026-07-08 11:43:36 +08:00
leave330
edeccaa419 feat: add slash-command-create shortcut with --force idempotent re-run 2026-07-08 11:38:39 +08:00
leave330
84962a1927 feat: add slash command name-to-id resolution helper 2026-07-08 11:29:38 +08:00
leave330
a72a562c7d feat: add application domain with slash-command-list shortcut 2026-07-08 11:25:20 +08:00
leave330
deb866f981 feat: add application domain description and slash command helpers 2026-07-08 11:17:15 +08:00
169 changed files with 1950 additions and 7928 deletions

View File

@@ -2,29 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.67] - 2026-07-08
### Features
- **mail**: add message modify and trash shortcuts (#1567)
- support whiteboard file inputs in docs XML (#1784)
- **vc**: refine meeting-events output and reaction forwarding (#1674)
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
### Bug Fixes
- accept opaque wiki node tokens (#1789)
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
- preserve original filename in multipart file upload (#1767)
### Documentation
- restore one-time authorization guidance in lark-apps skill (#1794)
### Misc
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
## [v1.0.66] - 2026-07-07
### Features
@@ -1421,7 +1398,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64

View File

@@ -10,33 +10,18 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -62,5 +47,3 @@ replace the Go tips (not merged), so keep tips in one place.
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,42 +1,6 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -4,14 +4,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -1073,157 +1069,3 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"}
return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
}

View File

@@ -21,16 +21,12 @@ import (
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillpolicy"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -46,7 +42,6 @@ type buildConfig struct {
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
skillsOverlay *platform.SkillsOverlay
}
// WithIO sets the IO streams for the CLI by wrapping raw reader/writers.
@@ -64,17 +59,6 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
}
}
// WithEmbeddedSkills customizes the CLI's embedded skills for a caller that builds
// the command tree directly instead of registering a plugin. It is the
// build-option analogue of a plugin's EmbeddedSkills(...): the same single-owner
// rule applies, so combining WithEmbeddedSkills with a plugin that also customizes
// skills aborts startup. nil is a no-op.
func WithEmbeddedSkills(spec *platform.SkillsOverlay) BuildOption {
return func(c *buildConfig) {
c.skillsOverlay = spec
}
}
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
@@ -87,21 +71,6 @@ var embeddedSkillContent fs.FS
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// withEmbeddedSkillsOwner labels a WithEmbeddedSkills contribution in the skill resolver so a
// WithEmbeddedSkills + plugin-Skills collision names a stable, non-plugin owner.
const withEmbeddedSkillsOwner = "cmd.WithEmbeddedSkills"
// resolveSkillContent composes the effective embedded skill tree from the CLI
// default, an optional WithEmbeddedSkills spec, and plugin SkillsOverlays, enforcing the
// single-owner rule across all sources.
func resolveSkillContent(cfg *buildConfig, pluginSkills []skillpolicy.PluginSkill) (fs.FS, error) {
sources := pluginSkills
if cfg.skillsOverlay != nil {
sources = append([]skillpolicy.PluginSkill{{PluginName: withEmbeddedSkillsOwner, SkillsOverlay: cfg.skillsOverlay}}, sources...)
}
return skillpolicy.Resolve(embeddedSkillContent, sources)
}
// HideProfile sets the visibility policy for the root-level --profile flag.
// When hide is true the flag stays registered (so existing invocations still
// parse) but is omitted from help and shell completion. Typically called as
@@ -185,14 +154,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
cfg.streams = cmdutil.SystemIO()
}
// Reset every process-global snapshot up front, not only inside
// applyUserPolicyPruning: the skipPlugins and install-failure paths
// return before pruning runs, and a previous build's state must not
// leak into this one (long-lived embedders, test sequences).
policystate.SetPluginDeniedDomains(nil)
cmdpolicy.SetActive(nil)
internalplatform.SetActiveInventory(nil)
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
@@ -214,16 +175,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
// The skill-content getter also gates on the skills command domain:
// every pointer it feeds renders as `lark-cli skills read ...`, so with
// that domain plugin-denied the pointers would all be dead ends even
// though the content itself still exists.
installTipsHelpFunc(rootCmd, func() fs.FS {
if policystate.DomainDeniedByPlugin("skills") {
return nil
}
return f.SkillContent
})
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
// SilenceUsage as a static field (not only in PersistentPreRun) so it also
// covers flag-parse errors, which fail before PreRun runs — otherwise cobra
@@ -271,13 +223,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
}
if cfg.skipPlugins {
installHelpCommand(rootCmd)
resolved, err := resolveSkillContent(cfg, nil)
if err != nil {
installPluginSkillErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
f.SkillContent = resolved
recordInventory(nil)
return f, rootCmd, nil
}
@@ -288,52 +233,23 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var pluginSkills []skillpolicy.PluginSkill
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
pluginSkills = installResult.PluginSkills
registry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
denied, policyErr := applyUserPolicyPruning(rootCmd, pluginRules)
if policyErr != nil {
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, policyErr)
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, policyErr)
warnPolicyError(cfg.streams.ErrOut, err)
}
// The custom help command attaches AFTER policy evaluation on purpose:
// it is a framework meta command, and inside the evaluated tree an
// allow-list rule (Allow: ["im/**"]) would deny it as
// domain_not_allowed — cobra's stock help command is likewise attached
// only at Execute time and never evaluated.
installHelpCommand(rootCmd)
// Presentation passes: a capability an integrator plugin denied
// presents as absent — retired global flags (--profile), no skills
// footer, diagnostics hidden or retired. Enforcement stays with
// cmdpolicy.Apply above; these only shape help and fixed hints.
applyPluginPresentation(rootCmd, installResult, denied)
// Resolve the embedded skill tree BEFORE wiring hooks: an invalid
// SkillsOverlay must fail fast, before wireHooks emits Startup, so a Startup
// side effect is never stranded without its Shutdown. Both skill readers
// -- `skills list`/`read` and the --help guidance -- then read this one
// f.SkillContent. Fails closed: never silently ship defaults once a
// customization is declared.
resolvedSkills, skillErr := resolveSkillContent(cfg, pluginSkills)
if skillErr != nil {
installPluginSkillErrorGuard(rootCmd, skillErr)
return f, rootCmd, nil
}
f.SkillContent = resolvedSkills
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)

View File

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
)
type noopConfigKeychain struct{}
@@ -565,54 +564,3 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
}
})
}
// The "no active profile" hint points at `lark-cli profile list`; that pointer
// must be gated on the profile domain still being present. When a plugin denies
// the profile domain, the hint would be a dead end, so it is omitted — the error
// itself is unchanged.
func TestConfigShowRun_ProfileHintGatedByPluginDenial(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
hintOf := func(t *testing.T) string {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
err := configShowRun(&ConfigShowOptions{Factory: f})
var cfgErr *errs.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("expected *errs.ConfigError, got %T %v", err, err)
}
if cfgErr.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("subtype = %q, want not_configured", cfgErr.Subtype)
}
return cfgErr.Hint
}
t.Run("profile domain present: hint points at profile list", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
if h := hintOf(t); !strings.Contains(h, "lark-cli profile list") {
t.Errorf("hint = %q, want it to reference `lark-cli profile list`", h)
}
})
t.Run("profile domain plugin-denied: hint omitted", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"profile": true})
if h := hintOf(t); h != "" {
t.Errorf("hint = %q, want empty when the profile domain is plugin-denied", h)
}
})
}

View File

@@ -85,9 +85,6 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
if len(p.Rules) > 0 {
entry["rules"] = p.Rules
}
if p.EmbeddedSkills != nil {
entry["embedded_skills"] = p.EmbeddedSkills
}
entry["hooks"] = map[string]any{
"observers": p.Observers,
"wrappers": p.Wrappers,

View File

@@ -1,93 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"encoding/json"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// config plugins show must surface a plugin's EmbeddedSkills contribution in
// the rendered JSON, not only in the internal inventory struct: this command is
// the operator's window into what a fork trimmed, so the Allow/Remove/Overlay/
// Base summary has to reach stdout. Guards the render layer, which asserting the
// inventory struct alone does not exercise.
func TestConfigPluginsShow_RendersEmbeddedSkills(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
EmbeddedSkills: &internalplatform.SkillsOverlayView{
Allow: []string{"lark-im"},
Remove: []string{"lark-a"},
Overlay: true,
Base: true,
},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got struct {
Plugins []struct {
EmbeddedSkills *internalplatform.SkillsOverlayView `json:"embedded_skills"`
} `json:"plugins"`
}
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if len(got.Plugins) != 1 {
t.Fatalf("want 1 plugin, got %d", len(got.Plugins))
}
es := got.Plugins[0].EmbeddedSkills
if es == nil {
t.Fatalf("embedded_skills missing from rendered output:\n%s", out.String())
}
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
len(es.Remove) != 1 || es.Remove[0] != "lark-a" ||
!es.Overlay || !es.Base {
t.Errorf("embedded_skills summary mismatch: %+v", es)
}
}
// A plugin that did not customize embedded skills must not emit an
// embedded_skills key, so the field's presence is a reliable signal that a fork
// trimmed the tree.
func TestConfigPluginsShow_OmitsEmbeddedSkillsWhenAbsent(t *testing.T) {
internalplatform.SetActiveInventory(&internalplatform.Inventory{
Plugins: []internalplatform.PluginEntry{{
Name: "acme",
Version: "1.0",
Capabilities: internalplatform.CapabilitiesView{Restricts: true, FailurePolicy: "fail-closed"},
}},
})
t.Cleanup(func() { internalplatform.SetActiveInventory(nil) })
out := &bytes.Buffer{}
f := &cmdutil.Factory{IOStreams: cmdutil.NewIOStreams(nil, out, &bytes.Buffer{})}
if err := runConfigPluginsShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var raw map[string]any
if err := json.Unmarshal(out.Bytes(), &raw); err != nil {
t.Fatalf("not json: %v", err)
}
plugins, ok := raw["plugins"].([]any)
if !ok || len(plugins) != 1 {
t.Fatalf("want 1 plugin in output, got: %s", out.String())
}
if _, ok := plugins[0].(map[string]any)["embedded_skills"]; ok {
t.Errorf("embedded_skills must be omitted when the plugin customized no skills; got:\n%s", out.String())
}
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/spf13/cobra"
)
@@ -56,12 +55,7 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
e := errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile")
// No recovery hint when the profile domain is absent from this build.
if !policystate.DomainDeniedByPlugin("profile") {
e = e.WithHint("run: lark-cli profile list")
}
return e
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
}
users := "(no logged-in users)"
if len(app.Users) > 0 {

View File

@@ -1,130 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
)
// globalFlagDomains maps each root persistent flag to the command domain
// it belongs to. A new domain-tied global flag must add a row.
var globalFlagDomains = map[string]string{
"profile": "profile",
}
// flagGateAnnotation distinguishes a policy-retired flag from one hidden
// cosmetically (single-app mode force-shows the latter in root help).
const flagGateAnnotation = "lark:policy_denied_flag"
// applyPluginFlagGate hides and rejects the global flags whose whole
// domain a plugin denied. yaml denials do not gate flags. Must run after
// RegisterGlobalFlags and cmdpolicy.Apply.
func applyPluginFlagGate(root *cobra.Command, denied map[string]cmdpolicy.Denial) {
gated := false
for flagName, domain := range globalFlagDomains {
d, ok := denied[domain]
if !ok || !cmdpolicy.IsPluginPolicySource(d.PolicySource) {
continue
}
fl := root.PersistentFlags().Lookup(flagName)
if fl == nil {
continue
}
fl.Hidden = true
if fl.Annotations == nil {
fl.Annotations = map[string][]string{}
}
fl.Annotations[flagGateAnnotation] = []string{"true"}
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
gated = true
}
if gated {
installFlagGateRejection(root)
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// gatedFlagValue rejects at parse time, before cobra's help/version fast
// paths (which never reach PersistentPreRunE). Its Set error carries
// cobra's own unknown-flag wording so the root FlagErrorFunc renders the
// same envelope an unregistered flag produces.
type gatedFlagValue struct {
name string
inner pflag.Value
}
func (g *gatedFlagValue) String() string { return g.inner.String() }
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
func (g *gatedFlagValue) Set(string) error {
// Intermediate parse error, not a final envelope: pflag wraps it and
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
// unknown-flag validation error.
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
}
// installFlagGateRejection rejects gated flags right after parsing.
// pflag has no runtime unregister, so the flag still parses; and cobra
// resolves PersistentPreRunE "first non-nil wins" walking up from the
// leaf, so every command carrying its own (cmd/auth, cmd/config) must be
// wrapped too, not just the root.
func installFlagGateRejection(root *cobra.Command) {
var walk func(c *cobra.Command)
walk = func(c *cobra.Command) {
if prev := c.PersistentPreRunE; prev != nil {
c.PersistentPreRunE = func(cc *cobra.Command, args []string) error {
if err := rejectGatedFlags(cc); err != nil {
return err
}
return prev(cc, args)
}
}
for _, child := range c.Commands() {
walk(child)
}
}
prevRun := root.PersistentPreRun
root.PersistentPreRun = nil
root.PersistentPreRunE = func(c *cobra.Command, args []string) error {
if err := rejectGatedFlags(c); err != nil {
return err
}
if prevRun != nil {
prevRun(c, args)
}
return nil
}
for _, child := range root.Commands() {
walk(child)
}
}
// rejectGatedFlags fails a set policy-retired flag with the same shape an
// unregistered flag produces (see flagDidYouMean).
//
// VisitAll + fl.Changed, not Visit: cobra parses a persistent flag on the leaf
// command's merged flagset, so root.PersistentFlags()'s "changed" table stays
// empty and Visit (which only walks that table) never fires on the dispatch
// path. The flag objects are shared by pointer across the merge, so fl.Changed
// reflects a real leaf-level parse.
func rejectGatedFlags(c *cobra.Command) error {
var rejected error
c.Root().PersistentFlags().VisitAll(func(fl *pflag.Flag) {
if rejected == nil && fl.Changed && isPolicyGatedFlag(fl) {
rejected = errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+fl.Name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + fl.Name, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
})
return rejected
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -36,15 +35,7 @@ const userPolicyFileName = "policy.yml"
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
//
// The returned denied map (nil when no rule denied anything) feeds the
// post-pruning presentation passes in build.go: the global-flag gate and
// the fixed-hint filter both key off which domains a plugin denied.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
// Reset up front so every early return leaves the process-global
// snapshot clean; the success path re-populates it.
policystate.SetPluginDeniedDomains(nil)
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
// yaml). When a plugin contributed rules we therefore do NOT even
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
@@ -74,7 +65,7 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return nil, lerr
return lerr
}
yamlRules = loaded
}
@@ -86,11 +77,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
})
if err != nil {
cmdpolicy.SetActive(nil)
return nil, err
return err
}
if len(rules) == 0 {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil, nil
return nil
}
// RuleName attributes a denial to a specific rule in the envelope.
@@ -103,39 +94,17 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
ruleName = rules[0].Name
}
// DeniedMessage is build-level: the first non-empty message across
// the single owner's rules speaks for all of them.
deniedMessage := ""
for _, r := range rules {
if r.DeniedMessage != "" {
deniedMessage = r.DeniedMessage
break
}
}
engine := cmdpolicy.NewSet(rules)
decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName, deniedMessage)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedPaths: len(denied),
DeniedByPath: denied,
Rules: rules,
Source: source,
DeniedPaths: len(denied),
})
// Whole-domain denials surface as aggregate entries keyed by the
// bare domain name (no slash); record them for render-time hint
// emitters (internal/auth, internal/client, the notice provider).
pluginDomains := map[string]bool{}
for path, d := range denied {
if !strings.Contains(path, "/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
pluginDomains[path] = true
}
}
policystate.SetPluginDeniedDomains(pluginDomains)
return denied, nil
return nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
@@ -187,22 +156,7 @@ func recordInventory(installResult *internalplatform.InstallResult) {
AllowUnannotated: r.Rule.AllowUnannotated,
})
}
skillSrcs := make([]internalplatform.SkillsInventorySource, 0, len(installResult.PluginSkills))
for _, ps := range installResult.PluginSkills {
if ps.SkillsOverlay == nil {
continue
}
skillSrcs = append(skillSrcs, internalplatform.SkillsInventorySource{
PluginName: ps.PluginName,
View: internalplatform.SkillsOverlayView{
Allow: ps.SkillsOverlay.Allow,
Remove: ps.SkillsOverlay.Remove,
Overlay: ps.SkillsOverlay.Overlay != nil,
Base: ps.SkillsOverlay.Base != nil,
},
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs, skillSrcs))
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command

View File

@@ -116,7 +116,7 @@ max_risk: write
`)
root := fakeTree(t)
if _, err := applyUserPolicyPruning(root, nil); err != nil {
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("apply policy: %v", err)
}
@@ -175,7 +175,7 @@ func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
tmpHome(t) // home set but no policy.yml written
root := fakeTree(t)
if _, err := applyUserPolicyPruning(root, nil); err != nil {
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("missing policy should not error, got %v", err)
}
@@ -196,7 +196,7 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "::: not yaml :::")
root := fakeTree(t)
_, err := applyUserPolicyPruning(root, nil)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("malformed yaml should produce an error")
}
@@ -221,7 +221,7 @@ func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
}},
}
root := fakeTree(t)
if _, err := applyUserPolicyPruning(root, pluginRules); err != nil {
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
}
@@ -243,7 +243,7 @@ func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
writePolicy(t, cfgDir, "max_risk: nukem\n")
root := fakeTree(t)
_, err := applyUserPolicyPruning(root, nil)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("invalid MaxRisk should produce an error")
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/skillpolicy"
)
// installFatalGuard wires a fail-closed guard at every cobra dispatch
@@ -111,27 +110,6 @@ func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
installFatalGuard(rootCmd, makeErr)
}
// installPluginSkillErrorGuard surfaces a plugin SkillsOverlay configuration
// error before any command runs. Two failure modes, split by reason code:
//
// - "invalid_skills_overlay" - a Remove/Overlay that cannot compose
// - "multiple_skills_overlay_plugins" - two plugins each customizing skills
//
// The CLI must NOT silently fall back to default skills once an
// integrator has declared a customization.
func installPluginSkillErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() error {
reasonCode := internalplatform.ReasonInvalidSkillsOverlay
if errors.Is(err, skillpolicy.ErrMultipleSkillsOverlays) {
reasonCode = internalplatform.ReasonMultipleSkillsOverlays
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithHint("skill customization is broken (reason_code %s); fix the conflicting SkillsOverlay (a plugin's EmbeddedSkills or the WithEmbeddedSkills build option)", reasonCode).
WithCause(err)
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a typed validation error (failed_precondition). The hint's
// reason code splits returned-error vs panic so consumers (audit /

View File

@@ -1,199 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
)
// applyPluginPresentation makes plugin-denied capabilities present as
// absent: retired flags, no skills footer, diagnostics hidden or retired.
// Presentation only — enforcement happened in cmdpolicy.Apply.
func applyPluginPresentation(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
applyPluginFlagGate(rootCmd, denied)
if domainDeniedByPlugin(denied, "skills") {
rootCmd.SetUsageTemplate(strings.Replace(rootUsageTemplate, skillsSetupFooter, "", 1))
}
if installResult != nil && hideDiagnosticsOwner(installResult.Plugins) != "" {
retireDiagnostics(rootCmd, installResult, denied)
}
// Group convergence runs on both paths: with the exemptions retired
// (or merely concealed), nothing keeps their domain group alive, and
// it must present as absent like any fully-denied domain.
concealDiagnostics(rootCmd, denied)
}
// domainDeniedByPlugin reads the freshly-built denied map, unlike
// policystate.DomainDeniedByPlugin which serves render-time consumers.
func domainDeniedByPlugin(denied map[string]cmdpolicy.Denial, domain string) bool {
d, ok := denied[domain]
return ok && cmdpolicy.IsPluginPolicySource(d.PolicySource)
}
// hideDiagnosticsOwner returns the plugin that declared HideDiagnostics,
// or "". The host already enforced that it also Restricts.
func hideDiagnosticsOwner(plugins []internalplatform.PluginInfo) string {
for _, p := range plugins {
if p.Capabilities.HideDiagnostics {
return p.Name
}
}
return ""
}
// retireDiagnostics installs the unavailable presentation on the
// diagnostic exemptions, same as any other plugin-denied command.
func retireDiagnostics(rootCmd *cobra.Command, installResult *internalplatform.InstallResult, denied map[string]cmdpolicy.Denial) {
source := "plugin:" + hideDiagnosticsOwner(installResult.Plugins)
message := ""
for _, d := range denied {
if cmdpolicy.IsPluginPolicySource(d.PolicySource) && d.DeniedMessage != "" {
message = d.DeniedMessage
break
}
}
// A rule can declare DeniedMessage yet deny no command of its own;
// the synthesized diagnostics denial still speaks in its voice.
if message == "" {
if ap := cmdpolicy.GetActive(); ap != nil && ap.Source.Kind == cmdpolicy.SourcePlugin {
for _, r := range ap.Rules {
if r.DeniedMessage != "" {
message = r.DeniedMessage
break
}
}
}
}
diag := map[string]cmdpolicy.Denial{}
for _, path := range cmdpolicy.DiagnosticPaths() {
// The whole exemption chain retires: the leaf and every group
// between it and the top-level domain answer unavailable, not
// bare help at exit 0.
for p := path; strings.Contains(p, "/"); p = p[:strings.LastIndex(p, "/")] {
diag[p] = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: source,
ReasonCode: "diagnostics_hidden",
Reason: "policy self-inspection hidden by the integrator",
DeniedMessage: message,
}
}
}
cmdpolicy.Apply(rootCmd, diag)
cmdpolicy.AppendActiveDenials(diag)
}
// concealDiagnostics hides the diagnostic exemptions from help — without
// touching their RunE, so they stay dispatchable — once every non-exempt
// sibling in their domain is already hidden. The group itself then gets
// the unavailable stub: it escaped denial aggregation only because the
// exemptions kept a runnable descendant alive, and its unknown-subcommand
// guard RunE would otherwise keep it listed in help.
func concealDiagnostics(rootCmd *cobra.Command, denied map[string]cmdpolicy.Denial) {
for _, group := range diagnosticDomainGroups(rootCmd) {
if !pluginDeniedUnder(denied, cmdpolicy.CanonicalPath(group)) {
continue
}
exemptAncestors := map[*cobra.Command]bool{}
for _, path := range cmdpolicy.DiagnosticPaths() {
for c := findByPath(rootCmd, path); c != nil && c != group; c = c.Parent() {
exemptAncestors[c] = true
}
}
allOthersHidden := true
for _, child := range group.Commands() {
if child.Name() == "help" || exemptAncestors[child] {
continue
}
if !child.Hidden {
allOthersHidden = false
break
}
}
if !allOthersHidden {
continue
}
for c := range exemptAncestors {
c.Hidden = true
}
var sample cmdpolicy.Denial
for path, d := range denied {
if strings.HasPrefix(path, cmdpolicy.CanonicalPath(group)+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
sample = d
break
}
}
groupDenial := cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: sample.PolicySource,
RuleName: sample.RuleName,
ReasonCode: "all_children_denied",
Reason: "all child commands are denied",
DeniedMessage: sample.DeniedMessage,
}
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): groupDenial,
})
// The bootstrap snapshot could not see this whole-domain denial
// (the exemptions were still alive then); record it now so
// render-time hints and policy introspection stop pointing into
// the converged domain.
policystate.AddPluginDeniedDomain(cmdpolicy.CanonicalPath(group))
cmdpolicy.AppendActiveDenials(map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): groupDenial,
})
}
}
// diagnosticDomainGroups returns the top-level groups containing a
// diagnostic exemption (today just `config`).
func diagnosticDomainGroups(rootCmd *cobra.Command) []*cobra.Command {
seen := map[*cobra.Command]bool{}
var out []*cobra.Command
for _, path := range cmdpolicy.DiagnosticPaths() {
top, _, _ := strings.Cut(path, "/")
if c := findByPath(rootCmd, top); c != nil && !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func pluginDeniedUnder(denied map[string]cmdpolicy.Denial, prefix string) bool {
for path, d := range denied {
if strings.HasPrefix(path, prefix+"/") && cmdpolicy.IsPluginPolicySource(d.PolicySource) {
return true
}
}
return false
}
// findByPath resolves a canonical slash path (e.g. "config/policy/show")
// to the command node, or nil.
func findByPath(rootCmd *cobra.Command, path string) *cobra.Command {
cur := rootCmd
for _, seg := range strings.Split(path, "/") {
var next *cobra.Command
for _, child := range cur.Commands() {
if child.Name() == seg {
next = child
break
}
}
if next == nil {
return nil
}
cur = next
}
return cur
}

View File

@@ -1,653 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/update"
)
// restrictingPlugin registers a plugin that denies the given globs; extra
// customizes the builder further (nil for none).
func restrictingPlugin(t *testing.T, deny []string, extra func(*platform.Builder) *platform.Builder) {
t.Helper()
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
b := platform.NewPlugin("acme", "1.0").Restrict(&platform.Rule{Deny: deny})
if extra != nil {
b = extra(b)
}
platform.Register(b.MustBuild())
}
// runnableUnder returns the first runnable non-exempt descendant under
// the named top-level group of the real command tree (the diagnostic
// exemptions keep their original RunE and would not exercise the stub).
func runnableUnder(t *testing.T, root *cobra.Command, group string) *cobra.Command {
t.Helper()
g := findByPath(root, group)
if g == nil {
t.Fatalf("group %q not found in command tree", group)
}
var find func(c *cobra.Command) *cobra.Command
find = func(c *cobra.Command) *cobra.Command {
if c.RunE != nil && len(c.Commands()) == 0 && !cmdpolicy.IsDiagnosticPath(cmdpolicy.CanonicalPath(c)) {
return c
}
for _, child := range c.Commands() {
if leaf := find(child); leaf != nil {
return leaf
}
}
return nil
}
leaf := find(g)
if leaf == nil {
t.Fatalf("no runnable non-exempt leaf under %q", group)
}
return leaf
}
// A plugin-denied command answers with command_unavailable: default
// message, no hint, no policy vocabulary.
func TestBuildInternal_pluginDenyPresentsUnavailable(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want command_unavailable", ve.Subtype)
}
if ve.Message != cmdpolicy.DefaultUnavailableMessage || ve.Hint != "" {
t.Errorf("message=%q hint=%q, want default message and empty hint", ve.Message, ve.Hint)
}
}
// Rule.DeniedMessage speaks in the integrator's product voice.
func TestBuildInternal_deniedMessageOverride(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
platform.Register(platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Deny: []string{"config/**"}, DeniedMessage: "not part of acme cli"}).
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
err := leaf.RunE(leaf, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if ve.Message != "not part of acme cli" {
t.Errorf("message = %q, want the integrator's DeniedMessage", ve.Message)
}
}
// Explicit help on a plugin-denied command must not render the original
// usage; it answers with the same unavailable envelope.
func TestBuildInternal_pluginDenyHelpIntercepted(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := runnableUnder(t, root, "config")
var buf bytes.Buffer
leaf.SetOut(&buf)
leaf.SetErr(&buf)
root.HelpFunc()(leaf, nil)
out := buf.String()
if !strings.Contains(out, "command_unavailable") {
t.Errorf("explicit help must answer command_unavailable, got:\n%s", out)
}
if strings.Contains(out, "Usage:") {
t.Errorf("explicit help must not render the original usage, got:\n%s", out)
}
// yaml-source presentation is untouched: a command outside the denied
// domain still renders normal help.
var normal bytes.Buffer
alive := findByPath(root, "skills")
alive.SetOut(&normal)
alive.SetErr(&normal)
root.HelpFunc()(alive, nil)
if strings.Contains(normal.String(), "command_unavailable") {
t.Errorf("non-denied command help must render normally, got:\n%s", normal.String())
}
}
// `lark-cli help <plugin-restricted-cmd>` must fail with the typed
// unavailable error (exit non-zero), not print an envelope and exit 0.
func TestBuildInternal_helpCommandReturnsUnavailable(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
helpCmd := findByPath(root, "help")
if helpCmd == nil || helpCmd.RunE == nil {
t.Fatal("custom help command not installed")
}
err := helpCmd.RunE(helpCmd, []string{"config"})
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on a restricted command must return command_unavailable, got %v", err)
}
// A live target renders normally and returns nil.
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
if err := helpCmd.RunE(helpCmd, []string{"skills"}); err != nil {
t.Errorf("help on a live command must succeed, got %v", err)
}
}
// help is a framework meta command: an allow-list rule must not deny it.
// `help <live-cmd>` renders; `help <denied-cmd>` returns unavailable.
func TestBuildInternal_helpSurvivesAllowList(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
platform.Register(platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Allow: []string{"im/**"}, AllowUnannotated: true}).
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
helpCmd := findByPath(root, "help")
if helpCmd == nil || helpCmd.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
t.Fatalf("help must not be policy-denied under an allow-list; annotations=%v", helpCmd.Annotations)
}
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
if err := helpCmd.RunE(helpCmd, []string{"im"}); err != nil {
t.Errorf("help on an allowed domain must render, got %v", err)
}
err := helpCmd.RunE(helpCmd, []string{"config"})
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on a denied domain must return command_unavailable, got %v", err)
}
}
// The plugin inventory surfaces the new contributions so `config plugins
// show` can answer "what did this build customize".
func TestBuildInternal_inventoryCoversNewCapabilities(t *testing.T) {
tmpHome(t)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
restrictingPlugin(t, []string{"profile/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics().
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
})
buildInternal(context.Background(), buildInvocationForTest(t))
inv := internalplatform.GetActiveInventory()
if inv == nil || len(inv.Plugins) != 1 {
t.Fatalf("inventory = %+v, want 1 plugin", inv)
}
p := inv.Plugins[0]
if !p.Capabilities.HideDiagnostics {
t.Error("inventory must surface HideDiagnostics")
}
if p.EmbeddedSkills == nil || len(p.EmbeddedSkills.Remove) != 1 || p.EmbeddedSkills.Remove[0] != "lark-a" {
t.Errorf("inventory must summarise EmbeddedSkills, got %+v", p.EmbeddedSkills)
}
}
// Denying the whole profile domain retires --profile: hidden from help,
// and setting it fails like an unknown flag.
func TestBuildInternal_profileFlagGate(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
fl := root.PersistentFlags().Lookup("profile")
if fl == nil {
t.Fatal("--profile not registered")
}
if !fl.Hidden || !isPolicyGatedFlag(fl) {
t.Errorf("flag should be hidden and policy-gated; hidden=%v gated=%v", fl.Hidden, isPolicyGatedFlag(fl))
}
// The gate rejects at the Value level: cobra's parse (help/version
// fast paths included) can never set a gated flag.
err := root.PersistentFlags().Set("profile", "prod")
if err == nil || !strings.Contains(err.Error(), "unknown flag: --profile") {
t.Errorf("setting a gated flag must fail as unknown flag at parse time, got %v", err)
}
}
// The gate must also fire on the real dispatch path. cobra parses a persistent
// flag on the leaf command's merged flagset, so a gate that walks
// root.PersistentFlags()'s changed table is a no-op once the flag is passed to
// a subcommand. Drive root.Execute end to end and confirm the gated --profile
// is rejected before the command body runs.
func TestBuildInternal_profileFlagGate_RejectsOnDispatch(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
ranBody := false
root.AddCommand(&cobra.Command{
Use: "gateprobe",
RunE: func(*cobra.Command, []string) error { ranBody = true; return nil },
})
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--profile", "prod", "gateprobe"})
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
t.Errorf("a gated --profile passed on the dispatch path must fail as unknown flag, got %v", err)
}
if ranBody {
t.Error("gated --profile must be rejected before the command body runs")
}
}
// Without the profile domain denied, the gate stays inert.
func TestBuildInternal_profileFlagUntouchedWithoutDenial(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if fl := root.PersistentFlags().Lookup("profile"); isPolicyGatedFlag(fl) {
t.Error("--profile must not be gated when its domain is not denied")
}
}
// Denying the skills domain drops the root-help skills-setup footer.
func TestBuildInternal_skillsFooterSuppressed(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if strings.Contains(root.UsageTemplate(), "Skills setup") {
t.Error("skills-setup footer must be dropped when the skills domain is denied")
}
// Control: without the denial the footer stays.
platform.ResetForTesting()
policystate.ResetForTesting()
_, root2, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if !strings.Contains(root2.UsageTemplate(), "Skills setup") {
t.Error("skills-setup footer must stay in the default build")
}
}
// Denying the skills command domain kills every `skills read` pointer in
// domain help, even when the skill content itself is still embedded — the
// command the pointers name is absent, so they would all be dead ends.
func TestBuildInternal_skillsDomainDenyKillsHelpPointers(t *testing.T) {
tmpHome(t)
withBaseSkills(t, map[string]string{"lark-im/SKILL.md": "---\ndescription: im\n---\n"})
restrictingPlugin(t, []string{"skills", "skills/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
im := findByPath(root, "im")
if im == nil {
t.Fatal("im domain not in tree")
}
var buf bytes.Buffer
im.SetOut(&buf)
im.SetErr(&buf)
root.HelpFunc()(im, nil)
if strings.Contains(buf.String(), "skills read") {
t.Errorf("domain help must not point at the denied skills command, got:\n%s", buf.String())
}
}
// Denying the update domain silences the _notice providers that would
// steer the caller to `lark-cli update`.
func TestComposePendingNotice_updateDomainDenied(t *testing.T) {
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "1.0.1"})
t.Cleanup(func() { update.SetPending(nil) })
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"update": true})
if got := composePendingNotice(); got != nil {
t.Errorf("notices must be silenced with the update domain denied, got %+v", got)
}
policystate.SetPluginDeniedDomains(nil)
if got := composePendingNotice(); got == nil {
t.Error("notices must render without the denial")
}
}
// HideDiagnostics retires the exemptions — and with them gone, nothing keeps
// the config group alive: it must converge exactly like any fully-denied
// domain (absent from help, bare invocation and explicit help both answer
// command_unavailable). Guards the group level, which the leaf-only test
// below does not.
func TestBuildInternal_hideDiagnosticsConvergesDomainGroup(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
cfg := findByPath(root, "config")
if cfg == nil {
t.Fatal("config group not in tree")
}
if cfg.IsAvailableCommand() {
t.Error("config group must leave help/completion when HideDiagnostics retires its last live descendants")
}
if cfg.RunE == nil {
t.Fatal("converged config group must carry an unavailable stub RunE")
}
err := cfg.RunE(cfg, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("bare `config` must answer command_unavailable, got %v", err)
}
var buf bytes.Buffer
cfg.SetOut(&buf)
cfg.SetErr(&buf)
root.HelpFunc()(cfg, nil)
out := buf.String()
if !strings.Contains(out, "command_unavailable") {
t.Errorf("`config --help` must answer command_unavailable, got:\n%.200s", out)
}
if strings.Contains(out, "Usage:") {
t.Errorf("`config --help` must not render the original usage, got:\n%.200s", out)
}
// The converged domain must also reach the render-time snapshot, so
// fixed hints (e.g. the strict-mode stub's `config strict-mode`
// pointer) stop pointing at a domain this build presents as absent.
if !policystate.DomainDeniedByPlugin("config") {
t.Error("converged config domain must be recorded for render-time hint gating")
}
}
// Default: diagnostics stay executable but leave help when their whole
// domain is denied — cobra then drops the empty config group entirely.
func TestBuildInternal_concealDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
show := findByPath(root, "config/policy/show")
if show == nil {
t.Fatal("config policy show not in tree")
}
if !show.Hidden {
t.Error("exempt diagnostic should be hidden from help when its domain is denied")
}
// Still dispatchable: its RunE is the original, not an unavailable stub.
if show.Annotations[cmdpolicy.AnnotationDenialLayer] != "" {
t.Error("exempt diagnostic must not carry a denial stub by default")
}
if cfg := findByPath(root, "config"); cfg.IsAvailableCommand() {
t.Error("config group with no visible children must drop from help")
}
}
// HideDiagnostics retires the exemptions like any other denied command.
func TestBuildInternal_hideDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
show := findByPath(root, "config/policy/show")
if show == nil {
t.Fatal("config policy show not in tree")
}
err := show.RunE(show, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("hidden diagnostic must answer command_unavailable, got %v", err)
}
}
// A plugin Wrapper that swallows errors (returns nil without calling next)
// must not defeat the help meta command's unavailable interception: `help
// <restricted>` is framework presentation, not business dispatch, so it stays
// outside the Wrap chain and keeps answering command_unavailable.
func TestBuildInternal_helpCommandImmuneToWrapperSwallow(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return nil }
})
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
helpCmd := findByPath(root, "help")
if helpCmd == nil || helpCmd.RunE == nil {
t.Fatal("custom help command not installed")
}
err := helpCmd.RunE(helpCmd, []string{"config"})
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on a restricted command must answer command_unavailable even under a swallowing Wrapper, got %v", err)
}
}
// A gated --profile must be rejected at parse time, so cobra's help/version
// fast paths (which never reach PersistentPreRunE) cannot slip past the gate.
func TestBuildInternal_gatedProfileRejectedOnHelpFastPath(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
for _, args := range [][]string{
{"--profile", "prod", "--help"},
{"--profile", "prod", "--version"},
{"--profile", "prod", "skills", "--help"},
} {
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs(args)
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, `unknown flag "--profile"`) {
t.Errorf("%v: gated --profile must fail as unknown flag on the fast path, got %v", args, err)
}
}
// Un-gated flags parse normally (no over-rejection).
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Errorf("bare --help must still work, got %v", err)
}
}
// With HideDiagnostics, every group on the exemption chain answers
// unavailable — bare `config policy` / `config plugins` included, not only
// the show leaves.
func TestBuildInternal_retireDiagnosticsCoversIntermediates(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
for _, path := range []string{"config/policy", "config/plugins", "config/policy/show", "config/plugins/show"} {
c := findByPath(root, path)
if c == nil || c.RunE == nil {
t.Fatalf("%s missing or without RunE", path)
}
err := c.RunE(c, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("%s must answer command_unavailable, got %v", path, err)
}
}
}
// Rule.DeniedMessage reaches the synthesized diagnostics denial even when
// the rule itself denies no command.
func TestBuildInternal_deniedMessageAppliesToRetiredDiagnostics(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
platform.Register(platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Deny: []string{"zz-not-a-cmd/**"}, DeniedMessage: "not part of acme cli"}).
HideDiagnostics().
MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
c := findByPath(root, "config/policy/show")
err := c.RunE(c, nil)
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected validation error, got %T %v", err, err)
}
if ve.Message != "not part of acme cli" {
t.Errorf("retired diagnostic message = %q, want the rule's DeniedMessage", ve.Message)
}
}
// The custom help command keeps stock cobra fidelity: subcommand completion
// works (denied commands, being hidden, never appear), and its own help
// carries no Risk line the stock command lacks.
func TestBuildInternal_helpCompletionMatchesStock(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
h := findByPath(root, "help")
if h.ValidArgsFunction == nil {
t.Fatal("help must offer subcommand completion like stock cobra")
}
comps, _ := h.ValidArgsFunction(h, nil, "")
joined := strings.Join(comps, "\n")
if !strings.Contains(joined, "im\t") {
t.Errorf("help completion must offer live commands, got:\n%s", joined)
}
if strings.Contains(joined, "config\t") {
t.Errorf("help completion must not offer a denied domain, got:\n%s", joined)
}
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"help", "help"})
if err := root.Execute(); err != nil {
t.Fatalf("help help: %v", err)
}
if strings.Contains(buf.String(), "Risk:") {
t.Errorf("help's own help must not carry a Risk line, got:\n%s", buf.String())
}
}
// A plugin-denied command's local flags leave shell completion: the command
// presents as absent, so its flag surface must not be enumerable.
func TestBuildInternal_deniedCommandFlagsLeaveCompletion(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"__complete", "skills", "read", "--"})
_ = root.Execute()
if strings.Contains(buf.String(), "--json") {
t.Errorf("denied command's local flags must not complete, got:\n%s", buf.String())
}
}
// Presentation-time denials (retired diagnostics, converged groups) reach the
// recorded ActivePolicy, so `config policy show` reflects the shipped tree.
func TestBuildInternal_activePolicyIncludesRetiredDiagnostics(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"config/**"}, func(b *platform.Builder) *platform.Builder {
return b.HideDiagnostics()
})
buildInternal(context.Background(), buildInvocationForTest(t))
ap := cmdpolicy.GetActive()
if ap == nil {
t.Fatal("no active policy recorded")
}
for _, path := range []string{"config", "config/policy", "config/policy/show", "config/plugins", "config/plugins/show"} {
if _, ok := ap.DeniedByPath[path]; !ok {
t.Errorf("ActivePolicy.DeniedByPath missing %q (presentation-time denial not recorded)", path)
}
}
}
// A bare gated --profile (no value) presents as unknown, same as a set one:
// pflag's "needs an argument" path must not leak a different shape.
func TestBuildInternal_gatedProfileBareFlagPresentsUnknown(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"profile", "profile/**"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"--profile"})
err := root.Execute()
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, `unknown flag "--profile"`) {
t.Errorf("bare gated --profile must present as unknown flag, got %v", err)
}
}
// A plugin-denied command offers no positional completion either.
func TestBuildInternal_deniedCommandPositionalsLeaveCompletion(t *testing.T) {
tmpHome(t)
restrictingPlugin(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
var buf bytes.Buffer
root.SetOut(&buf)
root.SetErr(&buf)
root.SetArgs([]string{"__complete", "skills", "read", ""})
_ = root.Execute()
if strings.Contains(buf.String(), "lark-") {
t.Errorf("denied command must not complete positional args, got:\n%s", buf.String())
}
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -106,14 +105,8 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
hint := fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)
// The switch-policy pointer names `config strict-mode`; with
// the config domain plugin-denied it would be a dead end.
if !policystate.DomainDeniedByPlugin("config") {
hint += "; " + stubHint
}
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("%s", hint).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/spf13/cobra"
)
@@ -380,41 +379,3 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
t.Errorf("denial annotation overwritten or missing")
}
}
// The strict-mode stub's RunE appends a `config strict-mode` switch-policy
// pointer to its hint — but only when the config domain is still present. With
// the config domain plugin-denied that pointer is a dead end, so it is omitted;
// the denial itself (message, subtype) is unchanged.
func TestStrictModeStub_ConfigHintGatedByPluginDenial(t *testing.T) {
hintOf := func(t *testing.T) string {
t.Helper()
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
stub := strictModeStubFrom(child, core.StrictModeBot)
err := stub.RunE(stub, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want failed_precondition", verr.Subtype)
}
return verr.Hint
}
t.Run("config domain present: switch-policy pointer included", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
if h := hintOf(t); !strings.Contains(h, "config strict-mode") {
t.Errorf("hint = %q, want it to reference `config strict-mode`", h)
}
})
t.Run("config domain plugin-denied: switch-policy pointer omitted", func(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
if h := hintOf(t); strings.Contains(h, "config strict-mode") {
t.Errorf("hint = %q, want no `config strict-mode` pointer when config is plugin-denied", h)
}
})
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"strings"
@@ -22,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/update"
@@ -82,16 +80,11 @@ Global Flags:
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}` + skillsSetupFooter + `
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}
`
// skillsSetupFooter is the root-help pointer at the human one-time skills
// setup. Split out so the presentation pass can drop it from the template
// when an integrator plugin denies the skills domain.
const skillsSetupFooter = `{{if not .HasParent}}
Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}}`
// Execute runs the root command and returns the process exit code.
// rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's
// UnknownFlags whitelist (installUnknownSubcommandGuard) swallows unknown flags
@@ -174,10 +167,6 @@ func setupNotices() {
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
// All three notices steer the caller to `lark-cli update`.
if policystate.DomainDeniedByPlugin("update") {
return nil
}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
@@ -611,15 +600,6 @@ func isLarkDomain(c *cobra.Command) bool {
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
// A policy-gated flag invoked bare ("flag needs an argument")
// never reaches its rejecting Value; it still presents as
// unregistered, exactly like a set one.
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
@@ -642,25 +622,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
WithHint("%s", hint)
}
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
// argument: --name" for a policy-gated flag on this command's flag set.
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
const p = "flag needs an argument: --"
msg := ferr.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
name := msg[i+len(p):]
if j := strings.IndexAny(name, " \t"); j >= 0 {
name = name[:j]
}
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
return name, true
}
return "", false
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
@@ -697,103 +658,16 @@ func visibleFlagNames(c *cobra.Command) []string {
return names
}
// installHelpCommand replaces cobra's default help command so that
// `lark-cli help <plugin-restricted-cmd>` returns a typed error (exit 2)
// instead of printing an envelope and exiting 0 — cobra's stock help
// command has no error channel.
func installHelpCommand(root *cobra.Command) {
helpCmd := &cobra.Command{
Use: "help [command]",
Short: "Help about any command",
Long: "Help provides help for any command in the application.\n" +
"Simply type " + root.DisplayName() + " help [path to command] for full details.",
ValidArgsFunction: func(c *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Mirrors cobra's stock help completion: available (non-hidden)
// subcommands of the resolved path — a denied command is
// Hidden and therefore never offered.
cmd, _, e := root.Find(args)
if e != nil || cmd == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
var comps []string
for _, sub := range cmd.Commands() {
if !sub.IsAvailableCommand() && sub.Name() != "help" {
continue
}
if strings.HasPrefix(sub.Name(), toComplete) {
comps = append(comps, sub.Name()+"\t"+sub.Short)
}
}
return comps, cobra.ShellCompDirectiveNoFileComp
},
RunE: func(c *cobra.Command, args []string) error {
target, _, err := root.Find(args)
if err != nil || target == nil {
c.Printf("Unknown help topic %#q\n", args)
return root.Usage()
}
if msg, ok := unavailableHelpMessage(target); ok {
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg)
}
target.InitDefaultHelpFlag()
return target.Help()
},
}
// help attaches after policy evaluation (framework meta command, never
// policy-evaluated). No risk annotation: it would render a "Risk:"
// line that stock cobra help output does not carry.
cmdutil.DisableAuthCheck(helpCmd)
if helpCmd.Annotations == nil {
helpCmd.Annotations = map[string]string{}
}
helpCmd.Annotations[cmdpolicy.AnnotationFrameworkMeta] = "true"
root.SetHelpCommand(helpCmd)
// SetHelpCommand alone defers attachment to Execute's
// InitDefaultHelpCmd; add it now so the built tree is complete
// (InitDefaultHelpCmd re-adds idempotently).
root.AddCommand(helpCmd)
}
// unavailableHelpMessage returns the message to render in place of help
// for a plugin-restricted command. yaml-source denials keep original help.
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
if cmd == nil || cmd.Annotations == nil {
return "", false
}
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerPolicy ||
!cmdpolicy.IsPluginPolicySource(cmd.Annotations[cmdpolicy.AnnotationDenialSource]) {
return "", false
}
if msg := cmd.Annotations[cmdpolicy.AnnotationDenialMessage]; msg != "" {
return msg, true
}
return cmdpolicy.DefaultUnavailableMessage, true
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
// when rendering the root command's own help, so users discovering the CLI
// still see them at `lark-cli --help`.
//
// skillContent is read lazily at help-render time (not captured up front) so
// the domain-guide pointer reflects the resolved skill tree -- the same
// f.SkillContent that `skills list`/`read` serve -- even though plugin skill
// customization is applied after this help func is installed.
func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
// Explicit help on a plugin-restricted command answers the same
// unavailable envelope as its RunE stub, not the original usage.
if msg, ok := unavailableHelpMessage(cmd); ok {
output.WriteTypedErrorEnvelope(cmd.ErrOrStderr(),
errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg), "")
return
}
if cmd == root {
// Force-show flags hidden by single-app mode; never a
// policy-retired one.
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden && !isPolicyGatedFlag(f) {
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
f.Hidden = false
defer func() { f.Hidden = true }()
}
@@ -801,15 +675,11 @@ func installTipsHelpFunc(root *cobra.Command, skillContent func() fs.FS) {
// Domain and method commands compose their agent guidance into Long lazily
// here (shortcuts attach after service registration); both skip the generic
// bottom-of-help append below.
if service.PrepareDomainHelp(cmd, skillContent()) {
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, skillContent()) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, skillContent()) {
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}

View File

@@ -5,7 +5,6 @@ package cmd
import (
"bytes"
"io/fs"
"strings"
"testing"
@@ -13,10 +12,6 @@ import (
"github.com/spf13/cobra"
)
// nilSkills is the skill-content getter used by help-func tests that do
// not exercise the domain-guide pointer.
func nilSkills() fs.FS { return nil }
// rendersHelp runs the wrapped help func and returns stdout.
func rendersHelp(t *testing.T, cmd *cobra.Command) string {
t.Helper()
@@ -29,7 +24,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")
@@ -43,7 +38,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "list", Short: "list items"}
root.AddCommand(child)
@@ -56,7 +51,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
installTipsHelpFunc(root, nilSkills)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")

View File

@@ -71,18 +71,11 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short.
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
@@ -92,7 +85,7 @@ func captureHelpBase(cmd *cobra.Command, key string) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[key] = base
cmd.Annotations[domainBaseAnnotation] = base
return base
}
@@ -108,12 +101,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -122,7 +115,10 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -132,11 +128,8 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -148,15 +141,22 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
var b strings.Builder
b.WriteString(cmd.Short)
writeRisk(&b, cmd)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,93 +164,15 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
@@ -267,8 +189,12 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -281,13 +207,7 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -71,8 +70,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
@@ -120,7 +119,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, nil) {
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -137,133 +136,11 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {
@@ -297,26 +174,6 @@ func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) {
}
// A service domain carries only a Short at help time; it seeds the base.
// The domain-guide pointer is likewise gated: removing the domain's skill
// drops the pointer instead of leaving it dangling.
func TestPrepareDomainHelp_GatesGuidePointerOnFS(t *testing.T) {
present := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(present, fstest.MapFS{"lark-event/SKILL.md": &fstest.MapFile{Data: []byte("x")}}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if !strings.Contains(present.Long, "lark-cli skills read lark-event") {
t.Errorf("skill present should emit the domain-guide pointer; got:\n%s", present.Long)
}
removed := domainCmd("Consume and manage real-time events", "")
if !PrepareDomainHelp(removed, fstest.MapFS{}) {
t.Fatal("PrepareDomainHelp returned false for a domain-tagged command")
}
if strings.Contains(removed.Long, "skills read lark-event") {
t.Errorf("removed skill must leave no domain-guide pointer; got:\n%s", removed.Long)
}
}
func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
dom := domainCmd("Message and group chat management", "")
if !PrepareDomainHelp(dom, nil) {

View File

@@ -4,14 +4,10 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -1136,63 +1132,6 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

View File

@@ -1,227 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/skillcontent"
)
// withBaseSkills swaps the process-global embedded skill tree for the
// duration of a test, restoring it afterward.
func withBaseSkills(t *testing.T, files map[string]string) {
t.Helper()
base := fstest.MapFS{}
for p, content := range files {
base[p] = &fstest.MapFile{Data: []byte(content)}
}
saved := embeddedSkillContent
t.Cleanup(func() { embeddedSkillContent = saved })
embeddedSkillContent = base
}
// A plugin's SkillsOverlay must reshape the tree the factory serves: skills
// list/read read f.SkillContent, so a resolved removal/overlay shows up here.
// (The --help pointers are gated on the same f.SkillContent; that gating is
// covered by the PrepareDomainHelp/PrepareMethodHelp tests in cmd/service.)
func TestBuildInternal_appliesPluginSkillsOverlay(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
})
overlay := fstest.MapFS{
"lark-new/SKILL.md": &fstest.MapFile{Data: []byte("---\ndescription: new\n---\n")},
}
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{
Remove: []string{"lark-shared"},
Overlay: overlay,
}).MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
if f.SkillContent == nil {
t.Fatal("f.SkillContent is nil after skill resolution")
}
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-b,lark-new" {
t.Errorf("skills = %q, want lark-a,lark-b,lark-new (shared removed, new added)", got)
}
}
// Two plugins each customizing skills must abort at dispatch with a
// structured envelope carrying reason_code multiple_skills_overlay_plugins, not
// silently fall back to the default tree.
func TestBuildInternal_multipleSkillPluginsGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
platform.Register(platform.NewPlugin("globex", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("skill conflict guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
t.Errorf("hint should surface reason_code multiple_skills_overlay_plugins, got %q", verr.Hint)
}
}
// Allow keeps only the listed skills from the base — a CLI upgrade adding
// new embedded skills cannot widen an allow-listed build.
func TestBuildInternal_appliesAllowList(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
t.Cleanup(func() { policystate.ResetForTesting() })
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-b/SKILL.md": "---\ndescription: b\n---\n",
"lark-c/SKILL.md": "---\ndescription: c\n---\n",
})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Allow: []string{"lark-a", "lark-c"}}).
MustBuild())
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t))
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a,lark-c" {
t.Errorf("skills = %q, want lark-a,lark-c (allow-list)", got)
}
}
// A plugin whose SkillsOverlay cannot compose (Remove naming a skill absent
// from the base) must abort with reason_code invalid_skills_overlay.
func TestBuildInternal_invalidSkillsOverlayGuard(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-does-not-exist"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
t.Errorf("hint should surface reason_code invalid_skills_overlay, got %q", verr.Hint)
}
}
// WithEmbeddedSkills customizes skills for a caller that builds the tree directly
// (no plugin) — the build-option analogue of a plugin's EmbeddedSkills(...).
func TestBuildInternal_withSkillsOption(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{
"lark-a/SKILL.md": "---\ndescription: a\n---\n",
"lark-shared/SKILL.md": "---\ndescription: shared\n---\n",
})
f, _, _ := buildInternal(context.Background(), buildInvocationForTest(t),
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}}))
skills, err := skillcontent.New(f.SkillContent).List()
if err != nil {
t.Fatalf("List: %v", err)
}
var names []string
for _, s := range skills {
names = append(names, s.Name)
}
if got := strings.Join(names, ","); got != "lark-a" {
t.Errorf("skills = %q, want lark-a (shared removed via WithEmbeddedSkills)", got)
}
}
// WithEmbeddedSkills and a plugin's EmbeddedSkills() are two owners of skill content; the
// single-owner rule aborts startup.
func TestBuildInternal_withSkillsPlusPluginConflicts(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
withBaseSkills(t, map[string]string{"lark-a/SKILL.md": "---\ndescription: a\n---\n"})
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).MustBuild())
_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t),
WithEmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}))
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatal("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T %+v", err, err)
}
if verr.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", verr.Subtype)
}
if !strings.Contains(verr.Hint, "multiple_skills_overlay_plugins") {
t.Errorf("WithEmbeddedSkills + plugin should conflict; hint = %q", verr.Hint)
}
}

View File

@@ -14,7 +14,6 @@ const (
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
SubtypeCommandUnavailable Subtype = "command_unavailable" // command not included in this build (integrator-restricted distribution); absent, not gated
)
// CategoryAuthentication subtypes

View File

@@ -60,7 +60,6 @@ You should see `audit` in the plugin list.
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | No (customizes embedded skills) |
### Plugin lifecycle
@@ -80,7 +79,7 @@ sequenceDiagram
Host->>SDK: InstallAll()
SDK->>Plugin: Capabilities()
SDK->>Plugin: Install(Registrar)
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
SDK->>Plugin: On(Startup) fire
Note over Host,Plugin: Each command dispatch
@@ -114,35 +113,6 @@ the rejected dispatch.
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
may itself list several rules under `rules:`) is shadowed by any plugin
Restrict.
- A plugin may call `EmbeddedSkills()` at most once to customize the embedded
skill tree — `Allow` keeps only the listed skills (the allow-list
counterpart of `Rule.Allow`, so a CLI upgrade cannot widen the build;
`Remove` wins over `Allow`, and `Overlay` entries are exempt), `Remove`
drops skills, `Overlay` adds/replaces ones, or swap the whole `Base`
layered over the CLI default. Unlike `Restrict()`
it is NOT a security boundary and does not imply `FailClosed`: it
shapes fail-open guidance content. Removing a skill only drops its
`skills read` / `--help` guidance — it does NOT disable the matching
commands (use `Restrict()` for that). Only ONE plugin per binary may
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
`multiple_skills_overlay_plugins` error.
- A command denied by a **plugin** Rule presents as absent, not as
forbidden: it leaves `--help` and completion, explicit help on it is
intercepted, and invoking it answers `subtype=command_unavailable`
with no policy vocabulary and no recovery hint. Set
`Rule.DeniedMessage` to replace the default
"command not included in this build" with your product's own wording.
yaml-source denials keep the classic `command_denied` presentation —
the user owns that policy and needs to see how to adjust it. Denying a
whole domain also retires what points at it: `--profile` (profile
domain) hides and rejects use, the root-help skills footer (skills),
update notices (update), and the `auth login` recovery hint (auth)
stop rendering.
- `config policy show` / `config plugins show` stay executable under any
plugin policy (hidden from help when their domain is denied) so an
operator can still inspect the rule that locked the build. An
integrator shipping a fully-managed distribution opts out with
`HideDiagnostics()` — requires `Restrict()` on the same plugin.
- The `Wrap` factory runs **once per command dispatch**, not at
install time. Long-lived state (clients, caches, metrics counters)
must live on the Plugin struct or in package-level variables.
@@ -183,8 +153,6 @@ messages are localised and may change between releases.
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
| `invalid_skills_overlay` | Staging fault (`EmbeddedSkills(nil)` / second call in one plugin) honours FailurePolicy; a `SkillsOverlay` that can't compose (`Remove` names a skill absent from the base, `Overlay` entry lacks `SKILL.md`) always aborts via a fatal dispatch guard | Mixed — see left |
| `multiple_skills_overlay_plugins` | Two or more DISTINCT plugins each contributed a `SkillsOverlay` (only one may own skill content) | No — always aborts (dispatch guard) |
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
| `install_panic` | `Plugin.Install` panicked | Yes |
@@ -219,8 +187,8 @@ should additionally check `detail.layer == "policy"`.
- [Runnable example: audit observer](./examples/audit-observer/)
- [Runnable example: read-only policy](./examples/readonly-policy/)
- Builder API: see [`builder.go`](./builder.go) for the full DSL
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
`HideDiagnostics`, `FailOpen`/`FailClosed`, `MustBuild`).
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
`MustBuild`).
- Inventory diagnostic: run `lark-cli config plugins show` after
installing your plugin to see hooks/rules attributed to your plugin
name.

View File

@@ -36,9 +36,8 @@ type Builder struct {
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
actions []func(Registrar)
rules []*Rule
hookNames map[string]bool
errs []error
@@ -82,15 +81,6 @@ func (b *Builder) FailClosed() *Builder {
return b
}
// HideDiagnostics retires the policy self-inspection commands
// (`config policy show`, `config plugins show`), which otherwise stay
// executable under a plugin policy as the operator's escape hatch.
// Requires Restrict() on the same plugin; Build fails otherwise.
func (b *Builder) HideDiagnostics() *Builder {
b.caps.HideDiagnostics = true
return b
}
// Observer registers an Observer. Multiple calls accumulate.
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
if !b.validateHookName(hookName, "observer") {
@@ -155,35 +145,6 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
return b
}
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay)
// customizing the CLI's embedded skill content. Unlike Restrict it does
// NOT imply FailClosed: skill customization is guidance content, not a
// security boundary. A plugin owns at most one SkillsOverlay, so calling
// EmbeddedSkills more than once is a build error.
func (b *Builder) EmbeddedSkills(spec *SkillsOverlay) *Builder {
if spec == nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills(nil): spec must not be nil"))
return b
}
if b.skillsOverlay != nil {
b.errs = append(b.errs, errors.New("EmbeddedSkills() called more than once; a plugin owns at most one SkillsOverlay"))
return b
}
b.skillsOverlay = cloneSkillsOverlay(spec)
return b
}
// cloneSkillsOverlay snapshots the caller's spec so a later mutation of the
// same *SkillsOverlay cannot alter the staged copy. The Remove slice is
// copied; Overlay/Base are fs.FS handles retained by reference (an fs.FS
// is a read-only view, not caller-mutable state).
func cloneSkillsOverlay(spec *SkillsOverlay) *SkillsOverlay {
cp := *spec
cp.Allow = append([]string(nil), spec.Allow...)
cp.Remove = append([]string(nil), spec.Remove...)
return &cp
}
// Build returns the configured Plugin, or an error if any builder
// step found a fault. MustBuild panics on the same error.
//
@@ -194,20 +155,15 @@ func (b *Builder) Build() (Plugin, error) {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
if b.caps.HideDiagnostics && len(b.rules) == 0 {
b.errs = append(b.errs, errors.New(
"HideDiagnostics() requires Restrict(): there is no integrator policy to hide"))
}
if len(b.errs) > 0 {
return nil, errors.Join(b.errs...)
}
return &builtPlugin{
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
skillsOverlay: b.skillsOverlay,
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rules: b.rules,
}, nil
}
@@ -246,12 +202,11 @@ func (b *Builder) validateHookName(hookName, kind string) bool {
// builtPlugin is the Plugin implementation the builder emits.
type builtPlugin struct {
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
skillsOverlay *SkillsOverlay
name string
version string
caps Capabilities
actions []func(Registrar)
rules []*Rule
}
func (p *builtPlugin) Name() string { return p.name }
@@ -261,15 +216,6 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
sr, ok := r.(EmbeddedSkillsRegistrar)
if !ok {
// Fail closed: a declared skill customization must never be
// silently dropped by a host that cannot honour it.
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(p.skillsOverlay)
}
for _, action := range p.actions {
action(r)
}

View File

@@ -14,13 +14,11 @@ import (
// recorder Registrar captures everything a builder schedules so the
// test can assert what Install produced without involving the host.
type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
skillsOverlay *platform.SkillsOverlay
skillCalls int
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -32,10 +30,6 @@ func (r *recorder) Restrict(rule *platform.Rule) {
r.rule = rule
r.rules = append(r.rules, rule)
}
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
r.skillsOverlay = spec
r.skillCalls++
}
// Restrict must snapshot each rule: a caller that reuses and mutates the
// same *Rule object across two Restrict calls must still get two distinct
@@ -217,78 +211,3 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
}
}
// EmbeddedSkills() must snapshot Remove: a caller that mutates the same slice
// after the call must still get the value staged at call time.
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
remove := []string{"lark-shared"}
b := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: remove})
remove[0] = "mutated"
p, err := b.Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if r.skillCalls != 1 {
t.Fatalf("Skills calls = %d, want 1", r.skillCalls)
}
if r.skillsOverlay == nil || len(r.skillsOverlay.Remove) != 1 || r.skillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("staged Remove leaked later mutation: %+v", r.skillsOverlay)
}
}
func TestBuilder_skillsNilRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").EmbeddedSkills(nil).Build()
if err == nil {
t.Fatal("EmbeddedSkills(nil) must produce error")
}
}
func TestBuilder_skillsTwiceRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}}).
Build()
if err == nil {
t.Fatal("calling EmbeddedSkills() twice must produce error")
}
}
// EmbeddedSkills() customizes guidance content, not a security boundary, so
// unlike Restrict() it must NOT force FailClosed.
func TestBuilder_skillsDoesNotForceFailClosed(t *testing.T) {
p, err := platform.NewPlugin("p", "0").EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
caps := p.Capabilities()
if caps.Restricts {
t.Error("EmbeddedSkills() must not set Restricts")
}
if caps.FailurePolicy != platform.FailOpen {
t.Errorf("FailurePolicy = %v, want FailOpen (default)", caps.FailurePolicy)
}
}
// HideDiagnostics only makes sense alongside Restrict: without a rule
// there is no integrator policy whose inspection could be hidden.
func TestBuilder_hideDiagnosticsRequiresRestrict(t *testing.T) {
_, err := platform.NewPlugin("p", "0").HideDiagnostics().Build()
if err == nil {
t.Fatal("HideDiagnostics() without Restrict() must fail Build")
}
p, err := platform.NewPlugin("p", "0").
Restrict(&platform.Rule{Deny: []string{"config/**"}}).
HideDiagnostics().
Build()
if err != nil {
t.Fatalf("HideDiagnostics()+Restrict() must build: %v", err)
}
if !p.Capabilities().HideDiagnostics {
t.Error("Capabilities.HideDiagnostics must be set")
}
}

View File

@@ -47,9 +47,4 @@ type Capabilities struct {
// constants above; the framework requires FailClosed whenever
// Restricts=true.
FailurePolicy FailurePolicy
// HideDiagnostics retires the policy self-inspection commands
// (`config policy show`, `config plugins show`) from the build.
// Requires Restricts=true; the install fails otherwise.
HideDiagnostics bool
}

View File

@@ -36,22 +36,3 @@ type Registrar interface {
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
}
// EmbeddedSkillsRegistrar is the optional extension a host registrar
// implements to accept embedded-skill customization. It is deliberately NOT
// part of Registrar: every exported symbol in this package is a stability
// contract, and widening Registrar would break existing third-party
// implementations (fakes, decorators, custom hosts). A Builder-built plugin
// type-asserts for this interface at Install time and fails closed when the
// host lacks it -- a declared customization is never silently dropped.
//
// Skill content has a single owner: a second customizing plugin, or a
// SkillsOverlay that cannot compose, aborts startup unconditionally. Unlike
// Restrict, EmbeddedSkills is not a security boundary -- it shapes fail-open
// guidance content. Removing a skill drops its guidance but does not disable
// any command; use Restrict to actually block a command.
type EmbeddedSkillsRegistrar interface {
// EmbeddedSkills contributes a SkillsOverlay customizing the CLI's
// embedded skill content (see SkillsOverlay).
EmbeddedSkills(spec *SkillsOverlay)
}

View File

@@ -1,63 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import (
"context"
"testing"
)
// legacyRegistrarFake is a downstream Registrar implementation written
// BEFORE EmbeddedSkills existed. It must keep compiling: doc.go declares
// every exported symbol a stability contract, so Registrar can never widen.
// If this file goes red, a method was added to Registrar -- move it to an
// optional extension interface instead (see EmbeddedSkillsRegistrar).
type legacyRegistrarFake struct{}
func (legacyRegistrarFake) Observe(When, string, Selector, Observer) {}
func (legacyRegistrarFake) Wrap(string, Selector, Wrapper) {}
func (legacyRegistrarFake) On(LifecycleEvent, string, LifecycleHandler) {}
func (legacyRegistrarFake) Restrict(*Rule) {}
var _ Registrar = legacyRegistrarFake{}
// skillsRegistrarFake opts into the optional extension.
type skillsRegistrarFake struct {
legacyRegistrarFake
got *SkillsOverlay
}
func (f *skillsRegistrarFake) EmbeddedSkills(spec *SkillsOverlay) { f.got = spec }
var _ EmbeddedSkillsRegistrar = (*skillsRegistrarFake)(nil)
// Drive the legacy surface through the interface so the fake stays a
// faithful stand-in for downstream usage (and none of it reads as dead code).
func TestLegacyRegistrarFake_ImplementsContractSurface(t *testing.T) {
var r Registrar = legacyRegistrarFake{}
r.Observe(Before, "x.obs", All(), func(context.Context, Invocation) {})
r.Wrap("x.wrap", All(), func(next Handler) Handler { return next })
r.On(Startup, "x.boot", func(context.Context, *LifecycleContext) error { return nil })
r.Restrict(&Rule{Deny: []string{"config/**"}})
}
// A plugin that declared EmbeddedSkills fails closed against a host whose
// registrar lacks the optional extension, and succeeds against one that has it.
func TestBuiltPlugin_embeddedSkillsRequiresOptionalInterface(t *testing.T) {
p := NewPlugin("acme", "1.0").
EmbeddedSkills(&SkillsOverlay{Remove: []string{"lark-a"}}).
MustBuild()
if err := p.Install(legacyRegistrarFake{}); err == nil {
t.Error("Install must fail closed when the host cannot honour EmbeddedSkills")
}
host := &skillsRegistrarFake{}
if err := p.Install(host); err != nil {
t.Fatalf("Install: %v", err)
}
if host.got == nil || len(host.got.Remove) != 1 || host.got.Remove[0] != "lark-a" {
t.Errorf("SkillsOverlay must reach the opted-in host, got %+v", host.got)
}
}

View File

@@ -6,9 +6,8 @@ package platform
// Rule is the declarative policy rule data structure. yaml files and
// Plugin.Restrict() both produce the same Rule.
//
// At any moment there is at most one effective SOURCE of rules -- the
// resolver decides which wins (Plugin > yaml > none); the winning source
// may contribute several scoped rules. This package only defines the
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). This package only defines the
// shape; selection lives in internal/cmdpolicy.
//
// The four filter fields are joined by AND. See the engine's Evaluate for
@@ -58,11 +57,4 @@ type Rule struct {
// No yaml tag: yaml decoding lives in internal/cmdpolicy/yaml so
// platform stays free of a yaml library dependency.
AllowUnannotated bool `json:"allow_unannotated,omitempty"`
// DeniedMessage replaces the default "command not included in this
// build" message for plugin-denied commands. The message is
// build-level: the first non-empty DeniedMessage across the owning
// plugin's rules applies to every denial, so declare it once. yaml
// rules ignore it (they keep the command-denied presentation).
DeniedMessage string `json:"denied_message,omitempty"`
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "io/fs"
// SkillsOverlay declares how a plugin customizes the CLI's embedded
// skill content, contributed via Builder.EmbeddedSkills. At most one
// source may own skill content; two customizing plugins abort startup.
//
// Allow / Remove mirror Rule's Allow / Deny: an allow-list keeps only
// what it names, a remove-list drops what it names, and Remove wins
// over Allow. Composition order is fixed: Base (or the CLI default) ->
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
//
// Skills are addressed by exact name (a directory carrying SKILL.md,
// e.g. "lark-doc"), not by command path and not by glob — the skill
// list is flat, so misspellings abort startup instead of silently
// matching nothing. Removing a skill only drops its guidance; it does
// not disable any command (use Restrict for that).
type SkillsOverlay struct {
// Allow, when non-empty, keeps only these skills (by name) from the
// base tree — the allow-list counterpart of Rule.Allow. Skills the
// CLI adds in future versions stay out of the build until listed
// here, which a Remove-only spec cannot guarantee. A name not
// present in the base aborts startup. Overlay entries are exempt:
// content the integrator explicitly ships needs no allow-listing.
Allow []string
// Remove hides these skills, by name (e.g. "lark-shared"), from the
// base tree; it wins over Allow, mirroring Rule's Deny-over-Allow. A
// name not present in the base aborts startup rather than being
// silently ignored.
Remove []string
// Overlay contributes skills laid over the base: a same-named skill
// replaces the base's entirely, a new name adds one. It is rooted at
// the skill list (entries like "my-skill/SKILL.md"); each top-level
// entry must be a "<name>/" directory containing SKILL.md. Any fs.FS
// works (embed.FS, os.DirFS, fstest.MapFS); embed.FS is not required.
Overlay fs.FS
// Base replaces the entire base skill tree instead of layering over
// the CLI default. nil keeps the CLI default. Rare: most integrators
// leave it nil and use Remove/Overlay so unchanged skills follow the
// CLI version with no copy to maintain.
Base fs.FS
}

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -83,9 +83,10 @@ func commandFormResolver(service string) func(string) string {
}
}
return func(h string) string {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return headingToKey(h) // one home for the shortcut/method key convention
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -7,8 +7,6 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -86,38 +84,3 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,7 +19,6 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -35,56 +34,16 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
@@ -123,7 +82,6 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -134,14 +92,12 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
@@ -201,7 +157,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -9,7 +9,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
)
const (
@@ -42,15 +41,11 @@ func (e *NeedAuthorizationError) Error() string {
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"%s (user: %s)", needUserAuthorizationMarker, userOpenID).
WithUserOpenID(userOpenID).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(&NeedAuthorizationError{UserOpenId: userOpenID})
// No recovery hint when the auth domain is absent from this build.
if !policystate.DomainDeniedByPlugin("auth") {
e = e.WithHint("run: lark-cli auth login to re-authorize")
}
return e
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT

View File

@@ -1,28 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/policystate"
)
// The auth-login recovery hint points into the auth domain; when an
// integrator plugin denied that whole domain the hint would be a dead
// end, so it stays off the error.
func TestNeedUserAuthorization_hintFollowsAuthDomain(t *testing.T) {
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(nil)
if e := NewNeedUserAuthorizationError("ou_x"); !strings.Contains(e.Hint, "auth login") {
t.Errorf("default build must keep the auth login hint, got %q", e.Hint)
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
if e := NewNeedUserAuthorizationError("ou_x"); e.Hint != "" {
t.Errorf("auth-denied build must not steer to auth login, got %q", e.Hint)
}
}

View File

@@ -23,7 +23,6 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/policystate"
"github.com/larksuite/cli/internal/util"
)
@@ -72,14 +71,10 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
// for the defensive empty-token branch) and is preserved for errors.Is /
// errors.Unwrap traversal without being serialized on the wire.
func newTokenMissingError(as core.Identity, cause error) error {
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no access token available for %s", as).
WithHint("run: lark-cli auth login to re-authorize").
WithCause(cause)
// No recovery hint when the auth domain is absent from this build.
if !policystate.DomainDeniedByPlugin("auth") {
e = e.WithHint("run: lark-cli auth login to re-authorize")
}
return e
}
// buildApiReq converts a RawApiRequest into SDK types and collects

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// Same gate as internal/auth: the token-missing recovery hint points into
// the auth domain and stays off the error when a plugin denied it.
func TestTokenMissing_hintFollowsAuthDomain(t *testing.T) {
t.Cleanup(policystate.ResetForTesting)
policystate.SetPluginDeniedDomains(nil)
var ae *errs.AuthenticationError
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || !strings.Contains(ae.Hint, "auth login") {
t.Errorf("default build must keep the auth login hint, got %v", err)
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
if err := newTokenMissingError(core.AsUser, nil); !errors.As(err, &ae) || ae.Hint != "" {
t.Errorf("auth-denied build must not steer to auth login, got %v", err)
}
}

View File

@@ -2,11 +2,9 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
//
// Three axes:
//
@@ -53,12 +51,6 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -133,35 +125,6 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -23,9 +23,6 @@ type ActivePolicy struct {
Rules []*platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
// DeniedByPath is the full post-aggregation denial map.
DeniedByPath map[string]Denial
}
var (
@@ -50,27 +47,6 @@ func SetActive(p *ActivePolicy) {
activePolicy = cloneActivePolicy(p)
}
// AppendActiveDenials merges presentation-time denials (retired or
// converged diagnostics) into the recorded snapshot, so `config policy show`
// and other introspection reflect the tree that actually shipped rather
// than only the bootstrap-time aggregate.
func AppendActiveDenials(extra map[string]Denial) {
activeMu.Lock()
defer activeMu.Unlock()
if activePolicy == nil || len(extra) == 0 {
return
}
if activePolicy.DeniedByPath == nil {
activePolicy.DeniedByPath = map[string]Denial{}
}
for path, d := range extra {
if _, dup := activePolicy.DeniedByPath[path]; !dup {
activePolicy.DeniedPaths++
}
activePolicy.DeniedByPath[path] = d
}
}
// GetActive returns a deep copy of the recorded policy, or nil if
// bootstrap has not finished or no rule applied. Callers can freely
// mutate the result — including the embedded Rule slices — without
@@ -105,12 +81,6 @@ func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
cp.Rules[i] = &rule
}
}
if in.DeniedByPath != nil {
cp.DeniedByPath = make(map[string]Denial, len(in.DeniedByPath))
for k, v := range in.DeniedByPath {
cp.DeniedByPath[k] = v
}
}
return &cp
}

View File

@@ -70,7 +70,7 @@ func TestBuildDeniedByPath_parentAggregationAllChildrenDenied(t *testing.T) {
}
denied := cmdpolicy.BuildDeniedByPath(root, decisions,
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent", "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/policy.yml"}, "agent")
// Both leaves denied.
if _, ok := denied["im/+send"]; !ok {
@@ -110,7 +110,7 @@ func TestBuildDeniedByPath_partialDenialKeepsParent(t *testing.T) {
Deny: []string{"docs/+delete"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy", "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"}, "secaudit-policy")
if _, ok := denied["docs"]; ok {
t.Errorf("parent 'docs' must NOT be denied when some children are allowed")
@@ -129,7 +129,7 @@ func TestBuildDeniedByPath_rootNeverDenied(t *testing.T) {
root := buildTree()
e := cmdpolicy.New(&platform.Rule{Allow: []string{"nonexistent/**"}})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "", "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: "/p.yml"}, "")
// Every leaf should be denied. We do not assert on the root entry
// because Apply skips the root regardless; the contract is "root
@@ -156,7 +156,7 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
Allow: []string{"docs"},
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "", "")
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML, Name: ""}, "")
// docs/+delete denied (path doesn't match Allow=["docs"]).
if _, ok := denied["docs/+delete"]; !ok {
@@ -170,13 +170,13 @@ func TestBuildDeniedByPath_hybridParentOwnAllowedKeepsAlive(t *testing.T) {
// Apply returns a typed *errs.ValidationError that exposes BOTH paths
// consumers rely on:
// 1. cmd/root.go's envelope writer (errs.ProblemOf; plugin-source
// denials use subtype command_unavailable + exit code 2)
// 1. cmd/root.go's envelope writer (errs.ProblemOf / failed_precondition
// subtype + exit code 2)
// 2. in-process consumers extracting the platform.CommandDeniedError as
// the typed error's Cause via errors.As
//
// Plugin-source denials keep the policy metadata OFF the wire (no hint,
// no source / rule vocabulary); it stays reachable on the Cause only.
// The policy metadata (layer / policy_source / rule_name / reason_code)
// is folded into the Hint text rather than a separate detail map.
func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
root := buildTree()
denied := map[string]cmdpolicy.Denial{
@@ -196,31 +196,29 @@ func TestApply_runEReturnsExitErrorAndCommandDeniedError(t *testing.T) {
t.Fatalf("denied command should return error")
}
// Path 1: typed-envelope view. A plugin-source denial presents as
// "command unavailable": the capability is absent from this build, so
// the envelope carries no policy metadata and no recovery hint.
// Path 1: typed-envelope view. The denial is a failed_precondition
// ValidationError so cmd/root.go renders the structured envelope and
// the process exits 2 (ExitValidation).
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error chain must contain *errs.ValidationError, got %T", err)
}
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
if ve.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeFailedPrecondition)
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Errorf("exit code = %d, want ExitValidation (%d)", code, output.ExitValidation)
}
if ve.Message != cmdpolicy.DefaultUnavailableMessage {
t.Errorf("message = %q, want default unavailable message", ve.Message)
// The policy metadata is folded into the Hint text: reason_code,
// policy_source, and rule_name must all be discoverable there.
if !strings.Contains(ve.Hint, "write_not_allowed") {
t.Errorf("hint must carry reason_code write_not_allowed, got %q", ve.Hint)
}
// No hint, no policy vocabulary: the wire must not steer the caller
// toward a policy the integrator locked into the build.
if ve.Hint != "" {
t.Errorf("plugin-source denial must carry no hint, got %q", ve.Hint)
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
for _, leak := range []string{"policy", "plugin:secaudit", "secaudit-policy", "write_not_allowed"} {
if strings.Contains(ve.Message, leak) {
t.Errorf("message leaks %q: %q", leak, ve.Message)
}
if !strings.Contains(ve.Hint, "secaudit-policy") {
t.Errorf("hint must carry rule_name secaudit-policy, got %q", ve.Hint)
}
// Path 2: in-process typed-error view -- the *platform.CommandDeniedError
@@ -303,7 +301,7 @@ func TestHasRunnableDescendant_ignoresAnnotatedPureGroup(t *testing.T) {
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
if _, ok := denied["docs"]; !ok {
t.Fatalf("docs should be aggregated as fully denied (pure-group children excluded from live count); map=%+v", denied)
@@ -334,7 +332,7 @@ func TestBuildDeniedByPath_aggregatesAnnotatedPureGroup(t *testing.T) {
e := cmdpolicy.New(&platform.Rule{MaxRisk: "read"})
decisions := e.EvaluateAll(root)
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "", "")
denied := cmdpolicy.BuildDeniedByPath(root, decisions, cmdpolicy.ResolveSource{Kind: cmdpolicy.SourceYAML}, "")
if _, ok := denied["drive"]; !ok {
t.Fatalf("aggregator must install drive denial when all children denied; map=%+v", denied)

View File

@@ -4,10 +4,7 @@
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
@@ -76,16 +73,6 @@ const (
AnnotationDenialLayer = "lark:policy_denied_layer"
AnnotationDenialSource = "lark:policy_denied_source"
// AnnotationDenialMessage carries the resolved unavailable message
// for the cmd layer's help interceptor (plugin-source denials only).
AnnotationDenialMessage = "lark:policy_denied_message"
// AnnotationFrameworkMeta marks a framework meta command (help): its
// RunE is framework presentation, dispatched outside the plugin Wrap
// chain so a Wrapper cannot swallow or rewrite its result. Observers
// still see the invocation.
AnnotationFrameworkMeta = "lark:framework_meta"
// AnnotationPureGroup marks a cobra.Command that is logically a
// parent-only group but had a RunE attached by the bootstrap-time
// unknown-subcommand guard. The engine treats annotated commands
@@ -137,37 +124,6 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
WithCause(cd)
}
// IsPluginPolicySource reports whether a policy source names a plugin.
// Plugin sources select the "command unavailable" presentation.
func IsPluginPolicySource(source string) bool {
return strings.HasPrefix(source, "plugin:")
}
// DefaultUnavailableMessage is the message shown when a plugin-restricted
// command is invoked and the integrator supplied no Rule.DeniedMessage.
const DefaultUnavailableMessage = "command not included in this build"
// messageOf resolves the effective unavailable message for a denial.
func messageOf(d Denial) string {
if d.DeniedMessage != "" {
return d.DeniedMessage
}
return DefaultUnavailableMessage
}
// BuildUnavailableError is the plugin-source counterpart of
// BuildDenialError: no hint, no policy vocabulary on the wire. The
// *platform.CommandDeniedError stays reachable as the Cause for
// in-process consumers; Cause is never serialized.
func BuildUnavailableError(path string, d Denial) *errs.ValidationError {
msg := d.DeniedMessage
if msg == "" {
msg = DefaultUnavailableMessage
}
return errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", msg).
WithCause(CommandDeniedFromDenial(path, d))
}
// installDenyStub mutates a cobra.Command in place. Unlike cmd/prune.go
// which does RemoveCommand+AddCommand (changing the pointer), we modify
// the existing node so any external reference (snapshots, alias targets)
@@ -202,15 +158,6 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
}
cmd.Hidden = true
cmd.DisableFlagParsing = true
// A plugin-denied command presents as absent: its local flags leave
// shell completion too. Hidden, not removed — yaml denials keep the
// full usage render, and the plugin help path never renders usage.
if IsPluginPolicySource(d.PolicySource) {
cmd.Flags().VisitAll(func(f *pflag.Flag) { f.Hidden = true })
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
}
// Bypass cobra's pre-RunE gates that would otherwise short-circuit
// before the wrapped RunE (= where observers + denial guard live):
@@ -247,19 +194,11 @@ func installDenyStub(cmd *cobra.Command, path string, d Denial) bool {
cmd.Annotations[AnnotationDenialSource] = d.PolicySource
denial := d // capture by value for the closure
if IsPluginPolicySource(d.PolicySource) {
// The message annotation feeds the cmd layer's help interceptor.
cmd.Annotations[AnnotationDenialMessage] = messageOf(d)
cmd.RunE = func(c *cobra.Command, args []string) error {
return BuildUnavailableError(path, denial)
}
} else {
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
cmd.RunE = func(c *cobra.Command, args []string) error {
// The typed message carries the user-facing semantic ("a command
// was denied"); the hint carries the layer / source / rule
// distinction ("policy" vs "strict_mode") for debugging.
return BuildDenialError(path, denial)
}
// Clear any pre-existing Run hook: cobra prefers RunE when both are
// set, but leaving a stale Run around is a foot-gun for future

View File

@@ -25,10 +25,6 @@ type Denial struct {
RuleName string // matched Rule.Name (if any)
ReasonCode string // closed enum, see docs/extension/reason-codes.md
Reason string // human-readable
// DeniedMessage is Rule.DeniedMessage for the plugin-source
// unavailable presentation; empty means the default message.
DeniedMessage string
}
// ChildDenial is what AggregateChildren consumes — it pairs a Denial

View File

@@ -27,15 +27,3 @@ var diagnosticPaths = map[string]bool{
func IsDiagnosticPath(path string) bool {
return diagnosticPaths[path]
}
// DiagnosticPaths returns the exempt self-inspection command paths, for
// the presentation layer: an integrator's HideDiagnostics retires exactly
// this set, and the help-concealment pass hides it from listings when the
// surrounding domain is plugin-denied.
func DiagnosticPaths() []string {
out := make([]string, 0, len(diagnosticPaths))
for p := range diagnosticPaths {
out = append(out, p)
}
return out
}

View File

@@ -269,9 +269,8 @@ func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
// so `--help` and similar remain available.
//
// source / ruleName populate PolicySource and RuleName on the produced
// Denial values, so envelope output can attribute denials. deniedMessage
// is build-level and applies uniformly, aggregates included.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string, deniedMessage string) map[string]Denial {
// Denial values, so envelope output can attribute denials.
func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, source ResolveSource, ruleName string) map[string]Denial {
out := map[string]Denial{}
sourceLabel := policySourceLabel(source)
@@ -288,13 +287,6 @@ func BuildDeniedByPath(root *cobra.Command, decisions map[string]Decision, sourc
}
aggregateParents(root, out)
if deniedMessage != "" {
for path, d := range out {
d.DeniedMessage = deniedMessage
out[path] = d
}
}
return out
}

View File

@@ -34,7 +34,7 @@ func TestEnvelope_yamlPolicySourceDoesNotLeakHomePath(t *testing.T) {
cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml", // simulate an absolute path
}, "my-readonly-rule", "")
}, "my-readonly-rule")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
@@ -77,7 +77,7 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
})
denied := cmdpolicy.BuildDeniedByPath(root, e.EvaluateAll(root),
cmdpolicy.ResolveSource{Kind: cmdpolicy.SourcePlugin, Name: "secaudit"},
"secaudit-policy", "")
"secaudit-policy")
cmdpolicy.Apply(root, denied)
err := leaf.RunE(leaf, nil)
@@ -85,17 +85,10 @@ func TestEnvelope_pluginPolicySourceCarriesName(t *testing.T) {
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
// A plugin-source denial presents as absent: no hint, no plugin name
// on the wire. Plugin attribution moves to the in-process Cause
// (*platform.CommandDeniedError) for integrators debugging a denial.
if ve.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeCommandUnavailable)
}
if ve.Hint != "" || strings.Contains(ve.Message, "plugin:secaudit") {
t.Errorf("wire must not expose the plugin source; hint=%q message=%q", ve.Hint, ve.Message)
}
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) || cd.PolicySource != "plugin:secaudit" {
t.Errorf("in-process Cause must carry plugin:secaudit, got %+v", cd)
// The plugin name IS surfaced (in-binary, part of the contract): it
// must appear in the Hint so an integrator debugging a denial knows
// which plugin fired.
if !strings.Contains(ve.Hint, "plugin:secaudit") {
t.Errorf("hint must carry policy_source plugin:secaudit, got %q", ve.Hint)
}
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -129,7 +128,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
fd.AddFile(fieldName, bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -8,7 +8,6 @@ import (
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// isMalformedConfigError reports whether a config load failure indicates a
@@ -82,22 +81,14 @@ const (
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
e := errs.NewConfigError(errs.SubtypeNotConfigured, "not configured")
// With the config domain absent from this build the init/bind
// commands do not exist; no hint beats a dead-end hint.
if !policystate.DomainDeniedByPlugin("config") {
e = e.WithHint("%s", localInitHint)
}
return e
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
WithHint("%s", localInitHint)
}
// Agent workspace: the workspace name appears only in the message, never
// in the wire subtype, which stays not_configured.
e := errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display())
if !policystate.DomainDeniedByPlugin("config") {
e = e.WithHint("%s", agentBindHint)
}
return e
return errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", agentBindHint)
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// The not-configured recovery hint points at `config init`/`config bind`;
// with the config domain absent from the build it must not render. The error
// itself (subtype, message) is unchanged.
func TestNotConfiguredError_hintFollowsConfigDomain(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
var ce *errs.ConfigError
if err := NotConfiguredError(); !errors.As(err, &ce) || ce.Hint == "" || !strings.Contains(ce.Hint, "config") {
t.Fatalf("default build must hint at a config command, got %+v", err)
}
policystate.SetPluginDeniedDomains(map[string]bool{"config": true})
if err := NotConfiguredError(); !errors.As(err, &ce) {
t.Fatalf("expected *errs.ConfigError, got %T", err)
} else {
if ce.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured (gate must not change the error)", ce.Subtype)
}
if ce.Hint != "" {
t.Errorf("config-denied build must omit the recovery hint, got %q", ce.Hint)
}
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/policystate"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
@@ -371,24 +370,13 @@ func PermissionHint(missing []string, identity string, subtype errs.Subtype, con
}
return "the app developer must apply for the required scope(s) at the developer console"
case errs.SubtypeMissingScope:
// With the auth domain absent from this build there is no login
// command to point at; no hint beats a dead-end hint.
if policystate.DomainDeniedByPlugin("auth") {
return ""
}
if len(missing) > 0 {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
}
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
case errs.SubtypeTokenScopeInsufficient:
if policystate.DomainDeniedByPlugin("auth") {
return "check the token's granted scopes"
}
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
case errs.SubtypeUserUnauthorized:
if policystate.DomainDeniedByPlugin("auth") {
return "the operation may be blocked by external-chat or admin policy"
}
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
case errs.SubtypeAppUnavailable:
return "ask the tenant admin to check the app's install status in the Lark admin console"

View File

@@ -113,7 +113,6 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -17,7 +17,6 @@ var driveCodeMeta = map[int]CodeMeta{
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/policystate"
)
// Permission recovery hints point at `auth login`; with the auth domain
// absent from the build those pointers must not render. The tenant-admin and
// console-side hints are not auth-login recoveries and stay.
func TestPermissionHint_followsAuthDomain(t *testing.T) {
policystate.ResetForTesting()
t.Cleanup(policystate.ResetForTesting)
for _, st := range []errs.Subtype{errs.SubtypeMissingScope, errs.SubtypeTokenScopeInsufficient, errs.SubtypeUserUnauthorized} {
if h := PermissionHint([]string{"im:message"}, "user", st, ""); !strings.Contains(h, "auth login") {
t.Errorf("%s: default build hint should mention auth login, got %q", st, h)
}
}
policystate.SetPluginDeniedDomains(map[string]bool{"auth": true})
for _, st := range []errs.Subtype{errs.SubtypeMissingScope, errs.SubtypeTokenScopeInsufficient, errs.SubtypeUserUnauthorized} {
if h := PermissionHint([]string{"im:message"}, "user", st, ""); strings.Contains(h, "auth login") {
t.Errorf("%s: auth-denied build must not point at auth login, got %q", st, h)
}
}
// Non-auth-login recoveries are untouched.
if h := PermissionHint(nil, "bot", errs.SubtypeAppScopeNotApplied, "https://example.com"); !strings.Contains(h, "developer console") {
t.Errorf("console hint must survive the auth gate, got %q", h)
}
}

View File

@@ -107,11 +107,7 @@ func wrapRunE(cmd *cobra.Command, reg *Registry, snapshot CommandViewSource) {
// If denied, run the originalRunE directly (it is the denyStub
// installed by cmdpolicy.Apply). The Wrap chain is bypassed.
var err error
if inv.DeniedByPolicy() || isFrameworkMeta(c) {
// Framework meta commands (help) are presentation, not
// business dispatch: a plugin Wrapper must not swallow or
// rewrite their result (e.g. the command_unavailable answer
// for a restricted target). Observers above still ran.
if inv.DeniedByPolicy() {
err = invokeOriginal(ctx, c, args, originalRunE, originalRun)
} else {
// Compose matching Wrappers around the originalRunE. Each
@@ -338,13 +334,6 @@ var stderr = func() interface{ Write(p []byte) (int, error) } {
//
// This indirection lets us avoid an import cycle between hook and
// pruning packages.
// isFrameworkMeta reports whether the command is a framework meta command
// (stamped by cmd's installHelpCommand). Key duplicated by value, matching
// the denial keys below: hook sits beneath cmdpolicy in the import graph.
func isFrameworkMeta(c *cobra.Command) bool {
return c.Annotations["lark:framework_meta"] == "true"
}
func populateInvocationDenial(inv *invocation, c *cobra.Command) {
const layerKey = "lark:policy_denied_layer"
const sourceKey = "lark:policy_denied_source"

View File

@@ -412,46 +412,3 @@ func equalStrings(a, b []string) bool {
}
return true
}
// A framework meta command (help, stamped lark:framework_meta) dispatches
// outside the Wrap chain: a swallowing Wrapper must not intercept it, and its
// own RunE result must come through unchanged.
func TestInstall_frameworkMetaBypassesWrapChain(t *testing.T) {
root := &cobra.Command{Use: "root"}
metaRan := false
sentinel := errors.New("meta result")
leaf := &cobra.Command{
Use: "help",
RunE: func(*cobra.Command, []string) error {
metaRan = true
return sentinel
},
Annotations: map[string]string{"lark:framework_meta": "true"},
}
root.AddCommand(leaf)
reg := hook.NewRegistry()
wrapCalled := false
reg.AddWrapper(hook.WrapperEntry{
Name: "swallow", Selector: platform.All(),
Fn: func(next platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error {
wrapCalled = true
return nil
}
},
})
hook.Install(root, reg, fakeViewSource{view: fakeView{path: "help"}})
err := leaf.RunE(leaf, nil)
if wrapCalled {
t.Error("Wrap chain must not run for a framework meta command")
}
if !metaRan {
t.Error("meta command's own RunE must run")
}
if !errors.Is(err, sentinel) {
t.Errorf("meta result must pass through unchanged, got %v", err)
}
}

View File

@@ -8,11 +8,8 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -53,12 +53,4 @@ const (
ReasonInstallPanic = "install_panic"
ReasonDuplicatePluginName = "duplicate_plugin_name"
ReasonMultipleRestricts = "multiple_restrict_plugins"
// ReasonInvalidSkillsOverlay flags a plugin's SkillsOverlay that cannot
// compose -- EmbeddedSkills() called twice, a Remove naming a skill absent
// from the base, or an Overlay entry missing SKILL.md.
ReasonInvalidSkillsOverlay = "invalid_skills_overlay"
// ReasonMultipleSkillsOverlays flags two or more plugins each contributing
// a SkillsOverlay; only one may own skill content (mirrors
// ReasonMultipleRestricts).
ReasonMultipleSkillsOverlays = "multiple_skills_overlay_plugins"
)

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/skillpolicy"
)
// PluginInfo is the metadata of a successfully-installed plugin,
@@ -30,10 +29,9 @@ type PluginInfo struct {
// every plugin that committed successfully (FailOpen-skipped plugins
// are absent), for downstream diagnostics.
type InstallResult struct {
Registry *hook.Registry
PluginRules []cmdpolicy.PluginRule
PluginSkills []skillpolicy.PluginSkill
Plugins []PluginInfo
Registry *hook.Registry
PluginRules []cmdpolicy.PluginRule
Plugins []PluginInfo
}
// InstallAll runs every registered plugin through the staging
@@ -144,16 +142,6 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
}
}
// The Builder rejects this at Build time; hand-written plugins are
// caught here (authoring error, aborts unconditionally).
if caps.HideDiagnostics && !caps.Restricts {
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInvalidCapability,
Reason: "HideDiagnostics=true requires Restricts=true",
}
}
// Version compatibility check. Two distinct failure modes:
//
// 1. Parse error (constraint is malformed, e.g. ">=abc")
@@ -219,12 +207,6 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
Rule: rule,
})
}
if staging.skillsOverlay != nil {
result.PluginSkills = append(result.PluginSkills, skillpolicy.PluginSkill{
PluginName: name,
SkillsOverlay: staging.skillsOverlay,
})
}
// Record the plugin in the inventory. Version is fetched here under
// a recover-wrapped helper so a plugin's Version() panic does not

View File

@@ -431,84 +431,3 @@ func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
}
}
// skillPlugin contributes a SkillsOverlay via r.EmbeddedSkills; the install pipeline
// must capture it into PluginSkills for the skill resolver.
type skillPlugin struct{}
func (skillPlugin) Name() string { return "skiller" }
func (skillPlugin) Version() string { return "1.0.0" }
func (skillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (skillPlugin) Install(r platform.Registrar) error {
sr, ok := r.(platform.EmbeddedSkillsRegistrar)
if !ok {
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
return nil
}
func TestInstallAll_skillsCommitted(t *testing.T) {
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if len(result.PluginSkills) != 1 {
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
}
ps := result.PluginSkills[0]
if ps.PluginName != "skiller" {
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
}
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
}
}
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
// FailClosed so the staging error aborts InstallAll deterministically.
type doubleSkillPlugin struct{}
func (doubleSkillPlugin) Name() string { return "double-skiller" }
func (doubleSkillPlugin) Version() string { return "1.0.0" }
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (doubleSkillPlugin) Install(r platform.Registrar) error {
sr := r.(platform.EmbeddedSkillsRegistrar)
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
return nil
}
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
if err == nil {
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
}
}
// hideDiagnosticsOnlyPlugin declares HideDiagnostics without Restricts —
// an authoring error the host rejects unconditionally.
type hideDiagnosticsOnlyPlugin struct{}
func (hideDiagnosticsOnlyPlugin) Name() string { return "hider" }
func (hideDiagnosticsOnlyPlugin) Version() string { return "1.0.0" }
func (hideDiagnosticsOnlyPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{HideDiagnostics: true, FailurePolicy: platform.FailOpen}
}
func (hideDiagnosticsOnlyPlugin) Install(platform.Registrar) error { return nil }
func TestInstallAll_hideDiagnosticsRequiresRestricts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{hideDiagnosticsOnlyPlugin{}}, nil)
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
t.Fatalf("HideDiagnostics without Restricts must abort with invalid_capability, got %v", err)
}
}

View File

@@ -29,10 +29,6 @@ type PluginEntry struct {
// plugin did not call r.Restrict.
Rules []*RuleView
// EmbeddedSkills summarises the plugin's EmbeddedSkills contribution;
// nil when the plugin did not customize embedded skills.
EmbeddedSkills *SkillsOverlayView `json:"embedded_skills,omitempty"`
Observers []HookEntry
Wrappers []HookEntry
Lifecycles []HookEntry
@@ -45,7 +41,6 @@ type CapabilitiesView struct {
Restricts bool `json:"restricts"`
FailurePolicy string `json:"failure_policy"`
RequiredCLIVersion string `json:"required_cli_version,omitempty"`
HideDiagnostics bool `json:"hide_diagnostics,omitempty"`
}
// NewCapabilitiesView converts a platform.Capabilities value into the
@@ -55,7 +50,6 @@ func NewCapabilitiesView(c platform.Capabilities) CapabilitiesView {
Restricts: c.Restricts,
FailurePolicy: failurePolicyLabel(c.FailurePolicy),
RequiredCLIVersion: c.RequiredCLIVersion,
HideDiagnostics: c.HideDiagnostics,
}
}
@@ -80,22 +74,6 @@ type RuleView struct {
AllowUnannotated bool `json:"allow_unannotated"`
}
// SkillsOverlayView is the displayable summary of an EmbeddedSkills
// contribution. Overlay / Base report presence only (an fs.FS has no
// display form).
type SkillsOverlayView struct {
Allow []string `json:"allow,omitempty"`
Remove []string `json:"remove,omitempty"`
Overlay bool `json:"overlay"`
Base bool `json:"base"`
}
// SkillsInventorySource pairs a plugin name with its overlay summary.
type SkillsInventorySource struct {
PluginName string
View SkillsOverlayView
}
// Inventory is the full snapshot.
type Inventory struct {
Plugins []PluginEntry
@@ -130,7 +108,7 @@ type RuleInventorySource struct {
// Hooks are attributed to plugins by the namespaced name convention:
// each entry's Name starts with "<plugin>.", and we group by the
// leading segment up to the first dot.
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource, skills []SkillsInventorySource) *Inventory {
func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, rules []RuleInventorySource) *Inventory {
byPlugin := make(map[string]*PluginEntry, len(plugins))
out := &Inventory{Plugins: make([]PluginEntry, 0, len(plugins))}
for _, p := range plugins {
@@ -184,15 +162,6 @@ func BuildInventory(plugins []PluginInventorySource, registry *hook.Registry, ru
})
}
}
for _, sk := range skills {
if entry := byPlugin[sk.PluginName]; entry != nil {
v := sk.View
v.Allow = append([]string(nil), sk.View.Allow...)
v.Remove = append([]string(nil), sk.View.Remove...)
entry.EmbeddedSkills = &v
}
}
return out
}
@@ -294,12 +263,6 @@ func cloneInventory(in *Inventory) *Inventory {
entry.Rules[j] = &rv
}
}
if p.EmbeddedSkills != nil {
sv := *p.EmbeddedSkills
sv.Allow = append([]string(nil), p.EmbeddedSkills.Allow...)
sv.Remove = append([]string(nil), p.EmbeddedSkills.Remove...)
entry.EmbeddedSkills = &sv
}
entry.Observers = append([]HookEntry(nil), p.Observers...)
entry.Wrappers = append([]HookEntry(nil), p.Wrappers...)
entry.Lifecycles = append([]HookEntry(nil), p.Lifecycles...)

View File

@@ -36,7 +36,7 @@ func TestBuildInventory_groupsByPluginName(t *testing.T) {
{PluginName: "a", RuleName: "a-rule", Allow: []string{"docs/**"}, MaxRisk: "read"},
}
inv := internalplatform.BuildInventory(plugins, r, rules, nil)
inv := internalplatform.BuildInventory(plugins, r, rules)
if got := len(inv.Plugins); got != 2 {
t.Fatalf("Plugins len = %d, want 2", got)
@@ -89,7 +89,7 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
{PluginName: "a", RuleName: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
}
inv := internalplatform.BuildInventory(plugins, nil, rules, nil)
inv := internalplatform.BuildInventory(plugins, nil, rules)
a := findPlugin(inv, "a")
if a == nil {
t.Fatalf("missing entry a")
@@ -103,45 +103,12 @@ func TestBuildInventory_multipleRulesPerPlugin(t *testing.T) {
}
func TestBuildInventory_empty(t *testing.T) {
inv := internalplatform.BuildInventory(nil, nil, nil, nil)
inv := internalplatform.BuildInventory(nil, nil, nil)
if got := len(inv.Plugins); got != 0 {
t.Errorf("Plugins len = %d, want 0", got)
}
}
// A non-nil SkillsInventorySource must surface on the owning plugin's entry as
// an EmbeddedSkills summary, and BuildInventory must clone the Allow/Remove
// slices so a later mutation of the source cannot corrupt the recorded view.
func TestBuildInventory_populatesAndClonesEmbeddedSkills(t *testing.T) {
plugins := []internalplatform.PluginInventorySource{{Name: "acme", Version: "1.0"}}
allow := []string{"lark-im"}
remove := []string{"lark-shared"}
skills := []internalplatform.SkillsInventorySource{
{PluginName: "acme", View: internalplatform.SkillsOverlayView{
Allow: allow, Remove: remove, Overlay: true, Base: false,
}},
}
inv := internalplatform.BuildInventory(plugins, nil, nil, skills)
entry := findPlugin(inv, "acme")
if entry == nil || entry.EmbeddedSkills == nil {
t.Fatalf("acme entry missing EmbeddedSkills: %+v", entry)
}
es := entry.EmbeddedSkills
if len(es.Allow) != 1 || es.Allow[0] != "lark-im" ||
len(es.Remove) != 1 || es.Remove[0] != "lark-shared" ||
!es.Overlay || es.Base {
t.Errorf("EmbeddedSkills summary mismatch: %+v", es)
}
// Mutating the source slices must not leak into the recorded view.
allow[0] = "MUTATED"
remove[0] = "MUTATED"
if es.Allow[0] != "lark-im" || es.Remove[0] != "lark-shared" {
t.Errorf("EmbeddedSkills slices not cloned; source mutation leaked: %+v", es)
}
}
func findPlugin(inv *internalplatform.Inventory, name string) *internalplatform.PluginEntry {
for i := range inv.Plugins {
if inv.Plugins[i].Name == name {

View File

@@ -41,13 +41,6 @@ type stagingRegistrar struct {
// can detect the call.
actuallyRestricted bool
// skillsOverlay holds the staged Skills contribution, captured for the
// host to feed into the skill resolver later. nil means the plugin
// did not call r.EmbeddedSkills. overlaySet records that the call happened so a
// second call in the same plugin can be rejected.
skillsOverlay *platform.SkillsOverlay
overlaySet bool
// seenHookNames detects duplicate hookName within this plugin's
// Install call.
seenHookNames map[string]bool
@@ -151,25 +144,6 @@ func (r *stagingRegistrar) Restrict(rule *platform.Rule) {
r.rules = append(r.rules, &cp)
}
func (r *stagingRegistrar) EmbeddedSkills(spec *platform.SkillsOverlay) {
if r.overlaySet {
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills() called more than once in the same plugin")
return
}
r.overlaySet = true
if spec == nil {
r.bufferErr(ReasonInvalidSkillsOverlay, "EmbeddedSkills(nil)")
return
}
// Defensive clone: freeze Remove so a plugin cannot mutate it after
// Install returns. Overlay/Base are read-only fs.FS views retained by
// reference.
cp := *spec
cp.Allow = append([]string(nil), spec.Allow...)
cp.Remove = append([]string(nil), spec.Remove...)
r.skillsOverlay = &cp
}
// --- helpers ---
func (r *stagingRegistrar) namespaced(name string) string {

View File

@@ -1,60 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package policystate answers "did an integrator plugin deny this whole
// command domain?" for render-time hint emitters. It is dependency-free
// because internal/auth and internal/client sit below internal/cmdpolicy
// in the import graph and cannot ask it directly. Written once by the
// bootstrap after policy pruning; read-only thereafter.
package policystate
import "sync"
var (
mu sync.RWMutex
pluginDeniedDomains map[string]bool
)
// SetPluginDeniedDomains records the plugin-denied top-level domains.
// nil clears.
func SetPluginDeniedDomains(domains map[string]bool) {
mu.Lock()
defer mu.Unlock()
if domains == nil {
pluginDeniedDomains = nil
return
}
cp := make(map[string]bool, len(domains))
for d, v := range domains {
cp[d] = v
}
pluginDeniedDomains = cp
}
// AddPluginDeniedDomain records one more plugin-denied domain after the
// bootstrap snapshot: presentation-time convergence (a domain whose last
// live descendants were retired) discovers whole-domain denials the
// bootstrap aggregate cannot see.
func AddPluginDeniedDomain(domain string) {
mu.Lock()
defer mu.Unlock()
if pluginDeniedDomains == nil {
pluginDeniedDomains = map[string]bool{}
}
pluginDeniedDomains[domain] = true
}
// DomainDeniedByPlugin reports whether the whole top-level domain was
// denied by an integrator plugin. yaml denials never register here.
func DomainDeniedByPlugin(domain string) bool {
mu.RLock()
defer mu.RUnlock()
return pluginDeniedDomains[domain]
}
// ResetForTesting clears the recorded state.
func ResetForTesting() {
mu.Lock()
defer mu.Unlock()
pluginDeniedDomains = nil
}

View File

@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty:
// no scopes are special-cased into the auto-approve set anymore.
if len(allowSet) != 0 {
t.Errorf("expected empty override allow set, got %d entries", len(allowSet))
// recommend.allow special-cases scopes absent from scope_priorities.json
// (application v7 is not in the platform catalog yet) so interactive
// login's "common scopes" tier still offers them. Only the read scope is
// admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
}
}

View File

@@ -12,7 +12,9 @@
"vc:meeting.meetingevent:read": 75
},
"recommend": {
"allow": [],
"allow": [
"application:app_slash_command:read"
],
"deny": [
"im:chat",
"im:message.send_as_user"

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" }
},
"application": {
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands of the current bound app (NOT Miaoda low-code apps)" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理(非妙搭低代码应用)" }
},
"apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

View File

@@ -1,224 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"io"
"io/fs"
"sort"
"strings"
"time"
)
// overlayFS layers an upper skill tree over a lower one at skill (top
// path segment) granularity: a skill present in upper is served wholly
// from upper, a skill named in removed is hidden, and everything else
// falls through to lower. It is the runtime form of a SkillsOverlay's
// Base -> Remove -> Overlay composition.
//
// It implements the io/fs fast-path interfaces (ReadDir/Stat/ReadFile)
// so the io/fs helpers route through the merge instead of hitting a
// single underlying tree.
type overlayFS struct {
lower fs.FS // base (or SkillsOverlay.Base); may be nil
upper fs.FS // SkillsOverlay.Overlay; may be nil
removed map[string]bool // skill names whited out from lower
allowed map[string]bool // nil = no allow-list; gates lower at access time
upperNames map[string]bool // skill names owned by upper
}
var (
_ fs.FS = (*overlayFS)(nil)
_ fs.ReadDirFS = (*overlayFS)(nil)
_ fs.StatFS = (*overlayFS)(nil)
_ fs.ReadFileFS = (*overlayFS)(nil)
)
func newOverlayFS(lower, upper fs.FS, remove, allow []string) *overlayFS {
o := &overlayFS{
lower: lower,
upper: upper,
removed: make(map[string]bool, len(remove)),
upperNames: map[string]bool{},
}
for _, r := range remove {
o.removed[r] = true
}
// The allow-list gates every access, not a resolve-time snapshot: a
// skill added to the lower FS after composition stays outside the
// build unless allow-listed.
if len(allow) > 0 {
o.allowed = make(map[string]bool, len(allow))
for _, a := range allow {
o.allowed[a] = true
}
}
if upper != nil {
if entries, err := fs.ReadDir(upper, "."); err == nil {
for _, e := range entries {
if e.IsDir() {
o.upperNames[e.Name()] = true
}
}
}
}
return o
}
// route picks the tree owning name by its top path segment. whiteout is
// true when name belongs to a removed skill (present in neither tree).
func (o *overlayFS) route(name string) (target fs.FS, whiteout bool) {
top := name
if i := strings.IndexByte(name, '/'); i >= 0 {
top = name[:i]
}
switch {
case o.upperNames[top]:
return o.upper, false
case o.removed[top]:
return nil, true
case o.allowed != nil && !o.allowed[top]:
return nil, true
default:
return o.lower, false
}
}
func (o *overlayFS) Open(name string) (fs.File, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrInvalid}
}
if name == "." {
entries, err := o.ReadDir(".")
if err != nil {
return nil, err
}
return &rootDir{entries: entries}, nil
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "open", Path: name, Err: fs.ErrNotExist}
}
return target.Open(name)
}
func (o *overlayFS) Stat(name string) (fs.FileInfo, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrInvalid}
}
if name == "." {
return rootInfo{}, nil
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "stat", Path: name, Err: fs.ErrNotExist}
}
return fs.Stat(target, name)
}
func (o *overlayFS) ReadFile(name string) ([]byte, error) {
if !fs.ValidPath(name) || name == "." {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrInvalid}
}
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "readfile", Path: name, Err: fs.ErrNotExist}
}
return fs.ReadFile(target, name)
}
func (o *overlayFS) ReadDir(name string) ([]fs.DirEntry, error) {
if !fs.ValidPath(name) {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrInvalid}
}
if name != "." {
target, whiteout := o.route(name)
if whiteout || target == nil {
return nil, &fs.PathError{Op: "readdir", Path: name, Err: fs.ErrNotExist}
}
return fs.ReadDir(target, name)
}
return o.mergedRoot()
}
// mergedRoot lists the top-level skills: every upper skill, plus every
// lower skill that is neither removed nor shadowed by a same-named upper.
// Sorted so listings are deterministic.
func (o *overlayFS) mergedRoot() ([]fs.DirEntry, error) {
seen := map[string]bool{}
var out []fs.DirEntry
if o.upper != nil {
entries, err := fs.ReadDir(o.upper, ".")
if err != nil {
return nil, err
}
for _, e := range entries {
if !e.IsDir() {
continue
}
out = append(out, e)
seen[e.Name()] = true
}
}
if o.lower != nil {
entries, err := fs.ReadDir(o.lower, ".")
if err != nil {
return nil, err
}
for _, e := range entries {
if o.removed[e.Name()] || seen[e.Name()] {
continue
}
if o.allowed != nil && !o.allowed[e.Name()] {
continue
}
out = append(out, e)
seen[e.Name()] = true
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
return out, nil
}
// rootInfo is the synthetic FileInfo for the overlay's "." directory,
// which has no single backing entry in either tree.
type rootInfo struct{}
func (rootInfo) Name() string { return "." }
func (rootInfo) Size() int64 { return 0 }
func (rootInfo) Mode() fs.FileMode { return fs.ModeDir | 0o555 }
func (rootInfo) ModTime() time.Time { return time.Time{} }
func (rootInfo) IsDir() bool { return true }
func (rootInfo) Sys() any { return nil }
// rootDir is the synthetic directory file returned by Open(".").
type rootDir struct {
entries []fs.DirEntry
off int
}
func (d *rootDir) Stat() (fs.FileInfo, error) { return rootInfo{}, nil }
func (d *rootDir) Close() error { return nil }
func (d *rootDir) Read([]byte) (int, error) {
return 0, &fs.PathError{Op: "read", Path: ".", Err: fs.ErrInvalid}
}
func (d *rootDir) ReadDir(n int) ([]fs.DirEntry, error) {
if n <= 0 {
rest := d.entries[d.off:]
d.off = len(d.entries)
return rest, nil
}
if d.off >= len(d.entries) {
return nil, io.EOF
}
end := d.off + n
if end > len(d.entries) {
end = len(d.entries)
}
part := d.entries[d.off:end]
d.off = end
return part, nil
}

View File

@@ -1,158 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package skillpolicy composes the CLI's effective embedded skill tree
// from a base skill FS and at most one plugin-supplied SkillsOverlay. It is
// the skill-side analogue of internal/cmdpolicy: plugins contribute a
// delta over a base, and one resolver produces the single tree that both
// skill readers consume -- `skills list`/`read` and the --help
// domain-guide pointer.
package skillpolicy
import (
"errors"
"fmt"
"io/fs"
"strings"
"github.com/larksuite/cli/extension/platform"
)
// PluginSkill pairs a plugin name with the SkillsOverlay it contributed, so a
// conflict can be attributed to specific owners. Mirrors
// cmdpolicy.PluginRule.
type PluginSkill struct {
PluginName string
SkillsOverlay *platform.SkillsOverlay
}
// ErrMultipleSkillsOverlays reports that more than one plugin tried to
// customize skill content. Mirrors cmdpolicy.ErrMultipleRestricts: only
// one owner is allowed so independent plugins cannot silently overwrite
// each other's skill tree.
var ErrMultipleSkillsOverlays = errors.New("multiple plugins customized skills; only one plugin may own skill content")
// Resolve composes the effective skill tree from base and the supplied
// specs. base is the CLI's embedded skill FS (nil when the build embeds
// none). With no spec it returns base unchanged. With exactly one spec it
// applies, in fixed order, Base override -> Allow -> Remove -> Overlay,
// returning an overlay FS in which a same-named skill resolves to
// Overlay. Two or more distinct owners is a configuration error.
func Resolve(base fs.FS, specs []PluginSkill) (fs.FS, error) {
owners := distinctOwners(specs)
if len(owners) > 1 {
return nil, fmt.Errorf("%w: %v", ErrMultipleSkillsOverlays, owners)
}
if len(specs) == 0 || specs[0].SkillsOverlay == nil {
return base, nil
}
owner, spec := specs[0].PluginName, specs[0].SkillsOverlay
lower := base
if spec.Base != nil {
lower = spec.Base
}
if err := validate(lower, spec); err != nil {
return nil, fmt.Errorf("plugin %q skill spec: %w", owner, err)
}
if lower == nil && spec.Overlay == nil {
return nil, nil
}
return newOverlayFS(lower, spec.Overlay, spec.Remove, spec.Allow), nil
}
// distinctOwners returns the unique contributing plugin names in
// first-seen order. Mirrors cmdpolicy.distinctOwners.
func distinctOwners(specs []PluginSkill) []string {
seen := map[string]bool{}
owners := make([]string, 0, len(specs))
for _, s := range specs {
if !seen[s.PluginName] {
seen[s.PluginName] = true
owners = append(owners, s.PluginName)
}
}
return owners
}
// validate rejects a spec that cannot compose cleanly, so the failure
// surfaces at startup with a named cause rather than as a silently
// missing skill at read time.
func validate(lower fs.FS, spec *platform.SkillsOverlay) error {
if spec.Base != nil {
if _, err := fs.ReadDir(spec.Base, "."); err != nil {
return fmt.Errorf("Base: cannot read root: %w", err)
}
}
for _, name := range spec.Allow {
if !isSkillName(name) {
return fmt.Errorf("Allow: %q is not a valid skill name", name)
}
ok, err := skillExists(lower, name)
if err != nil {
return fmt.Errorf("Allow: probing skill %q: %w", name, err)
}
if !ok {
return fmt.Errorf("Allow: skill %q is not in the base tree", name)
}
}
for _, name := range spec.Remove {
if !isSkillName(name) {
return fmt.Errorf("Remove: %q is not a valid skill name", name)
}
ok, err := skillExists(lower, name)
if err != nil {
return fmt.Errorf("Remove: probing skill %q: %w", name, err)
}
if !ok {
return fmt.Errorf("Remove: skill %q is not in the base tree", name)
}
}
if spec.Overlay != nil {
entries, err := fs.ReadDir(spec.Overlay, ".")
if err != nil {
return fmt.Errorf("Overlay: cannot read root: %w", err)
}
for _, e := range entries {
if !e.IsDir() {
return fmt.Errorf("Overlay: %q is not a directory; every Overlay entry must be a <skill>/ dir", e.Name())
}
if !isSkillName(e.Name()) {
return fmt.Errorf("Overlay: %q is not a valid skill name", e.Name())
}
ok, err := skillExists(spec.Overlay, e.Name())
if err != nil {
return fmt.Errorf("Overlay: probing skill %q: %w", e.Name(), err)
}
if !ok {
return fmt.Errorf("Overlay: skill %q is missing SKILL.md", e.Name())
}
}
}
return nil
}
// skillExists reports whether fsys holds a skill named name -- a
// directory carrying SKILL.md, the shape internal/skillcontent treats as
// a skill.
func skillExists(fsys fs.FS, name string) (bool, error) {
if fsys == nil {
return false, nil
}
info, err := fs.Stat(fsys, name+"/SKILL.md")
switch {
case err == nil:
return !info.IsDir(), nil
case errors.Is(err, fs.ErrNotExist) || errors.Is(err, fs.ErrInvalid):
return false, nil
default:
// A permission or I/O fault is a real cause, not absence.
return false, err
}
}
// isSkillName rejects empty, dotted, or path-bearing names so Remove
// cannot smuggle a traversal or match outside the top level.
func isSkillName(name string) bool {
return name != "" && name != "." && name != ".." && !strings.ContainsAny(name, `/\`)
}

View File

@@ -1,330 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"errors"
"io/fs"
"slices"
"sort"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/extension/platform"
)
// skillFS builds an in-memory skill tree; each map entry is a path -> content.
func skillFS(files map[string]string) fstest.MapFS {
m := fstest.MapFS{}
for p, content := range files {
m[p] = &fstest.MapFile{Data: []byte(content)}
}
return m
}
// baseTree is a representative base: three skills, one with a reference file.
func baseTree() fstest.MapFS {
return skillFS(map[string]string{
"lark-a/SKILL.md": "base a",
"lark-a/references/x.md": "base a ref",
"lark-b/SKILL.md": "base b",
"lark-shared/SKILL.md": "base shared",
})
}
// topLevel returns the sorted top-level skill names of fsys.
func topLevel(t *testing.T, fsys fs.FS) []string {
t.Helper()
entries, err := fs.ReadDir(fsys, ".")
if err != nil {
t.Fatalf("ReadDir(.): %v", err)
}
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
return names
}
func readFile(t *testing.T, fsys fs.FS, name string) string {
t.Helper()
data, err := fs.ReadFile(fsys, name)
if err != nil {
t.Fatalf("ReadFile(%q): %v", name, err)
}
return string(data)
}
func mustResolve(t *testing.T, base fs.FS, spec *platform.SkillsOverlay) fs.FS {
t.Helper()
got, err := Resolve(base, []PluginSkill{{PluginName: "acme", SkillsOverlay: spec}})
if err != nil {
t.Fatalf("Resolve: unexpected error: %v", err)
}
return got
}
func TestResolve_NoSpecs_ReturnsBaseUnchanged(t *testing.T) {
base := baseTree()
got, err := Resolve(base, nil)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b", "lark-shared"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
}
func TestResolve_Remove_HidesSkill(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
// The removed skill is gone for the affordance reader too (Stat gates it).
if _, err := fs.Stat(got, "lark-shared/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Stat removed skill: err = %v, want ErrNotExist", err)
}
// Surviving skills still read from base.
if c := readFile(t, got, "lark-a/SKILL.md"); c != "base a" {
t.Errorf("lark-a content = %q, want %q", c, "base a")
}
}
func TestResolve_Overlay_AddsNewSkill(t *testing.T) {
overlay := skillFS(map[string]string{"lark-new/SKILL.md": "new skill"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
gotNames := topLevel(t, got)
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
if c := readFile(t, got, "lark-new/SKILL.md"); c != "new skill" {
t.Errorf("lark-new content = %q, want %q", c, "new skill")
}
}
func TestResolve_Overlay_ReplacesSameNameWholeSkill(t *testing.T) {
overlay := skillFS(map[string]string{"lark-a/SKILL.md": "overlaid a"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Overlay: overlay})
// Same-named skill resolves to the overlay (upper wins).
if c := readFile(t, got, "lark-a/SKILL.md"); c != "overlaid a" {
t.Errorf("lark-a content = %q, want overlaid", c)
}
// Replacement is whole-skill: the base's reference file is shadowed, not merged.
if _, err := fs.Stat(got, "lark-a/references/x.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("base reference should be shadowed by overlay skill; err = %v, want ErrNotExist", err)
}
}
func TestResolve_Base_ReplacesEntireTree(t *testing.T) {
replacement := skillFS(map[string]string{"lark-only/SKILL.md": "only"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Base: replacement})
gotNames := topLevel(t, got)
if want := []string{"lark-only"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
if _, err := fs.Stat(got, "lark-a/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("base skill should be gone after Base replace; err = %v", err)
}
}
func TestResolve_Base_WithRemoveAndOverlay(t *testing.T) {
replacement := skillFS(map[string]string{
"lark-p/SKILL.md": "p",
"lark-q/SKILL.md": "q",
})
overlay := skillFS(map[string]string{"lark-r/SKILL.md": "r"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Base: replacement,
Remove: []string{"lark-q"},
Overlay: overlay,
})
gotNames := topLevel(t, got)
if want := []string{"lark-p", "lark-r"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
}
// Allow keeps only the listed base skills — the allow-list counterpart
// of Rule.Allow, so a CLI upgrade adding new embedded skills cannot
// widen an allow-listed build.
func TestResolve_Allow_KeepsOnlyListed(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Allow: []string{"lark-a"}})
gotNames := topLevel(t, got)
if want := []string{"lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
if _, err := fs.Stat(got, "lark-b/SKILL.md"); !errors.Is(err, fs.ErrNotExist) {
t.Errorf("non-allow-listed skill must be absent; err = %v", err)
}
// Kept skills still read from base, references included.
if c := readFile(t, got, "lark-a/references/x.md"); c != "base a ref" {
t.Errorf("kept skill content = %q, want base content", c)
}
}
// Remove wins over Allow, mirroring Rule's Deny-over-Allow.
func TestResolve_RemoveWinsOverAllow(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Allow: []string{"lark-a", "lark-b"},
Remove: []string{"lark-b"},
})
gotNames := topLevel(t, got)
if want := []string{"lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
}
// Overlay entries are exempt from Allow: content the integrator
// explicitly ships needs no allow-listing.
func TestResolve_OverlayExemptFromAllow(t *testing.T) {
overlay := skillFS(map[string]string{"acme-guide/SKILL.md": "mine"})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Allow: []string{"lark-a"},
Overlay: overlay,
})
gotNames := topLevel(t, got)
if want := []string{"acme-guide", "lark-a"}; !slices.Equal(gotNames, want) {
t.Errorf("top level = %v, want %v", gotNames, want)
}
}
// An Allow name absent from the base aborts startup, same as Remove.
func TestResolve_AllowUnknown_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-nope"}}}})
if err == nil {
t.Fatal("expected error allow-listing a skill absent from base")
}
}
func TestResolve_RemoveUnknown_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-nope"}}}})
if err == nil {
t.Fatal("expected error removing a skill absent from base")
}
}
func TestResolve_RemoveInvalidName_Errors(t *testing.T) {
for _, bad := range []string{"", ".", "..", "a/b", `a\b`} {
if _, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{bad}}}}); err == nil {
t.Errorf("Remove %q: expected error, got nil", bad)
}
}
}
func TestResolve_OverlayMissingSKILLmd_Errors(t *testing.T) {
overlay := skillFS(map[string]string{"lark-bad/other.md": "no skill.md here"})
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
if err == nil {
t.Fatal("expected error for overlay entry missing SKILL.md")
}
}
func TestResolve_OverlayNonDirEntry_Errors(t *testing.T) {
overlay := skillFS(map[string]string{"loose.md": "top-level file, not a skill dir"})
_, err := Resolve(baseTree(), []PluginSkill{{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay}}})
if err == nil {
t.Fatal("expected error for non-directory overlay entry")
}
}
func TestResolve_TwoOwners_Errors(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{
{PluginName: "acme", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-a"}}},
{PluginName: "globex", SkillsOverlay: &platform.SkillsOverlay{Remove: []string{"lark-b"}}},
})
if !errors.Is(err, ErrMultipleSkillsOverlays) {
t.Fatalf("err = %v, want ErrMultipleSkillsOverlays", err)
}
}
// TestResolve_ComposedConformsToFS runs the composed tree through the
// standard io/fs conformance checker to catch Open/ReadDir/Stat drift.
func TestResolve_ComposedConformsToFS(t *testing.T) {
overlay := skillFS(map[string]string{
"lark-new/SKILL.md": "new",
"lark-new/references/y.md": "new ref",
})
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{
Remove: []string{"lark-shared"},
Overlay: overlay,
})
if err := fstest.TestFS(got,
"lark-a/SKILL.md",
"lark-a/references/x.md",
"lark-b/SKILL.md",
"lark-new/SKILL.md",
"lark-new/references/y.md",
); err != nil {
t.Fatalf("composed FS failed fs conformance: %v", err)
}
}
// brokenFS fails every Open, standing in for an integrator Base that points at
// a missing or unreadable tree.
type brokenFS struct{}
func (brokenFS) Open(string) (fs.File, error) { return nil, fs.ErrNotExist }
// A custom Base whose root cannot be read must fail at Resolve (startup), not
// surface later as an empty `skills list`.
func TestResolve_unreadableBaseFailsFast(t *testing.T) {
_, err := Resolve(baseTree(), []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Base: brokenFS{}},
}})
if err == nil || !strings.Contains(err.Error(), "Base") {
t.Fatalf("unreadable Base must fail fast with a named error, got %v", err)
}
}
// Overlay top-level directories are skill names and must pass the same name
// rule as Allow/Remove; a name the reader can never serve (backslash and
// friends) fails composition instead of being listed but unreadable.
func TestResolve_overlayInvalidSkillNameFailsFast(t *testing.T) {
overlay := skillFS(map[string]string{`bad\name/SKILL.md`: "x"})
_, err := Resolve(baseTree(), []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Overlay: overlay},
}})
if err == nil || !strings.Contains(err.Error(), "not a valid skill name") {
t.Fatalf("invalid overlay skill name must fail fast, got %v", err)
}
}
// The allow-list is an access-time gate, not a resolve-time snapshot: a
// skill added to the base FS after composition must stay invisible to both
// list and read unless allow-listed.
func TestResolve_allowGatesSkillsAddedAfterResolve(t *testing.T) {
base := baseTree()
got, err := Resolve(base, []PluginSkill{{
PluginName: "acme",
SkillsOverlay: &platform.SkillsOverlay{Allow: []string{"lark-a"}},
}})
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if want := []string{"lark-a"}; !slices.Equal(topLevel(t, got), want) {
t.Fatalf("top level = %v, want %v", topLevel(t, got), want)
}
// Mutate the base afterwards, as a CLI upgrade (or a misbehaving
// integrator FS) would.
base["lark-late/SKILL.md"] = &fstest.MapFile{Data: []byte("late")}
if names := topLevel(t, got); !slices.Equal(names, []string{"lark-a"}) {
t.Errorf("late-added skill leaked into the listing: %v", names)
}
if _, err := fs.ReadFile(got, "lark-late/SKILL.md"); err == nil {
t.Error("late-added skill must not be readable through the allow gate")
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.67",
"version": "1.0.66",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application provides shortcuts for Open Platform app
// self-management (slash commands of the current bound app).
package application
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all shortcuts of the application domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlashCommandList,
SlashCommandCreate,
SlashCommandUpdate,
SlashCommandDelete,
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
)
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// clientCacheHint is printed to stderr after every successful write.
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
func parseDescriptionI18n(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
m := make(map[string]string, len(values))
for _, v := range values {
idx := strings.Index(v, "=")
if idx <= 0 || idx == len(v)-1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
WithParam("--description-i18n")
}
lang := strings.TrimSpace(v[:idx])
text := v[idx+1:]
if lang == "" || strings.TrimSpace(text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: language and text must be non-empty", v).
WithParam("--description-i18n")
}
if _, dup := m[lang]; dup {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"duplicate language %q in --description-i18n", lang).
WithParam("--description-i18n")
}
m[lang] = text
}
return m, nil
}
// validateCommandName rejects empty and slash-prefixed command names.
func validateCommandName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not be empty", flagName).WithParam(flagName)
}
if strings.HasPrefix(trimmed, "/") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not start with \"/\" - the slash is implied (use %q)",
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
}
return nil
}
// buildSlashCommandBody assembles a create/update request body. Only provided
// fields are included: PATCH is field-level partial (absent top-level fields
// are preserved server-side; a provided i18n map REPLACES the whole map).
// icon sits at the top level, sibling of description (verified live; the
// official create sample nesting icon inside description is a doc bug).
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
body := map[string]interface{}{}
if command != "" {
body["command"] = command
}
if description != "" || len(i18n) > 0 {
desc := map[string]interface{}{}
if description != "" {
desc["default_value"] = description
}
if len(i18n) > 0 {
desc["i18n"] = i18n
}
body["description"] = desc
}
if iconKey != "" {
body["icon"] = map[string]interface{}{"icon_key": iconKey}
}
return body
}
// isCommandExists reports whether err is the server-side name-collision error
// (code=40000000, message contains "command already exists"; verified live).
func isCommandExists(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return strings.Contains(p.Message, "command already exists")
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDescriptionI18n_OK(t *testing.T) {
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["zh_cn"] != "你好" {
t.Errorf("zh_cn = %q", m["zh_cn"])
}
// 只按首个 = 分割:值内可含 =
if m["en_us"] != "Hello=World" {
t.Errorf("en_us = %q", m["en_us"])
}
}
func TestParseDescriptionI18n_Empty(t *testing.T) {
m, err := parseDescriptionI18n(nil)
if err != nil || m != nil {
t.Fatalf("nil input: m=%v err=%v", m, err)
}
}
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
_, err := parseDescriptionI18n([]string{bad})
if err == nil {
t.Errorf("%q: expected error", bad)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%q: expected validation problem, got %v", bad, err)
}
}
}
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
if err == nil || !strings.Contains(err.Error(), "duplicate language") {
t.Fatalf("expected duplicate language error, got %v", err)
}
}
func TestValidateCommandName(t *testing.T) {
if err := validateCommandName("greet", "--command"); err != nil {
t.Fatalf("greet: %v", err)
}
for _, bad := range []string{"", " ", "/greet"} {
if err := validateCommandName(bad, "--command"); err == nil {
t.Errorf("%q: expected error", bad)
}
}
}
func TestBuildSlashCommandBody(t *testing.T) {
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
if body["command"] != "greet" {
t.Errorf("command = %v", body["command"])
}
desc := body["description"].(map[string]interface{})
if desc["default_value"] != "hi" {
t.Errorf("default_value = %v", desc["default_value"])
}
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
t.Errorf("i18n = %v", desc["i18n"])
}
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
t.Errorf("icon = %v", body["icon"])
}
// partial不提供的字段不出现PATCH 语义依赖)
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
if _, has := partial["command"]; has {
t.Error("empty command must be omitted")
}
if _, has := partial["description"]; has {
t.Error("empty description must be omitted")
}
}
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
// reversal of the OAuth-isolation design: all four slash-command shortcuts
// declare identical scopes for the bot and user identities (plain Scopes /
// ConditionalScopes, no per-identity overrides), so a user-identity
// pre-flight sees the same scope set a bot identity would.
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
wantScope string
wantConditional string
hasConditional bool
}{
{
name: "list",
shortcut: SlashCommandList,
wantScope: "application:app_slash_command:read",
},
{
name: "create",
shortcut: SlashCommandCreate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "update",
shortcut: SlashCommandUpdate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "delete",
shortcut: SlashCommandDelete,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, identity := range []string{"user", "bot"} {
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
if !containsStr(declared, tc.wantScope) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
}
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
}
}
})
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandCreate registers a new slash command on the current bound app.
var SlashCommandCreate = common.Shortcut{
Service: "application",
Command: "+slash-command-create",
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and PATCH it instead (like `gh label create --force`)"},
},
Tips: []string{
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
return err
}
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description must not be blank").WithParam("--description")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody(runtime.Str("command"), runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI().
Desc("Create a slash command on the current bound app").
POST(slashCommandBasePath).
Body(body)
if runtime.Bool("force") {
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
name := runtime.Str("command")
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
action := "created"
if err != nil {
if !isCommandExists(err) {
return err
}
if !runtime.Bool("force") {
p, _ := errs.ProblemOf(err)
rewrapped := errs.NewAPIError(p.Subtype, "slash command %q already exists", name).
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
WithCause(err)
if p.Code != 0 {
rewrapped = rewrapped.WithCode(p.Code)
}
if p.LogID != "" {
rewrapped = rewrapped.WithLogID(p.LogID)
}
return rewrapped
}
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
id, _, rerr := resolveCommandID(runtime, name)
if rerr != nil {
return rerr
}
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, patchBody)
if err != nil {
return err
}
action = "updated"
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = action
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func createOKStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", "id-new"),
},
}
}
func createConflictStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
},
}
}
func patchOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/application/v7/app_slash_commands/" + id,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", id),
},
}
}
func TestSlashCommandCreate_OK(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createOKStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi",
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
if data["action"] != "created" {
t.Fatalf("action = %v", data["action"])
}
if data["command_id"] != "id-new" {
t.Fatalf("command_id = %v", data["command_id"])
}
}
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := [][]string{
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
}
for i, args := range cases {
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
if err == nil {
t.Errorf("case %d: expected validation error", i)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("case %d: expected validation problem, got %v", i, err)
}
}
}
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected conflict error")
}
p, _ := errs.ProblemOf(err)
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
}
if errors.Unwrap(apiErr) == nil {
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
}
}
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v (force must convert to update)", data["action"])
}
}
func createIconInvalidStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
},
}
}
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
// blindly treating ANY POST failure as a name collision: only the
// "command already exists" (40000000) shape may fall through to the
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
// the code mistakenly attempted a PATCH, the httpmock registry would fail
// the unexpected request and surface a different (registry) error instead
// of the original icon_key failure asserted below.
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createIconInvalidStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected the original icon_key error, got nil")
}
if !strings.Contains(err.Error(), "icon_key") {
t.Fatalf("expected original icon_key failure to surface unchanged, got %v", err)
}
}
func TestSlashCommandCreate_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
t.Fatalf("dry-run must show POST path: %s", out)
}
// icon 顶层dry-run body 里 icon 不嵌套在 description 内
if !strings.Contains(out, "icon_key") {
t.Fatalf("dry-run must include body: %s", out)
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandDelete removes a slash command (irreversible; command_id is not
// reused - recreating the same name yields a NEW id).
var SlashCommandDelete = common.Shortcut{
Service: "application",
Command: "+slash-command-delete",
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
Risk: "high-risk-write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
},
Tips: []string{
"lark-cli application +slash-command-delete --command greet --yes --as bot",
"deleted commands may linger in clients for ~5 minutes (client cache)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
return validateCommandName(name, "--command")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
target := runtime.Str("command-id")
if target == "" {
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command")))
target = "<resolved_command_id>"
}
return d.DELETE(slashCommandBasePath + "/" + target)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, name)
if err != nil {
return err
}
id = resolved
}
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, nil); err != nil {
return err
}
out := map[string]interface{}{"action": "deleted", "command_id": id}
if name != "" {
out["command"] = name
}
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted command_id %s\n", id)
})
return nil
},
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func deleteOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "DELETE",
URL: slashCommandBasePath + "/" + id,
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
}
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected confirmation_required without --yes")
}
if errs.CategoryOf(err) != errs.CategoryConfirmation {
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
}
}
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id1"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
// 上游 DELETE 返回空对象CLI 必须补 action/command_id写操作返回资源 ID
if data["action"] != "deleted" || data["command_id"] != "id1" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
reg.Register(deleteOKStub("id7"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
data := got["data"].(map[string]interface{})
if data["command"] != "greet" || data["command_id"] != "id7" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
// 两条 Desc 都必须保留top-level HIGH-RISK 说明和 GET 调用的 resolve 说明
// 不能被覆盖DryRunAPI.Desc 在没有 call 时设置 top-levelappend 后设置 per-call
if !strings.Contains(out, "HIGH-RISK") {
t.Fatalf("dry-run must keep top-level HIGH-RISK desc: %s", out)
}
if !strings.Contains(out, "resolve command_id") {
t.Fatalf("dry-run must keep per-call resolve desc: %s", out)
}
}
func TestSlashCommandDelete_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
for _, args := range [][]string{
{"+slash-command-delete", "--yes", "--as", "bot"},
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
} {
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
if err == nil {
t.Errorf("%v: expected validation error", args)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandList lists all slash commands of the current bound app.
var SlashCommandList = common.Shortcut{
Service: "application",
Command: "+slash-command-list",
Description: "List all slash commands (/ commands) registered on the current bound Open Platform app; source of command_id for update/delete (NOT for Miaoda apps - use the apps domain for those)",
Risk: "read",
Scopes: []string{"application:app_slash_command:read"},
AuthTypes: []string{"bot", "user"},
Tips: []string{
"lark-cli application +slash-command-list --as bot",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
"the upstream API returns all commands at once (max 100 per app, no pagination)",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List all slash commands of the current bound app (read-only)").
GET(slashCommandBasePath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
if items == nil {
items = []interface{}{}
}
out := map[string]interface{}{"items": items, "count": len(items)}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
desc := ""
if d, ok := m["description"].(map[string]interface{}); ok {
desc, _ = d["default_value"].(string)
}
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
}
})
return nil
},
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func appTestConfig() *core.CliConfig {
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
// Mirrors shortcuts/contact tests.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "application"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func listStub(items []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"items": items},
},
}
}
func sampleItem(name, id string) map[string]interface{} {
return map[string]interface{}{
"command": name, "command_id": id,
"create_time": "1783318553", "update_time": "1783318553",
"description": map[string]interface{}{"default_value": "desc of " + name},
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
}
}
func TestSlashCommandList_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
items := data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("items = %d", len(items))
}
if data["count"] != float64(2) {
t.Fatalf("count = %v", data["count"])
}
first := items[0].(map[string]interface{})
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
if _, ok := first[k]; !ok {
t.Errorf("missing item key %q", k)
}
}
}
func TestSlashCommandList_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
items, ok := data["items"].([]interface{})
if !ok || len(items) != 0 {
t.Fatalf("empty list must be [] not %v", data["items"])
}
if data["count"] != float64(0) {
t.Fatalf("count = %v", data["count"])
}
}
func TestSlashCommandList_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
t.Fatalf("dry-run must show GET path, got %s", out)
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// matchCommandItem finds the item whose "command" equals name (exact match -
// the server enforces name uniqueness, so first hit is the only hit).
func matchCommandItem(items []interface{}, name string) (string, map[string]interface{}) {
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
if m["command"] == name {
id, _ := m["command_id"].(string)
if id != "" {
return id, m
}
}
}
return "", nil
}
// commandNotFoundError reports a resolution miss against the live list as an
// API-category not-found error (the name is a valid argument shape; the
// resource simply does not exist server-side - this is not a validation
// failure of caller input).
func commandNotFoundError(name string) error {
return errs.NewAPIError(errs.SubtypeNotFound,
"slash command %q not found in the current bound app", name).
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
}
// resolveCommandID resolves a command name to its command_id via the live
// list endpoint (in-memory only; never touches local files). Requires the
// read scope on the current identity.
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, map[string]interface{}, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", nil, err
}
items, _ := data["items"].([]interface{})
id, item := matchCommandItem(items, name)
if id == "" {
return "", nil, commandNotFoundError(name)
}
return id, item, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
)
func TestMatchCommandItem(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id, item := matchCommandItem(items, "weather")
if id != "id2" || item == nil {
t.Fatalf("got id=%q item=%v", id, item)
}
id, item = matchCommandItem(items, "nope")
if id != "" || item != nil {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id, _ = matchCommandItem(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil || !strings.Contains(err.Error(), `"nope"`) {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("err should say not found: %v", err)
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
// least one editable field; --description-i18n requires --description (PATCH
// replaces the whole description object - sending i18n alone would drop
// default_value; conservative rule, see spec amendment #3).
func validateUpdateTarget(runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
if err := validateCommandName(name, "--command"); err != nil {
return err
}
}
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
hasI18n := len(runtime.StrArray("description-i18n")) > 0
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
if !hasDesc && !hasI18n && !hasIcon {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
}
if hasI18n && !hasDesc {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
}
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
var SlashCommandUpdate = common.Shortcut{
Service: "application",
Command: "+slash-command-update",
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
{Name: "description", Desc: "new default description (description.default_value)"},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
},
Tips: []string{
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
"PATCH is field-level partial: fields you do not pass are preserved server-side",
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateTarget(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := runtime.Str("command-id")
if target == "" {
d.Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command"))).
GET(slashCommandBasePath)
target = "<resolved_command_id>"
}
return d.PATCH(slashCommandBasePath + "/" + target).Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
if err != nil {
return err
}
id = resolved
}
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = "updated"
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestSlashCommandUpdate_ByID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id1"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v", data["action"])
}
}
func TestSlashCommandUpdate_ByName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
reg.Register(patchOKStub("id9"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found, got %v", err)
}
}
func TestSlashCommandUpdate_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := []struct {
name string
args []string
}{
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
}
for _, c := range cases {
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
if err == nil {
t.Errorf("%s: expected validation error", c.name)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}

View File

@@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{
{Name: "until", Desc: "filter: event at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -145,10 +145,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
existing := map[string]bool{}
token := ""
for {
params := map[string]interface{}{"page_size": 100}
if env != "" {
params["env"] = env
}
params := map[string]interface{}{"env": env, "page_size": 100}
if token != "" {
params["page_token"] = token
}
@@ -171,11 +168,7 @@ func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[st
// fetchAuditEnabledTables 拉审计状态返回当前已开启审计的表名集合status 命令同源接口)。
func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) {
statusParams := map[string]interface{}{}
if env != "" {
statusParams["env"] = env
}
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), statusParams, nil)
data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil)
if err != nil {
return nil, err
}
@@ -215,10 +208,11 @@ func auditListTables(rctx *common.RuntimeContext) []string {
// buildAuditListParams 组装 audit_list 查询参数env / tables(逗号拼接) / page_size 及可选 since/until/page_token。
func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"tables": strings.Join(tables, ","),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to enable audit for", Required: true},
{Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -47,7 +47,7 @@ var AppsDBAuditEnable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Enable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -60,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{
stop := rctx.StartSpinner("Enabling audit logging for " + table)
defer stop()
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": true, "retention": retention})
stop()
if err != nil {
@@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "table to disable audit for", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -108,7 +108,7 @@ var AppsDBAuditDisable = common.Shortcut{
return common.NewDryRunAPI().
POST(appAuditSetPath(appID)).
Desc("Disable table audit").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Params(map[string]interface{}{"env": dbEnv(rctx)}).
Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -118,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{
}
table := strings.TrimSpace(rctx.Str("table"))
data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID),
dbEnvParams(rctx, map[string]interface{}{}),
map[string]interface{}{"env": dbEnv(rctx)},
map[string]interface{}{"table": table, "enabled": false})
if err != nil {
return withAppsHint(err, dbAuditSetHint)

View File

@@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "table", Desc: "show status for a single table (default: all configured tables)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{
// buildAuditStatusParams 组装 audit_status 查询参数env 及可选 table单表查询
func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if t := strings.TrimSpace(rctx.Str("table")); t != "" {
params["table"] = t
}

View File

@@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{
{Name: "until", Desc: "filter: changed at or before; same formats as --since"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
{Name: "page-token", Desc: "pagination cursor from previous response"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -77,9 +77,10 @@ var AppsDBChangelogList = common.Shortcut{
// buildChangelogParams 组装 changelog_list 查询参数env / page_size 及可选 table/change_id/since/until/page_token。
func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{
params := map[string]interface{}{
"env": dbEnv(rctx),
"page_size": rctx.Int("page-size"),
})
}
addStr := func(flag, key string) {
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
params[key] = v

View File

@@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{
{Name: "table", Desc: "source table", Required: true},
{Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default: <table>.csv)"},
{Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"},
}, dbEnvFlags("", []string{"dev", "online"}, "source db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -75,10 +75,10 @@ var AppsDBDataExport = common.Shortcut{
return common.NewDryRunAPI().
GET(appDataExportPath(appID)).
Desc("Export Miaoda app table data (raw bytes)").
Params(dbEnvParams(rctx, map[string]interface{}{
"table": strings.TrimSpace(rctx.Str("table")),
Params(map[string]interface{}{
"env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")),
"format": format, "limit": rctx.Int("limit"),
}))
})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
@@ -95,18 +95,15 @@ var AppsDBDataExport = common.Shortcut{
// total 查询失败不阻断导出——回退到按导出文件内容数行。
total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table)
exportQuery := larkcore.QueryParams{
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
}
if env := dbEnv(rctx); env != "" {
exportQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: exportQuery,
HttpMethod: http.MethodGet,
ApiPath: appDataExportPath(appID),
QueryParams: larkcore.QueryParams{
"env": []string{dbEnv(rctx)},
"table": []string{table},
"format": []string{format},
"limit": []string{strconv.Itoa(rctx.Int("limit"))},
},
})
if err != nil {
return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint)
@@ -160,11 +157,8 @@ var AppsDBDataExport = common.Shortcut{
// queryExportTotal 调 GetAppTableRecordListpage_size=1取 total符合条件的记录总数
// 该接口与 +db-data-export 同为 spark:app:read scope避免导出命令被迫升级到写权限。
func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) {
params := map[string]interface{}{"page_size": 1}
if env != "" {
params["env"] = env
}
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), params, nil)
raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table),
map[string]interface{}{"env": env, "page_size": 1}, nil)
if err != nil {
return 0, err
}

View File

@@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true},
{Name: "table", Desc: "target table (default: file name without extension)"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -76,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{
return common.NewDryRunAPI().
POST(appDataImportPath(appID)).
Desc("Import data file into Miaoda app table (multipart upload)").
Params(dbEnvParams(rctx, map[string]interface{}{"table": importTableName(rctx)})).
Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}).
Body(map[string]interface{}{"file_name": fileName, "file": "<contents of --file>"})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -100,14 +100,10 @@ var AppsDBDataImport = common.Shortcut{
fd.AddField("file_name", fileName)
fd.AddFile("file", bytes.NewReader(content))
importQuery := larkcore.QueryParams{"table": []string{table}}
if env := dbEnv(rctx); env != "" {
importQuery["env"] = []string{env}
}
resp, err := rctx.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: appDataImportPath(appID),
QueryParams: importQuery,
QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}},
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {

View File

@@ -121,31 +121,6 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) {
}
}
// TestAppsDBDataImport_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 dry-run 的 query
// 不带 env 键(交服务端按应用形态自动选分支),但仍携带 table。
func TestAppsDBDataImport_DryRunOmitsEnvWhenUnset(t *testing.T) {
chdirTemp(t)
_ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600)
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBDataImport,
[]string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
p := env.API[0].Params
if _, ok := p["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", p)
}
if p["table"] != "orders" {
t.Fatalf("table should still default to file basename, got params=%v", p)
}
}
// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。
func TestAppsDBDataImport_Success(t *testing.T) {
chdirTemp(t)

View File

@@ -97,16 +97,6 @@ var AppsDBEnvMigrate = common.Shortcut{
if err != nil {
return err
}
// 先 dry_run 预览拿待发布变更数(对齐 miaoda-cli 的 diff-then-apply服务端在未经
// dry_run 预热时直接 apply虽发布成功却把 changes_applied 回填成 0展示「Migrated (0 changes)」)。
// 这一步既预热服务端计数、又作为 apply 仍回 0 时的兜底数。dry_run 报错(如无待发布变更)不阻断,
// 交由下面真实 apply 统一报同样的业务错。
pending := 0
var previewFrom, previewTo string
if preview, perr := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}); perr == nil {
pending = len(projectMigrationChanges(preview["changes"]))
previewFrom, previewTo = common.GetString(preview, "from"), common.GetString(preview, "to")
}
stop := rctx.StartSpinner("Applying migration (dev → online)")
defer stop()
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
@@ -114,12 +104,6 @@ var AppsDBEnvMigrate = common.Shortcut{
return withAppsHint(err, dbEnvMigrateHint)
}
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
if from == "" {
from = previewFrom
}
if to == "" {
to = previewTo
}
taskID := common.GetString(submit, "task_id")
applied := intFromAny(submit["changes_applied"])
if applied == 0 {
@@ -147,10 +131,6 @@ var AppsDBEnvMigrate = common.Shortcut{
applied = n
}
}
// 服务端把发布成功的变更数回 0 时,用发布前 dry_run 预览的 pending 数兜底,避免误显示「(0 changes)」。
if applied == 0 && pending > 0 {
applied = pending
}
stop() // clear spinner before printing the result
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
rctx.OutFormat(out, nil, func(w io.Writer) {

View File

@@ -105,10 +105,8 @@ func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) {
// 异步submit 返 task_idstatus 立刻 applied → CLI 对外统一 migrated。
func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -128,10 +126,8 @@ func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) {
// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。
func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
// ReusableExecute 现在会先打一次 dry_run 预览拿待发布数、再打 apply对齐 miaoda-cli 的
// diff-then-apply兜底服务端 apply 少报 changes_applied 的情况),故同一 POST 端点被调用两次。
reg.Register(&httpmock.Stub{
Method: "POST", URL: dbEnvMigrateURL, Reusable: true,
Method: "POST", URL: dbEnvMigrateURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}},
})
reg.Register(&httpmock.Stub{
@@ -323,31 +319,6 @@ func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) {
}
// 配额未对接storage_quota_bytes=0→ json 删 quota/usage_percent仅留已用量与 tables/views。
// TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset 验证不传 --environment 时 quota-get 的 dry-run
// query 不带 env 键(交服务端按应用形态自动选分支)。
func TestAppsDBQuotaGet_DryRunOmitsEnvWhenUnset(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsDBQuotaGet,
[]string{"+db-quota-get", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
var env struct {
API []struct {
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
_ = json.Unmarshal([]byte(stdout.String()), &env)
a := env.API[0]
if a.Method != "GET" || a.URL != dbQuotaURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env key must be omitted, got params=%v", a.Params)
}
}
func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{

View File

@@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{
{Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file",
Input: []string{common.Stdin}},
{Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -291,9 +291,10 @@ func parseErrorSentinel(data string) (int, string) {
//
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
return dbEnvParams(rctx, map[string]interface{}{
return map[string]interface{}{
"env": dbEnv(rctx),
"transactional": false,
})
}
}
// resolveExecuteSQL 返回要执行的 SQL在用时DryRun/Execute现读使 --file 的内容

View File

@@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{
HasFormat: true,
Flags: append([]common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -41,14 +41,14 @@ var AppsDBQuotaGet = common.Shortcut{
return common.NewDryRunAPI().
GET(appDbQuotaPath(appID)).
Desc("Get Miaoda app database storage usage").
Params(dbEnvParams(rctx, map[string]interface{}{}))
Params(map[string]interface{}{"env": dbEnv(rctx)})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}

View File

@@ -32,23 +32,19 @@ var AppsDBRecoveryDiff = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -85,23 +81,19 @@ var AppsDBRecoveryApply = common.Shortcut{
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: append([]common.Flag{
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
}
if err := rejectLegacyEnvFlag(rctx); err != nil {
return err
}
return normalizeTimeFlags(rctx, "target")
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
Params(dbEnvParams(rctx, map[string]interface{}{})).
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
@@ -112,7 +104,7 @@ var AppsDBRecoveryApply = common.Shortcut{
target := rctx.Str("target")
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": false})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
if err != nil {
return withAppsHint(err, dbRecoveryHint)
}
@@ -127,7 +119,7 @@ var AppsDBRecoveryApply = common.Shortcut{
}
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{}), nil)
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "status")) {
@@ -165,7 +157,7 @@ var AppsDBRecoveryApply = common.Shortcut{
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
defer stop()
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), dbEnvParams(rctx, map[string]interface{}{}), map[string]interface{}{"target": target, "dry_run": true})
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
if err != nil {
return nil, withAppsHint(err, dbRecoveryHint)
}
@@ -175,7 +167,7 @@ func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[
}
return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute,
func() (map[string]interface{}, error) {
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), dbEnvParams(rctx, map[string]interface{}{"preview_request_id": prid}), nil)
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
},
func(d map[string]interface{}) (bool, error) {
switch strings.ToLower(common.GetString(d, "preview_status")) {
@@ -203,13 +195,13 @@ type recoveryChange struct {
// recoveryDiffOutput 组装 diff 输出target / tables_affected / changes[] / estimated_seconds。
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
arr, _ := preview["changes"].([]interface{})
raw := make([]recoveryChange, 0, len(arr))
changes := make([]recoveryChange, 0, len(arr))
for _, it := range arr {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
raw = append(raw, recoveryChange{
changes = append(changes, recoveryChange{
Table: common.GetString(m, "table"),
Inserted: m["inserted"],
Deleted: m["deleted"],
@@ -217,33 +209,16 @@ func recoveryDiffOutput(target string, preview map[string]interface{}) map[strin
DroppedAt: common.GetString(m, "dropped_at"),
})
}
// 服务端可能对同一张表既下发 schema 动作(drop/restore/alter)、又下发纯数据行变更。
// schema 动作已涵盖数据结果(如 drop 隐含删光行),丢弃该表的冗余数据行那条,避免同表
// 两行 + tables_affected 翻倍。
hasSchema := map[string]bool{}
for _, c := range raw {
if c.Action != "" {
hasSchema[c.Table] = true
}
}
changes := make([]recoveryChange, 0, len(raw))
for _, c := range raw {
if c.Action == "" && hasSchema[c.Table] {
continue
}
changes = append(changes, c)
}
// tables_affected 按去重后的不同表数计(而非变更条数)。
seen := map[string]bool{}
for _, c := range changes {
seen[c.Table] = true
tablesAffected := intFromAny(preview["tables_affected"])
if tablesAffected == 0 {
tablesAffected = len(changes)
}
est := intFromAny(preview["estimated_seconds"])
if est == 0 {
est = 30 // PRD 兜底
}
return map[string]interface{}{
"target": target, "tables_affected": len(seen),
"target": target, "tables_affected": tablesAffected,
"changes": changes, "estimated_seconds": est,
}
}

View File

@@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{
Flags: append([]common.Flag{
{Name: "app-id", Desc: "app id", Required: true},
{Name: "table", Desc: "table name", Required: true},
}, dbEnvFlags("", []string{"dev", "online"}, "target db environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online")...),
}, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...),
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
return err
@@ -80,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{
// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl要求返 CREATE 语句文本;
// 其他 format含默认 json不传该参数让 server 返默认结构化字段。
func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} {
params := dbEnvParams(rctx, map[string]interface{}{})
params := map[string]interface{}{"env": dbEnv(rctx)}
if rctx.Format == "pretty" {
params["format"] = "ddl"
}

Some files were not shown because too many files have changed in this diff Show More