Compare commits

..

1 Commits

Author SHA1 Message Date
zhangjun.1
2a9fcaeed5 event description support rich text 2026-07-08 21:13:24 +08:00
155 changed files with 693 additions and 8128 deletions

View File

@@ -263,19 +263,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Run dry-run E2E tests
env:
@@ -283,28 +277,7 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression'
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
@@ -319,22 +292,15 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -344,24 +310,16 @@ jobs:
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$')
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests

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

@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta

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

@@ -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,109 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"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"}
gated = true
}
if gated {
installFlagGateRejection(root)
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// 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("plugin skill customization is broken (reason_code %s); fix the plugin's SkillsOverlay or remove the conflicting plugin", 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,169 +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"
)
// 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)
} else {
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
}
}
diag := map[string]cmdpolicy.Denial{}
for _, path := range cmdpolicy.DiagnosticPaths() {
diag[path] = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: source,
ReasonCode: "diagnostics_hidden",
Reason: "policy self-inspection hidden by the integrator",
DeniedMessage: message,
}
}
cmdpolicy.Apply(rootCmd, 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
}
}
cmdpolicy.Apply(rootCmd, map[string]cmdpolicy.Denial{
cmdpolicy.CanonicalPath(group): {
Layer: cmdpolicy.LayerPolicy,
PolicySource: sample.PolicySource,
RuleName: sample.RuleName,
ReasonCode: "all_children_denied",
Reason: "all child commands are denied",
DeniedMessage: sample.DeniedMessage,
},
})
}
}
// 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,397 +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))
}
// Setting the flag (as cobra's parse would) must be rejected as unknown.
if err := root.PersistentFlags().Set("profile", "prod"); err != nil {
t.Fatalf("Set: %v", err)
}
err := rejectGatedFlags(root)
var ve *errs.ValidationError
if !errors.As(err, &ve) || !strings.Contains(ve.Message, "unknown flag") {
t.Errorf("setting a gated flag must fail as unknown flag, 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")
}
}
// 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)
}
}

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,
@@ -669,80 +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.",
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); the read annotation is defensive in case a future
// pass re-evaluates the finished tree.
cmdutil.SetRisk(helpCmd, "read")
cmdutil.DisableAuthCheck(helpCmd)
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 }()
}
@@ -750,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

@@ -14,13 +14,11 @@ import (
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
@@ -105,11 +103,6 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
}
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true
@@ -120,11 +113,7 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
}
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
@@ -132,29 +121,6 @@ func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Facto
return rootCmd
}
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
t.Setenv(envvars.CliAppID, "")
@@ -389,11 +355,10 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run",
})
if code != output.ExitValidation {

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,9 +216,6 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
r.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

@@ -35,18 +35,4 @@ type Registrar interface {
// Plugin rules take precedence over the yaml source; two distinct
// plugins both calling Restrict abort startup.
Restrict(r *Rule)
// Skills contributes a SkillsOverlay customizing the CLI's embedded skill
// content (see SkillsOverlay). Skill content has a single owner: a second
// customizing plugin, or a SkillsOverlay that cannot compose, aborts
// startup unconditionally. A malformed call inside one plugin --
// EmbeddedSkills(nil) or a second EmbeddedSkills() -- is a staging fault handled like
// any Install error (FailClosed aborts, FailOpen skips); the Builder
// rejects it earlier, at MustBuild.
//
// 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.
EmbeddedSkills(spec *SkillsOverlay)
}

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 (
@@ -84,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,8 +4,6 @@
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -75,10 +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"
// 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
@@ -130,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)
@@ -231,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

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

@@ -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,79 +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 {
r.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 {
r.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
r.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,47 +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
}
// 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

@@ -1,209 +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
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 []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
}
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
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
}
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,156 +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, effectiveRemove(lower, spec)), nil
}
// effectiveRemove folds a non-empty Allow into the remove set: every
// base skill not allow-listed is treated as removed, and explicit
// Remove entries win regardless (Remove-over-Allow, mirroring Rule's
// Deny-over-Allow). Overlay entries are never folded in — the overlay
// FS resolves them from the upper layer before the whiteout applies.
func effectiveRemove(lower fs.FS, spec *platform.SkillsOverlay) []string {
if len(spec.Allow) == 0 {
return spec.Remove
}
allowed := map[string]bool{}
for _, name := range spec.Allow {
allowed[name] = true
}
removed := append([]string(nil), spec.Remove...)
entries, err := fs.ReadDir(lower, ".")
if err != nil {
return removed // validate already vetted lower; nothing more to fold
}
for _, e := range entries {
if e.IsDir() && !allowed[e.Name()] {
removed = append(removed, e.Name())
}
}
return removed
}
// 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 {
for _, name := range spec.Allow {
if !isSkillName(name) {
return fmt.Errorf("Allow: %q is not a valid skill name", name)
}
if !skillExists(lower, name) {
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)
}
if !skillExists(lower, name) {
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 !skillExists(spec.Overlay, e.Name()) {
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 {
if fsys == nil {
return false
}
info, err := fs.Stat(fsys, name+"/SKILL.md")
return err == nil && !info.IsDir()
}
// 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,272 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package skillpolicy
import (
"errors"
"io/fs"
"sort"
"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)
}
if want := []string{"lark-a", "lark-b", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), want)
}
}
func TestResolve_Remove_HidesSkill(t *testing.T) {
got := mustResolve(t, baseTree(), &platform.SkillsOverlay{Remove: []string{"lark-shared"}})
if want := []string{"lark-a", "lark-b"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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})
if want := []string{"lark-a", "lark-b", "lark-new", "lark-shared"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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})
if want := []string{"lark-only"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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,
})
if want := []string{"lark-p", "lark-r"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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"}})
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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"},
})
if want := []string{"lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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,
})
if want := []string{"acme-guide", "lark-a"}; !equalStrings(topLevel(t, got), want) {
t.Errorf("top level = %v, want %v", topLevel(t, got), 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)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

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

@@ -215,73 +215,6 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -304,23 +237,13 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi

View File

@@ -1,54 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

View File

@@ -1,71 +0,0 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

View File

@@ -1,224 +0,0 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,7 +4,6 @@
const fs = require("node:fs/promises");
const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================
// Constants & Configuration
@@ -36,6 +35,33 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = {
@@ -259,7 +285,13 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) {
return labelDomainsForPath(filePath)[0] || "";
const normalized = normalizePath(filePath);
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
}
async function detectNewShortcutDomain(files) {

View File

@@ -8,17 +8,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp"
git_init() {

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

View File

@@ -8,7 +8,6 @@ import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
@@ -43,7 +42,7 @@ var AppsDBTableList = common.Shortcut{
{Name: "app-id", Desc: "app id", Required: true},
{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
@@ -111,9 +110,10 @@ func projectTableListItems(raw interface{}) []dbTableListItem {
}
func buildDBTableListParams(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"),
})
}
if token := strings.TrimSpace(rctx.Str("page-token")); token != "" {
params["page_token"] = token
}
@@ -286,17 +286,6 @@ func numericAsFloat(raw interface{}) (float64, bool) {
return 0, false
}
return f, true
case string:
// 服务端有些数值字段(如 recovery diff 的 inserted/deleted 行数)以字符串下发。
s := strings.TrimSpace(v)
if s == "" {
return 0, false
}
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return f, true
case nil:
return 0, false
}

View File

@@ -236,11 +236,7 @@ func TestNumericAsFloat_AllTypes(t *testing.T) {
{"json.Number valid", json.Number("13.5"), 13.5, true},
{"json.Number invalid", json.Number("abc"), 0, false},
{"nil", nil, 0, false},
{"non-numeric string", "x", 0, false},
{"numeric string", "13.5", 13.5, true},
{"numeric string int", "2", 2, true},
{"numeric string padded", " 13.5 ", 13.5, true},
{"empty string", "", 0, false},
{"unsupported string", "x", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {

View File

@@ -34,16 +34,6 @@ func dbEnv(rctx *common.RuntimeContext) string {
return rctx.Str("environment")
}
// dbEnvParams 把 env 并入 params仅当显式指定了环境非空才带 env 键;未指定(空)时
// 省略该键由服务端按应用多环境状态自动选分支多环境→dev单环境→online。与家族对
// 空可选参数的 omit-empty 约定一致——不发空串wire 上真正不带 env。原样返回同一个 map 便于链式。
func dbEnvParams(rctx *common.RuntimeContext, params map[string]interface{}) map[string]interface{} {
if env := dbEnv(rctx); env != "" {
params["env"] = env
}
return params
}
// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env显式传了就报清晰的 validation 错,指向 --environment。
func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error {
if rctx.Changed("env") {

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "test"}
s.Mount(parent, &cmdutil.Factory{})
cmd, _, err := parent.Find([]string{name})
if err != nil {
t.Fatalf("Find(%s) error = %v", name, err)
}
return cmd
}
// record-list 获得 --json 简写
func TestRecordListRegistersJSONShorthand(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("+record-list missing --json shorthand")
}
if fl.Usage != "shorthand for --format json" {
t.Errorf("usage = %q, want shorthand", fl.Usage)
}
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
t.Errorf("format default = %q, want markdown (unchanged)", def)
}
}
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
for _, tc := range []struct {
name string
shortcut common.Shortcut
cmdName string
}{
{"record-search", BaseRecordSearch, "+record-search"},
{"record-get", BaseRecordGet, "+record-get"},
} {
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatalf("%s: --json (request body) missing", tc.name)
}
if strings.Contains(fl.Usage, "shorthand") {
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
}
if fl.Value.Type() != "string" {
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
}
}
}
// Enum 已接入help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)"
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
usage := cmd.Flags().Lookup("format").Usage
if !strings.Contains(usage, "(markdown|json)") {
t.Fatalf("format usage missing enum suffix: %q", usage)
}
}

View File

@@ -85,7 +85,6 @@ func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Enum: []string{"markdown", "json"},
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -33,6 +33,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if descriptionRich := runtime.Str("description-rich"); descriptionRich != "" {
eventData["description_rich"] = descriptionRich
}
return eventData
}
@@ -99,7 +102,8 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description (plain text)"},
{Name: "description-rich", Desc: "rich-text description as Markdown (@file or - for stdin); supports bold/italic/underline/strikethrough, links, and lists", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},

View File

@@ -29,7 +29,8 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description (plain text)"},
{Name: "description-rich", Desc: "rich-text description as Markdown (@file or - for stdin); supports bold/italic/underline/strikethrough, links, and lists", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -70,7 +71,7 @@ func validateCalendarUpdate(runtime *common.RuntimeContext) error {
return err
}
if !hasCalendarUpdateOperation(runtime) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "nothing to update: specify at least one of --summary, --description, --description-rich, --start/--end, --rrule, --add-attendee-ids, or --remove-attendee-ids")
}
return nil
}
@@ -114,6 +115,10 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
hasFields = true
}
}
if runtime.Cmd.Flags().Changed("description-rich") {
body["description_rich"] = runtime.Str("description-rich")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
if rrule != "" {
@@ -353,6 +358,9 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if description, _ := event["description"].(string); description != "" {
result["description"] = description
}
if descriptionRich, _ := event["description_rich"].(string); descriptionRich != "" {
result["description_rich"] = descriptionRich
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start
}

View File

@@ -889,7 +889,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
@@ -1027,7 +1026,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
@@ -1173,75 +1171,6 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
}
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
// pretty-print switch or base +record-search's request-body payload).
// Framework-injected flags never appear in s.Flags, so this cleanly separates
// "self-declared json" from "injected shorthand".
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "json" {
return true
}
}
return false
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
// 1. the command has a format flag (self-declared or framework-injected), AND
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
// 3. no flag named "json" is registered yet — pflag panics on duplicate
// registration, and commands that declare their own --json (event
// +subscribe, base +record-search/-get) keep their custom semantics.
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// A shortcut that declares its own "json" flag defines custom semantics
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("format") == nil {
return
}
if !shortcutFormatSupportsJSON(s) {
return
}
// Safety net: pflag panics on duplicate registration.
if cmd.Flags().Lookup("json") != nil {
return
}
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
// applyJSONShorthand folds the injected --json shorthand into the format flag
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
// "json". An explicitly passed --format always wins over the shorthand (the
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
}
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc
@@ -1305,8 +1234,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
ensureJSONShorthand(cmd, s)
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}

View File

@@ -1,200 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
const jsonShorthandUsage = "shorthand for --format json"
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
s.Mount(parent, f)
cmd, _, err := parent.Find([]string{s.Command})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
return cmd
}
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
// 默认输出格式不被改变
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
t.Errorf("format default = %q, want table", def)
}
}
// 自定义 format 但 Enum 不含 json → 不注册
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+no-json", Description: "x",
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format Enum lacks json")
}
}
// 自定义 format 但无 Enum现状 triage 形态)→ 不注册Enum 是判定依据)
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+legacy", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format has no Enum metadata")
}
}
// 自声明 json flagsubscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("self-declared --json missing")
}
if fl.Usage != "pretty-print JSON instead of NDJSON" {
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
}
}
// parseMounted mounts the shortcut and parses args against the command's FlagSet
// (registration side effects included), without executing RunE.
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
t.Helper()
cmd := mountTestShortcut(t, s)
if err := cmd.ParseFlags(args); err != nil {
t.Fatalf("ParseFlags(%v) error = %v", args, err)
}
return cmd
}
func customFormatShortcut() Shortcut {
return Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
}
// --json 单独使用 → format 归一化为 json
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 显式 --format 优先于 --json 简写:--format table --json → table
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (explicit --format must win)", got)
}
}
// --format json --json → json一致无冲突
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 均不传 → 默认值不变
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, nil)
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (default untouched)", got)
}
}
// 自声明 string 型 --jsonrecord-search 形态format+json 双声明)→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "base", Command: "+fake-record-search", Description: "x",
Flags: []Flag{
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
{Name: "json", Desc: "request body JSON object"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
}
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
t.Fatalf("request-body --json corrupted: %q", got)
}
}
// 自声明 bool 型 --jsonsubscribe 形态:无自定义 format框架注入 format→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
// 注入的 format 默认即 json这里断言的是 Changed 状态未被归一化污染
if cmd.Flags().Changed("format") {
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
}
}
// 无自定义 format普通命令→ 注入默认 format + 简写(现状回归)
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "im", Command: "+plain", Description: "x",
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json missing on default-format shortcut (regression)")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
}

View File

@@ -150,10 +150,12 @@ var ContactSearchUser = common.Shortcut{
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)

View File

@@ -27,17 +27,12 @@ const (
html5BlockDataAttr = "data"
html5BlockReferenceRoot = "doc-fetch-resources"
html5BlockReferenceMaxRaw = 1024
whiteboardTag = "whiteboard"
whiteboardTypeAttr = "type"
whiteboardPathAttr = "path"
)
var (
html5BlockStartTagPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>`)
html5BlockElementPattern = regexp.MustCompile(`(?is)<html5-block\b[^>]*>(.*?)</html5-block>`)
html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
whiteboardElementPattern = regexp.MustCompile(`(?is)<whiteboard\b[^>]*(?:/>|>.*?</whiteboard>)`)
)
type html5BlockReferenceEntry struct {
@@ -63,11 +58,6 @@ type html5BlockStartTag struct {
SelfClosing bool
}
type whiteboardStartTag struct {
Attrs []html5BlockAttr
SelfClosing bool
}
func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) {
body := buildCreateBody(runtime)
if runtime.Str("content") == "" && !runtime.Changed("reference-map") {
@@ -125,11 +115,7 @@ func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteIn
return docsV2WriteInput{}, err
}
content, err := prepareWhiteboardWriteContent(runtime, runtime.Str("doc-format"), input.Content)
if err != nil {
return docsV2WriteInput{}, err
}
content, html5RefMap, err = prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), content, html5RefMap)
content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap)
if err != nil {
return docsV2WriteInput{}, err
}
@@ -246,248 +232,6 @@ func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string
return out, compactReferenceMap(refMap), nil
}
func prepareWhiteboardWriteContent(runtime *common.RuntimeContext, format string, content string) (string, error) {
if !strings.Contains(content, "<whiteboard") {
return content, nil
}
rewrite := func(segment string) (string, error) {
return rewriteWhiteboardFileRefs(runtime, segment)
}
if strings.TrimSpace(format) != "markdown" {
return rewrite(content)
}
var rewriteErrs []error
out := applyOutsideCodeFences(content, func(segment string) string {
outSegment, rewriteErr := rewrite(segment)
if rewriteErr != nil {
rewriteErrs = append(rewriteErrs, rewriteErr)
return segment
}
return outSegment
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRefs(runtime *common.RuntimeContext, content string) (string, error) {
var rewriteErrs []error
out := whiteboardElementPattern.ReplaceAllStringFunc(content, func(raw string) string {
rewritten, err := rewriteWhiteboardFileRef(runtime, raw)
if err != nil {
rewriteErrs = append(rewriteErrs, err)
return raw
}
return rewritten
})
if len(rewriteErrs) > 0 {
return "", aggregateWhiteboardRewriteErrors(rewriteErrs)
}
return out, nil
}
func rewriteWhiteboardFileRef(runtime *common.RuntimeContext, raw string) (string, error) {
startRaw, body, _, ok := splitWhiteboardElement(raw)
if !ok {
return raw, nil
}
tag, err := parseWhiteboardStartTag(startRaw)
if err != nil {
return "", common.ValidationErrorf("invalid whiteboard tag: %v", err).WithParam("whiteboard")
}
pathValue, hasPath := tag.attr(whiteboardPathAttr)
bodyPath, hasBodyPath := whiteboardBodyPathRef(body)
if !hasPath && !hasBodyPath {
return raw, nil
}
if hasPath && strings.TrimSpace(body) != "" {
return "", common.ValidationErrorf("whiteboard cannot contain both path and inline content").WithParam("whiteboard")
}
if hasPath && hasBodyPath {
return "", common.ValidationErrorf("whiteboard cannot contain both path and @file body").WithParam("whiteboard")
}
typRaw, ok := tag.attr(whiteboardTypeAttr)
if !ok || strings.TrimSpace(typRaw) == "" {
return "", common.ValidationErrorf("whiteboard file input requires type=\"svg\", type=\"mermaid\", or type=\"plantuml\"").WithParam("type")
}
typ, ok := canonicalWhiteboardFileType(typRaw)
if !ok {
return "", common.ValidationErrorf("whiteboard file input only supports type=\"svg\", type=\"mermaid\", or type=\"plantuml\", got %q", typRaw).WithParam("type")
}
if hasBodyPath {
pathValue = bodyPath
}
data, err := readWhiteboardPath(runtime, pathValue, typ)
if err != nil {
return "", err
}
tag.setAttr(whiteboardTypeAttr, typ)
tag.removeAttrs(whiteboardPathAttr)
return tag.render(false) + whiteboardContentForType(typ, data) + "</" + whiteboardTag + ">", nil
}
func splitWhiteboardElement(raw string) (startTag string, body string, selfClosing bool, ok bool) {
trimmed := strings.TrimSpace(raw)
selfClosing = strings.HasSuffix(trimmed, "/>")
if selfClosing {
return raw, "", true, true
}
startEnd := strings.Index(raw, ">")
if startEnd < 0 {
return "", "", false, false
}
endStart := strings.LastIndex(strings.ToLower(raw), "</whiteboard>")
if endStart < 0 || endStart < startEnd {
return "", "", false, false
}
return raw[:startEnd+1], raw[startEnd+1 : endStart], false, true
}
func whiteboardBodyPathRef(body string) (string, bool) {
trimmed := strings.TrimSpace(body)
if !strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "@@") {
return "", false
}
if strings.ContainsAny(trimmed, "\r\n") {
return "", false
}
return trimmed, true
}
func canonicalWhiteboardFileType(raw string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "svg":
return "svg", true
case "mermaid":
return "mermaid", true
case "plantuml":
return "plantuml", true
default:
return "", false
}
}
func readWhiteboardPath(runtime *common.RuntimeContext, pathValue string, typ string) (string, error) {
pathRaw := strings.TrimSpace(pathValue)
if !strings.HasPrefix(pathRaw, "@") {
return "", common.ValidationErrorf("whiteboard %s path %q must start with @, for example @diagram.%s", typ, pathValue, exampleWhiteboardExt(typ)).WithParam("path")
}
relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@"))
if relPath == "" {
return "", common.ValidationErrorf("whiteboard %s path cannot be empty after @", typ).WithParam("path")
}
clean := filepath.Clean(relPath)
if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", common.ValidationErrorf("whiteboard %s path %q must be a relative path within the current working directory", typ, pathValue).WithParam("path")
}
if !whiteboardExtAllowed(typ, strings.ToLower(filepath.Ext(clean))) {
return "", common.ValidationErrorf("whiteboard %s path %q must point to a %s file", typ, pathValue, whiteboardExtList(typ)).WithParam("path")
}
data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean)
if err != nil {
return "", common.ValidationErrorf("whiteboard %s path %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", typ, clean, err).
WithParam("path").
WithParams(errs.InvalidParam{Name: clean, Reason: fmt.Sprintf("whiteboard %s path cannot be read", typ)}).
WithCause(err)
}
return string(data), nil
}
func whiteboardExtAllowed(typ string, ext string) bool {
for _, allowed := range whiteboardAllowedExts(typ) {
if ext == allowed {
return true
}
}
return false
}
func whiteboardAllowedExts(typ string) []string {
switch typ {
case "svg":
return []string{".svg"}
case "mermaid":
return []string{".mermaid", ".mmd"}
case "plantuml":
return []string{".plantuml", ".puml", ".pu", ".uml"}
default:
return nil
}
}
func whiteboardExtList(typ string) string {
return strings.Join(whiteboardAllowedExts(typ), ", ")
}
func exampleWhiteboardExt(typ string) string {
exts := whiteboardAllowedExts(typ)
if len(exts) == 0 {
return "txt"
}
return strings.TrimPrefix(exts[0], ".")
}
func whiteboardContentForType(typ string, data string) string {
if typ == "svg" {
return data
}
return escapeXMLText(data)
}
func aggregateWhiteboardRewriteErrors(rewriteErrs []error) error {
flatErrs := flattenWhiteboardRewriteErrors(rewriteErrs)
messages := make([]string, 0, len(flatErrs))
params := make([]errs.InvalidParam, 0, len(flatErrs))
for _, err := range flatErrs {
messages = append(messages, err.Error())
params = append(params, whiteboardInvalidParamsFromError(err)...)
}
validationErr := common.ValidationErrorf("whiteboard file input failed: %s", strings.Join(messages, "; ")).
WithParam("whiteboard").
WithCause(errors.Join(flatErrs...))
if len(params) > 0 {
validationErr.WithParams(params...)
}
return validationErr
}
func flattenWhiteboardRewriteErrors(rewriteErrs []error) []error {
flatErrs := make([]error, 0, len(rewriteErrs))
for _, err := range rewriteErrs {
var validationErr *errs.ValidationError
if errors.As(err, &validationErr) && validationErr.Param == "whiteboard" && validationErr.Cause != nil {
if joined, ok := validationErr.Cause.(interface{ Unwrap() []error }); ok {
flatErrs = append(flatErrs, flattenWhiteboardRewriteErrors(joined.Unwrap())...)
continue
}
}
flatErrs = append(flatErrs, err)
}
return flatErrs
}
func whiteboardInvalidParamsFromError(err error) []errs.InvalidParam {
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
return nil
}
if len(validationErr.Params) > 0 {
return validationErr.Params
}
if validationErr.Param != "" {
return []errs.InvalidParam{{Name: validationErr.Param, Reason: validationErr.Message}}
}
return nil
}
func validateHTML5BlockWriteElementBodies(format string, content string) error {
validateSegment := func(segment string) error {
matches := html5BlockElementPattern.FindAllStringSubmatchIndex(segment, -1)
@@ -877,34 +621,6 @@ func parseHTML5BlockStartTag(raw string) (html5BlockStartTag, error) {
return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func parseWhiteboardStartTag(raw string) (whiteboardStartTag, error) {
trimmed := strings.TrimSpace(raw)
selfClosing := strings.HasSuffix(trimmed, "/>")
decoder := xml.NewDecoder(strings.NewReader(raw))
for {
tok, err := decoder.Token()
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return whiteboardStartTag{}, err
}
start, ok := tok.(xml.StartElement)
if !ok {
continue
}
if start.Name.Local != whiteboardTag {
return whiteboardStartTag{}, fmt.Errorf("expected <%s>, got <%s>", whiteboardTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
attrs := make([]html5BlockAttr, 0, len(start.Attr))
for _, attr := range start.Attr {
attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value})
}
return whiteboardStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil
}
return whiteboardStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors.
}
func (t html5BlockStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
@@ -914,15 +630,6 @@ func (t html5BlockStartTag) attr(name string) (string, bool) {
return "", false
}
func (t whiteboardStartTag) attr(name string) (string, bool) {
for _, attr := range t.Attrs {
if attr.Name == name {
return attr.Value, true
}
}
return "", false
}
func (t html5BlockStartTag) hasAttr(name string) bool {
_, ok := t.attr(name)
return ok
@@ -943,31 +650,6 @@ func (t *html5BlockStartTag) removeAttrs(names ...string) {
t.Attrs = attrs
}
func (t *whiteboardStartTag) removeAttrs(names ...string) {
remove := make(map[string]struct{}, len(names))
for _, name := range names {
remove[name] = struct{}{}
}
attrs := t.Attrs[:0]
for _, attr := range t.Attrs {
if _, ok := remove[attr.Name]; ok {
continue
}
attrs = append(attrs, attr)
}
t.Attrs = attrs
}
func (t *whiteboardStartTag) setAttr(name string, value string) {
for i, attr := range t.Attrs {
if attr.Name == name {
t.Attrs[i].Value = value
return
}
}
t.Attrs = append(t.Attrs, html5BlockAttr{Name: name, Value: value})
}
func (t html5BlockStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
@@ -992,25 +674,6 @@ func (t html5BlockStartTag) render(selfClosing bool) string {
return b.String()
}
func (t whiteboardStartTag) render(selfClosing bool) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(whiteboardTag)
for _, attr := range t.Attrs {
b.WriteByte(' ')
b.WriteString(attr.Name)
b.WriteString(`="`)
b.WriteString(escapeXMLAttr(attr.Value))
b.WriteByte('"')
}
if selfClosing {
b.WriteString("/>")
} else {
b.WriteByte('>')
}
return b.String()
}
func escapeXMLAttr(value string) string {
var b strings.Builder
for _, r := range value {
@@ -1031,18 +694,3 @@ func escapeXMLAttr(value string) string {
}
return b.String()
}
func escapeXMLText(value string) string {
var b strings.Builder
for _, r := range value {
switch r {
case '&':
b.WriteString("&amp;")
case '<':
b.WriteString("&lt;")
default:
b.WriteRune(r)
}
}
return b.String()
}

View File

@@ -6,13 +6,11 @@ package doc
import (
"bytes"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
@@ -118,61 +116,6 @@ func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputs(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
files := map[string]string{
"diagram.svg": `<svg viewBox="0 0 10 10"><text>A</text></svg>`,
"flow.mmd": "flowchart TD\nA --> B",
"sequence.puml": "@startuml\nAlice -> Bob: hi\n@enduml",
}
for name, content := range files {
if err := os.WriteFile(name, []byte(content), 0o600); err != nil {
t.Fatalf("WriteFile(%s) error: %v", name, err)
}
}
f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{
"document": map[string]interface{}{
"document_id": "doxcn_new_doc",
"revision_id": float64(1),
},
})
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@diagram.svg"></whiteboard>`,
`<whiteboard type="mermaid">@flow.mmd</whiteboard>`,
`<whiteboard type="plantUML" path="@sequence.puml"/>`,
}, "\n"),
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeRequestBody(t, stub.CapturedBody)
got := body["content"].(string)
for _, want := range []string{
`<whiteboard type="svg"><svg viewBox="0 0 10 10"><text>A</text></svg></whiteboard>`,
"<whiteboard type=\"mermaid\">flowchart TD\nA --> B</whiteboard>",
"<whiteboard type=\"plantuml\">@startuml\nAlice -> Bob: hi\n@enduml</whiteboard>",
} {
if !strings.Contains(got, want) {
t.Fatalf("content missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `path="@`) {
t.Fatalf("content still contains whiteboard path attr: %s", got)
}
if _, ok := body["reference_map"]; ok {
t.Fatalf("whiteboard file input must not create reference_map: %#v", body)
}
}
func findDocsTestFlag(flags []common.Flag, name string) common.Flag {
for _, flag := range flags {
if flag.Name == name {
@@ -464,119 +407,6 @@ func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) {
}
}
func TestDocsCreateV2WhiteboardFileInputReportsAllMissingPaths(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@missing.svg"></whiteboard>`,
`<whiteboard type="mermaid">@missing.mmd</whiteboard>`,
`<whiteboard type="plantuml" path="@missing.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"missing.svg",
"missing.mmd",
"missing.puml",
}, []string{
`whiteboard svg path "missing.svg" cannot be read`,
`whiteboard mermaid path "missing.mmd" cannot be read`,
`whiteboard plantuml path "missing.puml" cannot be read`,
})
}
func TestDocsCreateV2WhiteboardFileInputMarkdownReportsMissingPathsAcrossFences(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, ""))
err := runDocsCreateShortcut(t, f, stdout, []string{
"+create",
"--api-version", "v2",
"--doc-format", "markdown",
"--content", strings.Join([]string{
`<whiteboard type="svg" path="@before.svg"></whiteboard>`,
"```",
`<whiteboard type="svg" path="@inside.svg"></whiteboard>`,
"```",
`<whiteboard type="plantuml" path="@after.puml"></whiteboard>`,
}, "\n"),
"--as", "user",
})
if err == nil {
t.Fatal("expected aggregated whiteboard path error")
}
assertWhiteboardFileInputValidation(t, err, []string{
"before.svg",
"after.puml",
}, []string{
`whiteboard svg path "before.svg" cannot be read`,
`whiteboard plantuml path "after.puml" cannot be read`,
})
if strings.Contains(err.Error(), "inside.svg") {
t.Fatalf("error should ignore fenced whiteboard path, got: %v", err)
}
}
func assertWhiteboardFileInputValidation(t *testing.T, err error, wantParams []string, wantMessages []string) {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed problem, got %T %v", err, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category/subtype = %s/%s, want %s/%s", problem.Category, problem.Subtype, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if validationErr.Param != "whiteboard" {
t.Fatalf("param = %q, want whiteboard", validationErr.Param)
}
if validationErr.Cause == nil {
t.Fatal("expected aggregated error to preserve cause")
}
var childValidationErr *errs.ValidationError
if !errors.As(validationErr.Cause, &childValidationErr) || childValidationErr.Cause == nil {
t.Fatalf("expected child validation cause to preserve file read cause, got %#v", validationErr.Cause)
}
gotParams := make(map[string]string, len(validationErr.Params))
for _, param := range validationErr.Params {
gotParams[param.Name] = param.Reason
}
if len(gotParams) != len(wantParams) {
t.Fatalf("params = %#v, want names %v", validationErr.Params, wantParams)
}
for _, param := range wantParams {
reason, ok := gotParams[param]
if !ok {
t.Fatalf("params = %#v, want name %q", validationErr.Params, param)
}
if reason == "" {
t.Fatalf("param %q missing reason: %#v", param, validationErr.Params)
}
}
for _, want := range wantMessages {
if !strings.Contains(err.Error(), want) {
t.Fatalf("error missing %q:\n%v", want, err)
}
if !strings.Contains(validationErr.Cause.Error(), want) {
t.Fatalf("cause missing %q:\n%v", want, validationErr.Cause)
}
}
}
func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) {
dir := t.TempDir()
cmdutil.TestChdir(t, dir)

View File

@@ -623,10 +623,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Code == 1062507:
decision.Class = "parent_sibling_limit"
decision.Terminal = true
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:

View File

@@ -1334,75 +1334,6 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
}
}
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 1062507,
"msg": "parent node out of sibling num.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

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