Compare commits

..

11 Commits

Author SHA1 Message Date
niezhiwei
307e753b3f feat(slides): add +update-slide to apply a page of XML by diffing it
Editing most of a page previously meant one block_replace part per
element, each carrying that element's full XML (coordinates, size, font
size included). Restyling every text block on a page was impractical as a
result.

+update-slide takes the page the caller wants, reads the page that is
there, and sends one element-level part per difference. The indirection is
forced: ReplacePart.block_id is validated as a short ELEMENT id (it must
start with "b"), so a single part covering the page is impossible — the
page's own id is "p"-prefixed and the background fill's id is
"f"-prefixed, and both are rejected with 3350001 while element-level parts
on the same page succeed. Element ids are the only handles this endpoint
offers.

--content is the page's target state. An element carrying its original id
is replaced when it differs, an element without an id is inserted at the
position it was written, an element missing from --content is deleted, and
a missing <note> clears the speaker notes. Nothing differing means no
request is sent at all.

Comparison is canonical because the server returns pretty-printed XML with
reordered attributes and injected style defaults the caller never wrote:
attributes are namespace-qualified, quoted and sorted, and indentation
between structural elements is dropped. Text inside a <p> subtree is
compared verbatim and escaped — SML collapses a literal space between
inline tags, but a preserved space written as &#32; decodes to the very
same token, so trimming would drop a real edit as unchanged; and without
escaping, a paragraph holding the literal text "</p><p>" reads identically
to two empty paragraphs. What is sent is the caller's exact bytes, so
their formatting survives into the page.

Anything the diff cannot express fails loudly instead of being silently
dropped: a background change (<style> has no id of its own and its <fill>
id is "f"-prefixed), a reorder of existing elements (there is no move
operation), an id that does not exist on the page (omit the id to create
an element), a root id that differs from --slide-id (the classic
read-page-A-write-page-B mistake), any slide-level structure other than
one <style>, one <data> and one <note>, and container attributes — the
root additionally accepts the SML namespace in the three forms the
repository's SXSD validator recognizes (the official identifier and the
two server read-back spellings), since the primary workflow feeds
+xml-get output straight back in. Unrepresentable structure in --content
is a validation error; a page that comes back with it is a failed
precondition.

Pages carrying an <undefined> placeholder — the server's stand-in for an
object it could not export, such as a whiteboard read without its export
option — are refused outright. Whether the whole-page rewrite behind
slide.replace preserves an untouched placeholder is a server-owned
behavior that no self-contained test can pin down (boards cannot be
created programmatically: the CLI has no whiteboard-create and SML has no
whiteboard element), and editing on an unverifiable assumption risks
silently destroying the one object the caller cannot see. +replace-slide
remains the element-level path for such pages.

--revision-id is documented as what it is. Passing a stale revision is not
rejected by the backend: it means "apply against this snapshot", so
pinning an older one discards every later edit to the page. -1, the
default, is the safe value.

slides:presentation:read sits in the enforced pre-flight scopes, not in
ConditionalScopes: every execution reads the page before writing it, and
ConditionalScopes is metadata only, so a write-only token would reach the
GET before failing.

Also:
- +update as a hidden alias, derived from the canonical shortcut so scopes,
  identities and flags cannot drift. Agents reach for "slide update"
  before reading --help and used to burn turns on the error plus a help
  dump.
- --xml / --slide-xml / --slide-content / --content-xml as hidden spellings
  of --content, scoped to the whole-page commands: --content exists on
  other slides shortcuts, and resolving these aliases there would rewrite
  a mistyped flag into one the caller never meant to use.
- A backend failure reason becomes a typed API error rather than a field on
  a success envelope.
- www.larkoffice.com joins the public-domain allowlist: it is the host of
  the SML namespace identifier, the same vendor domain family as the
  already-listed www.feishu.cn / www.larksuite.com, and the quality gate
  matches exact hostnames.
- Live and dry-run E2E coverage per AGENTS.md. The live workflow walks
  replace, insert, delete and the no-op against the real API, checks a
  background change is refused with the page left untouched and the deck
  order holds, and is self-contained where that is knowable: for stored
  credentials it probes the cleanup scopes with a dry-run delete and
  refuses to create a deck it cannot delete (unexpected probe failures are
  fatal); environment tokens carry no scope metadata, so those runs rest
  on the documented requirement that the CI identity is provisioned with
  the cleanup scopes, and a cleanup failure stays fatal and visible. The
  dry-run suite pins the validation process contract (launch error, exit
  code 2, empty stdout, typed envelope on stderr). An earlier design
  passed every HTTP stub and was rejected outright by the real endpoint;
  coverage.md records that the live test skips without a user token, which
  is the blind spot that allowed it.
- Skill docs: what the command does, what it refuses, why the background
  cannot be changed, and a portable (BSD/GNU) sed spelling in the primary
  example.
2026-07-31 18:20:43 +08:00
dc-bytedance
b79827d60a fix: drop stale target version from root upgrade prompt (#2100) 2026-07-31 12:45:43 +08:00
zhaojiaxing-coding
0f35676a28 feat(drive): extend permission shortcuts for Miaoda (#2070)
* feat(drive): support Miaoda apps in permission shortcuts

Extend Drive permission shortcuts to accept Miaoda page URLs and the apps resource type while keeping each endpoint's accepted resource contract explicit.

Key features:

- Infer apps from /page/ URLs and accept explicit --type=apps in +apply-permission, +member-add, +member-list, and +permission-get-setting

- Decouple secure-label target parsing so expanding apply-permission does not widen secure-label support

- Align skill guidance and unit/dry-run coverage with the new resource type

* test(drive): cover apps permission target validation

Add focused coverage for Miaoda apps target handling across apply-permission and secure-label boundaries.

Exercise malformed page URLs, explicit apps bare tokens, typed validation errors, and command-level rejection so future resource-type changes cannot silently widen unsupported secure-label behavior.

* fix(drive): parse permission markers from URL paths

Keep drive +apply-permission resource inference aligned with URL component boundaries. Parse and validate URL inputs before extracting tokens so query strings and fragments cannot redirect permission requests to a different resource.

Key fixes:

- Match document and apps markers only against the parsed URL path

- Reject malformed URLs with a typed --token validation error

- Cover /page/ markers found only in query strings or fragments

* docs(skills): redact Miaoda page token example

Replace the concrete Miaoda page token with a representative pagcn placeholder. This keeps the token shape recognizable while avoiding exposure of a real resource identifier in the skill documentation.

* fix(drive): harden permission target resolution

Make Drive shortcut targets unambiguous before they reach read or write API paths. URL inputs now bind to a recognized root path and a single validated token segment, preventing encoded separators, dot segments, and type conflicts from silently changing the addressed resource.

Key fixes:

- Reject non-root URLs, dot/traversal tokens, and URL/type conflicts for secure-label and permission-apply writes

- Keep permission-setting URL parsing and pretty output reversible for every supported command-local resource kind

- Add unit and dry-run E2E regressions plus aligned permission-apply guidance
2026-07-31 12:16:22 +08:00
wangweiming-01
946964e093 fix(drive): use title for default download filename (#2089) 2026-07-31 12:12:11 +08:00
HanShaoshuai-k
cfe76ad56a ci: add protected public domain allowlists (#2111)
Co-authored-by: HanShaoshuai-k <268785735+HanShaoshuai-k@users.noreply.github.com>
2026-07-31 11:02:04 +08:00
calendar-assistant
fa9c30c690 docs(calendar): confirm scope before editing recurring events (#2119)
Promote the recurring-event rule to a pre-routing gate so it is read
before the specific operation flow, and require confirming the scope
(this event / all / this-and-following) when the user is ambiguous
instead of defaulting to this-event-only. Removes the redundant and
conflicting "edit existing event" row that hard-coded the single-
instance default.
2026-07-30 21:59:08 +08:00
zcc
ba95252019 feat(drive): add comment-operation shortcuts (#1898)
Add comment-domain shortcuts: +batch-query-comments, +resolve-comment,
+restore-comment, +add-reply, +list-replies, +update-reply, +delete-reply
and +react-reply, sharing one target resolver with per-endpoint file_type
sets.

Flatten the comment reference docs by dropping the comments-guide routing
layer and folding its cross-command knowledge into the command refs:
comment-card model, comment/reply/interaction counting and sorting rules
into lark-drive-list-comments.md; the --solved-status prerequisite into
lark-drive-restore-comment.md; the apps exception into
lark-drive-add-comment.md. Comment intents now route straight from the
drive SKILL.md Shortcuts table to each command ref.

Cover the new shortcuts with unit tests, dry-run e2e and live workflow
e2e behind LARK_DRIVE_MD_COMMENT_E2E=1, and register them in
tests/cli_e2e/drive/coverage.md.
2026-07-30 21:53:21 +08:00
zhouyue-bytedance
4a16139348 fix(base): resolve Base URL block types accurately (#2099)
* fix: resolve Base URL block types accurately

* fix: resolve Base block selection from Wiki URLs

* fix(base): guide resolved folder and docx blocks

* fix(base): avoid field fallback for untyped URL blocks

* docs(base): specify URL example fence language

* test(base): cover unmatched URL block resolution
2026-07-30 20:29:47 +08:00
BD-ZERO
6e5308af01 feat: add SXSD schema validation to Slides lint (#2103)
- add XSD-backed SXSD validation for tags, attributes, structure, scalar values, and namespaces
- preserve supported server-filled fields and readback namespace compatibility
- isolate SXSD failures by slide so valid slides continue through layout checks
- improve actionable lint diagnostics and suppress duplicate errors
- add regression coverage for schema validation and Slides readback cases

Validated with unit tests and real Slides create/readback round trips.
2026-07-30 20:04:53 +08:00
liangshuo-1
87be09ef5f fix(contact): stop bot match segments carrying tags or empty entries (#2115) 2026-07-30 18:08:38 +08:00
sang-neo03
a575a8ba60 feat(contact): add bot search shortcut (#2083) 2026-07-30 17:03:49 +08:00
282 changed files with 19261 additions and 10087 deletions

View File

@@ -23,6 +23,41 @@ lark-cli contact +search-user --query "alice" --as user
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +search-bot
Search bots (apps) by keyword. Pass `--query` or `--queries`; use `--chat-ids` to search within specific chats.
### Skills
- lark-contact/references/lark-contact-search-bot.md
### Avoid when
- Looking for a person rather than a bot → use [[+search-user]]
- Running as a bot — this shortcut is user-only
### Tips
- `has_more=true` means the search is incomplete; refine the keyword or search scope instead of paginating
### Examples
**Find bots by keyword**
```bash
lark-cli contact +search-bot --query "会议助手" --as user
```
**Search inside one chat**
```bash
lark-cli contact +search-bot --query "助手" --chat-ids "oc_3a8b****6a7b" --as user
```
**Find bots you've chatted with**
```bash
lark-cli contact +search-bot --query "助手" --has-chatted --as user
```
**Search several bot keywords in one call**
```bash
lark-cli contact +search-bot --queries "会议助手,日报助手,审批助手" --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.

View File

@@ -1,38 +0,0 @@
# docs
> skill: lark-doc
## +create
Create a document from XML or Markdown content.
### Skills
- lark-doc/references/lark-doc-create.md
## +fetch
Fetch a document or a focused portion of its content.
### Skills
- lark-doc/references/lark-doc-fetch.md
## +update
Update document content with a supported document command.
### Skills
- lark-doc/references/lark-doc-update.md
## +history-list
List document history versions.
### Skills
- lark-doc/references/lark-doc-history.md
## +history-revert
Revert a document to a history version.
### Skills
- lark-doc/references/lark-doc-history.md
## +history-revert-status
Check the status of a document history revert.
### Skills
- lark-doc/references/lark-doc-history.md

View File

@@ -18,21 +18,10 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/recovery"
)
// NewCmdAuth creates the auth command with subcommands.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
return newCmdAuth(f, nil)
}
// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
// presenter while preserving NewCmdAuth's established function signature.
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdAuth(f, projector)
}
func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
@@ -51,10 +40,10 @@ func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comman
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(newCmdAuthList(f, nil, projector))
cmd.AddCommand(newCmdAuthCheck(f, nil, projector))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -13,7 +13,6 @@ import (
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// CheckOptions holds all inputs for auth check.
@@ -25,14 +24,6 @@ type CheckOptions struct {
// NewCmdAuthCheck creates the auth check subcommand.
func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
return newCmdAuthCheck(f, runF, nil)
}
func newCmdAuthCheck(
f *cmdutil.Factory,
runF func(*CheckOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &CheckOptions{Factory: f}
cmd := &cobra.Command{
@@ -42,7 +33,7 @@ func newCmdAuthCheck(
if runF != nil {
return runF(opts)
}
return authCheckRunWithRecovery(opts, projector)
return authCheckRun(opts)
},
}
@@ -55,10 +46,6 @@ func newCmdAuthCheck(
}
func authCheckRun(opts *CheckOptions) error {
return authCheckRunWithRecovery(opts, nil)
}
func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
@@ -95,7 +82,7 @@ func authCheckRunWithRecovery(opts *CheckOptions, projector *recovery.Projector)
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 && projector.CanReference(recovery.TargetAuthLogin) {
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
output.PrintJson(f.IOStreams.Out, result)

View File

@@ -6,7 +6,6 @@ package auth
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -14,8 +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/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/zalando/go-keyring"
)
@@ -165,70 +162,3 @@ func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
}
}
func TestAuthCheckRun_ConcealedLoginOmitsSuggestion(t *testing.T) {
keyring.MockInit()
t.Setenv("HOME", t.TempDir())
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_user",
UserName: "tester",
}
now := time.Now()
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
AppId: cfg.AppID,
UserOpenId: cfg.UserOpenId,
AccessToken: "user-access-token",
RefreshToken: "refresh-token",
ExpiresAt: now.Add(time.Hour).UnixMilli(),
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
GrantedAt: now.Add(-time.Hour).UnixMilli(),
Scope: "im:message",
}); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
visibleFactory, visibleStdout, _, _ := cmdutil.TestFactory(t, cfg)
if err := authCheckRun(&CheckOptions{
Factory: visibleFactory,
Scope: "calendar:calendar:read",
}); output.ExitCodeOf(err) != 1 {
t.Fatalf("default check exit = %d, want predicate miss exit 1", output.ExitCodeOf(err))
}
var visiblePayload map[string]any
if err := json.Unmarshal(visibleStdout.Bytes(), &visiblePayload); err != nil {
t.Fatalf("default stdout must be valid JSON: %v", err)
}
if suggestion, _ := visiblePayload["suggestion"].(string); !strings.Contains(suggestion, "auth login") {
t.Fatalf("default output lost established login suggestion: %#v", visiblePayload)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
err := authCheckRunWithRecovery(
&CheckOptions{Factory: f, Scope: "calendar:calendar:read"},
recovery.NewProjector(func() *surface.Plan { return plan }),
)
if got := output.ExitCodeOf(err); got != 1 {
t.Fatalf("exit code = %d, want predicate miss exit 1", got)
}
if stderr.Len() != 0 {
t.Fatalf("stderr must stay empty, got:\n%s", stderr.String())
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
}
if _, ok := payload["suggestion"]; ok {
t.Fatalf("concealed auth/login left a dead suggestion: %#v", payload["suggestion"])
}
if missing, ok := payload["missing"].([]any); !ok || len(missing) != 1 {
t.Fatalf("projection removed missing-scope facts: %#v", payload["missing"])
}
}

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/recovery"
)
// ListOptions holds all inputs for auth list.
@@ -25,14 +24,6 @@ type ListOptions struct {
// NewCmdAuthList creates the auth list subcommand.
func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
return newCmdAuthList(f, runF, nil)
}
func newCmdAuthList(
f *cmdutil.Factory,
runF func(*ListOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &ListOptions{Factory: f}
cmd := &cobra.Command{
@@ -42,7 +33,7 @@ func newCmdAuthList(
if runF != nil {
return runF(opts)
}
return authListRunWithRecovery(opts, projector)
return authListRun(opts)
},
}
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
@@ -52,10 +43,6 @@ func newCmdAuthList(
}
func authListRun(opts *ListOptions) error {
return authListRunWithRecovery(opts, nil)
}
func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
@@ -74,7 +61,7 @@ func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) e
// workspace-aware, so we pull the message+hint out of
// NotConfiguredError() instead of hard-coding it.
var cfgErr *errs.ConfigError
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
if errors.As(core.NotConfiguredError(), &cfgErr) {
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
if cfgErr.Hint != "" {
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
@@ -93,11 +80,7 @@ func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) e
})
return nil
}
fmt.Fprint(f.IOStreams.ErrOut, "No logged-in users.")
if projector.CanReference(recovery.TargetAuthLogin) {
fmt.Fprint(f.IOStreams.ErrOut, " Run `lark-cli auth login` to log in.")
}
fmt.Fprintln(f.IOStreams.ErrOut)
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}

View File

@@ -10,8 +10,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// TestAuthListRun_NotConfigured_ReturnsExitZero pins the contract that
@@ -128,49 +126,7 @@ func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) {
if stdout.Len() != 0 {
t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
!strings.Contains(got, "auth login") {
t.Errorf("stderr = %q, want established no-users login hint", got)
}
}
func TestAuthListRun_ConcealedLoginKeepsStateWithoutDeadRecovery(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
if err := authListRunWithRecovery(
&ListOptions{Factory: f},
recovery.NewProjector(func() *surface.Plan { return plan }),
); err != nil {
t.Fatalf("auth list should remain a successful probe: %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout must stay empty, got:\n%s", stdout.String())
}
if got := stderr.String(); !strings.Contains(got, "No logged-in users") ||
strings.Contains(got, "auth login") {
t.Fatalf("concealed recovery = %q, want state without dead login action", got)
}
}
func TestAuthListRun_ConcealedConfigInitProjectsManualErrorOutput(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr, _ := cmdutil.TestFactory(t, nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigInit: surface.CommandConcealed,
})
if err := authListRunWithRecovery(
&ListOptions{Factory: f},
recovery.NewProjector(func() *surface.Plan { return plan }),
); err != nil {
t.Fatalf("auth list should remain a successful probe: %v", err)
}
if got := stderr.String(); strings.Contains(got, "config init") {
t.Fatalf("manual config error rendering retained concealed recovery: %q", got)
if !strings.Contains(stderr.String(), "No logged-in users") {
t.Errorf("stderr = %q, want no-users hint", stderr.String())
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// StatusOptions holds all inputs for auth status.
@@ -23,14 +22,6 @@ type StatusOptions struct {
// NewCmdAuthStatus creates the auth status subcommand.
func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
return newCmdAuthStatus(f, runF, nil)
}
func newCmdAuthStatus(
f *cmdutil.Factory,
runF func(*StatusOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
@@ -40,7 +31,7 @@ func newCmdAuthStatus(
if runF != nil {
return runF(opts)
}
return authStatusRun(opts, projector)
return authStatusRun(opts)
},
}
@@ -51,7 +42,7 @@ func newCmdAuthStatus(
return cmd
}
func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
config, err := f.Config()
@@ -69,14 +60,11 @@ func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
"defaultAs": defaultAs,
}
diagnostics := identitydiag.FilterRecovery(
identitydiag.Diagnose(context.Background(), f, config, opts.Verify),
projector.CanReference,
)
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics, projector.CanReference(recovery.TargetAuthLogin))
addStatusNote(result, diagnostics)
output.PrintJson(f.IOStreams.Out, result)
return nil
@@ -118,21 +106,13 @@ func addEffectiveVerification(result map[string]interface{}, d identitydiag.Resu
}
}
func addStatusNote(result map[string]interface{}, d identitydiag.Result, canAuthLogin bool) {
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
switch {
case !d.User.Available && d.Bot.Available:
note := "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls."
if canAuthLogin {
note += " Run `lark-cli auth login` to enable user identity."
}
result["note"] = note
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
case d.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
case !d.User.Available && !d.Bot.Available:
note := "No usable identity is available. Configure bot credentials"
if canAuthLogin {
note += " or run `lark-cli auth login`"
}
result["note"] = note + "."
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
}
}

View File

@@ -18,7 +18,7 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}, nil); err != nil {
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
@@ -54,7 +54,7 @@ func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
},
})
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}, nil); err != nil {
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}

View File

@@ -3,10 +3,7 @@
package cmd
import (
"errors"
"testing"
)
import "testing"
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
@@ -73,18 +70,3 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}
func TestIsDeferredBootstrapProfileError(t *testing.T) {
if !isDeferredBootstrapProfileError(errors.New("flag needs an argument: --profile")) {
t.Fatal("missing --profile value must be deferred to the completed Cobra tree")
}
for _, err := range []error{
nil,
errors.New("flag needs an argument: --future"),
errors.New("invalid argument for --profile"),
} {
if isDeferredBootstrapProfileError(err) {
t.Fatalf("unexpected deferred bootstrap error: %v", err)
}
}
}

View File

@@ -21,7 +21,6 @@ import (
cmdupdate "github.com/larksuite/cli/cmd/update"
"github.com/larksuite/cli/cmd/whoami"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
@@ -29,12 +28,7 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillpolicy"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
@@ -43,29 +37,14 @@ import (
type BuildOption func(*buildConfig)
type buildConfig struct {
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
presentation restrictionPresentationConfig
skipPlugins bool
skipStrictMode bool
skipService bool
deferStartup bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
startupBrandSet bool
hideProfileSet bool
}
// buildRuntime owns presentation state for exactly one command tree. Factory
// remains the business dependency container; distribution policy never enters
// it. The embedded pointer preserves convenient access to Factory fields in
// cmd-internal tests without exposing the surface plan to business packages.
type buildRuntime struct {
*cmdutil.Factory
surface *surface.Plan
recovery *recovery.Projector
skillReferences *skillref.Resolver
streams *cmdutil.IOStreams
keychain keychain.KeychainAccess
globals GlobalOptions
skipPlugins bool
skipStrictMode bool
skipService bool
serviceCatalog *apicatalog.Catalog
startupBrand core.LarkBrand
}
// WithStartupBrand initializes the API registry with the given brand before
@@ -76,7 +55,6 @@ type buildRuntime struct {
func WithStartupBrand(brand core.LarkBrand) BuildOption {
return func(c *buildConfig) {
c.startupBrand = brand
c.startupBrandSet = true
}
}
@@ -107,12 +85,6 @@ var embeddedSkillContent fs.FS
// supply its own skill content.
func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
// SetEmbeddedAffordanceContent registers the per-domain command guidance tree.
// Wrapper mains should wire the repository's affordance directory alongside
// embedded skills so generic --help presentation remains complete and skill
// references follow the composed distribution.
func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(fsys) }
// 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
@@ -120,7 +92,6 @@ func SetEmbeddedAffordanceContent(fsys fs.FS) { affordance.SetSource(fsys) }
func HideProfile(hide bool) BuildOption {
return func(c *buildConfig) {
c.globals.HideProfile = hide
c.hideProfileSet = true
}
}
@@ -176,11 +147,11 @@ func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOpti
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Returns (runtime, rootCmd, registry). The registry is nil when plugin
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
// hooks; callers that wire Shutdown emit must nil-check before calling
// hook.Emit.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*buildRuntime, *cobra.Command, *hook.Registry) {
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
@@ -189,16 +160,6 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
o(cfg)
}
}
return buildInternalWithConfig(ctx, inv, cfg)
}
// buildInternalWithConfig assembles one command tree from an already-applied
// option snapshot. Execute uses this boundary so stateful BuildOptions are
// never evaluated once for bootstrap inspection and a second time for Build.
func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext, cfg *buildConfig) (*buildRuntime, *cobra.Command, *hook.Registry) {
if cfg == nil {
cfg = &buildConfig{}
}
// Default streams when WithIO is not supplied so the root command's
// SetIn/Out/Err calls below don't deref nil. NewDefault also normalizes
// partial streams internally; keep both in sync so cfg.streams reflects
@@ -206,28 +167,18 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
if cfg.streams == nil {
cfg.streams = cmdutil.SystemIO()
}
// Initialize the registry brand before anything touches the runtime
// catalog (its sync.Once would otherwise lock onto the Feishu default).
if cfg.startupBrand != "" {
registry.InitWithBrand(cfg.startupBrand)
}
// Reset the legacy process-global diagnostic snapshots before paths that
// may return early. Distribution presentation state is deliberately not
// stored here; it belongs to this build's immutable surface plan.
cmdpolicy.SetActive(nil)
internalplatform.SetActiveInventory(nil)
f := cmdutil.NewDefault(cfg.streams, inv)
if cfg.keychain != nil {
f.Keychain = cfg.keychain
}
f.SkillContent = embeddedSkillContent
runtime := &buildRuntime{Factory: f}
runtime.recovery = recovery.NewProjector(func() *surface.Plan {
return runtime.surface
})
f.Recovery = runtime.recovery
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
@@ -244,17 +195,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
// rootUsageTemplate.
rootCmd.SetUsageTemplate(rootUsageTemplate)
// Framework-generated skill pointers read this build's final content and
// exact command surface lazily. A second Build therefore cannot rewrite
// help rendered by the first tree.
installTipsHelpFunc(rootCmd, func() fs.FS {
if !runtime.surface.CanReference(surface.CommandSkillsRead) {
return nil
}
return runtime.SkillContent
}, func() *skillref.Resolver {
return runtime.skillReferences
}, runtime.recovery)
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
@@ -270,11 +211,11 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
f.CurrentCommand = cmd
}
rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(profile.NewCmdProfile(f))
rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
@@ -290,93 +231,52 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
}
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
classifyRootCommands(rootCmd)
groupRootCommands(rootCmd)
installUnknownSubcommandGuard(rootCmd)
// Bare `lark-cli` in an interactive terminal offers an interactive upgrade
// before printing help; non-bare invocations and non-TTY are unaffected.
installRootUpgradePrompt(f, rootCmd, runtime.recovery)
installRootUpgradePrompt(f, rootCmd)
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
pruneForStrictMode(rootCmd, mode)
}
var (
installResult *internalplatform.InstallResult
pluginRules []cmdpolicy.PluginRule
pluginSkills []skillpolicy.PluginSkill
hookRegistry *hook.Registry
denied map[string]cmdpolicy.Denial
)
if cfg.skipPlugins {
recordInventory(nil)
return f, rootCmd, nil
}
if !cfg.skipPlugins {
var installErr error
installResult, installErr = installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return finalizeFailedBuild(runtime, rootCmd)
}
if installResult != nil {
pluginRules = installResult.PluginRules
pluginSkills = installResult.PluginSkills
hookRegistry = 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.
var policyErr error
denied, policyErr = applyUserPolicyPruning(rootCmd, pluginRules)
if policyErr != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, policyErr)
return finalizeFailedBuild(runtime, rootCmd)
}
warnPolicyError(cfg.streams.ErrOut, policyErr)
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
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.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)
}
// Presentation is an explicit host projection over the exact enforcement
// decisions. With no opt-in, legacy Restrict and YAML policy behavior is
// mechanically unchanged.
var hasConcealedCommands bool
runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied)
// Resolve skill assets and canonical references before installing hooks.
// A declared customization is a build-integrity boundary: failure must
// happen before Startup so no lifecycle side effect is stranded.
skillResolution, skillErr := skillpolicy.ResolveWithReferences(embeddedSkillContent, pluginSkills)
if skillErr != nil {
installPluginSkillErrorGuard(rootCmd, skillErr)
return finalizeFailedBuild(runtime, rootCmd)
}
f.SkillContent = skillResolution.Content
runtime.skillReferences = skillResolution.References
// Install hooks only on business commands. The concealment-specific help
// command is attached afterwards, preserving Cobra's historical contract
// that help is not observed or wrapped by plugins.
if hookRegistry != nil {
installHooks(rootCmd, hookRegistry)
}
if hasConcealedCommands {
installHelpCommand(rootCmd)
}
finalizeRootCommandGroups(rootCmd, runtime.surface)
if hookRegistry != nil && !cfg.deferStartup {
if err := emitStartup(ctx, hookRegistry); err != nil {
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
recordInventory(installResult)
return runtime, rootCmd, nil
return f, rootCmd, nil
}
}
recordInventory(installResult)
return runtime, rootCmd, hookRegistry
}
func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) {
finalizeRootCommandGroups(root, runtime.surface)
return runtime, root, nil
return f, rootCmd, registry
}

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs"
@@ -29,10 +28,6 @@ func TestBuild_ExternalAPI(t *testing.T) {
// Exercise SetDefaultFS both directions. Passing nil restores the OS FS.
SetDefaultFS(vfs.OsFs{})
SetDefaultFS(nil)
SetEmbeddedAffordanceContent(fstest.MapFS{
"docs.md": {Data: []byte("# docs\n")},
})
t.Cleanup(func() { SetEmbeddedAffordanceContent(nil) })
var in, out, errOut bytes.Buffer
rootCmd := Build(

View File

@@ -18,7 +18,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/recovery"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -60,14 +59,6 @@ type BindOptions struct {
// NewCmdConfigBind creates the config bind subcommand.
func NewCmdConfigBind(f *cmdutil.Factory, runF func(*BindOptions) error) *cobra.Command {
return newCmdConfigBind(f, runF, nil)
}
func newCmdConfigBind(
f *cmdutil.Factory,
runF func(*BindOptions) error,
projector *recovery.Projector,
) *cobra.Command {
opts := &BindOptions{Factory: f, UILang: i18n.LangZhCN}
cmd := &cobra.Command{
@@ -107,7 +98,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
if runF != nil {
return runF(opts)
}
return configBindRunWithRecovery(opts, projector)
return configBindRun(opts)
},
}
@@ -125,10 +116,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
// helper whose signature declares its contract; the body reads as the shape of
// the bind flow itself, not its mechanics.
func configBindRun(opts *BindOptions) error {
return configBindRunWithRecovery(opts, nil)
}
func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector) error {
if err := validateBindFlags(opts); err != nil {
return err
}
@@ -167,7 +154,7 @@ func configBindRunWithRecovery(opts *BindOptions, projector *recovery.Projector)
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
noticeUserDefaultRisk(opts)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath, projector)
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
}
// existingBinding is the outcome of checking whether a workspace was already
@@ -417,13 +404,7 @@ func priorLang(previousConfigBytes []byte) i18n.Lang {
// any), and a JSON success envelope. Cleanup runs only after the new config
// is durably written — if anything fails earlier, the old workspace stays
// usable.
func commitBinding(
opts *BindOptions,
appConfig *core.AppConfig,
previousConfigBytes []byte,
source, configPath string,
projector *recovery.Projector,
) error {
func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigBytes []byte, source, configPath string) error {
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
@@ -481,7 +462,7 @@ func commitBinding(
case "bot-only":
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
case "user-default":
envelope["message"] = userDefaultBindMessage(prefMsg, appConfig.AppId, display, projector)
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
}
resultJSON, _ := json.Marshal(envelope)
@@ -489,17 +470,6 @@ func commitBinding(
return nil
}
func userDefaultBindMessage(
messages *bindMsg,
appID, display string,
projector *recovery.Projector,
) string {
if projector.CanReference(recovery.TargetAuthLogin) {
return fmt.Sprintf(messages.MessageUserDefault, appID, display, display)
}
return fmt.Sprintf(messages.MessageUserDefaultFallback, appID, display)
}
// cleanupKeychainFromData removes keychain entries referenced by a previous
// config snapshot, skipping any entry whose keychain ID is still in use by
// the new app config. This prevents rebinding the same appId from deleting

View File

@@ -37,9 +37,6 @@ type bindMsg struct {
// MessageBotOnly format: app_id, source display name, brand.
// MessageUserDefault format: app_id, source display name, source display
// name (second source ref anchors the "run in this chat" directive).
// MessageUserDefaultFallback format: app_id, source display name. It keeps
// the completed bind facts but uses target-free recovery when auth/login
// is not part of this distribution.
// MessageUserDefault directs the Agent at the blocking single-call
// `auth login --recommend` flow: the CLI streams verification_url to
// stderr, which Agent runtimes (OpenClaw, Hermes) relay to the user in
@@ -47,9 +44,8 @@ type bindMsg struct {
// The Agent also needs an explicit "do not navigate the URL yourself"
// guard — its own browser is sandboxed and cannot complete the user's
// authorization.
MessageBotOnly string
MessageUserDefault string
MessageUserDefaultFallback string
MessageBotOnly string
MessageUserDefault string
// Identity preset (collapses strict-mode + default-as into one choice).
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
@@ -112,9 +108,8 @@ var bindMsgZh = &bindMsg{
ConflictCancel: "保留当前配置",
ConflictCancelled: "已保留当前配置",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
MessageUserDefaultFallback: "已绑定应用 %s 到 %s。请通过该发行版支持的授权流程获取或刷新用户凭证然后再继续执行用户的请求。",
MessageBotOnly: "已绑定应用 %s 到 %s可立即以应用bot身份调用%s API现在可以继续执行用户的请求。",
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
SelectIdentity: "你希望 AI 如何与你协作?",
IdentityBotOnly: "以机器人身份",
@@ -149,9 +144,8 @@ var bindMsgEn = &bindMsg{
ConflictCancel: "Keep current config",
ConflictCancelled: "Current config kept. No changes made.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
MessageUserDefaultFallback: "Bound app %s to %s. Obtain or refresh a user credential through this distribution's supported authorization flow before continuing with the user's request.",
MessageBotOnly: "Bound app %s to %s. The %s app (bot) identity is ready — you can now continue with the user's request.",
MessageUserDefault: "Bound app %s to %s. Next, in this %s chat, run `lark-cli auth login --recommend`. The command prints the verification URL to stderr and then blocks until the user authorizes it; relay the URL to the user so they can approve it in their own browser (do not call browser_navigate or any tool that opens a browser yourself — your browser is sandboxed and cannot complete the authorization). The command returns automatically once authorization completes.",
SelectIdentity: "How should the AI work with you?",
IdentityBotOnly: "As bot",

View File

@@ -10,8 +10,6 @@ import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// runHermesBindWithIdentity boots a Hermes-shaped fake env, runs `config bind`
@@ -62,29 +60,3 @@ func TestConfigBindRun_BotOnlyIdentity_NoImpersonationWarning(t *testing.T) {
t.Errorf("bot-only bind must NOT warn about impersonation; got: %s", out)
}
}
func TestUserDefaultBindMessageProjectsConcealedLogin(t *testing.T) {
visible := userDefaultBindMessage(bindMsgEn, "cli_test", "Hermes", nil)
if !strings.Contains(visible, "lark-cli auth login --recommend") {
t.Fatalf("default message lost established login action: %q", visible)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
concealed := userDefaultBindMessage(
bindMsgEn,
"cli_test",
"Hermes",
recovery.NewProjector(func() *surface.Plan { return plan }),
)
if strings.Contains(concealed, "auth login") ||
!strings.Contains(concealed, "supported authorization flow") {
t.Fatalf("concealed message = %q, want target-free authorization fallback", concealed)
}
for _, want := range []string{"cli_test", "Hermes"} {
if !strings.Contains(concealed, want) {
t.Errorf("concealed message lost binding fact %q: %q", want, concealed)
}
}
}

View File

@@ -6,22 +6,11 @@ package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/spf13/cobra"
)
// NewCmdConfig creates the config command with subcommands.
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
return newCmdConfig(f, nil)
}
// NewCmdConfigWithRecovery creates the config command with build-local
// recovery projection while preserving NewCmdConfig's established signature.
func NewCmdConfigWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdConfig(f, projector)
}
func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
@@ -37,7 +26,7 @@ func newCmdConfig(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(newCmdConfigBind(f, nil, projector))
cmd.AddCommand(NewCmdConfigBind(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))

View File

@@ -20,8 +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/recovery"
"github.com/larksuite/cli/internal/surface"
)
type noopConfigKeychain struct{}
@@ -566,59 +564,3 @@ func TestPrintLangPreferenceConfirmation(t *testing.T) {
}
})
}
// The "no active profile" producer annotates its profile/list recovery target.
// Rendering against one build's surface filters a clone without mutating the
// value another command tree may render.
func TestConfigShowRun_ProfileHintUsesBuildLocalSurface(t *testing.T) {
multi := &core.MultiAppConfig{
CurrentApp: "missing",
Apps: []core.AppConfig{{
Name: "default",
AppId: "app-default",
AppSecret: core.PlainSecret("secret-default"),
Brand: core.BrandFeishu,
}},
}
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)
source := configShowRun(&ConfigShowOptions{Factory: f})
var original *errs.ConfigError
if !errors.As(source, &original) {
t.Fatalf("expected *errs.ConfigError, got %T %v", source, source)
}
if original.Subtype != errs.SubtypeNotConfigured {
t.Fatalf("subtype = %q, want not_configured", original.Subtype)
}
if !strings.Contains(original.Hint, "lark-cli profile list") {
t.Fatalf("producer hint = %q, want profile list", original.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandProfileList: surface.CommandConcealed,
})
var concealed *errs.ConfigError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if strings.Contains(concealed.Hint, "profile list") ||
!strings.Contains(concealed.Hint, "select or configure an available profile") {
t.Errorf("concealed hint = %q, want target-free profile recovery", concealed.Hint)
}
var visible *errs.ConfigError
if !errors.As(recovery.Render(source, nil), &visible) ||
!strings.Contains(visible.Hint, "lark-cli profile list") {
t.Errorf("visible render must keep profile list, got %+v", visible)
}
if !strings.Contains(original.Hint, "lark-cli profile list") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

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/recovery"
)
// ConfigInitOptions holds all inputs for config init.
@@ -41,44 +40,13 @@ type ConfigInitOptions struct {
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
// ForceInit overrides the agent-workspace guard. Without it, running
// init under OPENCLAW_HOME / HERMES_HOME refuses so the distribution's
// supported Agent-app setup flow remains the default. Manual users with
// a legitimate need for a separate app can pass --force-init to bypass.
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
// at config bind — which is what AI agents almost always want. Manual
// users with a legitimate need for a separate app can pass --force-init
// to bypass.
ForceInit bool
}
const (
configInitLongPrefix = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command`
configInitBindGuidance = `
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one.`
configInitBindFallback = `
refuses by default to avoid creating a parallel app alongside Agent-managed
credentials. Reuse the Agent's existing app through this distribution's
supported setup flow.`
configInitBindSuffix = ` Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`
configInitFallbackSuffix = ` Pass --force-init only if the user explicitly wants a
separate app inside the Agent workspace.`
configInitLongWithBind = configInitLongPrefix + configInitBindGuidance + configInitBindSuffix
configInitLongWithoutBind = configInitLongPrefix + configInitBindFallback + configInitFallbackSuffix
forceInitUsageWithBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
forceInitUsageWithoutBind = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
)
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f, UILang: i18n.LangZhCN}
@@ -86,7 +54,16 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
cmd := &cobra.Command{
Use: "init",
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
Long: configInitLongWithBind,
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
@@ -109,30 +86,12 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, forceInitUsageWithBind)
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
return cmd
}
// ProjectInitHelp keeps the default command-specific guidance intact and
// replaces it only when this build conceals config bind. The config package
// owns both variants; the root presentation pass supplies the build-local
// availability decision after plugin policy has finalized the command tree.
func ProjectInitHelp(cmd *cobra.Command, canReferenceBind bool) {
if cmd == nil {
return
}
long, forceInitUsage := configInitLongWithBind, forceInitUsageWithBind
if !canReferenceBind {
long, forceInitUsage = configInitLongWithoutBind, forceInitUsageWithoutBind
}
cmd.Long = long
if flag := cmd.Flags().Lookup("force-init"); flag != nil {
flag.Usage = forceInitUsage
}
}
// printLangPreferenceConfirmation echoes the set preference to stderr, only
// when --lang explicitly set a non-empty value.
func printLangPreferenceConfirmation(opts *ConfigInitOptions) {
@@ -166,14 +125,9 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
if ws.IsLocal() {
return nil
}
return recovery.Attach(
errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()),
recovery.Join(" ",
recovery.Command(recovery.TargetConfigBind, "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead."),
recovery.Text("Pass --force-init only if the user explicitly wants a separate app in this workspace."),
),
)
return errs.NewConfigError(errs.SubtypeNotConfigured,
"config init is refused inside %s context (would create a parallel app and shadow the existing %s binding)", ws.Display(), ws.Display()).
WithHint("see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace.")
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.

View File

@@ -9,8 +9,6 @@ import (
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
func TestGuardAgentWorkspace_LocalAllows(t *testing.T) {
@@ -46,82 +44,6 @@ func TestGuardAgentWorkspace_OpenClawRefuses(t *testing.T) {
}
}
func TestGuardAgentWorkspace_BindRecoveryUsesBuildLocalSurface(t *testing.T) {
t.Setenv("OPENCLAW_HOME", t.TempDir())
source := guardAgentWorkspace(&ConfigInitOptions{})
var original *errs.ConfigError
if !errors.As(source, &original) {
t.Fatalf("guardAgentWorkspace() error = %T, want *errs.ConfigError", source)
}
const visibleHint = "see `lark-cli config bind --help` to bind lark-cli to the Agent's existing app instead. Pass --force-init only if the user explicitly wants a separate app in this workspace."
if original.Hint != visibleHint {
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigBind: surface.CommandConcealed,
})
var concealed *errs.ConfigError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
}
const forceInitHint = "Pass --force-init only if the user explicitly wants a separate app in this workspace."
if concealed.Hint != forceInitHint {
t.Errorf("concealed hint = %q, want %q", concealed.Hint, forceInitHint)
}
if original.Hint != visibleHint {
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
}
}
func TestProjectInitHelpPreservesDefaultAndProjectsConcealedBind(t *testing.T) {
cmd := NewCmdConfigInit(nil, nil)
forceInit := cmd.Flags().Lookup("force-init")
if forceInit == nil {
t.Fatal("config init command has no --force-init flag")
}
const defaultLong = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
existing app instead of creating a parallel one. Pass --force-init only
if the user explicitly wants a separate app inside the Agent workspace.`
const defaultForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app"
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
t.Fatalf("default help lost config bind recovery:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
ProjectInitHelp(cmd, false)
const concealedLong = `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default to avoid creating a parallel app alongside Agent-managed
credentials. Reuse the Agent's existing app through this distribution's
supported setup flow. Pass --force-init only if the user explicitly wants a
separate app inside the Agent workspace.`
const concealedForceInitUsage = "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME) only when the user explicitly wants a separate app"
if cmd.Long != concealedLong || forceInit.Usage != concealedForceInitUsage {
t.Fatalf("concealed help was not projected:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
if strings.Contains(cmd.Long, "config bind") || strings.Contains(forceInit.Usage, "config bind") {
t.Fatalf("concealed help retained config bind:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
ProjectInitHelp(cmd, true)
if cmd.Long != defaultLong || forceInit.Usage != defaultForceInitUsage {
t.Fatalf("visible projection did not restore default help:\nLong:\n%s\n--force-init: %s", cmd.Long, forceInit.Usage)
}
}
func TestGuardAgentWorkspace_HermesRefuses(t *testing.T) {
t.Setenv("HERMES_HOME", t.TempDir())

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

@@ -57,7 +57,7 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
out := map[string]any{
"source": string(active.Source.Kind),
"source_name": sourceName,
"denied_paths": active.DeniedPathCount(),
"denied_paths": active.DeniedPaths,
}
if len(active.Rules) > 0 {
rules := make([]map[string]any, 0, len(active.Rules))

View File

@@ -62,10 +62,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
Kind: cmdpolicy.SourcePlugin,
Name: "secaudit",
},
DeniedByPath: map[string]cmdpolicy.Denial{
"docs/create": {},
"docs/update": {},
},
DeniedPaths: 42,
})
f, out, _ := newPolicyTestFactory()
@@ -83,8 +80,8 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
t.Errorf("source_name = %v, want secaudit", got["source_name"])
}
// json.Unmarshal returns float64 for numbers.
if got["denied_paths"] != float64(2) {
t.Errorf("denied_paths = %v, want 2", got["denied_paths"])
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
}
rulesAny, ok := got["rules"].([]any)
if !ok || len(rulesAny) != 1 {

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/recovery"
"github.com/spf13/cobra"
)
@@ -56,14 +55,7 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
hint := recovery.Join("",
recovery.Command(recovery.TargetProfileList, "run: lark-cli profile list")).
WithFallback("select or configure an available profile through this distribution")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
WithHint("%s", hint.String()),
hint,
)
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

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/transport"
"github.com/larksuite/cli/internal/update"
)
@@ -34,17 +33,6 @@ type DoctorOptions struct {
// NewCmdDoctor creates the doctor command.
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
return newCmdDoctor(f, nil)
}
// NewCmdDoctorWithRecovery creates the doctor command with a build-local
// recovery presenter. Distribution assembly uses this boundary; ordinary
// callers keep NewCmdDoctor's original function signature and default output.
func NewCmdDoctorWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdDoctor(f, projector)
}
func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
opts := &DoctorOptions{Factory: f}
cmd := &cobra.Command{
@@ -52,7 +40,7 @@ func newCmdDoctor(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
Short: "CLI health check: config, auth, and connectivity",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
return doctorRun(opts, projector)
return doctorRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
@@ -86,13 +74,13 @@ func skip(name, msg string) checkResult {
return checkResult{Name: name, Status: "skip", Message: msg}
}
func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
func doctorRun(opts *DoctorOptions) error {
f := opts.Factory
var checks []checkResult
// ── 0. CLI version & update check ──
checks = append(checks, pass("cli_version", build.Version))
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
if !opts.Offline {
checks = append(checks, checkCLIUpdate()...)
}
@@ -108,7 +96,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
msg, hint := err.Error(), ""
if errors.Is(err, os.ErrNotExist) {
var cfgErr *errs.ConfigError
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
if errors.As(core.NotConfiguredError(), &cfgErr) {
msg, hint = cfgErr.Message, cfgErr.Hint
}
}
@@ -122,7 +110,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
if err != nil {
hint := ""
var cfgErr *errs.ConfigError
if errors.As(projector.Render(err), &cfgErr) {
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}
checks = append(checks, fail("app_resolved", err.Error(), hint))
@@ -133,10 +121,7 @@ func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
ep := core.ResolveEndpoints(cfg.Brand)
// ── 3. Identity readiness ──
diagnostics := identitydiag.FilterRecovery(
identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline),
projector.CanReference,
)
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
@@ -230,7 +215,7 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
func checkCLIUpdate() []checkResult {
latest, err := fetchLatestForDoctor()
latest, err := update.FetchLatest()
if err != nil {
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
}
@@ -243,8 +228,6 @@ func checkCLIUpdate() []checkResult {
return []checkResult{pass("cli_update", latest+" (up to date)")}
}
var fetchLatestForDoctor = update.FetchLatest
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
for _, c := range checks {

View File

@@ -17,8 +17,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
@@ -103,31 +101,6 @@ func TestNetworkChecks_Offline(t *testing.T) {
}
}
func TestDoctorRunDoesNotFetchUpdateWhenCommandIsConcealed(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
oldFetch := fetchLatestForDoctor
t.Cleanup(func() { fetchLatestForDoctor = oldFetch })
fetches := 0
fetchLatestForDoctor = func() (string, error) {
fetches++
return "9.9.9", nil
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
f, _, _, _ := cmdutil.TestFactory(t, nil)
_ = doctorRun(&DoctorOptions{
Factory: f,
Ctx: context.Background(),
}, projector)
if fetches != 0 {
t.Fatalf("concealed update triggered %d npm fetch(es)", fetches)
}
}
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
@@ -151,7 +124,7 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
Factory: f,
Ctx: context.Background(),
Offline: true,
}, nil)
})
if err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
@@ -229,7 +202,7 @@ func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T
IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}},
}
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}, nil); err == nil {
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil {
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
}
var got struct {

View File

@@ -4,6 +4,7 @@
package cmd
import (
"errors"
"fmt"
"strings"
@@ -14,64 +15,11 @@ import (
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
)
// rootErrorPresenter owns the final command-facing error transformation for
// one Cobra tree. Producers report typed facts and optional semantic recovery;
// this boundary clones, completes, and projects them without exposing the
// build-local surface plan to business packages.
type rootErrorPresenter struct {
f *cmdutil.Factory
projector *recovery.Projector
}
func newRootErrorPresenter(f *cmdutil.Factory, projector *recovery.Projector) *rootErrorPresenter {
return &rootErrorPresenter{f: f, projector: projector}
}
func (p *rootErrorPresenter) Present(err error) error {
if err == nil || errs.IsRaw(err) {
return err
}
rendered := p.projector.Render(err)
p.completePermissionRecovery(rendered)
applyNeedAuthorizationHint(p.f, rendered)
return rendered
}
// completePermissionRecovery supplies the canonical recovery for direct
// PermissionError producers. API classification paths that already carry an
// owned structured annotation keep their rendered Hint unchanged.
func (p *rootErrorPresenter) completePermissionRecovery(err error) {
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return
}
permissionErr, ok := typed.(*errs.PermissionError) //nolint:errorlint // presentation must not descend into the clone's original Cause
if !ok || permissionErr.Hint != "" {
return
}
identity := permissionErr.Identity
if identity == "" && p.f != nil {
identity = string(p.f.ResolvedIdentity)
}
if identity == "" {
identity = string(core.AsUser)
}
hint := errclass.PermissionRecovery(
permissionErr.MissingScopes,
identity,
permissionErr.Subtype,
permissionErr.ConsoleURL,
)
permissionErr.Hint = p.projector.RenderHint(hint)
}
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
// "current command requires scope(s): X, Y" hint when the underlying error is
// a need_user_authorization signal AND the current command declares scopes
@@ -84,12 +32,8 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
if !internalauth.IsNeedUserAuthorizationError(err) {
return
}
typed, ok := errs.UnwrapTypedError(err)
if !ok {
return
}
authErr, ok := typed.(*errs.AuthenticationError) //nolint:errorlint // enrich only the presented clone, never a nested producer Cause
if !ok {
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)

View File

@@ -1,139 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
func TestRootErrorPresenterCompletesDirectPermissionRecoveryWithoutMutatingProducer(t *testing.T) {
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
WithMissingScopes("docx:document").
WithIdentity("user")
visible := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(nil),
).Present(source)
visibleProblem, _ := errs.ProblemOf(visible)
if !strings.Contains(visibleProblem.Hint, `auth login --scope "docx:document"`) {
t.Fatalf("visible recovery = %q, want scoped auth login", visibleProblem.Hint)
}
if source.Hint != "" {
t.Fatalf("presenter mutated producer hint: %q", source.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
concealed := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(func() *surface.Plan { return plan }),
).Present(source)
concealedProblem, _ := errs.ProblemOf(concealed)
if strings.Contains(concealedProblem.Hint, "auth login") ||
!strings.Contains(concealedProblem.Hint, "supported authorization flow") {
t.Fatalf("concealed recovery = %q, want target-free fallback", concealedProblem.Hint)
}
}
func TestRootErrorPresenterDoesNotRecommendUserLoginForBotPermission(t *testing.T) {
source := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope").
WithMissingScopes("drive:file:download").
WithIdentity("bot")
rendered := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsBot},
recovery.NewProjector(nil),
).Present(source)
problem, _ := errs.ProblemOf(rendered)
if strings.Contains(problem.Hint, "auth login") ||
!strings.Contains(problem.Hint, "app developer") {
t.Fatalf("bot recovery = %q", problem.Hint)
}
}
func TestRootErrorPresenterDoesNotMutateNestedPermissionCause(t *testing.T) {
inner := errs.NewPermissionError(errs.SubtypeMissingScope, "inner permission").
WithMissingScopes("docx:document").
WithIdentity("user")
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
WithHint("retry the operation").
WithCause(inner)
rendered := newRootErrorPresenter(
&cmdutil.Factory{ResolvedIdentity: core.AsUser},
recovery.NewProjector(nil),
).Present(outer)
if inner.Hint != "" {
t.Fatalf("presenter mutated nested producer hint: %q", inner.Hint)
}
problem, _ := errs.ProblemOf(rendered)
if got, want := problem.Hint, "retry the operation"; got != want {
t.Fatalf("rendered outer hint = %q, want %q", got, want)
}
}
func TestRootErrorPresenterDoesNotMutateNestedAuthenticationCause(t *testing.T) {
f := factoryWithDeclaredServiceScope(t)
source := internalauth.NewNeedUserAuthorizationError("ou_nested")
var inner *errs.AuthenticationError
if !errors.As(source, &inner) {
t.Fatalf("source = %T, want nested *errs.AuthenticationError", source)
}
originalHint := inner.Hint
outer := errs.NewInternalError(errs.SubtypeUnknown, "outer failure").
WithHint("retry the operation").
WithCause(source)
rendered := newRootErrorPresenter(f, recovery.NewProjector(nil)).Present(outer)
if got := inner.Hint; got != originalHint {
t.Fatalf("presenter mutated nested authentication hint: got %q want %q", got, originalHint)
}
problem, _ := errs.ProblemOf(rendered)
if got, want := problem.Hint, "retry the operation"; got != want {
t.Fatalf("rendered outer hint = %q, want %q", got, want)
}
}
func factoryWithDeclaredServiceScope(t *testing.T) *cmdutil.Factory {
t.Helper()
f := &cmdutil.Factory{ResolvedIdentity: core.AsUser}
var target registry.CommandEntry
for _, entry := range registry.CollectCommandScopes([]string{"calendar"}, "user") {
if len(entry.Scopes) > 0 {
target = entry
break
}
}
if target.Command == "" {
t.Fatal("failed to locate a service command with declared user scopes")
}
parts := strings.Split(target.Command, " ")
if len(parts) != 2 {
t.Fatalf("service command = %q, want resource and method", target.Command)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "calendar"}
resource := &cobra.Command{Use: parts[0]}
method := &cobra.Command{Use: parts[1]}
root.AddCommand(domain)
domain.AddCommand(resource)
resource.AddCommand(method)
f.CurrentCommand = method
return f
}

View File

@@ -278,24 +278,27 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
if len(missing) == 0 {
return nil
}
permissionErr := errs.NewPermissionError(errs.SubtypeMissingScope,
return errs.NewPermissionError(errs.SubtypeMissingScope,
"missing required scopes for EventKey %s (as %s): %s",
pf.eventKey, pf.identity, strings.Join(missing, ", ")).
WithIdentity(string(pf.identity)).
WithMissingScopes(missing...)
if pf.identity.IsBot() {
permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing))
}
return permissionErr
WithMissingScopes(missing...).
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
}
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
// The bot-specific scan-to-enable link adds the scopes to the app manifest,
// after which the tenant token carries them. User recovery is generated from
// the PermissionError's identity and missing_scopes by the root presenter.
func botScopeRemediationHint(brand core.LarkBrand, appID string, missing []string) string {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(core.AsBot, missing)))
// Bot: the scan-to-enable link adds the scopes to the app manifest, after which
// the tenant token carries them. User: the scan link only updates the app
// manifest — the user's own token still lacks the scopes until it is
// re-authorized — so direct the user to re-login instead.
func scopeRemediationHint(brand core.LarkBrand, appID string, identity core.Identity, missing []string) string {
if identity.IsBot() {
return fmt.Sprintf("grant these scopes by scanning: %s",
addonsHintURL(brand, appID, missingScopeAddons(identity, missing)))
}
return fmt.Sprintf(
"run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.",
strings.Join(missing, " "))
}
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
@@ -376,7 +379,7 @@ func resolveTenantToken(ctx context.Context, f *cmdutil.Factory, appID string) (
if result == nil || result.Token == "" {
return "", errs.NewAuthenticationError(errs.SubtypeTokenMissing,
"no tenant access token available for app %s", appID).
WithHint("check that app_secret is configured for this distribution")
WithHint("Check that app_secret is configured (lark-cli config show) and try 'lark-cli auth login'.")
}
return result.Token, nil
}

View File

@@ -264,9 +264,18 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
}
}
func TestBotScopeRemediationHintUsesScanLink(t *testing.T) {
bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []string{"im:message"})
func TestScopeRemediationHint_ByIdentity(t *testing.T) {
// bot: scan-to-enable link (adds scopes to app manifest)
bot := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsBot, []string{"im:message"})
if !strings.Contains(bot, "/page/launcher?clientID=cli_x&addons=") {
t.Errorf("bot hint should give the scan link, got: %s", bot)
}
// user: re-login (scan link cannot grant scopes to the user's own token)
user := scopeRemediationHint(core.BrandFeishu, "cli_x", core.AsUser, []string{"im:message"})
if !strings.Contains(user, "auth login --scope") {
t.Errorf("user hint should direct to auth login, got: %s", user)
}
if strings.Contains(user, "/page/launcher") {
t.Errorf("user hint must NOT use the scan link, got: %s", user)
}
}

View File

@@ -1,67 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/larksuite/cli/internal/surface"
)
// globalFlagTargets maps each root persistent flag to the command capability
// it belongs to. A new domain-tied global flag must add a row.
var globalFlagTargets = map[string]surface.CommandID{
"profile": surface.CommandProfile,
}
// flagGateAnnotation distinguishes a surface-retired flag from one hidden
// cosmetically (single-app mode force-shows the latter in root help).
const flagGateAnnotation = "lark:surface_concealed_flag"
// applyPluginFlagGate hides and rejects global flags whose exact command
// capability is absent from this build. It is called only by the explicit
// distribution presentation pass.
func applyPluginFlagGate(root *cobra.Command, plan *surface.Plan) {
for flagName, target := range globalFlagTargets {
if plan.CanReference(target) {
continue
}
fl := root.PersistentFlags().Lookup(flagName)
if fl == nil {
continue
}
fl.Hidden = true
if fl.Annotations == nil {
fl.Annotations = map[string][]string{}
}
fl.Annotations[flagGateAnnotation] = []string{"true"}
fl.Value = &gatedFlagValue{name: flagName, inner: fl.Value}
}
}
func isPolicyGatedFlag(fl *pflag.Flag) bool {
return fl != nil && fl.Annotations[flagGateAnnotation] != nil
}
// gatedFlagValue rejects at parse time, before cobra's help/version fast
// paths (which never reach PersistentPreRunE). Its Set error carries
// cobra's own unknown-flag wording so the root FlagErrorFunc classifies it
// as an ordinary unknown flag without exposing policy state. Cobra may add
// different parse context on root/group paths than on leaf commands.
type gatedFlagValue struct {
name string
inner pflag.Value
}
func (g *gatedFlagValue) String() string { return g.inner.String() }
func (g *gatedFlagValue) Type() string { return g.inner.Type() }
func (g *gatedFlagValue) Set(string) error {
// Intermediate parse error, not a final envelope: pflag wraps it and
// the root FlagErrorFunc (flagDidYouMean) converts it to the typed
// unknown-flag validation error.
return errors.New("unknown flag: --" + g.name) //nolint:forbidigo // intermediate parse error; flagDidYouMean emits the typed envelope
}

View File

@@ -23,7 +23,7 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
Skill: "lark-sheets",
})
got := composePendingNotice(nil)
got := composePendingNotice()
if got == nil {
t.Fatal("composePendingNotice() = nil, want deprecated_command entry")
}
@@ -51,7 +51,7 @@ func TestComposePendingNoticeEmpty(t *testing.T) {
t.Cleanup(func() { deprecation.SetPending(nil) })
deprecation.SetPending(nil)
if got := composePendingNotice(nil); got != nil {
if got := composePendingNotice(); got != nil {
// update/skills pending are process-global; only assert the absence of
// our own key to stay robust against unrelated pending state.
if _, ok := got["deprecated_command"]; ok {

View File

@@ -35,10 +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
// optional, build-local distribution presentation pass in build.go.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) (map[string]cmdpolicy.Denial, error) {
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
@@ -68,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
}
@@ -80,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,12 +100,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rules: rules,
Source: source,
DeniedByPath: denied,
Rules: rules,
Source: source,
DeniedPaths: len(denied),
})
return denied, nil
return nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
@@ -160,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
@@ -186,20 +167,7 @@ func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry)
if reg == nil {
return nil
}
installHooks(rootCmd, reg)
return emitStartup(ctx, reg)
}
func installHooks(rootCmd *cobra.Command, reg *hook.Registry) {
if reg != nil {
hook.Install(rootCmd, reg, cobraCommandViewSource{})
}
}
func emitStartup(ctx context.Context, reg *hook.Registry) error {
if reg == nil {
return nil
}
hook.Install(rootCmd, reg, cobraCommandViewSource{})
return hook.Emit(ctx, reg, platform.Startup, nil)
}

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,33 +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
}
typed := errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", err.Error()).
WithCause(err)
if errors.Is(err, skillpolicy.ErrNoBaseSkillContent) {
return typed.WithHint("this build embeds no base skill content; call cmd.SetEmbeddedSkillContent before Execute or provide a non-empty EmbeddedSkills.Base (reason_code %s)", reasonCode)
}
if errors.Is(err, skillpolicy.ErrInvalidHostBase) {
return typed.WithHint("the wrapper's embedded base skill tree is invalid; fix the content passed to cmd.SetEmbeddedSkillContent (reason_code %s)", reasonCode)
}
return typed.WithHint("skill customization is broken (reason_code %s); fix the plugin's EmbeddedSkills configuration or remove the conflicting plugin", reasonCode)
}
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,326 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
configcmd "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/surface"
)
const annotationUnavailableMessage = "lark:presentation_unavailable_message"
type projectedCommand struct {
state surface.CommandState
denial cmdpolicy.Denial
}
// presentationProjection keeps one distribution's build-time presentation
// state and denial provenance together, so a concealed command retains the
// cause installed on its unavailable projection.
type presentationProjection struct {
commands map[surface.CommandID]projectedCommand
}
func newPresentationProjection(denied map[string]cmdpolicy.Denial) *presentationProjection {
projection := &presentationProjection{
commands: make(map[surface.CommandID]projectedCommand, len(denied)),
}
for path, denial := range denied {
projection.commands[surface.CommandID(path)] = projectedCommand{
state: surface.CommandDeniedVisible,
denial: denial,
}
}
return projection
}
func (p *presentationProjection) recordConcealed(path string, denial cmdpolicy.Denial) {
p.commands[surface.CommandID(path)] = projectedCommand{
state: surface.CommandConcealed,
denial: denial,
}
}
func (p *presentationProjection) denial(path string) (cmdpolicy.Denial, bool) {
command, ok := p.commands[surface.CommandID(path)]
if !ok || command.state != surface.CommandConcealed {
return cmdpolicy.Denial{}, false
}
return command.denial, true
}
func (p *presentationProjection) plan() *surface.Plan {
states := make(map[surface.CommandID]surface.CommandState, len(p.commands))
for id, command := range p.commands {
states[id] = command.state
}
return surface.NewPlan(states)
}
func (p *presentationProjection) hasConcealedCommands() bool {
for _, command := range p.commands {
if command.state == surface.CommandConcealed {
return true
}
}
return false
}
// applyDistributionPresentation projects enforcement decisions onto the
// command surface of this one build. Enforcement has already installed its
// policy-rich deny stubs. Without an explicit presentation option, those stubs
// and their legacy help/completion behavior are left untouched.
func applyDistributionPresentation(
root *cobra.Command,
cfg restrictionPresentationConfig,
denied map[string]cmdpolicy.Denial,
) (*surface.Plan, bool) {
projection := newPresentationProjection(denied)
if !cfg.enabled {
return projection.plan(), false
}
collectPluginConcealments(root, denied, projection)
if cfg.hidePolicyDiagnostics {
collectDiagnosticConcealments(root, projection)
}
propagateConcealedPureGroups(root, projection)
installUnavailableProjections(root, projection, cfg.effectiveUnavailableMessage())
plan := projection.plan()
applyPresentationAffordances(root, plan)
return plan, projection.hasConcealedCommands()
}
func collectPluginConcealments(
root *cobra.Command,
denied map[string]cmdpolicy.Denial,
projection *presentationProjection,
) {
for path, denial := range denied {
if !cmdpolicy.IsPluginPolicySource(denial.PolicySource) {
continue
}
cmd := findByPath(root, path)
if cmd == nil || commandDenialLayer(cmd) == cmdpolicy.LayerStrictMode {
continue
}
projection.recordConcealed(path, denial)
}
}
func collectDiagnosticConcealments(
root *cobra.Command,
projection *presentationProjection,
) {
for _, path := range cmdpolicy.DiagnosticPaths() {
if findByPath(root, path) == nil {
continue
}
projection.recordConcealed(path, cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "distribution:presentation",
ReasonCode: "diagnostics_concealed",
Reason: "policy diagnostics concealed by the distribution",
})
}
}
func propagateConcealedPureGroups(
root *cobra.Command,
projection *presentationProjection,
) {
// A pure parent becomes absent only when every live child is absent. Repeat
// bottom-up until all newly-empty intermediate groups converge.
for {
changed := false
plan := projection.plan()
walkCommandsPostOrder(root, func(cmd *cobra.Command) {
path, denial, ok := concealedPureGroup(cmd, plan, projection)
if !ok {
return
}
projection.recordConcealed(path, denial)
changed = true
})
if !changed {
break
}
}
}
func concealedPureGroup(
cmd *cobra.Command,
plan *surface.Plan,
projection *presentationProjection,
) (string, cmdpolicy.Denial, bool) {
path := cmdpolicy.CanonicalPath(cmd)
if !cmd.HasParent() || !isPresentationPureGroup(cmd) ||
plan.IsConcealed(surface.CommandID(path)) {
return "", cmdpolicy.Denial{}, false
}
children := cmd.Commands()
if len(children) == 0 {
return "", cmdpolicy.Denial{}, false
}
var cause cmdpolicy.Denial
for _, child := range children {
childPath := cmdpolicy.CanonicalPath(child)
if !plan.IsConcealed(surface.CommandID(childPath)) {
return "", cmdpolicy.Denial{}, false
}
if denial, ok := projection.denial(childPath); ok && cause.Layer == "" {
cause = denial
}
}
if cause.Layer == "" {
cause = cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "distribution:presentation",
ReasonCode: "all_children_concealed",
Reason: "all child commands are concealed",
}
}
return path, cause, true
}
func installUnavailableProjections(
root *cobra.Command,
projection *presentationProjection,
message string,
) {
for id, command := range projection.commands {
if command.state != surface.CommandConcealed {
continue
}
path := string(id)
if cmd := findByPath(root, path); cmd != nil {
installUnavailableProjection(cmd, path, command.denial, message)
}
}
}
func applyPresentationAffordances(root *cobra.Command, plan *surface.Plan) {
applyPluginFlagGate(root, plan)
configcmd.ProjectInitHelp(
findByPath(root, string(surface.CommandConfigInit)),
plan.CanReference(surface.CommandConfigBind),
)
root.Long = renderRootHelpSections(rootLongSections, plan)
root.SetUsageTemplate(renderRootUsageTemplate(plan))
}
func commandDenialLayer(cmd *cobra.Command) string {
if cmd == nil || cmd.Annotations == nil {
return ""
}
return cmd.Annotations[cmdpolicy.AnnotationDenialLayer]
}
func isPresentationPureGroup(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
return (cmd.Run == nil && cmd.RunE == nil) || cmdpolicy.IsPureGroup(cmd)
}
func walkCommandsPostOrder(cmd *cobra.Command, visit func(*cobra.Command)) {
for _, child := range cmd.Commands() {
walkCommandsPostOrder(child, visit)
}
visit(cmd)
}
// installUnavailableProjection changes presentation only. It preserves the
// enforcement denial as the in-process cause when one exists, while the wire
// intentionally exposes no policy source, rule name, or reason code.
func installUnavailableProjection(cmd *cobra.Command, path string, denial cmdpolicy.Denial, message string) {
cmd.Hidden = true
cmd.DisableFlagParsing = true
cmd.Args = cobra.ArbitraryArgs
cmd.PersistentPreRunE = func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
}
cmd.PersistentPreRun = nil
cmd.PreRunE = nil
cmd.PreRun = nil
hideFlags := func(flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
flag.Hidden = true
})
}
// Hide only flags owned by this command. cmd.Flags() may contain inherited
// flag pointers after Cobra merges sets; mutating those would hide a global
// flag from unrelated commands.
hideFlags(cmd.LocalNonPersistentFlags())
hideFlags(cmd.PersistentFlags())
cmd.ValidArgs = nil
cmd.ValidArgsFunction = func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return nil, cobra.ShellCompDirectiveNoFileComp
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[annotationUnavailableMessage] = message
if cmd.Annotations[cmdpolicy.AnnotationDenialLayer] == "" {
cmd.Annotations[cmdpolicy.AnnotationDenialLayer] = denial.Layer
cmd.Annotations[cmdpolicy.AnnotationDenialSource] = denial.PolicySource
}
cmd.RunE = func(*cobra.Command, []string) error {
err := errs.NewValidationError(errs.SubtypeCommandUnavailable, "%s", message)
if denial.Layer != "" {
err.WithCause(cmdpolicy.CommandDeniedFromDenial(path, denial))
}
return err
}
cmd.Run = nil
}
// unavailableHelpMessage is deliberately keyed only by the opt-in projection
// annotation. A legacy Restrict denial carries enforcement annotations but
// continues to use Cobra's stock explicit-help behavior.
func unavailableHelpMessage(cmd *cobra.Command) (string, bool) {
for current := cmd; current != nil; current = current.Parent() {
if current.Annotations == nil {
continue
}
if message := current.Annotations[annotationUnavailableMessage]; message != "" {
return message, true
}
}
return "", false
}
// findByPath resolves a canonical slash path (for example
// "config/policy/show") to a command node.
func findByPath(root *cobra.Command, path string) *cobra.Command {
cur := root
for _, segment := range strings.Split(path, "/") {
var next *cobra.Command
for _, child := range cur.Commands() {
if child.Name() == segment {
next = child
break
}
}
if next == nil {
return nil
}
cur = next
}
return cur
}

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
// defaultRestrictedCommandUnavailableMessage is the distribution-neutral
// fallback for a concealed command. It lives in the presentation layer rather
// than extension/platform.Rule: the same enforcement rule may be rendered as a
// visible policy denial by one host and as an absent capability by another.
const defaultRestrictedCommandUnavailableMessage = "command not included in this build"
// restrictionPresentationConfig is a per-Build snapshot. It is deliberately
// private so adding a future presentation knob cannot break downstream
// unkeyed struct literals.
type restrictionPresentationConfig struct {
enabled bool
unavailableMessage string
hidePolicyDiagnostics bool
}
func (c restrictionPresentationConfig) effectiveUnavailableMessage() string {
if c.unavailableMessage != "" {
return c.unavailableMessage
}
return defaultRestrictedCommandUnavailableMessage
}
// RestrictionPresentationOption configures the presentation of commands
// denied by an embedded distribution's Restrict plugin.
//
// Values are accepted only by ConcealRestrictedCommands. The pointed-to
// configuration type is private by design; callers use the constructors in
// this file instead of depending on a public struct layout.
type RestrictionPresentationOption func(*restrictionPresentationConfig)
// ConcealRestrictedCommands opts one command tree into presenting
// plugin-restricted commands as capabilities absent from the distribution.
//
// Restrict remains the enforcement boundary. Without this BuildOption,
// existing Restrict plugins keep their established failed_precondition
// envelope, explicit-help, and completion behavior.
//
// Pass the returned option to Build, or to ExecuteWithOptions when using the
// standard host entrypoint.
func ConcealRestrictedCommands(opts ...RestrictionPresentationOption) BuildOption {
presentation := restrictionPresentationConfig{enabled: true}
for _, opt := range opts {
if opt != nil {
opt(&presentation)
}
}
return func(cfg *buildConfig) {
cfg.presentation = presentation
}
}
// UnavailableMessage customizes the error message for a concealed command.
// An empty message selects the distribution-neutral default.
func UnavailableMessage(message string) RestrictionPresentationOption {
return func(cfg *restrictionPresentationConfig) {
cfg.unavailableMessage = message
}
}
// HidePolicyDiagnostics removes the policy self-inspection commands from a
// concealed distribution. Without it, those commands remain the operator's
// recovery and inspection escape hatch.
func HidePolicyDiagnostics() RestrictionPresentationOption {
return func(cfg *restrictionPresentationConfig) {
cfg.hidePolicyDiagnostics = true
}
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import "testing"
// Preserve callers that store the original entrypoint as a function value.
// Making Execute variadic would compile at ordinary call sites but break this
// established source contract.
var _ func() int = Execute
func TestConcealRestrictedCommandsDefaults(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands()(cfg)
if !cfg.presentation.enabled {
t.Fatal("concealment must be explicitly enabled by the BuildOption")
}
if cfg.presentation.hidePolicyDiagnostics {
t.Fatal("policy diagnostics must remain available by default")
}
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
}
}
func TestConcealRestrictedCommandsOptions(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands(
UnavailableMessage("not part of acme-cli"),
HidePolicyDiagnostics(),
)(cfg)
if !cfg.presentation.enabled {
t.Fatal("concealment must be enabled")
}
if !cfg.presentation.hidePolicyDiagnostics {
t.Fatal("HidePolicyDiagnostics option was not applied")
}
if got := cfg.presentation.effectiveUnavailableMessage(); got != "not part of acme-cli" {
t.Errorf("message = %q, want custom message", got)
}
}
func TestConcealRestrictedCommandsIsBuildLocal(t *testing.T) {
concealed := &buildConfig{}
ordinary := &buildConfig{}
ConcealRestrictedCommands(
UnavailableMessage("acme only"),
HidePolicyDiagnostics(),
)(concealed)
if ordinary.presentation.enabled {
t.Fatal("applying an option to one build must not enable another")
}
if ordinary.presentation.hidePolicyDiagnostics {
t.Fatal("applying an option to one build must not mutate another")
}
if got := ordinary.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("ordinary message = %q, want default", got)
}
}
func TestUnavailableMessageEmptyUsesDefault(t *testing.T) {
cfg := &buildConfig{}
ConcealRestrictedCommands(UnavailableMessage(""))(cfg)
if got := cfg.presentation.effectiveUnavailableMessage(); got != defaultRestrictedCommandUnavailableMessage {
t.Errorf("message = %q, want %q", got, defaultRestrictedCommandUnavailableMessage)
}
}

View File

@@ -1,757 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
)
func registerRestriction(t *testing.T, deny []string, configure func(*platform.Builder) *platform.Builder) {
t.Helper()
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
builder := platform.NewPlugin("acme", "1.0").
Restrict(&platform.Rule{Deny: deny})
if configure != nil {
builder = configure(builder)
}
platform.Register(builder.MustBuild())
}
func TestBuildInternalRestrictDefaultPreservesLegacyContract(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
runtime, root, _ := buildInternal(context.Background(), buildInvocationForTest(t))
leaf := findByPath(root, "skills/read")
if leaf == nil {
t.Fatal("skills/read not found")
}
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandDeniedVisible {
t.Fatalf("surface state = %v, want denied-visible", got)
}
if _, projected := unavailableHelpMessage(leaf); projected {
t.Fatal("legacy Restrict unexpectedly received concealment presentation")
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
}
if validation.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("subtype = %q, want failed_precondition", validation.Subtype)
}
if !strings.Contains(validation.Hint, "source plugin:acme") ||
!strings.Contains(validation.Hint, "reason_code") {
t.Errorf("legacy policy metadata missing from hint: %q", validation.Hint)
}
if flag := leaf.Flags().Lookup("json"); flag == nil || flag.Hidden {
t.Errorf("legacy Restrict must preserve local flag presentation; flag=%+v", flag)
}
var help bytes.Buffer
root.SetOut(&help)
root.SetErr(&help)
if err := root.Help(); err != nil {
t.Fatal(err)
}
for _, title := range []string{"Lark domains:", "Agent tooling:", "CLI management:"} {
if !strings.Contains(help.String(), title) {
t.Errorf("default root help lost group %q", title)
}
}
}
func TestBuildInternalConcealmentIsExplicitAndKeepsDenialAsCause(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
runtime, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(UnavailableMessage("not shipped by acme")),
)
leaf := findByPath(root, "skills/read")
if got := runtime.surface.State(surface.CommandSkillsRead); got != surface.CommandConcealed {
t.Fatalf("surface state = %v, want concealed", got)
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("RunE error = %T %v, want ValidationError", err, err)
}
if validation.Subtype != errs.SubtypeCommandUnavailable ||
validation.Message != "not shipped by acme" || validation.Hint != "" {
t.Errorf("concealed error = %+v", validation)
}
var denied *platform.CommandDeniedError
if !errors.As(err, &denied) || denied.Path != "skills/read" ||
denied.PolicySource != "plugin:acme" {
t.Errorf("enforcement cause not preserved: %T %+v", err, denied)
}
if flag := leaf.Flags().Lookup("json"); flag == nil || !flag.Hidden {
t.Errorf("concealed command must hide owned flags; flag=%+v", flag)
}
if flag := root.PersistentFlags().Lookup("profile"); flag == nil || flag.Hidden {
t.Errorf("concealing a leaf must not mutate inherited global flags; flag=%+v", flag)
}
if args, _ := leaf.ValidArgsFunction(leaf, nil, ""); len(args) != 0 {
t.Errorf("concealed command completed positionals: %v", args)
}
help := findByPath(root, "help")
if help == nil || help.RunE == nil {
t.Fatal("concealment-specific help command not installed")
}
err = help.RunE(help, []string{"skills", "read"})
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("help on concealed command = %v, want command_unavailable", err)
}
active := cmdpolicy.GetActive()
if active == nil || active.DeniedByPath["skills/read"].PolicySource != "plugin:acme" {
t.Fatalf("presentation overwrote enforcement snapshot: %+v", active)
}
}
func TestDistributionPresentationNeverConcealsYAMLPolicy(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
leaf := &cobra.Command{Use: "probe", RunE: func(*cobra.Command, []string) error { return nil }}
root.AddCommand(leaf)
denial := cmdpolicy.Denial{
Layer: cmdpolicy.LayerPolicy,
PolicySource: "yaml:/tmp/policy.yml",
ReasonCode: "command_denylisted",
Reason: "denied by user policy",
}
denied := map[string]cmdpolicy.Denial{"probe": denial}
cmdpolicy.Apply(root, denied)
plan, concealed := applyDistributionPresentation(
root,
restrictionPresentationConfig{enabled: true},
denied,
)
if concealed {
t.Fatal("user-owned YAML denial must not be projected as absent")
}
if got := plan.State("probe"); got != surface.CommandDeniedVisible {
t.Fatalf("surface state = %v, want denied-visible", got)
}
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) || validation.Subtype != errs.SubtypeFailedPrecondition {
t.Errorf("YAML denial changed by distribution presentation: %v", err)
}
}
func TestRootGroupsFollowSurfaceConcealmentNotLegacyHiddenState(t *testing.T) {
newRoot := func() *cobra.Command {
root := &cobra.Command{Use: "lark-cli"}
child := &cobra.Command{
Use: "skills",
GroupID: groupTooling,
RunE: func(*cobra.Command, []string) error { return nil },
}
root.AddCommand(child)
return root
}
yamlRoot := newRoot()
yamlChild := findByPath(yamlRoot, "skills")
yamlChild.Hidden = true
finalizeRootCommandGroups(yamlRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkills: surface.CommandDeniedVisible,
}))
if len(yamlRoot.Groups()) != 1 || yamlRoot.Groups()[0].ID != groupTooling {
t.Fatalf("legacy/YAML hidden command removed its group: %+v", yamlRoot.Groups())
}
concealedRoot := newRoot()
finalizeRootCommandGroups(concealedRoot, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkills: surface.CommandConcealed,
}))
if len(concealedRoot.Groups()) != 0 {
t.Fatalf("concealed-only group remained visible: %+v", concealedRoot.Groups())
}
if got := findByPath(concealedRoot, "skills").GroupID; got != "" {
t.Fatalf("concealed child retained undefined GroupID %q", got)
}
}
func TestPresentationDropsRootSkillsFooterWithSkillsRead(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.SetUsageTemplate(rootUsageTemplate)
applyPresentationAffordances(root, surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSkillsRead: surface.CommandConcealed,
}))
if strings.Contains(root.UsageTemplate(), "Skills setup (one-time, humans)") {
t.Fatalf("concealed skills/read left the root skills footer:\n%s", root.UsageTemplate())
}
}
func TestPresentationProjectsEveryFrameworkOwnedRootHelpTarget(t *testing.T) {
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
root.SetUsageTemplate(rootUsageTemplate)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
rootHelpAPI: surface.CommandConcealed,
surface.CommandSchema: surface.CommandConcealed,
rootHelpCalendarAgenda: surface.CommandConcealed,
rootHelpMailList: surface.CommandConcealed,
})
applyPresentationAffordances(root, plan)
for _, dead := range []string{
"lark-cli api ",
"lark-cli schema ",
"lark-cli calendar +agenda",
"lark-cli mail user_mailbox.messages list",
} {
if strings.Contains(root.Long, dead) || strings.Contains(root.UsageTemplate(), dead) {
t.Errorf("concealed root-help target %q survived:\nLong:\n%s\nTemplate:\n%s",
dead, root.Long, root.UsageTemplate())
}
}
if !strings.Contains(root.Long, "Browse commands:") ||
!strings.Contains(root.UsageTemplate(), "lark-cli <command>") {
t.Fatalf("target-independent root guidance was removed:\nLong:\n%s\nTemplate:\n%s",
root.Long, root.UsageTemplate())
}
}
func TestFrameworkOwnedRootHelpTargetsExistInDefaultTree(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
WithoutPlugins(),
)
var fragments []rootHelpFragment
for _, section := range rootLongSections {
fragments = append(fragments, section.fragments...)
}
fragments = append(fragments, rootUsageSynopsis...)
for _, fragment := range fragments {
if fragment.target == "" {
continue
}
if command := findByPath(root, string(fragment.target)); command == nil {
t.Errorf("root-help target %q does not resolve in the default command tree", fragment.target)
}
}
}
func TestPresentationKeepsDefaultRootHelpByteStable(t *testing.T) {
root := &cobra.Command{Use: "lark-cli", Long: rootLong}
root.SetUsageTemplate(rootUsageTemplate)
wantLong, wantUsage := root.Long, root.UsageTemplate()
applyPresentationAffordances(root, nil)
if root.Long != wantLong {
t.Fatalf("default root Long changed:\nwant:\n%s\n\ngot:\n%s", wantLong, root.Long)
}
if root.UsageTemplate() != wantUsage {
t.Fatalf("default root usage template changed:\nwant:\n%s\n\ngot:\n%s", wantUsage, root.UsageTemplate())
}
}
func TestHelpRejectsDescendantOfConcealedParent(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
parent := &cobra.Command{Use: "apps"}
child := &cobra.Command{Use: "+db-execute", RunE: func(*cobra.Command, []string) error { return nil }}
parent.AddCommand(child)
root.AddCommand(parent)
installUnavailableProjection(parent, "apps", cmdpolicy.Denial{}, "not shipped")
installHelpCommand(root)
help := findByPath(root, "help")
if help == nil || help.RunE == nil {
t.Fatal("help command not installed")
}
err := help.RunE(help, []string{"apps", "+db-execute"})
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable ||
validation.Message != "not shipped" {
t.Fatalf("help descendant error = %#v, want command_unavailable inherited from parent", err)
}
}
func TestHidePolicyDiagnosticsIsHostPresentationOnly(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"config/**"}, nil)
runtime, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(HidePolicyDiagnostics()),
)
for _, path := range []string{
"config",
"config/policy",
"config/policy/show",
"config/plugins",
"config/plugins/show",
} {
cmd := findByPath(root, path)
if cmd == nil || cmd.RunE == nil {
t.Fatalf("%s missing unavailable projection", path)
}
err := cmd.RunE(cmd, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Errorf("%s error = %v, want command_unavailable", path, err)
}
if !runtime.surface.IsConcealed(surface.CommandID(path)) {
t.Errorf("%s not recorded in build-local surface", path)
}
}
// Synthetic presentation decisions must not be reported as policy facts.
active := cmdpolicy.GetActive()
if active == nil {
t.Fatal("missing active enforcement policy")
}
if _, exists := active.DeniedByPath["config/policy/show"]; exists {
t.Errorf("presentation-only diagnostic concealment leaked into ActivePolicy: %+v", active)
}
}
func TestConcealedBuildOmitsEmptyRootGroup(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{
"auth", "auth/**",
"config", "config/**",
"profile", "profile/**",
"doctor",
"update",
}, nil)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(HidePolicyDiagnostics()),
)
var help bytes.Buffer
root.SetOut(&help)
root.SetErr(&help)
if err := root.Help(); err != nil {
t.Fatal(err)
}
if strings.Contains(help.String(), "CLI management:") {
t.Errorf("empty management group leaked into help:\n%s", help.String())
}
if !strings.Contains(help.String(), "Agent tooling:") {
t.Errorf("non-empty tooling group disappeared:\n%s", help.String())
}
// Cobra's Execute path validates GroupID definitions before parsing flags.
// Calling root.Help directly does not exercise this invariant.
help.Reset()
root.SetOut(&help)
root.SetErr(&help)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("concealed root Execute --help: %v", err)
}
if strings.Contains(help.String(), "CLI management:") {
t.Errorf("empty management group leaked through Execute:\n%s", help.String())
}
}
func TestRecoveryRenderingUsesExactBuildLocalSurfaceAndDoesNotMutate(t *testing.T) {
tmpHome(t)
previousWorkspace := core.CurrentWorkspace()
core.SetCurrentWorkspace(core.WorkspaceLocal)
t.Cleanup(func() { core.SetCurrentWorkspace(previousWorkspace) })
registerRestriction(t, []string{"config/init"}, nil)
concealedRuntime, _, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
platform.ResetForTesting()
defaultRuntime, _, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
WithoutPlugins(),
)
if concealedRuntime.surface.CanReference(surface.CommandConfigInit) {
t.Fatal("config/init should be concealed")
}
if !concealedRuntime.surface.CanReference(surface.CommandConfigStrictMode) {
t.Fatal("exact leaf concealment incorrectly removed config/strict-mode")
}
original := core.NotConfiguredError()
originalProblem, ok := errs.ProblemOf(original)
if !ok || originalProblem.Hint == "" {
t.Fatalf("invalid test error: %v", original)
}
wantHint := originalProblem.Hint
concealed := concealedRuntime.recovery.Render(original)
concealedProblem, _ := errs.ProblemOf(concealed)
if strings.Contains(concealedProblem.Hint, "config init") ||
!strings.Contains(concealedProblem.Hint, "configure this distribution") {
t.Errorf("concealed tree did not use target-free recovery fallback: %q", concealedProblem.Hint)
}
if originalProblem.Hint != wantHint {
t.Fatalf("rendering mutated source hint: %q -> %q", wantHint, originalProblem.Hint)
}
visible := defaultRuntime.recovery.Render(original)
visibleProblem, _ := errs.ProblemOf(visible)
if visibleProblem.Hint != wantHint {
t.Errorf("default tree lost recovery after second Build: %q", visibleProblem.Hint)
}
}
func TestConcurrentBuildsKeepIndependentSurfacePlans(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"config/init"}, nil)
inv := buildInvocationForTest(t)
const pairs = 4
type result struct {
concealed bool
state surface.CommandState
}
results := make(chan result, pairs*2)
start := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < pairs; i++ {
wg.Add(2)
go func() {
defer wg.Done()
<-start
runtime, _, _ := buildInternal(
context.Background(),
inv,
ConcealRestrictedCommands(),
)
results <- result{concealed: true, state: runtime.surface.State(surface.CommandConfigInit)}
}()
go func() {
defer wg.Done()
<-start
runtime, _, _ := buildInternal(
context.Background(),
inv,
WithoutPlugins(),
)
results <- result{state: runtime.surface.State(surface.CommandConfigInit)}
}()
}
close(start)
wg.Wait()
close(results)
for got := range results {
want := surface.CommandAvailable
if got.concealed {
want = surface.CommandConcealed
}
if got.state != want {
t.Errorf("concealed=%v state=%v, want %v", got.concealed, got.state, want)
}
}
}
func TestUpdateAffordancesDisappearWithoutDroppingIndependentRecovery(t *testing.T) {
update.SetPending(&update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"})
skillscheck.SetPending(&skillscheck.StaleNotice{Current: "1.0.0", Target: "2.0.0"})
deprecation.SetPending(&deprecation.Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
})
t.Cleanup(func() {
update.SetPending(nil)
skillscheck.SetPending(nil)
deprecation.SetPending(nil)
})
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
got := composePendingNotice(plan)
if got == nil {
t.Fatal("independent deprecation recovery was dropped")
}
if _, exists := got["update"]; exists {
t.Errorf("update notice survived concealed update: %+v", got)
}
if _, exists := got["skills"]; exists {
t.Errorf("skills drift notice survived concealed update: %+v", got)
}
entry, ok := got["deprecated_command"].(map[string]interface{})
if !ok {
t.Fatalf("missing deprecated_command: %+v", got)
}
if entry["replacement"] != "+cells-get" || entry["skill"] != "lark-sheets" {
t.Errorf("independent deprecation fields lost: %+v", entry)
}
if _, exists := entry["action"]; exists {
t.Errorf("unavailable update action survived: %+v", entry)
}
if strings.Contains(entry["message"].(string), "lark-cli update") {
t.Errorf("dead update pointer survived in message: %+v", entry)
}
}
func TestSetupNoticesDoesNoProviderWorkWhenUpdateIsConcealed(t *testing.T) {
oldCheck, oldRefresh, oldSkills := checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck
oldPending := output.PendingNotice
t.Cleanup(func() {
checkCachedUpdate, refreshUpdateCache, initializeSkillsCheck = oldCheck, oldRefresh, oldSkills
output.PendingNotice = oldPending
})
var checks, refreshes, skillChecks int
checkCachedUpdate = func(string) *update.UpdateInfo {
checks++
return nil
}
refreshUpdateCache = func(string) { refreshes++ }
initializeSkillsCheck = func(string) { skillChecks++ }
setupNotices(surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
}))
if checks != 0 || refreshes != 0 || skillChecks != 0 {
t.Fatalf("concealed update performed provider work: cache=%d refresh=%d skills=%d",
checks, refreshes, skillChecks)
}
}
func TestExecuteProfileBootstrapPreservesDefaultAndDefersOnlyForOptIn(t *testing.T) {
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
t.Run("default remains plain exit one", func(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
code, stdout, stderr := executeWithCapturedOS(t, nil, "--profile")
if code != 1 || stdout != "" ||
stderr != "Error: flag needs an argument: --profile\n" {
t.Fatalf("default --profile: exit=%d stdout=%q stderr=%q", code, stdout, stderr)
}
})
t.Run("opt-in concealed profile is an unknown flag", func(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"profile", "profile/**"}, nil)
code, _, stderr := executeWithCapturedOS(
t,
[]BuildOption{ConcealRestrictedCommands()},
"--profile",
)
if code != 2 ||
!strings.Contains(stderr, `"subtype": "invalid_argument"`) ||
!strings.Contains(stderr, `unknown flag \"--profile\"`) {
t.Fatalf("concealed --profile: exit=%d stderr=%s", code, stderr)
}
})
}
func TestExecuteWithOptionsAppliesEachBuildOptionOnce(t *testing.T) {
tmpHome(t)
t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1")
t.Setenv("LARKSUITE_CLI_NO_SKILLS_NOTIFIER", "1")
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
var applied int
option := BuildOption(func(*buildConfig) { applied++ })
code, _, stderr := executeWithCapturedOS(t, []BuildOption{option}, "--version")
if code != 0 {
t.Fatalf("--version exit=%d stderr=%s", code, stderr)
}
if applied != 1 {
t.Fatalf("BuildOption applied %d times, want exactly once", applied)
}
}
func TestConcealmentHelpIsOutsideBusinessHooks(t *testing.T) {
tmpHome(t)
var observed, wrapped int
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
return builder.
Observer(platform.Before, "observe", platform.All(), func(context.Context, platform.Invocation) {
observed++
}).
Wrap("wrap", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
wrapped++
return next(ctx, inv)
}
})
})
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
help := findByPath(root, "help")
err := help.RunE(help, []string{"skills", "read"})
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Fatalf("help error = %v", err)
}
if observed != 0 || wrapped != 0 {
t.Fatalf("help entered business hooks: observed=%d wrapped=%d", observed, wrapped)
}
}
func TestWrapperCannotSwallowConcealedCommandEnforcement(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, func(builder *platform.Builder) *platform.Builder {
return builder.Wrap("swallow", platform.All(), func(platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return nil }
})
})
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
leaf := findByPath(root, "skills/read")
err := leaf.RunE(leaf, nil)
var validation *errs.ValidationError
if !errors.As(err, &validation) ||
validation.Subtype != errs.SubtypeCommandUnavailable {
t.Fatalf("wrapper swallowed denial: %v", err)
}
}
func TestConcealedCommandLeavesFlagAndPositionalCompletion(t *testing.T) {
tmpHome(t)
registerRestriction(t, []string{"skills/read"}, nil)
_, root, _ := buildInternal(
context.Background(),
buildInvocationForTest(t),
ConcealRestrictedCommands(),
)
for _, args := range [][]string{
{"__complete", "skills", "read", "--"},
{"__complete", "skills", "read", ""},
} {
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
_ = root.Execute()
if strings.Contains(out.String(), "--json") || strings.Contains(out.String(), "lark-") {
t.Errorf("%v exposed concealed completion:\n%s", args, out.String())
}
}
}
func TestApplyStrictStubWinsOverPluginDenial(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login strict stub missing")
}
cmdpolicy.Apply(root, map[string]cmdpolicy.Denial{
"auth/login": {
Layer: cmdpolicy.LayerPolicy,
PolicySource: "plugin:acme",
},
})
if got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]; got != cmdpolicy.LayerStrictMode {
t.Fatalf("denial layer = %q, want strict_mode", got)
}
err := stub.RunE(stub, nil)
if err == nil || !strings.Contains(err.Error(), "strict mode") {
t.Errorf("double-restricted command lost strict-mode error: %v", err)
}
}
func executeWithCapturedOS(
t *testing.T,
opts []BuildOption,
args ...string,
) (int, string, string) {
t.Helper()
oldArgs, oldStdout, oldStderr := os.Args, os.Stdout, os.Stderr
stdout, err := os.CreateTemp(t.TempDir(), "stdout")
if err != nil {
t.Fatal(err)
}
stderr, err := os.CreateTemp(t.TempDir(), "stderr")
if err != nil {
t.Fatal(err)
}
restored := false
restore := func() {
if restored {
return
}
restored = true
os.Args, os.Stdout, os.Stderr = oldArgs, oldStdout, oldStderr
}
defer restore()
os.Args = append([]string{"e2e-cli"}, args...)
os.Stdout, os.Stderr = stdout, stderr
code := ExecuteWithOptions(opts...)
restore()
if err := stdout.Close(); err != nil {
t.Fatal(err)
}
if err := stderr.Close(); err != nil {
t.Fatal(err)
}
stdoutData, err := os.ReadFile(stdout.Name())
if err != nil {
t.Fatal(err)
}
stderrData, err := os.ReadFile(stderr.Name())
if err != nil {
t.Fatal(err)
}
return code, string(stdoutData), string(stderrData)
}

View File

@@ -16,8 +16,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/vfs"
)
@@ -183,47 +181,6 @@ func TestProfileRemoveRun_RemovesCurrentProfileAndSwitchesToFirstRemaining(t *te
}
}
func TestProfileRemoveRun_AddRecoveryUsesBuildLocalSurface(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{
CurrentApp: "only",
Apps: []core.AppConfig{{
Name: "only",
AppId: "app-only",
AppSecret: core.PlainSecret("secret-only"),
Brand: core.BrandFeishu,
}},
}
if err := core.SaveMultiAppConfig(multi); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
source := profileRemoveRun(nil, "only")
var original *errs.ValidationError
if !errors.As(source, &original) {
t.Fatalf("profileRemoveRun() error = %T, want *errs.ValidationError", source)
}
const visibleHint = "add another profile first: lark-cli profile add"
if original.Hint != visibleHint {
t.Fatalf("producer hint = %q, want %q", original.Hint, visibleHint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandProfileAdd: surface.CommandConcealed,
})
var concealed *errs.ValidationError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
}
const fallback = "configure another profile through this distribution before removing the only profile"
if concealed.Hint != fallback {
t.Errorf("concealed hint = %q, want %q", concealed.Hint, fallback)
}
if original.Hint != visibleHint {
t.Errorf("concealed render mutated producer hint: %q", original.Hint)
}
}
func TestProfileRenameRun_UpdatesCurrentAndPreviousReferences(t *testing.T) {
setupProfileConfigDir(t)
multi := &core.MultiAppConfig{

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/recovery"
)
// NewCmdProfileRemove creates the profile remove subcommand.
@@ -46,12 +45,8 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
}
if len(multi.Apps) == 1 {
return recovery.Attach(
errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile"),
recovery.Join("",
recovery.Command(recovery.TargetProfileAdd, "add another profile first: lark-cli profile add"),
).WithFallback("configure another profile through this distribution before removing the only profile"),
)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
WithHint("add another profile first: lark-cli profile add")
}
app := &multi.Apps[idx]

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/recovery"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -106,16 +105,9 @@ 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 := recovery.Join("; ",
recovery.Text(fmt.Sprintf("denied by %s policy (reason_code %s)", cd.Layer, cd.ReasonCode)),
recovery.Command(recovery.TargetConfigStrictMode, stubHint),
)
return recovery.Annotate(
errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("%s", hint.String()).
WithCause(cd),
hint,
)
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
WithCause(cd)
},
}
}

View File

@@ -14,8 +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/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
@@ -381,48 +379,3 @@ func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
t.Errorf("denial annotation overwritten or missing")
}
}
// The strict-mode stub carries a targeted config/strict-mode action alongside
// non-command policy context. Rendering for a concealed tree drops only the
// dead pointer and does not mutate the source error.
func TestStrictModeStub_ConfigHintUsesBuildLocalSurface(t *testing.T) {
child := &cobra.Command{Use: "search", RunE: func(*cobra.Command, []string) error { return nil }}
stub := strictModeStubFrom(child, core.StrictModeBot)
source := stub.RunE(stub, nil)
var original *errs.ValidationError
if !errors.As(source, &original) {
t.Fatalf("expected *errs.ValidationError, got %T %v", source, source)
}
if original.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("subtype = %q, want failed_precondition", original.Subtype)
}
if !strings.Contains(original.Hint, "config strict-mode") {
t.Fatalf("producer hint = %q, want config strict-mode", original.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigStrictMode: surface.CommandConcealed,
})
var concealed *errs.ValidationError
if rendered := recovery.Render(source, plan); !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ValidationError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if strings.Contains(concealed.Hint, "config strict-mode") {
t.Errorf("concealed hint still contains config strict-mode: %q", concealed.Hint)
}
if !strings.Contains(concealed.Hint, "reason_code identity_not_supported") {
t.Errorf("non-command policy guidance was lost: %q", concealed.Hint)
}
var visible *errs.ValidationError
if !errors.As(recovery.Render(source, nil), &visible) ||
!strings.Contains(visible.Hint, "config strict-mode") {
t.Errorf("visible render must keep config strict-mode, got %+v", visible)
}
if !strings.Contains(original.Hint, "config strict-mode") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"sort"
"strings"
@@ -22,16 +21,70 @@ import (
"github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).
EXAMPLES (one per command style, in order of preference):
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`
// rootUsageTemplate is cobra's default usage template with two root-only
// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis
// (replacing cobra's generic "[flags] / [command]") and a human skills-setup
// footer. Subcommands render the stock template unchanged. The rest is verbatim
// cobra so the command groups and flags are untouched.
const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}Usage:
lark-cli <command> [subcommand] [method] [flags]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method>{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
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}}{{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
@@ -41,69 +94,25 @@ import (
var rawInvocationArgs []string
func Execute() int {
return executeWithOptions(nil)
}
// ExecuteWithOptions is the standard entrypoint for wrapper distributions that
// need host-level Build options such as ConcealRestrictedCommands. Execute
// intentionally keeps its original non-variadic signature for source
// compatibility with callers that store it as a func() int value.
func ExecuteWithOptions(opts ...BuildOption) int {
return executeWithOptions(opts)
}
func executeWithOptions(opts []BuildOption) int {
rawInvocationArgs = os.Args[1:]
inv, bootstrapErr := BootstrapInvocationContext(os.Args[1:])
cfg := &buildConfig{}
for _, opt := range opts {
if opt != nil {
opt(cfg)
}
}
deferProfileError := cfg.presentation.enabled &&
isDeferredBootstrapProfileError(bootstrapErr)
if bootstrapErr != nil && !deferProfileError {
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
inv, err := BootstrapInvocationContext(os.Args[1:])
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
return 1
}
if cfg.streams == nil {
WithIO(os.Stdin, os.Stdout, os.Stderr)(cfg)
}
if !cfg.hideProfileSet {
HideProfile(isSingleAppMode())(cfg)
}
if !cfg.startupBrandSet {
WithStartupBrand(ResolveStartupBrand(inv.Profile))(cfg)
}
configureFlagCompletions(os.Args)
ctx := context.Background()
if deferProfileError {
cfg.deferStartup = true
}
runtime, rootCmd, reg := buildInternalWithConfig(ctx, inv, cfg)
f := runtime.Factory
if deferProfileError {
if runtime.surface.CanReference(surface.CommandProfile) {
// The completed distribution still ships --profile. Replay the
// exact pre-Build legacy failure and do not emit Startup, notices,
// or Shutdown for an invocation that never passed bootstrap.
fmt.Fprintln(os.Stderr, "Error:", bootstrapErr)
return 1
}
if reg != nil {
if err := emitStartup(ctx, reg); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
reg = nil
}
}
}
f, rootCmd, reg := buildInternal(
ctx, inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
)
// --- Notices (non-blocking) ---
if !isCompletionCommand(os.Args) {
setupNotices(runtime.surface)
setupNotices()
}
runErr := rootCmd.Execute()
@@ -117,98 +126,69 @@ func executeWithOptions(opts []BuildOption) int {
}
if runErr != nil {
return handleRootError(f, runErr, runtime.recovery)
return handleRootError(f, runErr)
}
return 0
}
// isDeferredBootstrapProfileError identifies the one bootstrap parse failure
// an explicitly concealed distribution may need the completed tree to render.
// Default and legacy builds never defer it.
func isDeferredBootstrapProfileError(err error) bool {
return err != nil && err.Error() == "flag needs an argument: --profile"
}
// Notice provider seams keep the "concealed update means no cache, network, or
// skills-state access" contract directly testable. Production always uses the
// concrete implementations below.
var (
checkCachedUpdate = update.CheckCached
refreshUpdateCache = update.RefreshCache
initializeSkillsCheck = skillscheck.Init
)
// setupNotices wires both the binary update notice and the skills
// staleness notice into output.PendingNotice as a composed function.
// Each provider populates an independent key under _notice; either
// or both may be present in any given envelope.
func setupNotices(plan *surface.Plan) {
if plan.CanReference(surface.CommandUpdate) {
// Binary update — synchronous cache check + async refresh.
if info := checkCachedUpdate(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
refreshUpdateCache(ver)
if update.GetPending() == nil {
if info := checkCachedUpdate(ver); info != nil {
update.SetPending(info)
}
func setupNotices() {
// Binary update — synchronous cache check + async refresh
if info := update.CheckCached(build.Version); info != nil {
update.SetPending(info)
}
ver := build.Version
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "update check panic: %v\n", r)
}
}()
update.RefreshCache(ver)
if update.GetPending() == nil {
if info := update.CheckCached(ver); info != nil {
update.SetPending(info)
}
}
}()
// Skills drift has only one recovery action: lark-cli update. Do not
// even inspect local drift state when that action is absent.
initializeSkillsCheck(build.Version)
}
// Skills check — synchronous, local-only (no network, no goroutine).
skillscheck.Init(build.Version)
// Capture this build's immutable plan; never consult another Build's state.
output.PendingNotice = func() map[string]interface{} {
return composePendingNotice(plan)
}
// Composed notice provider — emits keys only when each pending is set.
output.PendingNotice = composePendingNotice
}
// composePendingNotice merges all process-level pending notices (available
// update, skills/binary drift, deprecated-command alias) into the map surfaced
// as the JSON "_notice" envelope field. Returns nil when nothing is pending.
// Extracted from Execute so the composition is unit-testable.
func composePendingNotice(plan *surface.Plan) map[string]interface{} {
func composePendingNotice() map[string]interface{} {
notice := map[string]interface{}{}
canUpdate := plan.CanReference(surface.CommandUpdate)
// Update and skills-drift notices have no recovery path of their own:
// both exist solely to steer the caller to `lark-cli update`.
if canUpdate {
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if info := update.GetPending(); info != nil {
notice["update"] = map[string]interface{}{
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
notice["skills"] = map[string]interface{}{
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if dep := deprecation.GetPending(); dep != nil {
entry := map[string]interface{}{
"command": dep.Command,
"message": dep.MessageWithoutUpdateAction(),
}
if canUpdate {
entry["message"] = dep.Message()
entry["action"] = "lark-cli update"
"message": dep.Message(),
"action": "lark-cli update",
}
if dep.Replacement != "" {
entry["replacement"] = dep.Replacement
@@ -265,22 +245,15 @@ func configureFlagCompletions(args []string) {
// argument validation): typed as an invalid_argument envelope (exit 2),
// matching the explicit flag/subcommand guards. Flag parse errors are
// already typed upstream by the root FlagErrorFunc.
func handleRootError(
f *cmdutil.Factory,
err error,
projector *recovery.Projector,
) int {
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
renderedErr := err
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata. Both semantic recovery filtering and
// dynamic enrichment operate on a concrete clone, never the producer's
// reusable error value.
// local shortcut/service metadata — it never depends on server state.
if !errs.IsRaw(err) {
renderedErr = newRootErrorPresenter(f, projector).Present(err)
applyNeedAuthorizationHint(f, err)
}
// Staged dispatch: capture the typed exit code BEFORE attempting the
@@ -291,7 +264,7 @@ func handleRootError(
// WriteTypedErrorEnvelope still returns false when err carries no
// Problem; in that case we fall through to the signal / plain-text paths.
typedExit := output.ExitCodeOf(err)
if output.WriteTypedErrorEnvelope(errOut, renderedErr, string(f.ResolvedIdentity)) {
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return typedExit
}
@@ -584,10 +557,15 @@ const (
groupManagement = "cli-management"
)
// classifyRootCommands assigns root children to help groups after registration.
// Group definitions are attached separately, after optional distribution
// projection, so a concealed build can omit a now-empty heading.
func classifyRootCommands(root *cobra.Command) {
// groupRootCommands classifies root's direct children into the help groups,
// called once after all commands are registered. Unclassified commands fall to
// cobra's "Additional Commands" section.
func groupRootCommands(root *cobra.Command) {
root.AddGroup(
&cobra.Group{ID: groupDomains, Title: "Lark domains:"},
&cobra.Group{ID: groupTooling, Title: "Agent tooling:"},
&cobra.Group{ID: groupManagement, Title: "CLI management:"},
)
tooling := map[string]bool{"api": true, "schema": true, "skills": true}
management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true}
for _, c := range root.Commands() {
@@ -605,46 +583,6 @@ func classifyRootCommands(root *cobra.Command) {
}
}
// finalizeRootCommandGroups attaches Cobra group definitions once. A group is
// omitted only when this build's surface plan concealed all its children.
// Hidden legacy/YAML commands remain referenceable and therefore keep the
// historical (possibly empty) heading.
func finalizeRootCommandGroups(root *cobra.Command, plan *surface.Plan) {
if root == nil || len(root.Groups()) != 0 {
return
}
groups := []*cobra.Group{
{ID: groupDomains, Title: "Lark domains:"},
{ID: groupTooling, Title: "Agent tooling:"},
{ID: groupManagement, Title: "CLI management:"},
}
for _, group := range groups {
if plan != nil && !rootGroupHasReferenceableChild(root, group.ID, plan) {
// Cobra validates that every non-empty child GroupID has a
// matching definition before dispatch, including hidden children.
// If presentation removes an entire group, clear those now-hidden
// assignments as well as omitting the heading.
for _, child := range root.Commands() {
if child.GroupID == group.ID {
child.GroupID = ""
}
}
continue
}
root.AddGroup(group)
}
}
func rootGroupHasReferenceableChild(root *cobra.Command, groupID string, plan *surface.Plan) bool {
for _, child := range root.Commands() {
if child.GroupID == groupID &&
plan.CanReference(surface.CommandID(cmdpolicy.CanonicalPath(child))) {
return true
}
}
return false
}
// isLarkDomain reports whether a root child is a Lark domain (service-sourced or
// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp.
func isLarkDomain(c *cobra.Command) bool {
@@ -663,15 +601,6 @@ func isLarkDomain(c *cobra.Command) bool {
func flagDidYouMean(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagName(ferr)
if !isUnknown {
// A policy-gated flag invoked bare ("flag needs an argument")
// never reaches its rejecting Value; it still presents as
// unregistered, exactly like a set one.
if gated, ok := gatedFlagFromNeedsArg(c, ferr); ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+gated, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + gated, Reason: "unknown flag"}).
WithHint("run `%s --help` to see valid flags", c.CommandPath())
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
@@ -694,25 +623,6 @@ func flagDidYouMean(c *cobra.Command, ferr error) error {
WithHint("%s", hint)
}
// gatedFlagFromNeedsArg reports whether ferr is pflag's "flag needs an
// argument: --name" for a policy-gated flag on this command's flag set.
func gatedFlagFromNeedsArg(c *cobra.Command, ferr error) (string, bool) {
const p = "flag needs an argument: --"
msg := ferr.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
name := msg[i+len(p):]
if j := strings.IndexAny(name, " \t"); j >= 0 {
name = name[:j]
}
if fl := c.Root().PersistentFlags().Lookup(name); isPolicyGatedFlag(fl) {
return name, true
}
return "", false
}
// unknownFlagName extracts the offending long-flag name from cobra's flag-parse
// error text ("unknown flag: --query" → "query"). Returns ok=false for anything
// else (missing argument, invalid value, unknown shorthand) so the caller keeps
@@ -749,59 +659,16 @@ func visibleFlagNames(c *cobra.Command) []string {
return names
}
// installHelpCommand upgrades 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) {
root.InitDefaultHelpCmd()
helpCmd := findByPath(root, "help")
if helpCmd == nil {
return
}
helpCmd.Run = nil
helpCmd.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.SetContext(c.Context())
target.InitDefaultHelpFlag()
target.InitDefaultVersionFlag()
return target.Help()
}
// help attaches after policy evaluation (framework meta command, never
// policy-evaluated). No risk annotation: it would render a "Risk:"
// line that stock cobra help output does not carry.
cmdutil.DisableAuthCheck(helpCmd)
}
// 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,
skillReferences func() *skillref.Resolver,
projector *recovery.Projector,
) {
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
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 }()
}
@@ -809,22 +676,15 @@ func installTipsHelpFunc(
// 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.
var refs *skillref.Resolver
if skillReferences != nil {
refs = skillReferences()
}
content := skillContent()
if service.PrepareDomainHelpWithReferences(cmd, content, refs) {
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelpWithProjection(cmd, content, refs, func() bool {
return projector.CanReference(recovery.TargetSchema)
}) {
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelpWithReferences(cmd, content, refs) {
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}

View File

@@ -1,154 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"github.com/larksuite/cli/internal/surface"
)
// rootHelpFragment is one framework-owned root-help fragment. A fragment with
// a target is emitted only while that exact command remains referenceable in
// this build. Keeping the target next to the text prevents curated examples
// from becoming dead pointers in reduced distributions.
type rootHelpFragment struct {
target surface.CommandID
text string
}
// rootHelpSection keeps a heading coupled to the target-aware entries it
// introduces. When projection removes every entry, the heading disappears
// with them instead of leaving an empty section in reduced builds.
type rootHelpSection struct {
heading string
fragments []rootHelpFragment
}
const (
rootHelpAPI surface.CommandID = "api"
rootHelpCalendarAgenda surface.CommandID = "calendar/+agenda"
rootHelpMailList surface.CommandID = "mail/user_mailbox.messages/list"
)
var rootLongSections = []rootHelpSection{
{fragments: []rootHelpFragment{
{text: `lark-cli — Lark/Feishu CLI tool.
AGENT QUICKSTART (driving this as an agent? start here):
Browse commands: lark-cli <domain> --help # +shortcuts (preferred) and raw API resources`},
{target: surface.CommandSchema, text: `
Inspect a call: lark-cli schema <service>.<resource>.<method> # params, types, scopes, examples`},
{text: `
Prefer a +shortcut over the raw API resource when one matches the task.
Risk: each command's --help shows read | write | high-risk-write;
high-risk-write needs --yes, only after the user confirms.
On any API call: --jq <expr> filters JSON output, --dry-run previews the request (runs nothing).`},
}},
{
heading: "\n\nEXAMPLES (one per command style, in order of preference):",
fragments: []rootHelpFragment{
{target: rootHelpCalendarAgenda, text: `
lark-cli calendar +agenda # +shortcut — a high-level task, prefer these`},
{target: rootHelpMailList, text: `
lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method`},
{target: surface.CommandSchema, text: `
lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling`},
{target: rootHelpAPI, text: `
lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path`},
},
},
}
// rootLong is the fully-visible default text retained as a compatibility
// oracle. Reduced builds derive their text from the same typed fragments.
var rootLong = renderRootHelpSections(rootLongSections, nil)
func renderRootHelpSections(sections []rootHelpSection, plan *surface.Plan) string {
var b strings.Builder
for _, section := range sections {
body := renderRootHelpFragments(section.fragments, plan)
if body == "" {
continue
}
b.WriteString(section.heading)
b.WriteString(body)
}
return b.String()
}
func renderRootHelpFragments(fragments []rootHelpFragment, plan *surface.Plan) string {
var b strings.Builder
for _, fragment := range fragments {
if fragment.target != "" && !plan.CanReference(fragment.target) {
continue
}
b.WriteString(fragment.text)
}
return b.String()
}
var rootUsageSynopsis = []rootHelpFragment{
{text: `Usage:
lark-cli <command> [subcommand] [method] [flags]`},
{target: rootHelpAPI, text: `
lark-cli api <method> <path> [--params <json>] [--data <json>]`},
{target: surface.CommandSchema, text: `
lark-cli schema <service.resource.method>`},
}
const rootUsageTemplatePrefix = `{{if .HasParent}}Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{else}}`
// rootUsageTemplateSuffix is Cobra's default usage template after the root
// synopsis. Root-only framework affordances are assembled separately above
// and below it so each command reference carries an explicit target.
const rootUsageTemplateSuffix = `{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
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 is the root-help pointer at the human one-time skills
// setup. It is emitted only while skills/read remains referenceable.
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}}`
var rootUsageTemplate = renderRootUsageTemplate(nil)
func renderRootUsageTemplate(plan *surface.Plan) string {
var b strings.Builder
b.WriteString(rootUsageTemplatePrefix)
b.WriteString(renderRootHelpFragments(rootUsageSynopsis, plan))
b.WriteString(rootUsageTemplateSuffix)
if plan.CanReference(surface.CommandSkillsRead) {
b.WriteString(skillsSetupFooter)
}
b.WriteByte('\n')
return b.String()
}

View File

@@ -59,7 +59,7 @@ func executeRootIntegration(t *testing.T, f *cmdutil.Factory, rootCmd *cobra.Com
t.Helper()
rootCmd.SetArgs(args)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err, nil)
return handleRootError(f, err)
}
return 0
}
@@ -505,7 +505,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
output.PendingNotice = nil
})
setupNotices(nil)
setupNotices()
notice := output.GetNotice()
if notice == nil {
@@ -539,7 +539,7 @@ func TestSetupNotices_InSync(t *testing.T) {
output.PendingNotice = nil
})
setupNotices(nil)
setupNotices()
notice := output.GetNotice()
if notice != nil {
@@ -572,7 +572,7 @@ func TestSetupNotices_Drift(t *testing.T) {
output.PendingNotice = nil
})
setupNotices(nil)
setupNotices()
notice := output.GetNotice()
if notice == nil {
@@ -621,7 +621,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
output.PendingNotice = nil
})
setupNotices(nil)
setupNotices()
// After setupNotices, skills pending is set (drift). Manually populate
// the update side so the composed envelope has both keys — the update

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, nil, nil)
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, nil, nil)
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, nil, nil)
installTipsHelpFunc(root)
child := &cobra.Command{Use: "delete", Short: "delete a file"}
cmdutil.SetRisk(child, "high-risk-write")

View File

@@ -162,7 +162,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
ChallengeURL: "https://example.com/challenge",
}
gotExit := handleRootError(f, spErr, nil)
gotExit := handleRootError(f, spErr)
if gotExit != int(output.ExitContentSafety) {
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
}
@@ -209,7 +209,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
},
}
gotExit := handleRootError(f, spErr, nil)
gotExit := handleRootError(f, spErr)
if gotExit != int(output.ExitContentSafety) {
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
}
@@ -286,7 +286,7 @@ func TestHandleRootError_DeprecatedAliasMissingFlagStructured(t *testing.T) {
})
// The bare error shape cobra's ValidateRequiredFlags produces: not a typed
// errs.* error, so it reaches the deprecation fallback.
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -314,7 +314,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}
@@ -345,7 +345,7 @@ func TestHandleRootError_AuthConfigWireGolden(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, core.NotConfiguredError(), nil)
exit := handleRootError(f, core.NotConfiguredError())
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (config shares ExitAuth)", exit, int(output.ExitAuth))
}
@@ -393,7 +393,7 @@ func TestHandleRootError_NoDeprecationTypesUsageError(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"))
out := errOut.String()
if strings.HasPrefix(strings.TrimSpace(out), "Error:") {
@@ -424,7 +424,7 @@ func TestHandleRootError_LeakedUntypedErrorBecomesInternal(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF), nil)
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF))
errObj := decodeErrorEnvelope(t, errOut.Bytes())
if got := errObj["type"]; got != "internal" {
@@ -449,7 +449,7 @@ func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
f.IOStreams.ErrOut = w
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
exit := handleRootError(f, err, nil)
exit := handleRootError(f, err)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
}
@@ -466,7 +466,7 @@ func TestHandleRootError_BareErrorExitCodeNoStderr(t *testing.T) {
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
exit := handleRootError(f, output.ErrBare(output.ExitAuth))
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (BareError code propagated)", exit, int(output.ExitAuth))
}
@@ -492,7 +492,7 @@ func TestHandleRootError_TypedAuthErrorWithLegacyCausePreserved(t *testing.T) {
WithHint("custom producer hint").
WithCause(innerLegacy)
exit := handleRootError(f, outer, nil)
exit := handleRootError(f, outer)
if exit != int(output.ExitAuth) {
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -29,8 +28,6 @@ var runRootUpgrade = func(cmd *cobra.Command) {
}
}
var checkRootCachedUpdate = update.CheckCached
// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand,
// no flags) — the only invocation that triggers the interactive upgrade prompt.
// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty
@@ -54,10 +51,7 @@ func readYes(r io.Reader) bool {
// offerRootUpgrade prompts for an interactive upgrade when running bare
// `lark-cli` in an interactive terminal with a cached newer version. Every
// failure is swallowed — it must never affect help output or the exit code.
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recovery.Projector) {
if f == nil || !projector.CanReference(recovery.TargetUpdate) {
return
}
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
ios := f.IOStreams
// Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require
// stdout TTY too so this only fires in a pure foreground terminal session.
@@ -67,11 +61,21 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recover
// Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip)
// and the IsNewer/semver validation chain; it reads the on-disk cache that
// the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL).
info := checkRootCachedUpdate(build.Version)
info := update.CheckCached(build.Version)
if info == nil {
return
}
fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current)
// Deliberately no target version here: info.Latest comes from the on-disk
// cache, which has no expiry (the 24h TTL only throttles refreshes, and a
// failed refresh leaves the old value in place), so it can name a version
// that is no longer the one npm would install. The version actually
// installed is resolved live by the update subcommand, which prints
// "Updating lark-cli <cur> -> <latest> via <pm> ..." before installing —
// that is where the user sees the real target. Keep going through the
// update subcommand rather than calling RunNpmInstall directly, otherwise
// that line disappears and the user approves a global install without ever
// being told what gets installed.
fmt.Fprintf(ios.ErrOut, "A newer lark-cli is available (current %s). Upgrade now? [y/N]: ", info.Current)
if !readYes(ios.In) {
return
}
@@ -82,18 +86,14 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recover
// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli`
// invocation offers an interactive upgrade before printing help. Non-bare
// invocations are passed straight through, unchanged.
func installRootUpgradePrompt(
f *cmdutil.Factory,
root *cobra.Command,
projector *recovery.Projector,
) {
func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) {
inner := root.RunE
if inner == nil {
return
}
root.RunE = func(cmd *cobra.Command, args []string) error {
if isBareRootInvocation(args) {
offerRootUpgrade(f, cmd, projector)
offerRootUpgrade(f, cmd)
}
return inner(cmd, args)
}

View File

@@ -15,9 +15,6 @@ import (
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/update"
"github.com/spf13/cobra"
)
@@ -125,12 +122,23 @@ func TestOfferRootUpgrade(t *testing.T) {
OutIsTerminal: tc.out,
StderrIsTerminal: tc.err,
}}
offerRootUpgrade(f, &cobra.Command{}, nil)
offerRootUpgrade(f, &cobra.Command{})
gotPrompt := strings.Contains(errBuf.String(), "available")
if gotPrompt != tc.wantPrompt {
t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String())
}
// The prompt must not name a target version: info.Latest comes from
// the on-disk cache and can be stale, while the version actually
// installed is resolved live by the update subcommand.
if tc.wantPrompt {
if strings.Contains(errBuf.String(), tc.latest) {
t.Errorf("prompt must not name the cached target version %q (stderr=%q)", tc.latest, errBuf.String())
}
if !strings.Contains(errBuf.String(), build.Version) {
t.Errorf("prompt must name the current version %q (stderr=%q)", build.Version, errBuf.String())
}
}
if called != tc.wantRun {
t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun)
}
@@ -138,34 +146,6 @@ func TestOfferRootUpgrade(t *testing.T) {
}
}
func TestOfferRootUpgradeDoesNotReadCacheWhenUpdateIsConcealed(t *testing.T) {
oldCheck := checkRootCachedUpdate
t.Cleanup(func() { checkRootCachedUpdate = oldCheck })
cacheReads := 0
checkRootCachedUpdate = func(string) *update.UpdateInfo {
cacheReads++
return &update.UpdateInfo{Current: "1.0.0", Latest: "2.0.0"}
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandUpdate: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader("y\n"),
Out: &bytes.Buffer{},
ErrOut: &bytes.Buffer{},
IsTerminal: true,
OutIsTerminal: true,
StderrIsTerminal: true,
}}
offerRootUpgrade(f, &cobra.Command{}, projector)
if cacheReads != 0 {
t.Fatalf("concealed update read cache %d time(s)", cacheReads)
}
}
func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
orig := rawInvocationArgs
t.Cleanup(func() { rawInvocationArgs = orig })
@@ -178,7 +158,7 @@ func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root, nil)
installRootUpgradePrompt(f, root)
if err := root.RunE(root, []string{}); err != nil {
t.Fatalf("bare RunE err = %v", err)
@@ -215,7 +195,7 @@ func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
}}
installRootUpgradePrompt(f, root, nil)
installRootUpgradePrompt(f, root)
if root.RunE != nil {
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/skillref"
"github.com/spf13/cobra"
)
@@ -28,12 +27,6 @@ import (
// we fall back to it. The pristine base is captured once into an annotation so
// re-rendering does not append the guidance twice.
func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
return PrepareDomainHelpWithReferences(cmd, skillFS, nil)
}
// PrepareDomainHelpWithReferences is PrepareDomainHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareDomainHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
if cmd.Annotations[schemaPathAnnotation] != "" {
return false // a method command
}
@@ -68,12 +61,10 @@ func PrepareDomainHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, referenc
b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.")
}
b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.")
canonicalSkill := "lark-" + cmd.Name()
if declared, ok := affordance.DomainSkill(cmdmeta.Domain(cmd)); ok {
canonicalSkill = declared
}
if skill, ok := resolveSkillReference(canonicalSkill, skillFS, references); ok {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
if skill := "lark-" + cmd.Name(); skillFS != nil {
if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil {
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
}
}
cmd.Long = b.String()
return true
@@ -146,34 +137,6 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// 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 {
return PrepareMethodHelpWithReferences(cmd, skillFS, nil)
}
// PrepareMethodHelpWithReferences is PrepareMethodHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareMethodHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
return prepareMethodHelp(cmd, skillFS, references, nil)
}
// PrepareMethodHelpWithProjection is PrepareMethodHelpWithReferences with the
// command tree's lazy, build-local schema-reference decision. The established
// helpers remain fully-visible by default; cmd.Build uses this form so the
// framework-owned schema pointer follows the same surface as execution.
func PrepareMethodHelpWithProjection(
cmd *cobra.Command,
skillFS fs.FS,
references *skillref.Resolver,
canReferenceSchema func() bool,
) bool {
return prepareMethodHelp(cmd, skillFS, references, canReferenceSchema)
}
func prepareMethodHelp(
cmd *cobra.Command,
skillFS fs.FS,
references *skillref.Resolver,
canReferenceSchema func() bool,
) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -198,12 +161,10 @@ func prepareMethodHelp(
}
}
if canReferenceSchema == nil || canReferenceSchema() {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
}
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS, references)
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
@@ -216,9 +177,10 @@ func prepareMethodHelp(
// 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 with a
// hand-authored Long keeps it, while structured affordance guidance is
// appended below without clobbering the business description.
// 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
@@ -226,12 +188,6 @@ func prepareMethodHelp(
// (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 {
return PrepareShortcutHelpWithReferences(cmd, skillFS, nil)
}
// PrepareShortcutHelpWithReferences is PrepareShortcutHelp with a build-local
// canonical-to-runtime skill projection.
func PrepareShortcutHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, references *skillref.Resolver) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
@@ -254,7 +210,7 @@ func PrepareShortcutHelpWithReferences(cmd *cobra.Command, skillFS fs.FS, refere
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS, references)
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
@@ -278,14 +234,14 @@ func writeRisk(b *strings.Builder, cmd *cobra.Command) {
// 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, references *skillref.Resolver) {
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 resolved, ok := resolveSkillReference(s, skillFS, references); ok {
avail = append(avail, resolved)
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
}
}
if len(avail) == 0 {
@@ -297,22 +253,6 @@ func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS, refe
}
}
func resolveSkillReference(canonical string, skillFS fs.FS, references *skillref.Resolver) (string, bool) {
// A nil skillFS is also the command-surface gate supplied by cmd.Build:
// embedded bytes may still exist, but presenters must not point at them
// when `skills read` is concealed.
if skillFS == nil {
return "", false
}
if references != nil {
return references.ResolveString(canonical)
}
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(canonical)); err != nil {
return "", false
}
return canonical, true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
var affordanceLookup = affordance.For

View File

@@ -9,13 +9,9 @@ import (
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/affordance"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/skillref"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
)
@@ -146,40 +142,6 @@ func TestPrepareMethodHelp(t *testing.T) {
}
}
func TestPrepareMethodHelpProjectsConcealedSchemaPointer(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["发文本消息"]}`), true
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{
"id": "messages.create", "path": "messages", "httpMethod": "POST",
"description": "发送消息",
}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSchema: surface.CommandConcealed,
})
projector := recovery.NewProjector(func() *surface.Plan { return plan })
if !PrepareMethodHelpWithProjection(cmd, nil, nil, func() bool {
return projector.CanReference(recovery.TargetSchema)
}) {
t.Fatal("PrepareMethodHelpWithProjection returned false for a service-method command")
}
if strings.Contains(cmd.Long, "lark-cli schema") ||
strings.Contains(cmd.Long, "Full parameter schema:") {
t.Fatalf("concealed schema left a dead method-help pointer:\n%s", cmd.Long)
}
for _, want := range []string{"发送消息", "When to use:", "发文本消息"} {
if !strings.Contains(cmd.Long, want) {
t.Errorf("schema projection removed unrelated help %q:\n%s", want, cmd.Long)
}
}
}
// 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
@@ -271,61 +233,9 @@ func TestRelatedSkillsStatGating(t *testing.T) {
}
}
func TestDomainSkillReferenceRequiresReadableCommandSurface(t *testing.T) {
content := fstest.MapFS{
"lark-im/SKILL.md": {Data: []byte("# im")},
}
resolver, err := skillref.New(content, nil)
if err != nil {
t.Fatalf("skillref.New(): %v", err)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "im", Short: "IM"}
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
domain.AddCommand(&cobra.Command{Use: "messages", Run: func(*cobra.Command, []string) {}})
root.AddCommand(domain)
if !PrepareDomainHelpWithReferences(domain, nil, resolver) {
t.Fatal("PrepareDomainHelp returned false")
}
if strings.Contains(domain.Long, "skills read") {
t.Fatalf("concealed skills/read leaked through resolver:\n%s", domain.Long)
}
}
func TestDomainSkillReferenceUsesDeclaredAffordanceName(t *testing.T) {
affordance.SetSource(fstest.MapFS{
"docs.md": {Data: []byte("# docs\n> skill: lark-doc\n")},
})
t.Cleanup(func() { affordance.SetSource(nil) })
content := fstest.MapFS{
"lark-doc/SKILL.md": {Data: []byte("# docs")},
}
resolver, err := skillref.New(content, nil)
if err != nil {
t.Fatalf("skillref.New(): %v", err)
}
root := &cobra.Command{Use: "lark-cli"}
domain := &cobra.Command{Use: "docs", Short: "Docs"}
cmdmeta.SetSource(domain, cmdmeta.SourceService, false)
cmdmeta.SetDomain(domain, "docs")
domain.AddCommand(&cobra.Command{Use: "documents", Run: func(*cobra.Command, []string) {}})
root.AddCommand(domain)
if !PrepareDomainHelpWithReferences(domain, content, resolver) {
t.Fatal("PrepareDomainHelp returned false")
}
if !strings.Contains(domain.Long, "skills read lark-doc") {
t.Fatalf("declared domain skill was not used:\n%s", domain.Long)
}
if strings.Contains(domain.Long, "skills read lark-docs") {
t.Fatalf("command-name inference overrode declared domain skill:\n%s", domain.Long)
}
}
// A shortcut that sets a hand-authored Long keeps it as the lead: the
// affordance block is appended below, not clobbered, and re-rendering does not
// double-append.
// 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 })
@@ -387,26 +297,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

@@ -11,8 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -599,38 +597,6 @@ func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) {
}
}
func TestServiceMethod_MissingRequired_ProjectsOnlySchemaRecovery(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil)
cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"})
source := cmd.Execute()
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandSchema: surface.CommandConcealed,
})
rendered := recovery.NewProjector(func() *surface.Plan { return plan }).Render(source)
var ve *errs.ValidationError
if !errors.As(rendered, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", rendered, rendered)
}
for _, want := range []string{"--chat-id", `--params '{"chat_id": "<value>"}'`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("projected hint %q lost valid recovery %q", ve.Hint, want)
}
}
if strings.Contains(ve.Hint, "lark-cli schema") {
t.Errorf("projected hint retained concealed schema pointer: %q", ve.Hint)
}
var sourceValidation *errs.ValidationError
if !errors.As(source, &sourceValidation) {
t.Fatalf("source is not *errs.ValidationError: %T", source)
}
if !strings.Contains(sourceValidation.Hint, "lark-cli schema im.chat.members.create") {
t.Errorf("presentation mutated source hint: %q", sourceValidation.Hint)
}
}
// A params-only required field (kebab name claimed by the standard --format
// flag) has no typed flag to offer: the hint must give only the --params form,
// never steer the reader to the colliding flag.

View File

@@ -21,7 +21,6 @@ import (
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
@@ -491,15 +490,19 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
// newPreflightMissingScopeError constructs a PermissionError for the local
// pre-flight scope check that converges byte-for-byte with the dispatcher's
// BuildAPIError path. It records the same typed facts and canonical message;
// the root presenter supplies identity-appropriate recovery at the final
// command boundary.
// BuildAPIError path. Uses the canonical helpers in internal/errclass so
// Hint and Message stay in lock-step with the server-response classifier.
// ConsoleURL is deliberately omitted: the dispatcher only sets it for
// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this
// pre-flight path is user-perspective SubtypeMissingScope whose recovery is
// `lark-cli auth login --scope ...`, not a console deep-link.
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error {
return errclass.NewMissingScopeError(brand, appID, identity, missing)
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError {
consoleURL := errclass.ConsoleURL(brand, appID, missing)
return errs.NewPermissionError(errs.SubtypeMissingScope,
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
WithMissingScopes(missing...).
WithIdentity(identity)
}
// unusableParamValue reports whether a provided path/query parameter value
@@ -526,28 +529,12 @@ func unusableParamValue(v interface{}) bool {
// only the --params form: a flag with its kebab name exists but belongs to
// something else (e.g. the output --format), and the hint must not steer
// there. Asking the binder, not cmd.Flags(), is what tells those apart.
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) recovery.Hint {
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string {
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
var input string
if opts.binder.hasTypedFlag(f.Name) {
input = fmt.Sprintf("set --%s <value> (or %s)", f.FlagName(), paramsForm)
} else {
input = fmt.Sprintf("set %s", paramsForm)
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
}
return recovery.Join("; ",
recovery.Text(input),
recovery.Command(recovery.TargetSchema, "see: lark-cli schema "+opts.SchemaPath),
)
}
func missingRequiredParamError(opts *ServiceMethodOptions, f meta.Field, location string) error {
hint := missingParamHint(opts, f)
return recovery.Attach(
errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required %s parameter: %s", location, f.Name).
WithParam(f.Name),
hint,
)
return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath)
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
@@ -584,7 +571,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
}
val, ok := params[s.Name]
if !ok || unusableParamValue(val) {
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "path")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required path parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, s.Name); err != nil {
@@ -602,7 +592,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
value, exists := params[s.Name]
isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size")
if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) {
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "query")
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"missing required query parameter: %s", s.Name).
WithHint("%s", missingParamHint(opts, s)).
WithParam(s.Name)
}
if exists && !unusableParamValue(value) {
queryParams[s.Name] = value

View File

@@ -54,30 +54,6 @@ func driveMethod(httpMethod string, params map[string]interface{}) meta.Method {
return meta.FromMap(m)
}
func TestNewPreflightMissingScopeErrorUsesCanonicalFieldGate(t *testing.T) {
err := newPreflightMissingScopeError(
"feishu",
"cli_test",
"user",
[]string{"docx:document"},
)
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("error = %T, want *errs.PermissionError", err)
}
if permissionErr.Subtype != errs.SubtypeMissingScope {
t.Fatalf("subtype = %q, want %q", permissionErr.Subtype, errs.SubtypeMissingScope)
}
if permissionErr.ConsoleURL != "" {
t.Fatalf("missing_scope console_url = %q, want empty", permissionErr.ConsoleURL)
}
if len(permissionErr.MissingScopes) != 1 ||
permissionErr.MissingScopes[0] != "docx:document" ||
permissionErr.Identity != "user" {
t.Fatalf("permission facts = %+v", permissionErr)
}
}
// ── registerService ──
func TestRegisterService(t *testing.T) {

View File

@@ -1,208 +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/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.
// (Framework-generated --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)
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)
}
}
// A wrapper main that forgets to wire its embedded skill base should get the
// missing host assembly step, not the same recovery hint as a misspelled
// Allow/Remove name.
func TestBuildInternal_missingBaseSkillsGuardHint(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
saved := embeddedSkillContent
t.Cleanup(func() { embeddedSkillContent = saved })
embeddedSkillContent = nil
platform.Register(platform.NewPlugin("acme", "1.0").
EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}}).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, "this build embeds no base skill content") {
t.Errorf("hint should name the missing embedded content, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "cmd.SetEmbeddedSkillContent") {
t.Errorf("hint should name the wrapper-main wiring API, got %q", verr.Hint)
}
if !strings.Contains(verr.Hint, "invalid_skills_overlay") {
t.Errorf("hint should preserve reason_code invalid_skills_overlay, got %q", verr.Hint)
}
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
// whoamiResult is the structured output of `lark-cli whoami`.
@@ -55,22 +54,12 @@ type Options struct {
// local-only; when an external credential provider manages tokens, resolving
// the identity may contact that provider.
func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
return newCmdWhoami(f, nil)
}
// NewCmdWhoamiWithRecovery creates whoami with a build-local recovery
// presenter while preserving NewCmdWhoami's established function signature.
func NewCmdWhoamiWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdWhoami(f, projector)
}
func newCmdWhoami(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
opts := &Options{Factory: f}
cmd := &cobra.Command{
Use: "whoami",
Short: "Show the current effective identity, app, profile, and token status (JSON)",
RunE: func(cmd *cobra.Command, args []string) error {
return whoamiRun(cmd, opts, projector)
return whoamiRun(cmd, opts)
},
}
cmdutil.DisableAuthCheck(cmd)
@@ -84,7 +73,7 @@ func newCmdWhoami(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Comm
return cmd
}
func whoamiRun(cmd *cobra.Command, opts *Options, projector *recovery.Projector) error {
func whoamiRun(cmd *cobra.Command, opts *Options) error {
f := opts.Factory
cfg, err := f.Config()
if err != nil {
@@ -107,10 +96,7 @@ func whoamiRun(cmd *cobra.Command, opts *Options, projector *recovery.Projector)
f.IdentityAutoDetected,
f.ResolveStrictMode(ctx).ForcedIdentity(),
)
diag := identitydiag.FilterRecovery(
identitydiag.Diagnose(ctx, f, cfg, false),
projector.CanReference,
)
diag := identitydiag.Diagnose(ctx, f, cfg, false)
res := buildResult(cfg, as, source, diag)
output.PrintJson(f.IOStreams.Out, res)
return nil

View File

@@ -10,6 +10,7 @@ import (
"os"
"github.com/larksuite/cli/cmd"
"github.com/larksuite/cli/internal/affordance"
)
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
@@ -35,6 +36,6 @@ func init() {
if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil {
fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err)
} else {
cmd.SetEmbeddedAffordanceContent(sub)
affordance.SetSource(sub)
}
}

View File

@@ -509,9 +509,6 @@ Rare; the existing structs cover the 9 Categories with room. If you must:
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_test.go`.
4. Add the concrete type and deep-copy handling to
`internal/recovery.CloneTyped`, then extend
`TestRenderClonesEveryConcreteTypedErrorAndPreservesWireExtensions`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the

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

@@ -37,79 +37,21 @@ Wire into a fork:
package main
import (
"os"
_ "github.com/me/myplugin" // blank import → init() runs
"github.com/larksuite/cli/cmd"
"os"
)
func main() {
os.Exit(cmd.Execute())
}
func main() { os.Exit(cmd.Execute()) }
```
```sh
go build -o lark-cli ./cmd/larkx && ./lark-cli config plugins show
go build -o larkx ./cmd/larkx && ./larkx config plugins show
```
You should see `audit` in the plugin list.
That is sufficient for a hook-only plugin such as the audit observer. A
wrapper main does not compile lark-cli's repository-root `content_embed.go`,
so distribution content is a separate, explicit host choice.
### Ship skills and command guidance
If the distribution exposes embedded skills or customizes them with
`EmbeddedSkills`, copy or generate both content trees under the wrapper
package and wire both:
```go
package main
import (
"embed"
"io/fs"
"os"
_ "github.com/me/myplugin"
"github.com/larksuite/cli/cmd"
)
//go:embed skills affordance
var distributionContent embed.FS
func main() {
skillTree, err := fs.Sub(distributionContent, "skills")
if err != nil {
panic(err)
}
affordanceTree, err := fs.Sub(distributionContent, "affordance")
if err != nil {
panic(err)
}
cmd.SetEmbeddedSkillContent(skillTree)
cmd.SetEmbeddedAffordanceContent(affordanceTree)
os.Exit(cmd.Execute())
}
```
`go:embed` only reads files in the package being compiled; it cannot reach
into the replaced `github.com/larksuite/cli` module. Each
`skills/<name>/` must contain `SKILL.md`. The `affordance/*.md` files are the
structured source for command help and canonical skill references; ship the
ones for the domains your distribution retains. Without
`SetEmbeddedSkillContent`, `skills list` has no base content and an `Allow` or
`Remove` overlay deliberately aborts startup. A plugin may instead provide a
complete `SkillsOverlay.Base`. Without `SetEmbeddedAffordanceContent`,
commands still run, but distribution-specific guidance and its skill pointers
are absent.
Keep the executable available as `lark-cli` on `PATH`: command-linked
guidance invokes that canonical name.
## What you can hook
| Hook | Fires | Can block? |
@@ -118,7 +60,6 @@ guidance invokes that canonical name.
| `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 | Build-integrity (fail-closed) |
### Plugin lifecycle
@@ -138,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
@@ -152,8 +93,9 @@ sequenceDiagram
SDK->>Plugin: On(Shutdown) fire
```
A rule or strict-mode denial bypasses the `Wrap` chain entirely —
observers still fire so audit plugins see the rejected dispatch.
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
the `Wrap` chain entirely — observers still fire so audit plugins see
the rejected dispatch.
## Safety contract (read this)
@@ -171,79 +113,10 @@ observers still fire so audit plugins see 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 host-provided base skill tree. The repository's root
lark-cli binary wires its default in `content_embed.go`; an external fork
main must call `cmd.SetEmbeddedSkillContent` as shown above (unless its
plugin supplies `Base`) and should wire `cmd.SetEmbeddedAffordanceContent`
for command guidance. `EmbeddedSkills()` implies `FailClosed`: it
declares distribution assets, and silently falling back could republish
content the distribution explicitly removed or replaced. Removing a skill
drops its `skills read` content and every framework-owned structured help
block that depends on it; it does NOT disable matching commands (use
`Restrict()` for that). The inverse is also explicit: concealing a command
does not automatically delete its skill content. Command policy and
distribution assets are independent axes; use `EmbeddedSkills` when both
must be trimmed.
`ReferenceRemaps` can rename a whole referenced skill while preserving
relative paths, or override one exact reference:
```go
EmbeddedSkills(&platform.SkillsOverlay{
Base: customizedSkills,
ReferenceRemaps: []platform.SkillRefRemap{
platform.RemapSkillRef("lark-doc", "acme-docx"),
platform.RemapSkillRef(
"lark-doc/references/lark-doc-fetch.md",
"acme-docx/guides/fetch.md",
),
},
})
```
Remaps apply only to structured CLI help/affordance references; they never
scan or rewrite arbitrary prose or links inside Skill Markdown. An explicit
remap to a missing target aborts startup, while an unmapped canonical
reference removed from the final tree causes its complete dependent help
block to be omitted. Only ONE plugin per binary may
contribute a `SkillsOverlay`; two DISTINCT plugins is a deliberate
`multiple_skills_overlay_plugins` error. The top-level skill set and
each skill's owning FS are snapshotted during CLI build; files inside an
owned skill directory remain live. Both `Base` and `Overlay` must
contain only valid skill directories with `SKILL.md`.
- A command denied by a Rule is hidden from normal command discovery and
returns `validation/failed_precondition` with its policy source, rule, and
reason code in the recovery hint. This is the established `Restrict`
contract for both plugin and yaml sources. A distribution that wants
plugin-restricted commands to look absent must opt in from its wrapper main
with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`;
presentation is a host choice, not part of `Rule` or `Capabilities`. One
carve-out: a command already retired by the user's strict-mode setting
keeps its strict-mode identity error even when a plugin Rule also
matches it — strict-mode is a user-side security boundary and is never
re-labelled.
A wrapper may customize the absent-capability message:
```go
os.Exit(cmd.ExecuteWithOptions(
cmd.ConcealRestrictedCommands(
cmd.UnavailableMessage("capability not shipped by this distribution"),
),
))
```
- `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. A concealed
distribution can remove those escape hatches with the host-side
`cmd.HidePolicyDiagnostics()` presentation option.
- 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.
- Plugins cannot suppress a denied dispatch: the framework
- Plugins cannot suppress a `command_denied`: the framework
physically isolates denied commands from the Wrap chain (Observers
still fire).
- Commands missing a `risk_level` annotation are denied by default
@@ -258,20 +131,13 @@ observers still fire so audit plugins see the rejected dispatch.
## reason_code reference
Install and rule evaluation keep a closed `reason_code` taxonomy for
operator diagnostics and in-process errors. The established Restrict
presentation includes the reason code in the error hint. A distribution
that explicitly enables command concealment replaces that wire presentation
with `validation/command_unavailable`.
Every install / dispatch failure emits a `command_denied` or
`plugin_install` envelope carrying a `detail.reason_code` from the
closed enum below. Use the code (not the human-readable message) when
matching errors in agents, CI scripts, or downstream tools — the
messages are localised and may change between releases.
### Plugin installation/configuration diagnostics
Fail-closed bootstrap errors that reach the CLI dispatcher use
`error.type=validation` and `error.subtype=failed_precondition`. The
diagnostic `reason_code` values below currently appear in the human-readable
hint; they are not a separate `detail` field. In-process hosts should inspect
the wrapped platform error with `errors.As` / `errors.Is` when they need the
precise cause.
### Plugin install (`error.type = plugin_install`)
| reason_code | When it fires | Honours FailurePolicy? |
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
@@ -279,7 +145,7 @@ precise cause.
| `plugin_name_panic` | `Plugin.Name()` panicked | No — always aborts |
| `duplicate_plugin_name` | Two plugins return the same `Name()` | No — always aborts |
| `capabilities_panic` | `Plugin.Capabilities()` panicked | Yes |
| `invalid_capability` | `Capabilities` malformed: bad version/policy, or `EmbeddedSkills` contributed under `FailOpen` | No — always aborts |
| `invalid_capability` | `Capabilities` malformed: bad `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
| `capability_unmet` | Current CLI version doesn't satisfy `RequiredCLIVersion` | Yes |
| `restricts_mismatch` | `Restricts=true` without `FailClosed`, or `Restricts` flag inconsistent w/ Install | No — always aborts |
| `invalid_hook_name` | Hook name contains `.` or doesn't match the plugin namespace | Yes |
@@ -287,8 +153,6 @@ precise cause.
| `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` | Registration fault (`nil` / duplicate call), or invalid selection/content/reference remap | Registration honours policy; composition always aborts |
| `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 |
@@ -297,7 +161,7 @@ the host can't honour the plugin's declared `FailurePolicy` because the
declaration itself is suspect (e.g. an `invalid_capability` plugin
might also be lying about being `FailOpen`).
### Command rule evaluation (internal/operator diagnostics)
### Command dispatch (`error.type = command_denied`)
| reason_code | Meaning |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
@@ -311,20 +175,20 @@ might also be lying about being `FailOpen`).
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
These codes remain available to in-process hosts through the wrapped
`*platform.CommandDeniedError` cause. Operator commands expose the active
rule and shipped-tree summary. Agents consuming a host that explicitly
enabled concealment should match `error.type == "validation"` and
`error.subtype == "command_unavailable"` instead of branching on a
rule-specific reason.
The `detail.layer` field distinguishes who rejected the call:
`policy` (this SDK's user-layer engine) vs. `strict_mode`
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
dispatch on "any denial" should match `error.type == "command_denied"`
and ignore the layer; agents that only care about user-policy denials
should additionally check `detail.layer == "policy"`.
## Where to go next
- [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`,
`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

@@ -28,9 +28,6 @@ import (
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
// FailClosed, so plugin authors cannot accidentally ship a policy
// plugin under FailOpen)
// - EmbeddedSkills ↔ FailClosed consistency (declaring distribution
// assets is a build-integrity commitment; falling back to host defaults
// is never allowed)
// - Rule validation via ValidateRule analogues (delegated to
// internal/cmdpolicy at install time; Builder only fast-fails
// blatantly bad input)
@@ -39,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
@@ -72,16 +68,14 @@ func (b *Builder) RequireCLI(constraint string) *Builder {
}
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
// neither FailOpen nor FailClosed is called and neither Restrict nor
// EmbeddedSkills is used. Build rejects a final FailOpen state after either
// safety-sensitive contribution.
// neither FailOpen nor FailClosed is called and Restrict is not used.
func (b *Builder) FailOpen() *Builder {
b.caps.FailurePolicy = FailOpen
return b
}
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when
// Restrict() or EmbeddedSkills() is called.
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
// when Restrict() is called.
func (b *Builder) FailClosed() *Builder {
b.caps.FailurePolicy = FailClosed
return b
@@ -151,68 +145,25 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
return b
}
// EmbeddedSkills contributes a SkillsOverlay (see SkillsOverlay) customizing
// the CLI's embedded skill content. It implies FailClosed: although skill
// content is not a command-enforcement boundary, the overlay is a distribution
// build-integrity declaration. Silently skipping it could republish host
// defaults that the distribution explicitly removed or replaced.
//
// Calling FailOpen before EmbeddedSkills is allowed; EmbeddedSkills overrides
// it to FailClosed, matching Restrict. Calling FailOpen afterward leaves an
// invalid final state that Build rejects. A later FailClosed restores a valid
// final state. 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.caps.FailurePolicy = FailClosed
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. Selection and remap slices
// are 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...)
cp.ReferenceRemaps = append([]SkillRefRemap(nil), spec.ReferenceRemaps...)
return &cp
}
// Build returns the configured Plugin, or an error if any builder
// step found a fault. MustBuild panics on the same error.
//
// FailOpen mismatches are checked against the final builder state, not in the
// chained setters, because FailOpen/FailClosed and the contributing methods may
// be called in either order.
// The Restrict + FailOpen mismatch is checked here, not in the chained
// setters, because the two methods may be called in either order.
func (b *Builder) Build() (Plugin, error) {
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
if b.skillsOverlay != nil && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"EmbeddedSkills() requires FailClosed; do not call FailOpen() after EmbeddedSkills()"))
}
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
}
@@ -251,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 }
@@ -266,15 +216,6 @@ func (p *builtPlugin) Install(r Registrar) error {
for _, rule := range p.rules {
r.Restrict(rule)
}
if p.skillsOverlay != nil {
sr, ok := r.(EmbeddedSkillsRegistrar)
if !ok {
// Fail closed: a declared skill customization must never be
// silently dropped by a host that cannot honour it.
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(p.skillsOverlay)
}
for _, action := range p.actions {
action(r)
}

View File

@@ -14,13 +14,11 @@ import (
// recorder Registrar captures everything a builder schedules so the
// test can assert what Install produced without involving the host.
type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
skillsOverlay *platform.SkillsOverlay
skillCalls int
observers int
wrappers int
lifecycles int
rule *platform.Rule // last rule (existing single-rule assertions)
rules []*platform.Rule // every rule, in Restrict order
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
@@ -32,10 +30,6 @@ func (r *recorder) Restrict(rule *platform.Rule) {
r.rule = rule
r.rules = append(r.rules, rule)
}
func (r *recorder) EmbeddedSkills(spec *platform.SkillsOverlay) {
r.skillsOverlay = spec
r.skillCalls++
}
// Restrict must snapshot each rule: a caller that reuses and mutates the
// same *Rule object across two Restrict calls must still get two distinct
@@ -217,124 +211,3 @@ func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
}
}
// EmbeddedSkills() must snapshot selection and remap slices: a caller that
// mutates the same backing arrays after the call must still get the values
// staged at call time.
func TestBuilder_skillsInstalledAndCloned(t *testing.T) {
remove := []string{"lark-shared"}
remaps := []platform.SkillRefRemap{
platform.RemapSkillRef("lark-doc", "acme-docx"),
}
spec := &platform.SkillsOverlay{Remove: remove, ReferenceRemaps: remaps}
b := platform.NewPlugin("p", "0").EmbeddedSkills(spec)
remove[0] = "mutated"
remaps[0] = platform.RemapSkillRef("lark-doc", "mutated")
spec.ReferenceRemaps = nil
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)
}
if len(r.skillsOverlay.ReferenceRemaps) != 1 {
t.Fatalf("staged ReferenceRemaps = %+v, want one mapping", r.skillsOverlay.ReferenceRemaps)
}
remap := r.skillsOverlay.ReferenceRemaps[0]
if remap.From() != "lark-doc" || remap.To() != "acme-docx" {
t.Errorf("staged remap = %q -> %q, want lark-doc -> acme-docx", remap.From(), remap.To())
}
}
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 is a distribution build-integrity commitment: skipping it
// could silently restore content that the distribution intended to remove.
func TestBuilder_skillsForcesFailClosed(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.FailClosed {
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
}
}
func TestBuilder_skillsFailurePolicyOrder(t *testing.T) {
spec := func() *platform.SkillsOverlay {
return &platform.SkillsOverlay{Remove: []string{"lark-a"}}
}
tests := []struct {
name string
build func() *platform.Builder
wantError bool
}{
{
name: "FailOpen before EmbeddedSkills is overridden",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").FailOpen().EmbeddedSkills(spec())
},
},
{
name: "FailOpen after EmbeddedSkills is invalid",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen()
},
wantError: true,
},
{
name: "later FailClosed restores a valid final state",
build: func() *platform.Builder {
return platform.NewPlugin("p", "0").EmbeddedSkills(spec()).FailOpen().FailClosed()
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p, err := tc.build().Build()
if tc.wantError {
if err == nil {
t.Fatal("Build succeeded, want FailOpen+EmbeddedSkills rejection")
}
if !strings.Contains(err.Error(), "EmbeddedSkills() requires FailClosed") {
t.Fatalf("error = %v, want EmbeddedSkills FailClosed guidance", err)
}
return
}
if err != nil {
t.Fatalf("Build: %v", err)
}
if got := p.Capabilities().FailurePolicy; got != platform.FailClosed {
t.Fatalf("FailurePolicy = %v, want FailClosed", got)
}
})
}
}

View File

@@ -13,12 +13,11 @@ const (
// where missing audit data is preferable to a broken CLI.
FailOpen FailurePolicy = iota
// FailClosed — abort the entire CLI startup. Required for any plugin
// that contributes Restrict() (a missing policy plugin = missing
// security boundary), EmbeddedSkills() (silently dropping distribution
// assets would violate build integrity), or any other safety-sensitive
// concern. The Builder sets it automatically for Restrict and
// EmbeddedSkills; the host validates hand-written plugins after staging.
// FailClosed — abort the entire CLI startup. Required for any
// plugin that contributes Restrict() (a missing policy plugin =
// missing security boundary) or that owns any safety-sensitive
// concern. Enforced by the framework: Capabilities.Restricts=true
// must pair with FailurePolicy=FailClosed.
FailClosed
)
@@ -46,6 +45,6 @@ type Capabilities struct {
// FailurePolicy decides what happens on install failure. See the
// constants above; the framework requires FailClosed whenever
// Restricts=true or Install contributes EmbeddedSkills.
// Restricts=true.
FailurePolicy FailurePolicy
}

View File

@@ -32,9 +32,7 @@
// gives a comparable rank for the read < write < high-risk-write ordering
// - CommandDeniedError - structured error returned to denied callers
//
// Stability: every exported symbol here is part of the contract. Interfaces
// never gain methods; new host capability surfaces arrive as optional
// extension interfaces (see EmbeddedSkillsRegistrar). Internal
// Stability: every exported symbol here is part of the contract. Internal
// orchestration (staging, validation, RunE wrapping, denial guard) lives
// under internal/platform, internal/hook and internal/cmdpolicy and is not
// importable by third parties.

View File

@@ -1,8 +1,8 @@
# Example: read-only policy
A policy plugin that installs a `Rule` allowing only `docs/*` and
`im/*` read commands. Any denied command produces a structured
`failed_precondition` envelope with policy diagnostics.
`im/*` read commands. Any write command produces a structured
`command_denied` envelope.
## Build & run
@@ -15,19 +15,26 @@ go build -o readonly-cli .
# "source": "plugin",
# "source_name": "readonly",
# "denied_paths": N,
# "rules": [{
# "rule": {
# "name": "agent-readonly",
# "allow": ["docs/**", "im/**"],
# "deny": [],
# "max_risk": "read",
# "identities": [],
# "allow_unannotated": false
# }]
# }
# }
./readonly-cli docs +update --doc X --content Y
# {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
# "hint":"denied by policy policy (source plugin:readonly, ... reason_code write_not_allowed); ..."}}
./readonly-cli docs +update --doc-token X --content Y
# {"ok":false,"error":{
# "type":"command_denied",
# "detail":{
# "layer":"policy",
# "policy_source":"plugin:readonly",
# "rule_name":"agent-readonly",
# "reason_code":"write_not_allowed"
# }
# }}
./readonly-cli docs +fetch --doc-token X
# Normal read response (assuming credentials)
@@ -44,8 +51,6 @@ go build -o readonly-cli .
- `AllowUnannotated` is left default (false): unannotated commands
are denied with `risk_not_annotated`. Set it to true if you need
a gradual-adoption window for the lark-cli main tree.
- A fork that wants denied commands to present as absent can opt in from
`main` with `cmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...))`.
## Caveats

View File

@@ -3,15 +3,14 @@
// Command readonly-policy is a runnable fork of lark-cli that
// installs a Rule permitting only docs/* and im/* read commands.
// Any write command is rejected with the established Restrict policy
// envelope.
// Any write command produces a structured command_denied envelope.
//
// Build & run:
//
// cd extension/platform/examples/readonly-policy
// go build -o readonly-cli .
// ./readonly-cli docs +update --doc-token X --content Y
// # {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
// # {"ok":false,"error":{"type":"command_denied", ...}}
//
// ./readonly-cli config policy show
// # shows the active Rule with source=plugin:readonly

View File

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

View File

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

View File

@@ -6,9 +6,8 @@ package platform
// Rule is the declarative policy rule data structure. yaml files and
// Plugin.Restrict() both produce the same Rule.
//
// At any moment there is at most one effective SOURCE of rules -- the
// resolver decides which wins (Plugin > yaml > none); the winning source
// may contribute several scoped rules. This package only defines the
// At any moment there is at most one effective Rule -- the resolver decides
// which source wins (Plugin > yaml > none). This package only defines the
// shape; selection lives in internal/cmdpolicy.
//
// The four filter fields are joined by AND. See the engine's Evaluate for

View File

@@ -1,122 +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 host-provided base) ->
// Allow -> Remove -> Overlay, a same-named skill resolving to Overlay.
// The repository's root binary provides its base from content_embed.go;
// an external wrapper main has no implicit CLI default and must call
// cmd.SetEmbeddedSkillContent before Execute if it relies on that base.
//
// 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 drops its content and
// framework-generated guidance; it does not disable any command (use
// Restrict for that). ReferenceRemaps lets a distribution map the CLI's
// canonical skill references to the runtime names and files it ships.
//
// The top-level skill set and each skill's owning FS are snapshotted when
// the CLI builds. Later additions or removals of top-level directories do
// not change the manifest; files within an owned skill directory are read
// live. Base and Overlay must contain only valid skill directories.
// Declaring this asset composition is a build-integrity commitment: invalid
// selection, content, ownership, or reference remaps abort the build rather
// than silently falling back to host defaults.
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 host-provided base skill tree instead of layering
// over it. nil keeps whatever base the host wired with
// cmd.SetEmbeddedSkillContent; it does not import the repository
// binary's default into an external wrapper main. Every top-level
// entry must be a valid skill directory containing SKILL.md. Most
// integrators leave Base nil and use Remove/Overlay so unchanged
// host-provided skills need no copy inside the plugin.
Base fs.FS
// ReferenceRemaps maps CLI-authored canonical skill references to the
// runtime names and files this distribution ships. References use the
// same "name[/relative/path]" form accepted by `lark-cli skills read`.
//
// A bare source remaps the whole skill name while preserving the
// referenced relative path:
//
// RemapSkillRef("lark-doc", "acme-docx")
//
// maps both "lark-doc" and
// "lark-doc/references/lark-doc-fetch.md" to the corresponding paths
// under "acme-docx". A source carrying a relative path is an exact
// reference override and wins over the whole-skill mapping:
//
// RemapSkillRef(
// "lark-doc/references/lark-doc-fetch.md",
// "acme-docx/guides/fetch.md",
// )
//
// Remaps affect structured CLI help/affordance references only. They
// do not rewrite prose or links inside embedded Markdown. Every
// explicitly mapped target must exist in the composed tree; otherwise
// startup fails as an invalid SkillsOverlay. An unmapped canonical
// reference whose file is absent is simply unavailable to presenters.
ReferenceRemaps []SkillRefRemap
// Prevent cross-package unkeyed literals. SkillsOverlay is introduced as
// part of the plugin SDK and is expected to grow through keyed fields;
// rejecting positional construction now keeps future additions
// source-compatible for consumers.
_ struct{}
}
// SkillRefRemap is an immutable mapping between two structured embedded-skill
// references. Its fields are private so malformed values can only enter via a
// zero value (which the resolver rejects) or RemapSkillRef.
//
// Use From and To for diagnostics; callers should not parse their values to
// perform resolution themselves.
type SkillRefRemap struct {
from string
to string
}
// RemapSkillRef maps a canonical "name[/relative/path]" reference to the
// runtime reference shipped by a distribution. Syntax and target existence are
// validated when the SkillsOverlay is composed, so it participates in the same
// build-integrity failure path as Base, Allow, Remove, and Overlay.
func RemapSkillRef(from, to string) SkillRefRemap {
return SkillRefRemap{from: from, to: to}
}
// From returns the canonical source reference.
func (m SkillRefRemap) From() string { return m.from }
// To returns the distribution runtime target reference.
func (m SkillRefRemap) To() string { return m.to }

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "testing"
// These unkeyed literals intentionally model source published by consumers
// before distribution presentation existed. Adding a field to either exported
// struct makes this file fail to compile, catching that source break in CI.
var (
legacyCapabilitiesLiteral = Capabilities{"", false, FailOpen}
legacyRuleLiteral = Rule{"", "", nil, nil, RiskRead, nil, false}
)
func TestLegacyUnkeyedStructLiteralsRemainSourceCompatible(t *testing.T) {
if legacyCapabilitiesLiteral.FailurePolicy != FailOpen {
t.Errorf("legacy capabilities literal = %+v", legacyCapabilitiesLiteral)
}
if legacyRuleLiteral.MaxRisk != RiskRead {
t.Errorf("legacy rule literal = %+v", legacyRuleLiteral)
}
}

View File

@@ -21,16 +21,11 @@ import (
var (
mu sync.Mutex
byService = map[string]serviceAffordance{}
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build
)
type serviceAffordance struct {
skill string
methods map[string]json.RawMessage
}
// SetSource installs the markdown guidance tree (the top-level affordance/
// directory) as the source. Called once at startup before any lookup; clears
// the parse cache so re-sourcing (e.g. in tests) takes effect.
@@ -38,7 +33,7 @@ func SetSource(fsys fs.FS) {
mu.Lock()
defer mu.Unlock()
mdSource = fsys
byService = map[string]serviceAffordance{}
byService = map[string]map[string]json.RawMessage{}
tried = map[string]bool{}
}
@@ -52,43 +47,27 @@ func For(service, methodID string) (json.RawMessage, bool) {
tried[service] = true
byService[service] = loadService(service)
}
raw, ok := byService[service].methods[methodID]
raw, ok := byService[service][methodID]
return raw, ok && len(raw) > 0
}
// DomainSkill returns the service-level canonical skill declared by
// `> skill:`. It shares For's lazy parse cache, so domain and command help
// cannot disagree about the declared skill.
func DomainSkill(service string) (string, bool) {
mu.Lock()
defer mu.Unlock()
if !tried[service] {
tried[service] = true
byService[service] = loadService(service)
}
skill := byService[service].skill
return skill, skill != ""
}
// loadService parses a service's markdown guidance into its domain metadata
// and per-method overlays, marshalling each method to JSON so downstream
// callers keep the same wire shape.
func loadService(service string) serviceAffordance {
// loadService parses a service's markdown guidance into per-method overlays,
// marshalling each to JSON so downstream callers keep the same wire shape.
func loadService(service string) map[string]json.RawMessage {
if mdSource == nil {
return serviceAffordance{}
return nil
}
src, err := fs.ReadFile(mdSource, service+".md")
if err != nil {
return serviceAffordance{}
return nil
}
m := map[string]json.RawMessage{}
parsed := parseDomainMD(src, commandFormResolver(service))
for id, a := range parsed.methods {
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
if b, err := json.Marshal(a); err == nil {
m[id] = b
}
}
return serviceAffordance{skill: parsed.skill, methods: m}
return m
}
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages

View File

@@ -68,12 +68,6 @@ func TestFor(t *testing.T) {
if _, ok := For("approval", "instances.get"); !ok {
t.Error("second lookup in a cached service should still resolve")
}
if skill, ok := DomainSkill("approval"); !ok || skill != "lark-approval" {
t.Errorf("DomainSkill(approval) = %q, %v; want lark-approval, true", skill, ok)
}
if skill, ok := DomainSkill("no_such_service"); ok || skill != "" {
t.Errorf("DomainSkill(no_such_service) = %q, %v; want empty, false", skill, ok)
}
}
// Non-bullet paragraph lines under any section are preserved as items, not
@@ -81,7 +75,7 @@ func TestFor(t *testing.T) {
func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n"
got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar"
a, ok := got.methods["foo.bar"]
a, ok := got["foo.bar"]
if !ok {
t.Fatal("method not parsed")
}
@@ -102,13 +96,10 @@ func TestParseDomainMD_SkillsMerge(t *testing.T) {
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if got.skill != "lark-d" {
t.Errorf("domain skill = %q, want lark-d", got.skill)
}
if a := got.methods["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
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.methods["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
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)
}
}
@@ -118,8 +109,8 @@ func TestParseDomainMD_SkillsMerge(t *testing.T) {
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got.methods["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got.methods))
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}

View File

@@ -93,16 +93,11 @@ type mdSection struct {
cases []meta.AffordanceCase
}
type parsedDomain struct {
skill string
methods map[string]meta.Affordance
}
// parseDomainMD parses one domain's markdown into per-method Affordance values,
// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages
// list") to its method id ("user_mailbox.message.list"); nil falls back to the
// space→dot rule (valid only where the command form already equals the id).
func parseDomainMD(src []byte, resolve func(string) string) parsedDomain {
func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance {
if resolve == nil {
resolve = headingToKey
}
@@ -176,7 +171,7 @@ func parseDomainMD(src []byte, resolve func(string) string) parsedDomain {
case strings.HasPrefix(line, "# "):
continue
case strings.HasPrefix(t, "> skill:"):
skill = strings.Trim(strings.TrimSpace(t[len("> skill:"):]), "`")
skill = strings.TrimSpace(t[len("> skill:"):])
continue
case strings.HasPrefix(line, "### "):
flushPending()
@@ -225,5 +220,5 @@ func parseDomainMD(src []byte, resolve func(string) string) parsedDomain {
}
flushPending()
assemble()
return parsedDomain{skill: skill, methods: out}
return out
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/recovery"
)
const (
@@ -41,12 +40,12 @@ func (e *NeedAuthorizationError) Error() string {
// recovery vocabulary as the token-missing surface in internal/client, and the
// legacy *NeedAuthorizationError sentinel is preserved in the Cause chain for
// errors.As / errors.Is traversal.
func NewNeedUserAuthorizationError(userOpenID string) error {
e := errs.NewAuthenticationError(errs.SubtypeTokenMissing,
func NewNeedUserAuthorizationError(userOpenID string) *errs.AuthenticationError {
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})
return recovery.Attach(e, recovery.UserAuthorization())
}
// IsNeedUserAuthorizationError reports whether err represents a missing-UAT

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// The producer always describes its auth-login recovery action. Each command
// tree filters a clone at render time, so one concealed build cannot mutate
// the error subsequently rendered by another build.
func TestNeedUserAuthorization_hintUsesBuildLocalSurface(t *testing.T) {
source := NewNeedUserAuthorizationError("ou_x")
var original *errs.AuthenticationError
if !errors.As(source, &original) {
t.Fatalf("expected *errs.AuthenticationError, got %T", source)
}
if !strings.Contains(original.Hint, "auth login") {
t.Fatalf("producer hint = %q, want auth login", original.Hint)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
concealed := recovery.Render(source, plan)
var concealedAuth *errs.AuthenticationError
if !errors.As(concealed, &concealedAuth) {
t.Fatalf("rendered error = %T, want *errs.AuthenticationError", concealed)
}
if concealedAuth == original {
t.Fatal("Render must return a clone, not mutate the producer error")
}
if strings.Contains(concealedAuth.Hint, "auth login") ||
!strings.Contains(concealedAuth.Hint, "supported authorization flow") {
t.Errorf("concealed hint = %q, want target-free authorization fallback", concealedAuth.Hint)
}
if !IsNeedUserAuthorizationError(concealed) {
t.Error("render clone lost the NeedAuthorizationError cause")
}
var visible *errs.AuthenticationError
if !errors.As(recovery.Render(source, nil), &visible) || !strings.Contains(visible.Hint, "auth login") {
t.Errorf("visible render must keep auth login, got %+v", visible)
}
if !strings.Contains(original.Hint, "auth login") {
t.Errorf("concealed render mutated source hint: %q", original.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/recovery"
"github.com/larksuite/cli/internal/util"
)
@@ -72,13 +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)
if as == core.AsUser {
return recovery.Attach(e, recovery.UserAuthorization())
}
return e.WithHint("configure valid app credentials for the bot identity")
}
// buildApiReq converts a RawApiRequest into SDK types and collects

View File

@@ -1,53 +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/recovery"
"github.com/larksuite/cli/internal/surface"
)
// Token recovery metadata is producer-owned and immutable. Presentation is
// selected by the build-local surface passed to recovery.Render.
func TestTokenMissing_hintUsesBuildLocalSurface(t *testing.T) {
cause := errors.New("credential chain exhausted")
source := newTokenMissingError(core.AsUser, cause)
var original *errs.AuthenticationError
if !errors.As(source, &original) || !strings.Contains(original.Hint, "auth login") {
t.Fatalf("producer must keep auth login hint, got %v", source)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
var concealed *errs.AuthenticationError
rendered := recovery.Render(source, plan)
if !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.AuthenticationError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if strings.Contains(concealed.Hint, "auth login") ||
!strings.Contains(concealed.Hint, "supported authorization flow") {
t.Errorf("concealed hint = %q, want target-free authorization fallback", concealed.Hint)
}
if !errors.Is(rendered, cause) {
t.Error("render clone lost the credential-chain cause")
}
var visible *errs.AuthenticationError
if !errors.As(recovery.Render(source, nil), &visible) || !strings.Contains(visible.Hint, "auth login") {
t.Errorf("visible render must keep auth login, got %+v", visible)
}
if !strings.Contains(original.Hint, "auth login") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

View File

@@ -20,21 +20,9 @@ import (
// common single-rule case, several when a plugin or yaml declares scoped
// grants). nil/empty means "no rule applied".
type ActivePolicy struct {
Rules []*platform.Rule
Source ResolveSource
// DeniedByPath is the full post-aggregation denial map.
DeniedByPath map[string]Denial
}
// DeniedPathCount returns the number of post-aggregation denied commands.
// Deriving it from DeniedByPath prevents diagnostics from drifting away from
// the exact denial snapshot used by presentation.
func (p *ActivePolicy) DeniedPathCount() int {
if p == nil {
return 0
}
return len(p.DeniedByPath)
Rules []*platform.Rule
Source ResolveSource
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
}
var (
@@ -73,8 +61,8 @@ func GetActive() *ActivePolicy {
}
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
// each Rule's own slice fields. Source is a value type, so the struct copy
// already disjoints it.
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
// value types so the struct copy already disjoints them.
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
if in == nil {
return nil
@@ -93,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

@@ -4,8 +4,6 @@
package cmdpolicy
import (
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
@@ -126,13 +124,6 @@ func BuildDenialError(path string, d Denial) *errs.ValidationError {
WithCause(cd)
}
// IsPluginPolicySource reports whether a policy source names a plugin.
// The cmd-layer presentation projection uses this to distinguish embedded
// distribution restrictions from a user-owned yaml policy.
func IsPluginPolicySource(source string) bool {
return strings.HasPrefix(source, "plugin:")
}
// 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)

View File

@@ -27,14 +27,3 @@ var diagnosticPaths = map[string]bool{
func IsDiagnosticPath(path string) bool {
return diagnosticPaths[path]
}
// DiagnosticPaths returns the exempt self-inspection command paths for a
// build-local presentation projection. Enforcement always leaves these
// operator escape hatches available.
func DiagnosticPaths() []string {
out := make([]string, 0, len(diagnosticPaths))
for p := range diagnosticPaths {
out = append(out, p)
}
return out
}

View File

@@ -20,7 +20,6 @@ import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/recovery"
)
// Factory holds shared dependencies injected into every command.
@@ -46,18 +45,7 @@ type Factory struct {
FileIOProvider fileio.Provider // file transfer provider (default: local filesystem)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
Recovery *recovery.Projector // build-local recovery presentation; nil means the default fully-visible surface
}
// RenderRecoveryHint renders semantic recovery against this command tree.
// Factories created outside cmd.Build have no projector and therefore retain
// the default fully-visible wording.
func (f *Factory) RenderRecoveryHint(hint recovery.Hint) string {
if f == nil {
return hint.String()
}
return f.Recovery.RenderHint(hint)
SkillContent fs.FS // embedded skill tree (rooted at the skill list); nil when the build embeds no skills
}
// ResolveFileIO resolves a FileIO instance using the current execution context.
@@ -182,14 +170,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
mode := f.ResolveStrictMode(ctx)
if mode.IsActive() && !mode.AllowsIdentity(as) {
hint := recovery.Join("", recovery.Command(recovery.TargetConfigStrictMode,
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"))
return recovery.Annotate(
errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
WithHint("%s", hint.String()),
hint,
)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
return nil
}

View File

@@ -28,12 +28,6 @@ import (
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
)
func init() {
// Stable package wiring: assign once during initialization rather than on
// every Build/NewDefault call.
keychain.RuntimeDirFunc = core.GetRuntimeDir
}
// NewDefault creates a production Factory with cached closures.
// Initialization follows a credential-first order:
//
@@ -55,6 +49,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ws := core.DetectWorkspaceFromEnv(os.Getenv)
core.SetCurrentWorkspace(ws)
// Inject workspace-aware dir into keychain's log system.
// This breaks the core↔keychain import cycle by using a function variable.
keychain.RuntimeDirFunc = core.GetRuntimeDir
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()

View File

@@ -14,7 +14,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/i18n"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
@@ -299,10 +298,8 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (
return nil, err
}
if cfg.UserOpenId == "" {
return nil, recovery.Attach(
errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"),
recovery.UserAuthorization(),
)
return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").
WithHint("run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.")
}
return cfg, nil
}

View File

@@ -8,7 +8,6 @@ import (
"os"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
)
// isMalformedConfigError reports whether a config load failure indicates a
@@ -82,24 +81,14 @@ const (
func NotConfiguredError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
hint := recovery.Join("", recovery.Command(recovery.TargetConfigInit, localInitHint)).
WithFallback("configure this distribution before retrying")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
WithHint("%s", hint.String()),
hint,
)
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
WithHint("%s", localInitHint)
}
// Agent workspace: the workspace name appears only in the message, never
// in the wire subtype, which stays not_configured.
hint := recovery.Join("", recovery.Command(recovery.TargetConfigBind, agentBindHint)).
WithFallback("bind this agent workspace through the distribution's supported setup flow")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", hint.String()),
hint,
)
return errs.NewConfigError(errs.SubtypeNotConfigured,
"%s context detected but lark-cli is not bound to it", ws.Display()).
WithHint("%s", agentBindHint)
}
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
@@ -121,20 +110,10 @@ func reconfigureHint() string {
func NoActiveProfileError() error {
ws := CurrentWorkspace()
if ws.IsLocal() {
hint := recovery.Join("", recovery.Command(recovery.TargetConfigInit, localInitHint)).
WithFallback("configure this distribution before retrying")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
WithHint("%s", hint.String()),
hint,
)
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
WithHint("%s", localInitHint)
}
hint := recovery.Join("", recovery.Command(recovery.TargetConfigBind, agentBindHint)).
WithFallback("bind this agent workspace through the distribution's supported setup flow")
return recovery.Annotate(
errs.NewConfigError(errs.SubtypeNotConfigured,
"no active profile in %s workspace", ws.Display()).
WithHint("%s", hint.String()),
hint,
)
return errs.NewConfigError(errs.SubtypeNotConfigured,
"no active profile in %s workspace", ws.Display()).
WithHint("%s", agentBindHint)
}

View File

@@ -1,55 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// NotConfiguredError carries a semantic config/init recovery target. Rendering
// for one concealed tree filters only a clone and leaves the producer value
// reusable by a tree where config/init remains referenceable.
func TestNotConfiguredError_hintUsesBuildLocalSurface(t *testing.T) {
previous := CurrentWorkspace()
SetCurrentWorkspace(WorkspaceLocal)
t.Cleanup(func() { SetCurrentWorkspace(previous) })
source := NotConfiguredError()
var original *errs.ConfigError
if !errors.As(source, &original) || !strings.Contains(original.Hint, "config init") {
t.Fatalf("producer must hint at config init, got %+v", source)
}
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandConfigInit: surface.CommandConcealed,
})
rendered := recovery.Render(source, plan)
var concealed *errs.ConfigError
if !errors.As(rendered, &concealed) {
t.Fatalf("rendered error = %T, want *errs.ConfigError", rendered)
}
if concealed == original {
t.Fatal("Render must clone the typed error")
}
if concealed.Subtype != errs.SubtypeNotConfigured {
t.Errorf("subtype = %q, want not_configured", concealed.Subtype)
}
if strings.Contains(concealed.Hint, "config init") ||
!strings.Contains(concealed.Hint, "configure this distribution") {
t.Errorf("concealed hint = %q, want target-free configuration fallback", concealed.Hint)
}
var visible *errs.ConfigError
if !errors.As(recovery.Render(source, nil), &visible) || !strings.Contains(visible.Hint, "config init") {
t.Errorf("visible render must keep config init, got %+v", visible)
}
if !strings.Contains(original.Hint, "config init") {
t.Errorf("concealed render mutated source hint: %q", original.Hint)
}
}

View File

@@ -38,10 +38,9 @@ func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) e
}
switch oauthErr {
case "invalid_client", "unauthorized_client":
typed := errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
WithCode(code).
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
return errclass.AnnotateConfigRecovery(typed, errs.SubtypeInvalidClient)
}
if err := errclass.BuildAPIError(map[string]any{
"code": code,

View File

@@ -24,9 +24,10 @@ type Notice struct {
Skill string `json:"skill,omitempty"`
}
// MessageWithoutUpdateAction returns the useful migration context that remains
// valid even in a distribution that does not ship the update command.
func (n *Notice) MessageWithoutUpdateAction() string {
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
var b strings.Builder
b.WriteString(n.Command)
b.WriteString(" is a pre-refactor compatibility alias")
@@ -38,20 +39,13 @@ func (n *Notice) MessageWithoutUpdateAction() string {
if n.Skill != "" {
b.WriteString("; update your ")
b.WriteString(n.Skill)
b.WriteString(" skill")
b.WriteString(" skill, run: lark-cli update")
} else {
b.WriteString("; update your skill")
b.WriteString("; update your skill, run: lark-cli update")
}
return b.String()
}
// Message returns a single-line, AI-agent-parseable description of the alias
// plus the canonical fix (update the skill). Mirrors the style of
// internal/skillscheck.StaleNotice.Message ("..., run: lark-cli update").
func (n *Notice) Message() string {
return n.MessageWithoutUpdateAction() + ", run: lark-cli update"
}
// pending stores the latest deprecation notice for the current process.
var pending atomic.Pointer[Notice]

View File

@@ -3,10 +3,7 @@
package deprecation
import (
"strings"
"testing"
)
import "testing"
func TestNoticeMessage(t *testing.T) {
tests := []struct {
@@ -39,22 +36,6 @@ func TestNoticeMessage(t *testing.T) {
}
}
func TestNoticeMessageWithoutUpdateAction(t *testing.T) {
n := &Notice{
Command: "+read",
Replacement: "+cells-get",
Skill: "lark-sheets",
}
got := n.MessageWithoutUpdateAction()
want := "+read is a pre-refactor compatibility alias; use +cells-get instead; update your lark-sheets skill"
if got != want {
t.Fatalf("MessageWithoutUpdateAction() = %q, want %q", got, want)
}
if strings.Contains(got, "lark-cli update") {
t.Fatalf("MessageWithoutUpdateAction() contains an update command: %q", got)
}
}
func TestSetGetPending(t *testing.T) {
t.Cleanup(func() { SetPending(nil) })

View File

@@ -11,7 +11,6 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/recovery"
)
// ClassifyContext is the contextual data BuildAPIError uses to populate
@@ -232,45 +231,27 @@ func stringFromAny(v any) string {
// buildConfigError enriches a typed ConfigError with the canonical
// per-subtype recovery hint before returning it, so the wire envelope
// emitted via BuildAPIError always carries a hint for known config subtypes.
func buildConfigError(p errs.Problem) error {
func buildConfigError(p errs.Problem) *errs.ConfigError {
// Config categories have authoritative recovery guidance, so the curated
// ConfigHint deliberately overrides any server detail lifted into p.Hint
// (the opposite precedence from the CategoryAPI arm, where the lifted
// detail wins).
hint := configRecoveryHint(p.Subtype)
p.Hint = hint.String()
return recovery.Annotate(&errs.ConfigError{Problem: p}, hint)
p.Hint = ConfigHint(p.Subtype)
return &errs.ConfigError{Problem: p}
}
// ConfigHint returns the canonical per-subtype recovery hint for a typed
// ConfigError emitted via BuildAPIError.
func ConfigHint(subtype errs.Subtype) string {
return configRecoveryHint(subtype).String()
}
// AnnotateConfigRecovery attaches the same semantic recovery metadata used by
// BuildAPIError to a directly-constructed ConfigError.
func AnnotateConfigRecovery(err error, subtype errs.Subtype) error {
return recovery.Annotate(err, configRecoveryHint(subtype))
}
func configRecoveryHint(subtype errs.Subtype) recovery.Hint {
switch subtype {
case errs.SubtypeInvalidClient:
return recovery.Join("", recovery.Command(recovery.TargetConfigInit,
"run `lark-cli config init` to set valid app_id and app_secret")).
WithFallback("configure valid app credentials through this distribution's supported setup flow")
return "run `lark-cli config init` to set valid app_id and app_secret"
case errs.SubtypeNotConfigured:
return recovery.Join("", recovery.Command(recovery.TargetConfigInit,
"run `lark-cli config init` to set up app_id and app_secret")).
WithFallback("configure app credentials through this distribution's supported setup flow")
return "run `lark-cli config init` to set up app_id and app_secret"
case errs.SubtypeInvalidConfig:
return recovery.Join("; ",
recovery.Text("check the config file for syntax errors"),
recovery.Command(recovery.TargetConfigInit, "rerun `lark-cli config init` to reset"),
)
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
}
return recovery.Join("")
return ""
}
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
@@ -291,28 +272,8 @@ func APIHint(subtype errs.Subtype) string {
return ""
}
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) error {
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
missing := extractMissingScopes(resp)
return buildPermissionErrorFromFacts(p, missing, cc)
}
// NewMissingScopeError constructs the same typed missing-scope error as the
// API classifier from locally verified scope facts. Generated service
// preflight checks use this entrypoint so subtype-specific wire fields and
// recovery cannot drift from BuildAPIError.
func NewMissingScopeError(brand, appID, identity string, missing []string) error {
return buildPermissionErrorFromFacts(
errs.Problem{
Category: errs.CategoryAuthorization,
Subtype: errs.SubtypeMissingScope,
},
missing,
ClassifyContext{Brand: brand, AppID: appID, Identity: identity},
)
}
func buildPermissionErrorFromFacts(p errs.Problem, missing []string, cc ClassifyContext) error {
missing = append([]string(nil), missing...)
identity := cc.Identity
if identity == "" {
identity = "user"
@@ -323,8 +284,7 @@ func buildPermissionErrorFromFacts(p errs.Problem, missing []string, cc Classify
// grant, console URL), so the curated PermissionHint deliberately overrides
// any server detail lifted into p.Hint (the opposite precedence from the
// CategoryAPI arm, where the lifted detail wins).
hint := permissionRecoveryHint(missing, identity, p.Subtype, consoleURL)
p.Hint = hint.String()
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
permErr := &errs.PermissionError{
Problem: p,
MissingScopes: missing,
@@ -342,7 +302,7 @@ func buildPermissionErrorFromFacts(p errs.Problem, missing []string, cc Classify
if p.Subtype == errs.SubtypeAppScopeNotApplied {
permErr.ConsoleURL = consoleURL
}
return recovery.Annotate(permErr, hint)
return permErr
}
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
@@ -403,63 +363,33 @@ func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []st
// checkServiceScopes) can produce hints that match the dispatcher path
// byte-for-byte instead of hand-rolling divergent strings.
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
return permissionRecoveryHint(missing, identity, subtype, consoleURL).String()
}
// PermissionRecovery returns the semantic recovery represented by a
// PermissionError's machine fields. The root presenter uses it for direct
// business-produced PermissionErrors that did not need to know about command
// targets or distribution policy.
func PermissionRecovery(missing []string, identity string, subtype errs.Subtype, consoleURL string) recovery.Hint {
return permissionRecoveryHint(missing, identity, subtype, consoleURL)
}
func permissionRecoveryHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) recovery.Hint {
switch subtype {
case errs.SubtypeAppScopeNotApplied:
if consoleURL != "" {
return recovery.Join("", recovery.Text(fmt.Sprintf(
"the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)))
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
}
return recovery.Join("", recovery.Text(
"the app developer must apply for the required scope(s) at the developer console"))
return "the app developer must apply for the required scope(s) at the developer console"
case errs.SubtypeMissingScope:
if identity == "bot" {
if consoleURL != "" {
return recovery.Join("", recovery.Text(fmt.Sprintf(
"the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)))
}
return recovery.Join("", recovery.Text(
"the app developer must grant the required scope(s) to the bot identity"))
if len(missing) > 0 {
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
}
return recovery.UserAuthorization(missing...)
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
case errs.SubtypeTokenScopeInsufficient:
return recovery.Join("; ",
recovery.Text("check the token's granted scopes"),
recovery.Command(recovery.TargetAuthLogin,
"run `lark-cli auth login` to refresh if the scope was added after the token was issued"),
)
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
case errs.SubtypeUserUnauthorized:
return recovery.Join("; ",
recovery.Command(recovery.TargetAuthLogin,
"run `lark-cli auth login` to re-authorize this user"),
recovery.Text("if re-auth does not help, the operation may be blocked by external-chat or admin policy"),
)
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
case errs.SubtypeAppUnavailable:
return recovery.Join("", recovery.Text(
"ask the tenant admin to check the app's install status in the Lark admin console"))
return "ask the tenant admin to check the app's install status in the Lark admin console"
case errs.SubtypeAppDisabled:
return recovery.Join("", recovery.Text(
"ask the tenant admin to re-enable the app in the Lark admin console"))
return "ask the tenant admin to re-enable the app in the Lark admin console"
case errs.SubtypePermissionDenied:
who := "this user"
if identity == "bot" {
who = "this bot"
}
return recovery.Join("", recovery.Text(
fmt.Sprintf("check the resource owner has granted access to %s", who)))
return fmt.Sprintf("check the resource owner has granted access to %s", who)
}
return recovery.Join("", recovery.Text("check the calling identity has the required scope"))
return "check the calling identity has the required scope"
}
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason

View File

@@ -98,15 +98,6 @@ func matchesTypedError(err error, wantTyped string) bool {
return false
}
func requirePermissionError(t *testing.T, err error) *errs.PermissionError {
t.Helper()
var permission *errs.PermissionError
if !errors.As(err, &permission) {
t.Fatalf("expected *errs.PermissionError in error chain, got %T", err)
}
return permission
}
func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
cases := []struct {
name string
@@ -428,7 +419,10 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) {
func TestConsoleURL_FeishuBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
}
@@ -437,7 +431,10 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
func TestConsoleURL_LarkBrand(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err)
}
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
}
@@ -446,7 +443,7 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
func TestConsoleURL_EmptyAppID(t *testing.T) {
resp := appScopeNotAppliedResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.ConsoleURL != "" {
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
}
@@ -462,15 +459,13 @@ func TestConsoleURL_EmptyAppID(t *testing.T) {
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
bot := requirePermissionError(t,
errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc))
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
if bot.ConsoleURL == "" {
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
}
user := requirePermissionError(t, errclass.BuildAPIError(
missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}))
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
if user.ConsoleURL != "" {
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
}
@@ -543,7 +538,7 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
func TestPermissionError_DefaultIdentity(t *testing.T) {
resp := missingScopeResp("docx:document")
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123" /* no Identity */})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.Identity != "user" {
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
}
@@ -555,7 +550,7 @@ func TestPermissionError_NoViolations(t *testing.T) {
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
resp := map[string]any{"code": 99991672, "msg": "x"}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
}
@@ -578,7 +573,7 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
pe := requirePermissionError(t, err)
pe := err.(*errs.PermissionError)
if got, want := len(pe.MissingScopes), 2; got != want {
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
}
@@ -589,9 +584,12 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
// converges with the envelope produced by the direct-construction path used
// in cmd/service/service.go's checkServiceScopes pre-flight check.
//
// Both paths now share the same production constructor in internal/errclass.
// A future drift in subtype-specific field gating therefore fails this test
// without the test hand-copying either implementation.
// Both paths now share the same canonical helpers in internal/errclass for
// Message (CanonicalPermissionMessage), Hint (PermissionHint), and
// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically.
// A future drift on either side (e.g. a new extension field on
// PermissionError that only BuildAPIError populates, or service.go inlining
// its own message string again) fails this test loudly.
//
// One upstream-derived field is a documented exception: `code` (the Lark
// API numeric code). The pre-flight check runs against a locally cached
@@ -610,12 +608,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
resp := missingScopeResp(missing[0])
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
requirePermissionError(t, dispatcherErr)
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
}
// Path B: the production constructor used by cmd/service's local
// preflight. ConsoleURL is intentionally NOT set on either path for
// Path B: direct construction — exercises the same helpers that
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
// in lock-step with that helper; if either drifts the byte-comparison
// fails. ConsoleURL is intentionally NOT set on either path for
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
directErr := errclass.NewMissingScopeError(brand, appID, identity, missing)
consoleURL := errclass.ConsoleURL(brand, appID, missing)
directErr := errs.NewPermissionError(errs.SubtypeMissingScope,
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
WithMissingScopes(missing...).
WithIdentity(identity)
var bufA, bufB bytes.Buffer
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
@@ -720,9 +727,10 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) {
// missing_scope means the user authorized the app but did not grant
// this scope — recoverable by re-running `auth login`. An empty identity
// retains the historical user default.
for _, identity := range []string{"user", ""} {
// this scope — recoverable by re-running `auth login`. Both user and
// bot identities route the same way because the recovery action is
// user-initiated either way.
for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "")
if !strings.Contains(got, "lark-cli auth login") {
t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got)
@@ -731,9 +739,6 @@ func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) {
t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, got)
}
}
if got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope, ""); strings.Contains(got, "auth login") || !strings.Contains(got, "app developer") {
t.Errorf("bot missing-scope recovery must not recommend user login; got %q", got)
}
}
func TestBuildPermissionHint_NoScopes(t *testing.T) {
@@ -833,7 +838,10 @@ func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
}
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
pe := requirePermissionError(t, err)
pe, ok := err.(*errs.PermissionError)
if !ok {
t.Fatalf("expected *PermissionError, got %T", err)
}
if pe.Subtype != tc.wantSubtype {
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
}
@@ -930,7 +938,9 @@ func TestBuildAPIError_JSONNumberCode(t *testing.T) {
if err == nil {
t.Fatal("expected error for json.Number-encoded code")
}
requirePermissionError(t, err)
if _, ok := err.(*errs.PermissionError); !ok {
t.Errorf("expected *errs.PermissionError, got %T", err)
}
}
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses

View File

@@ -1,65 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/surface"
)
// Permission producers attach auth/login as a semantic recovery target. Each
// build filters a clone, while non-command guidance and the source error remain
// intact.
func TestPermissionHint_usesBuildLocalSurface(t *testing.T) {
plan := surface.NewPlan(map[surface.CommandID]surface.CommandState{
surface.CommandAuthLogin: surface.CommandConcealed,
})
for _, st := range []errs.Subtype{errs.SubtypeMissingScope, errs.SubtypeTokenScopeInsufficient, errs.SubtypeUserUnauthorized} {
hint := PermissionHint([]string{"im:message"}, "user", st, "")
sourceTyped := errs.NewPermissionError(st, "permission denied").
WithHint("%s", hint)
source := recovery.Attach(sourceTyped, permissionRecoveryHint([]string{"im:message"}, "user", st, ""))
rendered := recovery.Render(source, plan)
var concealed *errs.PermissionError
if !errors.As(rendered, &concealed) {
t.Fatalf("%s: rendered error = %T, want *errs.PermissionError", st, rendered)
}
if concealed == sourceTyped {
t.Errorf("%s: Render must clone the typed error", st)
}
if strings.Contains(concealed.Hint, "auth login") {
t.Errorf("%s: concealed hint still points at auth login: %q", st, concealed.Hint)
}
if !strings.Contains(sourceTyped.Hint, "auth login") {
t.Errorf("%s: render mutated producer hint: %q", st, sourceTyped.Hint)
}
var visible *errs.PermissionError
if !errors.As(recovery.Render(source, nil), &visible) || !strings.Contains(visible.Hint, "auth login") {
t.Errorf("%s: visible render must keep auth login, got %+v", st, visible)
}
}
// Non-command recovery guidance is retained under the same plan.
consoleHint := PermissionHint(nil, "bot", errs.SubtypeAppScopeNotApplied, "https://example.com")
consoleErr := errs.NewPermissionError(errs.SubtypeAppScopeNotApplied, "permission denied").
WithHint("%s", consoleHint)
consoleErr.ConsoleURL = "https://example.com"
rendered := recovery.Render(
recovery.Attach(consoleErr, permissionRecoveryHint(nil, "bot", errs.SubtypeAppScopeNotApplied, "https://example.com")),
plan,
)
var consoleClone *errs.PermissionError
if !errors.As(rendered, &consoleClone) || !strings.Contains(consoleClone.Hint, "developer console") {
t.Errorf("console guidance must survive auth concealment, got %+v", consoleClone)
}
if consoleClone.ConsoleURL != consoleErr.ConsoleURL {
t.Errorf("render clone lost ConsoleURL: got %q want %q", consoleClone.ConsoleURL, consoleErr.ConsoleURL)
}
}

View File

@@ -38,6 +38,10 @@ type Stub struct {
// matches after the first hit. Each match appends to CapturedBodies.
Reusable bool
// Optional (optional): when true, Verify does not require this stub to be
// matched. Useful for negative assertions via OnMatch.
Optional bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
@@ -137,6 +141,9 @@ func (r *Registry) Verify(t testing.TB) {
if s.matched {
continue
}
if s.Optional {
continue
}
// Reusable stubs never set s.matched; treat any captured hit as a match.
if s.Reusable && len(s.CapturedBodies) > 0 {
continue

View File

@@ -18,7 +18,6 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/recovery"
)
const (
@@ -55,34 +54,6 @@ type Identity struct {
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpiresAt string `json:"refreshExpiresAt,omitempty"`
GrantedAt string `json:"grantedAt,omitempty"`
recoveryTarget recovery.Target
}
// withCommandRecovery binds user-facing recovery text to the command it
// requires. Keeping the pair behind one constructor prevents a new diagnostic
// from emitting a command hint without the build-local filtering metadata.
func withCommandRecovery(identity Identity, target recovery.Target, hint string) Identity {
identity.Hint = hint
identity.recoveryTarget = target
return identity
}
// FilterRecovery returns a copy whose command-targeted hints are removed when
// the current presenter cannot reference their semantic target. Identity
// diagnosis itself remains unaware of plugins, policy, and distributions.
func FilterRecovery(result Result, canReference func(recovery.Target) bool) Result {
if canReference == nil {
return result
}
filter := func(identity Identity) Identity {
if identity.recoveryTarget != "" && !canReference(identity.recoveryTarget) {
identity.Hint = ""
}
return identity
}
result.Bot = filter(result.Bot)
result.User = filter(result.User)
return result
}
// Diagnose checks bot and user identities separately. When verify is false,
@@ -218,10 +189,11 @@ func externalCredentialHint(provider string) string {
func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return withCommandRecovery(Identity{
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app config)",
}, recovery.TargetConfig, "run: lark-cli config --help")
Hint: "run: lark-cli config --help",
}
}
if !cfg.CanBot() {
return Identity{
@@ -231,10 +203,11 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
}
}
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
return withCommandRecovery(Identity{
return Identity{
Status: StatusNotConfigured,
Message: "Bot identity: not configured (missing app secret or bot token)",
}, recovery.TargetConfig, "run: lark-cli config --help")
Hint: "run: lark-cli config --help",
}
}
id := Identity{
@@ -279,16 +252,18 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity {
if cfg == nil || cfg.AppID == "" {
return withCommandRecovery(Identity{
return Identity{
Status: StatusNotConfigured,
Message: "User identity: not configured (missing app config)",
}, recovery.TargetConfig, "run: lark-cli config --help")
Hint: "run: lark-cli config --help",
}
}
if cfg.UserOpenId == "" {
return withCommandRecovery(Identity{
return Identity{
Status: StatusMissing,
Message: "User identity: missing (no user logged in)",
}, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
Hint: "run: lark-cli auth login --help",
}
}
id := Identity{
@@ -299,7 +274,8 @@ func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig,
if stored == nil {
id.Status = StatusMissing
id.Message = "User identity: missing (no token in keychain for " + cfg.UserOpenId + ")"
return withCommandRecovery(id, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
id.Hint = "run: lark-cli auth login --help"
return id
}
fillTokenFields(&id, stored)
@@ -315,40 +291,41 @@ func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig,
default:
id.Status = StatusMissing
id.Message = "User identity: missing (refresh token expired)"
return withCommandRecovery(id, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
id.Hint = "run: lark-cli auth login --help"
return id
}
if !verify {
return id
}
markVerifyFailed := func(reason, hint string, target recovery.Target) Identity {
markVerifyFailed := func(reason, hint string) Identity {
id.Status = StatusVerifyFailed
id.Available = false
id.Verified = boolPtr(false)
id.Message = "User identity: verify failed: " + reason
if hint != "" {
id = withCommandRecovery(id, target, hint)
id.Hint = hint
}
return id
}
httpClient, err := f.HttpClient()
if err != nil {
return markVerifyFailed("create HTTP client: "+err.Error(), "", "")
return markVerifyFailed("create HTTP client: "+err.Error(), "")
}
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
if err != nil {
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help", recovery.TargetAuthLogin)
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help")
}
sdk, err := f.LarkClient()
if err != nil {
return markVerifyFailed("SDK init failed: "+err.Error(), "", "")
return markVerifyFailed("SDK init failed: "+err.Error(), "")
}
verifyCtx, cancel := context.WithTimeout(ctx, verifyTimeout)
defer cancel()
if err := larkauth.VerifyUserToken(verifyCtx, sdk, token); err != nil {
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help", recovery.TargetAuthLogin)
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help")
}
id.Verified = boolPtr(true)

View File

@@ -44,8 +44,7 @@ const (
ReasonCapabilitiesPanic = "capabilities_panic"
// ReasonInvalidCapability flags a plugin authoring error in
// Capabilities() output -- e.g. a syntactically malformed
// RequiredCLIVersion string, or FailOpen paired with an actual
// EmbeddedSkills contribution. This is distinct from
// RequiredCLIVersion string. This is distinct from
// ReasonCapabilityUnmet (legitimate version mismatch): an authoring
// bug must NOT be hidden by FailurePolicy=FailOpen, so this code is
// classified as untrusted-config and aborts unconditionally.
@@ -54,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
@@ -73,8 +71,8 @@ func InstallAll(plugins []platform.Plugin, errOut io.Writer) (*InstallResult, er
if err := installOne(name, p, result); err != nil {
// Some errors must abort regardless of FailurePolicy
// because they imply the plugin's FailurePolicy itself
// cannot be trusted (e.g. a Restrict or EmbeddedSkills
// contribution was paired with FailOpen).
// cannot be trusted (e.g. the consistency check between
// Restricts and FailClosed failed).
if isUntrustedConfigError(err) {
return nil, err
}
@@ -94,9 +92,9 @@ func InstallAll(plugins []platform.Plugin, errOut io.Writer) (*InstallResult, er
// isUntrustedConfigError flags errors where the plugin's declared
// FailurePolicy is itself part of the misconfiguration. For these the
// host MUST abort unconditionally; honouring a FailOpen declaration on
// a misconfigured Restrict/EmbeddedSkills plugin would defeat the whole point
// of the consistency check.
// host MUST abort unconditionally; honouring an FailOpen declaration on
// a misconfigured Restricts plugin would defeat the whole point of the
// consistency check.
func isUntrustedConfigError(err error) bool {
var pi *PluginInstallError
if !errors.As(err, &pi) {
@@ -172,28 +170,20 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
}
staging := newStagingRegistrar(name)
installErr := safeCallInstall(p, staging)
// A hand-written plugin can stage EmbeddedSkills and then return an error
// or panic. Validate the asset/failure-policy contract before classifying
// that ordinary Install failure; otherwise FailOpen would skip the plugin
// and silently republish the host's default skills.
if staging.overlaySet && caps.FailurePolicy != platform.FailClosed {
return staging.failOpenSkillsError()
}
if installErr != nil {
if err := safeCallInstall(p, staging); err != nil {
// Don't double-wrap typed PluginInstallError -- safeCallInstall
// already produces install_panic for recovered panics, and a
// re-wrap would bury the precise reason_code under
// install_failed.
var pi *PluginInstallError
if errors.As(installErr, &pi) {
return installErr
if errors.As(err, &pi) {
return err
}
return &PluginInstallError{
PluginName: name,
ReasonCode: ReasonInstallFailed,
Reason: "Install returned error",
Cause: installErr,
Cause: err,
}
}
@@ -217,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,161 +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.FailClosed}
}
func (skillPlugin) Install(r platform.Registrar) error {
sr, ok := r.(platform.EmbeddedSkillsRegistrar)
if !ok {
return errors.New("host registrar does not support EmbeddedSkills")
}
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
return nil
}
func TestInstallAll_skillsCommitted(t *testing.T) {
result, err := internalplatform.InstallAll([]platform.Plugin{skillPlugin{}}, nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
if len(result.PluginSkills) != 1 {
t.Fatalf("PluginSkills = %d, want 1", len(result.PluginSkills))
}
ps := result.PluginSkills[0]
if ps.PluginName != "skiller" {
t.Errorf("PluginName = %q, want skiller", ps.PluginName)
}
if ps.SkillsOverlay == nil || len(ps.SkillsOverlay.Remove) != 1 || ps.SkillsOverlay.Remove[0] != "lark-shared" {
t.Errorf("Spec = %+v, want Remove=[lark-shared]", ps.SkillsOverlay)
}
}
// A hand-written plugin can bypass Builder's automatic FailClosed flip. The
// staging registrar therefore validates the actual contribution before it is
// committed or reaches skill composition. Treat the mismatch as an invalid
// capability, not as an ordinary FailOpen install failure that may be skipped.
type failOpenSkillPlugin struct{}
func (failOpenSkillPlugin) Name() string { return "fail-open-skiller" }
func (failOpenSkillPlugin) Version() string { return "1.0.0" }
func (failOpenSkillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (failOpenSkillPlugin) Install(r platform.Registrar) error {
r.(platform.EmbeddedSkillsRegistrar).EmbeddedSkills(
&platform.SkillsOverlay{Remove: []string{"lark-shared"}})
return nil
}
func TestInstallAll_failOpenEmbeddedSkillsIsFatalInvalidCapability(t *testing.T) {
var warnings bytes.Buffer
result, err := internalplatform.InstallAll(
[]platform.Plugin{failOpenSkillPlugin{}}, &warnings)
if err == nil {
t.Fatal("FailOpen+EmbeddedSkills must abort before composition")
}
if result != nil {
t.Fatalf("result = %+v, want nil on fatal invalid capability", result)
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidCapability {
t.Fatalf("err = %v, want PluginInstallError reason_code %s",
err, internalplatform.ReasonInvalidCapability)
}
if !strings.Contains(pi.Reason, "EmbeddedSkills requires FailClosed") {
t.Fatalf("reason = %q, want EmbeddedSkills FailClosed guidance", pi.Reason)
}
if warnings.Len() != 0 {
t.Fatalf("fatal mismatch was rendered as a FailOpen warning: %q", warnings.String())
}
}
type failOpenSkillsThenFailurePlugin struct {
mode string
}
func (p failOpenSkillsThenFailurePlugin) Name() string { return "fail-open-skills-failure" }
func (p failOpenSkillsThenFailurePlugin) Version() string { return "1.0.0" }
func (p failOpenSkillsThenFailurePlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (p failOpenSkillsThenFailurePlugin) Install(r platform.Registrar) error {
sr := r.(platform.EmbeddedSkillsRegistrar)
if p.mode == "nil" {
sr.EmbeddedSkills(nil)
} else {
sr.EmbeddedSkills(&platform.SkillsOverlay{})
}
switch p.mode {
case "duplicate":
sr.EmbeddedSkills(&platform.SkillsOverlay{})
return nil
case "panic":
panic("after skills")
default:
return errors.New("after skills")
}
}
func TestInstallAll_failOpenCannotDowngradeStagedSkillsToInstallFailure(t *testing.T) {
for _, mode := range []string{"error", "panic", "duplicate", "nil"} {
t.Run(mode, func(t *testing.T) {
var warnings bytes.Buffer
result, err := internalplatform.InstallAll(
[]platform.Plugin{failOpenSkillsThenFailurePlugin{mode: mode}},
&warnings,
)
if result != nil {
t.Fatalf("result = %+v, want nil on fatal invalid capability", result)
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) {
t.Fatalf("InstallAll error = %T %v, want PluginInstallError", err, err)
}
if pi.ReasonCode != internalplatform.ReasonInvalidCapability {
t.Fatalf("reason_code = %q, want %q",
pi.ReasonCode, internalplatform.ReasonInvalidCapability)
}
if !strings.Contains(pi.Reason, "EmbeddedSkills requires FailClosed") {
t.Fatalf("reason = %q, want fail-closed guidance", pi.Reason)
}
if warnings.Len() != 0 {
t.Fatalf("fatal asset mismatch was rendered as fail-open warning: %q", warnings.String())
}
})
}
}
// doubleSkillPlugin calls r.EmbeddedSkills twice; staging must reject it. Declared
// FailClosed so the staging error aborts InstallAll deterministically.
type doubleSkillPlugin struct{}
func (doubleSkillPlugin) Name() string { return "double-skiller" }
func (doubleSkillPlugin) Version() string { return "1.0.0" }
func (doubleSkillPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (doubleSkillPlugin) Install(r platform.Registrar) error {
sr := r.(platform.EmbeddedSkillsRegistrar)
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-a"}})
sr.EmbeddedSkills(&platform.SkillsOverlay{Remove: []string{"lark-b"}})
return nil
}
func TestInstallAll_skillsCalledTwice_aborts(t *testing.T) {
_, err := internalplatform.InstallAll([]platform.Plugin{doubleSkillPlugin{}}, nil)
if err == nil {
t.Fatal("calling r.EmbeddedSkills twice must abort a FailClosed plugin")
}
var pi *internalplatform.PluginInstallError
if !errors.As(err, &pi) || pi.ReasonCode != internalplatform.ReasonInvalidSkillsOverlay {
t.Errorf("err = %v, want PluginInstallError reason_code %s", err, internalplatform.ReasonInvalidSkillsOverlay)
}
}

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
@@ -78,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
@@ -128,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 {
@@ -182,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
}
@@ -292,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 {

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