mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
19 Commits
v1.0.79-be
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d15abb547 | ||
|
|
1f565a290b | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
39
CHANGELOG.md
39
CHANGELOG.md
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [v1.0.79] - 2026-07-28
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: update xsd (#2067)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ci**: validate static workflow identity (#2015)
|
||||
- **sheets**: recognize OFL0X local office tokens (#2063)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **calendar**: clarify identity selection by event ownership (#2071)
|
||||
- **slides**: add formula inline element syntax to quick-ref (#2077)
|
||||
|
||||
## [v1.0.78] - 2026-07-27
|
||||
|
||||
### Features
|
||||
@@ -1685,6 +1722,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
|
||||
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
|
||||
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
|
||||
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
|
||||
|
||||
38
affordance/docs.md
Normal file
38
affordance/docs.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 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
|
||||
@@ -18,10 +18,21 @@ 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",
|
||||
@@ -40,10 +51,10 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
cmd.AddCommand(NewCmdAuthLogin(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthLogout(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthStatus(f, nil))
|
||||
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthScopes(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthList(f, nil))
|
||||
cmd.AddCommand(NewCmdAuthCheck(f, nil))
|
||||
cmd.AddCommand(newCmdAuthList(f, nil, projector))
|
||||
cmd.AddCommand(newCmdAuthCheck(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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.
|
||||
@@ -24,6 +25,14 @@ 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{
|
||||
@@ -33,7 +42,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authCheckRun(opts)
|
||||
return authCheckRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,6 +55,10 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -82,7 +95,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
ok := len(missing) == 0
|
||||
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
|
||||
if len(missing) > 0 {
|
||||
if len(missing) > 0 && projector.CanReference(recovery.TargetAuthLogin) {
|
||||
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -162,3 +165,70 @@ 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"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.
|
||||
@@ -24,6 +25,14 @@ 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{
|
||||
@@ -33,7 +42,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authListRun(opts)
|
||||
return authListRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
@@ -43,6 +52,10 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
|
||||
}
|
||||
|
||||
func authListRun(opts *ListOptions) error {
|
||||
return authListRunWithRecovery(opts, nil)
|
||||
}
|
||||
|
||||
func authListRunWithRecovery(opts *ListOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
multi, _ := core.LoadMultiAppConfig()
|
||||
@@ -61,7 +74,7 @@ func authListRun(opts *ListOptions) error {
|
||||
// workspace-aware, so we pull the message+hint out of
|
||||
// NotConfiguredError() instead of hard-coding it.
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, cfgErr.Message)
|
||||
if cfgErr.Hint != "" {
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, " hint: "+cfgErr.Hint)
|
||||
@@ -80,7 +93,11 @@ func authListRun(opts *ListOptions) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ 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
|
||||
@@ -126,7 +128,49 @@ 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 !strings.Contains(stderr.String(), "No logged-in users") {
|
||||
t.Errorf("stderr = %q, want no-users hint", stderr.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.
|
||||
@@ -22,6 +23,14 @@ 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{
|
||||
@@ -31,7 +40,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return authStatusRun(opts)
|
||||
return authStatusRun(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,7 +51,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
|
||||
return cmd
|
||||
}
|
||||
|
||||
func authStatusRun(opts *StatusOptions) error {
|
||||
func authStatusRun(opts *StatusOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
|
||||
config, err := f.Config()
|
||||
@@ -60,11 +69,14 @@ func authStatusRun(opts *StatusOptions) error {
|
||||
"defaultAs": defaultAs,
|
||||
}
|
||||
|
||||
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
|
||||
diagnostics := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(context.Background(), f, config, opts.Verify),
|
||||
projector.CanReference,
|
||||
)
|
||||
result["identities"] = diagnostics
|
||||
result["identity"] = effectiveIdentity(diagnostics)
|
||||
addEffectiveVerification(result, diagnostics)
|
||||
addStatusNote(result, diagnostics)
|
||||
addStatusNote(result, diagnostics, projector.CanReference(recovery.TargetAuthLogin))
|
||||
|
||||
output.PrintJson(f.IOStreams.Out, result)
|
||||
return nil
|
||||
@@ -106,13 +118,21 @@ func addEffectiveVerification(result map[string]interface{}, d identitydiag.Resu
|
||||
}
|
||||
}
|
||||
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
|
||||
func addStatusNote(result map[string]interface{}, d identitydiag.Result, canAuthLogin bool) {
|
||||
switch {
|
||||
case !d.User.Available && d.Bot.Available:
|
||||
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."
|
||||
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
|
||||
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:
|
||||
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
|
||||
note := "No usable identity is available. Configure bot credentials"
|
||||
if canAuthLogin {
|
||||
note += " or run `lark-cli auth login`"
|
||||
}
|
||||
result["note"] = note + "."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
|
||||
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f}, nil); 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}); err != nil {
|
||||
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}, nil); err != nil {
|
||||
t.Fatalf("authStatusRun() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package cmd
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
|
||||
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
|
||||
@@ -70,3 +73,18 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
192
cmd/build.go
192
cmd/build.go
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -28,7 +29,12 @@ 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"
|
||||
)
|
||||
@@ -37,14 +43,29 @@ import (
|
||||
type BuildOption func(*buildConfig)
|
||||
|
||||
type buildConfig struct {
|
||||
streams *cmdutil.IOStreams
|
||||
keychain keychain.KeychainAccess
|
||||
globals GlobalOptions
|
||||
skipPlugins bool
|
||||
skipStrictMode bool
|
||||
skipService bool
|
||||
serviceCatalog *apicatalog.Catalog
|
||||
startupBrand core.LarkBrand
|
||||
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
|
||||
}
|
||||
|
||||
// WithStartupBrand initializes the API registry with the given brand before
|
||||
@@ -55,6 +76,7 @@ type buildConfig struct {
|
||||
func WithStartupBrand(brand core.LarkBrand) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.startupBrand = brand
|
||||
c.startupBrandSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +107,12 @@ 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
|
||||
@@ -92,6 +120,7 @@ func SetEmbeddedSkillContent(fsys fs.FS) { embeddedSkillContent = fsys }
|
||||
func HideProfile(hide bool) BuildOption {
|
||||
return func(c *buildConfig) {
|
||||
c.globals.HideProfile = hide
|
||||
c.hideProfileSet = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,11 +176,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 (factory, rootCmd, registry). The registry is nil when plugin
|
||||
// Returns (runtime, 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) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
|
||||
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*buildRuntime, *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{}
|
||||
@@ -160,6 +189,16 @@ 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
|
||||
@@ -167,18 +206,28 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
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",
|
||||
@@ -195,7 +244,17 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
// rootUsageTemplate.
|
||||
rootCmd.SetUsageTemplate(rootUsageTemplate)
|
||||
|
||||
installTipsHelpFunc(rootCmd)
|
||||
// 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)
|
||||
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
|
||||
@@ -211,11 +270,11 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
f.CurrentCommand = cmd
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
|
||||
rootCmd.AddCommand(auth.NewCmdAuth(f))
|
||||
rootCmd.AddCommand(cmdconfig.NewCmdConfigWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(auth.NewCmdAuthWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(profile.NewCmdProfile(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoami(f))
|
||||
rootCmd.AddCommand(doctor.NewCmdDoctorWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(whoami.NewCmdWhoamiWithRecovery(f, runtime.recovery))
|
||||
rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil))
|
||||
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
|
||||
rootCmd.AddCommand(completion.NewCmdCompletion(f))
|
||||
@@ -231,52 +290,93 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
|
||||
}
|
||||
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
|
||||
|
||||
groupRootCommands(rootCmd)
|
||||
classifyRootCommands(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)
|
||||
installRootUpgradePrompt(f, rootCmd, runtime.recovery)
|
||||
|
||||
if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode {
|
||||
pruneForStrictMode(rootCmd, mode)
|
||||
}
|
||||
|
||||
if cfg.skipPlugins {
|
||||
recordInventory(nil)
|
||||
return f, rootCmd, nil
|
||||
}
|
||||
var (
|
||||
installResult *internalplatform.InstallResult
|
||||
pluginRules []cmdpolicy.PluginRule
|
||||
pluginSkills []skillpolicy.PluginSkill
|
||||
hookRegistry *hook.Registry
|
||||
denied map[string]cmdpolicy.Denial
|
||||
)
|
||||
|
||||
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
|
||||
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)
|
||||
}
|
||||
warnPolicyError(cfg.streams.ErrOut, err)
|
||||
}
|
||||
|
||||
if registry != nil {
|
||||
if err := wireHooks(ctx, rootCmd, registry); err != nil {
|
||||
// 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 {
|
||||
installPluginLifecycleErrorGuard(rootCmd, err)
|
||||
return f, rootCmd, nil
|
||||
recordInventory(installResult)
|
||||
return runtime, rootCmd, nil
|
||||
}
|
||||
}
|
||||
|
||||
recordInventory(installResult)
|
||||
return f, rootCmd, registry
|
||||
return runtime, rootCmd, hookRegistry
|
||||
}
|
||||
|
||||
func finalizeFailedBuild(runtime *buildRuntime, root *cobra.Command) (*buildRuntime, *cobra.Command, *hook.Registry) {
|
||||
finalizeRootCommandGroups(root, runtime.surface)
|
||||
return runtime, root, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -28,6 +29,10 @@ 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(
|
||||
|
||||
@@ -18,6 +18,7 @@ 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"
|
||||
)
|
||||
@@ -59,6 +60,14 @@ 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{
|
||||
@@ -98,7 +107,7 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return configBindRun(opts)
|
||||
return configBindRunWithRecovery(opts, projector)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,6 +125,10 @@ 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
|
||||
}
|
||||
@@ -154,7 +167,7 @@ func configBindRun(opts *BindOptions) error {
|
||||
applyPreferences(appConfig, opts, priorLang(existing.ConfigBytes))
|
||||
noticeUserDefaultRisk(opts)
|
||||
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath)
|
||||
return commitBinding(opts, appConfig, existing.ConfigBytes, source, targetConfigPath, projector)
|
||||
}
|
||||
|
||||
// existingBinding is the outcome of checking whether a workspace was already
|
||||
@@ -404,7 +417,13 @@ 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) error {
|
||||
func commitBinding(
|
||||
opts *BindOptions,
|
||||
appConfig *core.AppConfig,
|
||||
previousConfigBytes []byte,
|
||||
source, configPath string,
|
||||
projector *recovery.Projector,
|
||||
) error {
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
@@ -462,7 +481,7 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
case "bot-only":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageBotOnly, appConfig.AppId, display, brand)
|
||||
case "user-default":
|
||||
envelope["message"] = fmt.Sprintf(prefMsg.MessageUserDefault, appConfig.AppId, display, display)
|
||||
envelope["message"] = userDefaultBindMessage(prefMsg, appConfig.AppId, display, projector)
|
||||
}
|
||||
|
||||
resultJSON, _ := json.Marshal(envelope)
|
||||
@@ -470,6 +489,17 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
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
|
||||
|
||||
@@ -37,6 +37,9 @@ 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
|
||||
@@ -44,8 +47,9 @@ 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
|
||||
MessageBotOnly string
|
||||
MessageUserDefault string
|
||||
MessageUserDefaultFallback string
|
||||
|
||||
// Identity preset (collapses strict-mode + default-as into one choice).
|
||||
// IdentityBotOnly/IdentityUserDefault are short, single-line labels for
|
||||
@@ -108,8 +112,9 @@ 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 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
MessageBotOnly: "已绑定应用 %s 到 %s,可立即以应用(bot)身份调用%s API,现在可以继续执行用户的请求。",
|
||||
MessageUserDefault: "已绑定应用 %s 到 %s。请接着在此 %s 对话中运行 `lark-cli auth login --recommend`。该命令会在 stderr 打出 verification_url 后阻塞等待用户授权;请将此链接原样发给用户在其浏览器中完成授权(不要自己调 browser_navigate 之类的工具打开,授权必须在用户的浏览器里完成),命令会在用户授权完成后自动返回。",
|
||||
MessageUserDefaultFallback: "已绑定应用 %s 到 %s。请通过该发行版支持的授权流程获取或刷新用户凭证,然后再继续执行用户的请求。",
|
||||
|
||||
SelectIdentity: "你希望 AI 如何与你协作?",
|
||||
IdentityBotOnly: "以机器人身份",
|
||||
@@ -144,8 +149,9 @@ 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.",
|
||||
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.",
|
||||
|
||||
SelectIdentity: "How should the AI work with you?",
|
||||
IdentityBotOnly: "As bot",
|
||||
|
||||
@@ -10,6 +10,8 @@ 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`
|
||||
@@ -60,3 +62,29 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,22 @@ 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",
|
||||
@@ -26,7 +37,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigInit(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigBind(f, nil))
|
||||
cmd.AddCommand(newCmdConfigBind(f, nil, projector))
|
||||
cmd.AddCommand(NewCmdConfigRemove(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigShow(f, nil))
|
||||
cmd.AddCommand(NewCmdConfigDefaultAs(f))
|
||||
|
||||
@@ -20,6 +20,8 @@ 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{}
|
||||
@@ -564,3 +566,59 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.
|
||||
@@ -40,13 +41,44 @@ 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 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.
|
||||
// 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.
|
||||
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}
|
||||
@@ -54,16 +86,7 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
|
||||
cmd := &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
||||
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.`,
|
||||
Long: configInitLongWithBind,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
opts.langExplicit = cmd.Flags().Changed("lang")
|
||||
@@ -86,12 +109,30 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
||||
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, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
||||
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, forceInitUsageWithBind)
|
||||
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) {
|
||||
@@ -125,9 +166,14 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
||||
if ws.IsLocal() {
|
||||
return nil
|
||||
}
|
||||
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.")
|
||||
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."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
||||
|
||||
@@ -9,6 +9,8 @@ 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) {
|
||||
@@ -44,6 +46,82 @@ 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())
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ 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,
|
||||
|
||||
93
cmd/config/plugins_test.go
Normal file
93
cmd/config/plugins_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -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.DeniedPaths,
|
||||
"denied_paths": active.DeniedPathCount(),
|
||||
}
|
||||
if len(active.Rules) > 0 {
|
||||
rules := make([]map[string]any, 0, len(active.Rules))
|
||||
|
||||
@@ -62,7 +62,10 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
},
|
||||
DeniedPaths: 42,
|
||||
DeniedByPath: map[string]cmdpolicy.Denial{
|
||||
"docs/create": {},
|
||||
"docs/update": {},
|
||||
},
|
||||
})
|
||||
|
||||
f, out, _ := newPolicyTestFactory()
|
||||
@@ -80,8 +83,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(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
if got["denied_paths"] != float64(2) {
|
||||
t.Errorf("denied_paths = %v, want 2", got["denied_paths"])
|
||||
}
|
||||
rulesAny, ok := got["rules"].([]any)
|
||||
if !ok || len(rulesAny) != 1 {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -55,7 +56,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
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,
|
||||
)
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -20,6 +20,7 @@ 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"
|
||||
)
|
||||
@@ -33,6 +34,17 @@ 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{
|
||||
@@ -40,7 +52,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
|
||||
Short: "CLI health check: config, auth, and connectivity",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Ctx = cmd.Context()
|
||||
return doctorRun(opts)
|
||||
return doctorRun(opts, projector)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
@@ -74,13 +86,13 @@ func skip(name, msg string) checkResult {
|
||||
return checkResult{Name: name, Status: "skip", Message: msg}
|
||||
}
|
||||
|
||||
func doctorRun(opts *DoctorOptions) error {
|
||||
func doctorRun(opts *DoctorOptions, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
var checks []checkResult
|
||||
|
||||
// ── 0. CLI version & update check ──
|
||||
checks = append(checks, pass("cli_version", build.Version))
|
||||
if !opts.Offline {
|
||||
if !opts.Offline && projector.CanReference(recovery.TargetUpdate) {
|
||||
checks = append(checks, checkCLIUpdate()...)
|
||||
}
|
||||
|
||||
@@ -96,7 +108,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
msg, hint := err.Error(), ""
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(core.NotConfiguredError(), &cfgErr) {
|
||||
if errors.As(projector.Render(core.NotConfiguredError()), &cfgErr) {
|
||||
msg, hint = cfgErr.Message, cfgErr.Hint
|
||||
}
|
||||
}
|
||||
@@ -110,7 +122,7 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
if err != nil {
|
||||
hint := ""
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
if errors.As(projector.Render(err), &cfgErr) {
|
||||
hint = cfgErr.Hint
|
||||
}
|
||||
checks = append(checks, fail("app_resolved", err.Error(), hint))
|
||||
@@ -121,7 +133,10 @@ func doctorRun(opts *DoctorOptions) error {
|
||||
ep := core.ResolveEndpoints(cfg.Brand)
|
||||
|
||||
// ── 3. Identity readiness ──
|
||||
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
|
||||
diagnostics := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline),
|
||||
projector.CanReference,
|
||||
)
|
||||
checks = append(checks,
|
||||
identityCheck("bot_identity", diagnostics.Bot),
|
||||
identityCheck("user_identity", diagnostics.User),
|
||||
@@ -215,7 +230,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 := update.FetchLatest()
|
||||
latest, err := fetchLatestForDoctor()
|
||||
if err != nil {
|
||||
return []checkResult{warn("cli_update", "check failed: "+err.Error(), "")}
|
||||
}
|
||||
@@ -228,6 +243,8 @@ 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 {
|
||||
|
||||
@@ -17,6 +17,8 @@ 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) {
|
||||
@@ -101,6 +103,31 @@ 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{
|
||||
@@ -124,7 +151,7 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Offline: true,
|
||||
})
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("doctorRun() error = %v", err)
|
||||
}
|
||||
@@ -202,7 +229,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}); err == nil {
|
||||
if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}, nil); err == nil {
|
||||
t.Fatalf("doctorRun() = nil, want failure when no identity is available")
|
||||
}
|
||||
var got struct {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
@@ -15,11 +14,64 @@ 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
|
||||
@@ -32,8 +84,12 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if !internalauth.IsNeedUserAuthorizationError(err) {
|
||||
return
|
||||
}
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
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 {
|
||||
return
|
||||
}
|
||||
scopes := resolveDeclaredScopesForCurrentCommand(f)
|
||||
|
||||
139
cmd/error_presenter_test.go
Normal file
139
cmd/error_presenter_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
// 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
|
||||
}
|
||||
@@ -278,27 +278,24 @@ func preflightScopes(ctx context.Context, pf *preflightCtx) error {
|
||||
if len(missing) == 0 {
|
||||
return nil
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
permissionErr := 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...).
|
||||
WithHint("%s", scopeRemediationHint(pf.brand, pf.appID, pf.identity, missing))
|
||||
WithMissingScopes(missing...)
|
||||
if pf.identity.IsBot() {
|
||||
permissionErr.WithHint("%s", botScopeRemediationHint(pf.brand, pf.appID, missing))
|
||||
}
|
||||
return permissionErr
|
||||
}
|
||||
|
||||
// scopeRemediationHint returns an identity-appropriate fix for missing scopes.
|
||||
// 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, " "))
|
||||
// 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)))
|
||||
}
|
||||
|
||||
// preflightEventTypes verifies every RequiredConsoleEvents entry is subscribed
|
||||
@@ -379,7 +376,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 (lark-cli config show) and try 'lark-cli auth login'.")
|
||||
WithHint("check that app_secret is configured for this distribution")
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
@@ -264,18 +264,9 @@ func TestPreflightEventTypes_CallbackAllSubscribed_Passes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"})
|
||||
func TestBotScopeRemediationHintUsesScanLink(t *testing.T) {
|
||||
bot := botScopeRemediationHint(core.BrandFeishu, "cli_x", []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)
|
||||
}
|
||||
}
|
||||
|
||||
67
cmd/flag_gate.go
Normal file
67
cmd/flag_gate.go
Normal file
@@ -0,0 +1,67 @@
|
||||
// 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
|
||||
}
|
||||
@@ -23,7 +23,7 @@ func TestComposePendingNoticeDeprecatedCommand(t *testing.T) {
|
||||
Skill: "lark-sheets",
|
||||
})
|
||||
|
||||
got := composePendingNotice()
|
||||
got := composePendingNotice(nil)
|
||||
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(); got != nil {
|
||||
if got := composePendingNotice(nil); 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 {
|
||||
|
||||
@@ -35,7 +35,10 @@ const userPolicyFileName = "policy.yml"
|
||||
//
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
//
|
||||
// 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) {
|
||||
// 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
|
||||
@@ -65,7 +68,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 lerr
|
||||
return nil, lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
@@ -77,11 +80,11 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
@@ -100,11 +103,12 @@ func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.Plug
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedByPath: denied,
|
||||
})
|
||||
return nil
|
||||
|
||||
return denied, nil
|
||||
}
|
||||
|
||||
// installPluginsAndHooks runs the InstallAll phase on the globally-
|
||||
@@ -156,7 +160,22 @@ func recordInventory(installResult *internalplatform.InstallResult) {
|
||||
AllowUnannotated: r.Rule.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
|
||||
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))
|
||||
}
|
||||
|
||||
// wireHooks installs Observer/Wrapper hooks onto every runnable command
|
||||
@@ -167,7 +186,20 @@ func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry)
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
hook.Install(rootCmd, reg, cobraCommandViewSource{})
|
||||
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
|
||||
}
|
||||
return hook.Emit(ctx, reg, platform.Startup, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
@@ -110,6 +111,33 @@ 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 /
|
||||
|
||||
326
cmd/presentation.go
Normal file
326
cmd/presentation.go
Normal file
@@ -0,0 +1,326 @@
|
||||
// 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
|
||||
}
|
||||
72
cmd/presentation_options.go
Normal file
72
cmd/presentation_options.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
73
cmd/presentation_options_test.go
Normal file
73
cmd/presentation_options_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
757
cmd/presentation_test.go
Normal file
757
cmd/presentation_test.go
Normal file
@@ -0,0 +1,757 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -16,6 +16,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -181,6 +183,47 @@ 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{
|
||||
|
||||
@@ -14,6 +14,7 @@ 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.
|
||||
@@ -45,8 +46,12 @@ func profileRemoveRun(f *cmdutil.Factory, name string) error {
|
||||
}
|
||||
|
||||
if len(multi.Apps) == 1 {
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "cannot remove the only profile").
|
||||
WithHint("add another profile first: lark-cli profile add")
|
||||
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"),
|
||||
)
|
||||
}
|
||||
|
||||
app := &multi.Apps[idx]
|
||||
|
||||
14
cmd/prune.go
14
cmd/prune.go
@@ -13,6 +13,7 @@ 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.
|
||||
@@ -105,9 +106,16 @@ func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Comma
|
||||
},
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
|
||||
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "%s", stubMessage).
|
||||
WithHint("denied by %s policy (reason_code %s); %s", cd.Layer, cd.ReasonCode, stubHint).
|
||||
WithCause(cd)
|
||||
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,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ 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"
|
||||
)
|
||||
|
||||
@@ -379,3 +381,48 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
386
cmd/root.go
386
cmd/root.go
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -21,70 +22,16 @@ 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
|
||||
@@ -94,25 +41,69 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://
|
||||
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, err := BootstrapInvocationContext(os.Args[1:])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "Error:", err)
|
||||
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)
|
||||
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()
|
||||
f, rootCmd, reg := buildInternal(
|
||||
ctx, inv,
|
||||
WithIO(os.Stdin, os.Stdout, os.Stderr),
|
||||
HideProfile(isSingleAppMode()),
|
||||
WithStartupBrand(ResolveStartupBrand(inv.Profile)),
|
||||
)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notices (non-blocking) ---
|
||||
if !isCompletionCommand(os.Args) {
|
||||
setupNotices()
|
||||
setupNotices(runtime.surface)
|
||||
}
|
||||
|
||||
runErr := rootCmd.Execute()
|
||||
@@ -126,69 +117,98 @@ func Execute() int {
|
||||
}
|
||||
|
||||
if runErr != nil {
|
||||
return handleRootError(f, runErr)
|
||||
return handleRootError(f, runErr, runtime.recovery)
|
||||
}
|
||||
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() {
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
update.RefreshCache(ver)
|
||||
if update.GetPending() == nil {
|
||||
if info := update.CheckCached(ver); info != nil {
|
||||
update.SetPending(info)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Skills check — synchronous, local-only (no network, no goroutine).
|
||||
skillscheck.Init(build.Version)
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Composed notice provider — emits keys only when each pending is set.
|
||||
output.PendingNotice = composePendingNotice
|
||||
// Capture this build's immutable plan; never consult another Build's state.
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
return composePendingNotice(plan)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() map[string]interface{} {
|
||||
func composePendingNotice(plan *surface.Plan) map[string]interface{} {
|
||||
notice := map[string]interface{}{}
|
||||
if info := update.GetPending(); info != nil {
|
||||
notice["update"] = map[string]interface{}{
|
||||
"current": info.Current,
|
||||
"latest": info.Latest,
|
||||
"message": info.Message(),
|
||||
"command": "lark-cli update",
|
||||
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 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.Message(),
|
||||
"action": "lark-cli update",
|
||||
"message": dep.MessageWithoutUpdateAction(),
|
||||
}
|
||||
if canUpdate {
|
||||
entry["message"] = dep.Message()
|
||||
entry["action"] = "lark-cli update"
|
||||
}
|
||||
if dep.Replacement != "" {
|
||||
entry["replacement"] = dep.Replacement
|
||||
@@ -245,15 +265,22 @@ 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) int {
|
||||
func handleRootError(
|
||||
f *cmdutil.Factory,
|
||||
err error,
|
||||
projector *recovery.Projector,
|
||||
) 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 — it never depends on server state.
|
||||
// local shortcut/service metadata. Both semantic recovery filtering and
|
||||
// dynamic enrichment operate on a concrete clone, never the producer's
|
||||
// reusable error value.
|
||||
if !errs.IsRaw(err) {
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
renderedErr = newRootErrorPresenter(f, projector).Present(err)
|
||||
}
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
@@ -264,7 +291,7 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
// 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, err, string(f.ResolvedIdentity)) {
|
||||
if output.WriteTypedErrorEnvelope(errOut, renderedErr, string(f.ResolvedIdentity)) {
|
||||
return typedExit
|
||||
}
|
||||
|
||||
@@ -557,15 +584,10 @@ const (
|
||||
groupManagement = "cli-management"
|
||||
)
|
||||
|
||||
// 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:"},
|
||||
)
|
||||
// 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) {
|
||||
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() {
|
||||
@@ -583,6 +605,46 @@ func groupRootCommands(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 {
|
||||
@@ -601,6 +663,15 @@ 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())
|
||||
}
|
||||
@@ -623,6 +694,25 @@ 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
|
||||
@@ -659,16 +749,59 @@ 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`.
|
||||
func installTipsHelpFunc(root *cobra.Command) {
|
||||
//
|
||||
// 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,
|
||||
) {
|
||||
defaultHelp := root.HelpFunc()
|
||||
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
|
||||
if cmd == root {
|
||||
if f := root.PersistentFlags().Lookup("profile"); f != nil && f.Hidden {
|
||||
// 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) {
|
||||
f.Hidden = false
|
||||
defer func() { f.Hidden = true }()
|
||||
}
|
||||
@@ -676,15 +809,22 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
// Domain and method commands compose their agent guidance into Long lazily
|
||||
// here (shortcuts attach after service registration); both skip the generic
|
||||
// bottom-of-help append below.
|
||||
if service.PrepareDomainHelp(cmd, embeddedSkillContent) {
|
||||
var refs *skillref.Resolver
|
||||
if skillReferences != nil {
|
||||
refs = skillReferences()
|
||||
}
|
||||
content := skillContent()
|
||||
if service.PrepareDomainHelpWithReferences(cmd, content, refs) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareMethodHelpWithProjection(cmd, content, refs, func() bool {
|
||||
return projector.CanReference(recovery.TargetSchema)
|
||||
}) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
|
||||
if service.PrepareShortcutHelpWithReferences(cmd, content, refs) {
|
||||
defaultHelp(cmd, args)
|
||||
return
|
||||
}
|
||||
|
||||
154
cmd/root_help.go
Normal file
154
cmd/root_help.go
Normal file
@@ -0,0 +1,154 @@
|
||||
// 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()
|
||||
}
|
||||
@@ -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)
|
||||
return handleRootError(f, err, nil)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -505,7 +505,7 @@ func TestSetupNotices_ColdStart_NoNotice(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -539,7 +539,7 @@ func TestSetupNotices_InSync(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice != nil {
|
||||
@@ -572,7 +572,7 @@ func TestSetupNotices_Drift(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
notice := output.GetNotice()
|
||||
if notice == nil {
|
||||
@@ -621,7 +621,7 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
|
||||
output.PendingNotice = nil
|
||||
})
|
||||
|
||||
setupNotices()
|
||||
setupNotices(nil)
|
||||
|
||||
// After setupNotices, skills pending is set (drift). Manually populate
|
||||
// the update side so the composed envelope has both keys — the update
|
||||
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -12,6 +13,10 @@ 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()
|
||||
@@ -24,7 +29,7 @@ func rendersHelp(t *testing.T, cmd *cobra.Command) string {
|
||||
|
||||
func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
@@ -38,7 +43,7 @@ func TestHelpFunc_RendersRiskLineWhenAnnotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "list", Short: "list items"}
|
||||
root.AddCommand(child)
|
||||
@@ -51,7 +56,7 @@ func TestHelpFunc_NoRiskLineWhenUnannotated(t *testing.T) {
|
||||
|
||||
func TestHelpFunc_RiskLinePrecedesTips(t *testing.T) {
|
||||
root := &cobra.Command{Use: "lark-cli"}
|
||||
installTipsHelpFunc(root)
|
||||
installTipsHelpFunc(root, nilSkills, nil, nil)
|
||||
|
||||
child := &cobra.Command{Use: "delete", Short: "delete a file"}
|
||||
cmdutil.SetRisk(child, "high-risk-write")
|
||||
|
||||
@@ -162,7 +162,7 @@ func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
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)
|
||||
gotExit := handleRootError(f, spErr, nil)
|
||||
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"))
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
|
||||
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"))
|
||||
exit := handleRootError(f, internalauth.NewNeedUserAuthorizationError("u_golden"), nil)
|
||||
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())
|
||||
exit := handleRootError(f, core.NotConfiguredError(), nil)
|
||||
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"))
|
||||
exit := handleRootError(f, fmt.Errorf(`required flag(s) %q not set`, "values"), nil)
|
||||
|
||||
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))
|
||||
exit := handleRootError(f, fmt.Errorf("upstream helper exploded: %w", io.ErrUnexpectedEOF), nil)
|
||||
|
||||
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)
|
||||
exit := handleRootError(f, err, nil)
|
||||
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))
|
||||
exit := handleRootError(f, output.ErrBare(output.ExitAuth), nil)
|
||||
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)
|
||||
exit := handleRootError(f, outer, nil)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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"
|
||||
)
|
||||
@@ -28,6 +29,8 @@ 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
|
||||
@@ -51,7 +54,10 @@ 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) {
|
||||
func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command, projector *recovery.Projector) {
|
||||
if f == nil || !projector.CanReference(recovery.TargetUpdate) {
|
||||
return
|
||||
}
|
||||
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.
|
||||
@@ -61,7 +67,7 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
// 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 := update.CheckCached(build.Version)
|
||||
info := checkRootCachedUpdate(build.Version)
|
||||
if info == nil {
|
||||
return
|
||||
}
|
||||
@@ -76,14 +82,18 @@ func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) {
|
||||
// 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) {
|
||||
func installRootUpgradePrompt(
|
||||
f *cmdutil.Factory,
|
||||
root *cobra.Command,
|
||||
projector *recovery.Projector,
|
||||
) {
|
||||
inner := root.RunE
|
||||
if inner == nil {
|
||||
return
|
||||
}
|
||||
root.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if isBareRootInvocation(args) {
|
||||
offerRootUpgrade(f, cmd)
|
||||
offerRootUpgrade(f, cmd, projector)
|
||||
}
|
||||
return inner(cmd, args)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ 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"
|
||||
)
|
||||
|
||||
@@ -122,7 +125,7 @@ func TestOfferRootUpgrade(t *testing.T) {
|
||||
OutIsTerminal: tc.out,
|
||||
StderrIsTerminal: tc.err,
|
||||
}}
|
||||
offerRootUpgrade(f, &cobra.Command{})
|
||||
offerRootUpgrade(f, &cobra.Command{}, nil)
|
||||
|
||||
gotPrompt := strings.Contains(errBuf.String(), "available")
|
||||
if gotPrompt != tc.wantPrompt {
|
||||
@@ -135,6 +138,34 @@ 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 })
|
||||
@@ -147,7 +178,7 @@ func TestInstallRootUpgradePromptPreservesInner(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
|
||||
if err := root.RunE(root, []string{}); err != nil {
|
||||
t.Fatalf("bare RunE err = %v", err)
|
||||
@@ -184,7 +215,7 @@ func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) {
|
||||
f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{
|
||||
In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{},
|
||||
}}
|
||||
installRootUpgradePrompt(f, root)
|
||||
installRootUpgradePrompt(f, root, nil)
|
||||
if root.RunE != nil {
|
||||
t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)")
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,12 @@ 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
|
||||
}
|
||||
@@ -61,10 +68,12 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
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.")
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -137,6 +146,34 @@ 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
|
||||
@@ -161,10 +198,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
if canReferenceSchema == nil || canReferenceSchema() {
|
||||
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
|
||||
}
|
||||
b.WriteString(ann[paramsOnlyAnnotation])
|
||||
|
||||
writeRelatedSkills(&b, skills, skillFS)
|
||||
writeRelatedSkills(&b, skills, skillFS, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -177,10 +216,9 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
// read the skill" directive) keeps it — the affordance block is appended below,
|
||||
// never clobbering it.
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
@@ -188,6 +226,12 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
// (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
|
||||
}
|
||||
@@ -210,7 +254,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
writeRelatedSkills(&b, a.Skills, skillFS, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
@@ -234,14 +278,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) {
|
||||
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS, references *skillref.Resolver) {
|
||||
if skillFS == nil || len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
var avail []string
|
||||
for _, s := range skills {
|
||||
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
|
||||
avail = append(avail, s)
|
||||
if resolved, ok := resolveSkillReference(s, skillFS, references); ok {
|
||||
avail = append(avail, resolved)
|
||||
}
|
||||
}
|
||||
if len(avail) == 0 {
|
||||
@@ -253,6 +297,22 @@ func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -9,9 +9,13 @@ 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"
|
||||
)
|
||||
|
||||
@@ -142,6 +146,40 @@ 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
|
||||
@@ -233,9 +271,61 @@ func TestRelatedSkillsStatGating(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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.
|
||||
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
orig := affordanceLookup
|
||||
t.Cleanup(func() { affordanceLookup = orig })
|
||||
@@ -297,6 +387,26 @@ 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) {
|
||||
|
||||
@@ -11,6 +11,8 @@ 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"
|
||||
)
|
||||
@@ -597,6 +599,38 @@ 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.
|
||||
|
||||
@@ -21,6 +21,7 @@ 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"
|
||||
@@ -490,19 +491,15 @@ 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. Uses the canonical helpers in internal/errclass so
|
||||
// Hint and Message stay in lock-step with the server-response classifier.
|
||||
// BuildAPIError path. It records the same typed facts and canonical message;
|
||||
// the root presenter supplies identity-appropriate recovery at the final
|
||||
// command boundary.
|
||||
// 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) *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)
|
||||
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) error {
|
||||
return errclass.NewMissingScopeError(brand, appID, identity, missing)
|
||||
}
|
||||
|
||||
// unusableParamValue reports whether a provided path/query parameter value
|
||||
@@ -529,12 +526,28 @@ 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) string {
|
||||
func missingParamHint(opts *ServiceMethodOptions, f meta.Field) recovery.Hint {
|
||||
paramsForm := fmt.Sprintf("--params '{%q: \"<value>\"}'", f.Name)
|
||||
var input string
|
||||
if opts.binder.hasTypedFlag(f.Name) {
|
||||
return fmt.Sprintf("set --%s <value> (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath)
|
||||
input = fmt.Sprintf("set --%s <value> (or %s)", f.FlagName(), paramsForm)
|
||||
} else {
|
||||
input = fmt.Sprintf("set %s", paramsForm)
|
||||
}
|
||||
return fmt.Sprintf("set %s; see: lark-cli schema %s", 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,
|
||||
)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
@@ -571,10 +584,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
val, ok := params[s.Name]
|
||||
if !ok || unusableParamValue(val) {
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "path")
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, s.Name); err != nil {
|
||||
@@ -592,10 +602,7 @@ 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, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", s.Name).
|
||||
WithHint("%s", missingParamHint(opts, s)).
|
||||
WithParam(s.Name)
|
||||
return client.RawApiRequest{}, nil, missingRequiredParamError(opts, s, "query")
|
||||
}
|
||||
if exists && !unusableParamValue(value) {
|
||||
queryParams[s.Name] = value
|
||||
|
||||
@@ -54,6 +54,30 @@ 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) {
|
||||
|
||||
208
cmd/skill_customization_test.go
Normal file
208
cmd/skill_customization_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ 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`.
|
||||
@@ -54,12 +55,22 @@ 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)
|
||||
return whoamiRun(cmd, opts, projector)
|
||||
},
|
||||
}
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
@@ -73,7 +84,7 @@ func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
func whoamiRun(cmd *cobra.Command, opts *Options, projector *recovery.Projector) error {
|
||||
f := opts.Factory
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
@@ -96,7 +107,10 @@ func whoamiRun(cmd *cobra.Command, opts *Options) error {
|
||||
f.IdentityAutoDetected,
|
||||
f.ResolveStrictMode(ctx).ForcedIdentity(),
|
||||
)
|
||||
diag := identitydiag.Diagnose(ctx, f, cfg, false)
|
||||
diag := identitydiag.FilterRecovery(
|
||||
identitydiag.Diagnose(ctx, f, cfg, false),
|
||||
projector.CanReference,
|
||||
)
|
||||
res := buildResult(cfg, as, source, diag)
|
||||
output.PrintJson(f.IOStreams.Out, res)
|
||||
return nil
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/cmd"
|
||||
"github.com/larksuite/cli/internal/affordance"
|
||||
)
|
||||
|
||||
// embeddedContentFS bundles the agent-readable content that must ship in lockstep
|
||||
@@ -36,6 +35,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 {
|
||||
affordance.SetSource(sub)
|
||||
cmd.SetEmbeddedAffordanceContent(sub)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,6 +509,9 @@ 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
|
||||
|
||||
@@ -14,6 +14,7 @@ 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
|
||||
|
||||
@@ -37,21 +37,79 @@ 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 larkx ./cmd/larkx && ./larkx config plugins show
|
||||
go build -o lark-cli ./cmd/larkx && ./lark-cli 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? |
|
||||
@@ -60,6 +118,7 @@ You should see `audit` in the plugin list.
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||
| `EmbeddedSkills(SkillsOverlay)` | Bootstrap-time, ≤1 per plugin | Build-integrity (fail-closed) |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
@@ -79,7 +138,7 @@ sequenceDiagram
|
||||
Host->>SDK: InstallAll()
|
||||
SDK->>Plugin: Capabilities()
|
||||
SDK->>Plugin: Install(Registrar)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / On(Startup,Shutdown)
|
||||
Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
|
||||
SDK->>Plugin: On(Startup) fire
|
||||
|
||||
Note over Host,Plugin: Each command dispatch
|
||||
@@ -93,9 +152,8 @@ sequenceDiagram
|
||||
SDK->>Plugin: On(Shutdown) fire
|
||||
```
|
||||
|
||||
A `command_denied` decision (from `Restrict` or strict-mode) bypasses
|
||||
the `Wrap` chain entirely — observers still fire so audit plugins see
|
||||
the rejected dispatch.
|
||||
A rule or strict-mode denial bypasses the `Wrap` chain entirely —
|
||||
observers still fire so audit plugins see the rejected dispatch.
|
||||
|
||||
## Safety contract (read this)
|
||||
|
||||
@@ -113,10 +171,79 @@ 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 `command_denied`: the framework
|
||||
- Plugins cannot suppress a denied dispatch: the framework
|
||||
physically isolates denied commands from the Wrap chain (Observers
|
||||
still fire).
|
||||
- Commands missing a `risk_level` annotation are denied by default
|
||||
@@ -131,13 +258,20 @@ the rejected dispatch.
|
||||
|
||||
## reason_code reference
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
### Plugin install (`error.type = plugin_install`)
|
||||
### 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.
|
||||
|
||||
| reason_code | When it fires | Honours FailurePolicy? |
|
||||
| --------------------------- | ------------------------------------------------------------------------------ | ---------------------- |
|
||||
@@ -145,7 +279,7 @@ messages are localised and may change between releases.
|
||||
| `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 `RequiredCLIVersion`, unknown `FailurePolicy` | No — always aborts |
|
||||
| `invalid_capability` | `Capabilities` malformed: bad version/policy, or `EmbeddedSkills` contributed under `FailOpen` | 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 |
|
||||
@@ -153,6 +287,8 @@ messages are localised and may change between releases.
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||
| `invalid_skills_overlay` | 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 |
|
||||
|
||||
@@ -161,7 +297,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 dispatch (`error.type = command_denied`)
|
||||
### Command rule evaluation (internal/operator diagnostics)
|
||||
|
||||
| reason_code | Meaning |
|
||||
| ----------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
@@ -175,20 +311,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 |
|
||||
|
||||
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"`.
|
||||
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.
|
||||
|
||||
## 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`, `FailOpen`/`FailClosed`,
|
||||
`MustBuild`).
|
||||
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `EmbeddedSkills`,
|
||||
`FailOpen`/`FailClosed`, `MustBuild`).
|
||||
- Inventory diagnostic: run `lark-cli config plugins show` after
|
||||
installing your plugin to see hooks/rules attributed to your plugin
|
||||
name.
|
||||
|
||||
@@ -28,6 +28,9 @@ 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)
|
||||
@@ -36,8 +39,9 @@ type Builder struct {
|
||||
version string
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
@@ -68,14 +72,16 @@ func (b *Builder) RequireCLI(constraint string) *Builder {
|
||||
}
|
||||
|
||||
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
|
||||
// neither FailOpen nor FailClosed is called and Restrict is not used.
|
||||
// neither FailOpen nor FailClosed is called and neither Restrict nor
|
||||
// EmbeddedSkills is used. Build rejects a final FailOpen state after either
|
||||
// safety-sensitive contribution.
|
||||
func (b *Builder) FailOpen() *Builder {
|
||||
b.caps.FailurePolicy = FailOpen
|
||||
return b
|
||||
}
|
||||
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
|
||||
// when Restrict() is called.
|
||||
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit when
|
||||
// Restrict() or EmbeddedSkills() is called.
|
||||
func (b *Builder) FailClosed() *Builder {
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
return b
|
||||
@@ -145,25 +151,68 @@ 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.
|
||||
//
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
// 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.
|
||||
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,
|
||||
name: b.name,
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rules: b.rules,
|
||||
skillsOverlay: b.skillsOverlay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -202,11 +251,12 @@ 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
|
||||
name string
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rules []*Rule
|
||||
skillsOverlay *SkillsOverlay
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
@@ -216,6 +266,15 @@ 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)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@ 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
|
||||
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
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
@@ -30,6 +32,10 @@ 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
|
||||
@@ -211,3 +217,124 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ 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) or that owns any safety-sensitive
|
||||
// concern. Enforced by the framework: Capabilities.Restricts=true
|
||||
// must pair with FailurePolicy=FailClosed.
|
||||
// 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
|
||||
)
|
||||
|
||||
@@ -45,6 +46,6 @@ type Capabilities struct {
|
||||
|
||||
// FailurePolicy decides what happens on install failure. See the
|
||||
// constants above; the framework requires FailClosed whenever
|
||||
// Restricts=true.
|
||||
// Restricts=true or Install contributes EmbeddedSkills.
|
||||
FailurePolicy FailurePolicy
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
// 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. Internal
|
||||
// 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
|
||||
// orchestration (staging, validation, RunE wrapping, denial guard) lives
|
||||
// under internal/platform, internal/hook and internal/cmdpolicy and is not
|
||||
// importable by third parties.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Example: read-only policy
|
||||
|
||||
A policy plugin that installs a `Rule` allowing only `docs/*` and
|
||||
`im/*` read commands. Any write command produces a structured
|
||||
`command_denied` envelope.
|
||||
`im/*` read commands. Any denied command produces a structured
|
||||
`failed_precondition` envelope with policy diagnostics.
|
||||
|
||||
## Build & run
|
||||
|
||||
@@ -15,26 +15,19 @@ go build -o readonly-cli .
|
||||
# "source": "plugin",
|
||||
# "source_name": "readonly",
|
||||
# "denied_paths": N,
|
||||
# "rule": {
|
||||
# "rules": [{
|
||||
# "name": "agent-readonly",
|
||||
# "allow": ["docs/**", "im/**"],
|
||||
# "deny": [],
|
||||
# "max_risk": "read",
|
||||
# "identities": [],
|
||||
# "allow_unannotated": false
|
||||
# }
|
||||
# }]
|
||||
# }
|
||||
|
||||
./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 +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 +fetch --doc-token X
|
||||
# Normal read response (assuming credentials)
|
||||
@@ -51,6 +44,8 @@ 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
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
|
||||
// Command readonly-policy is a runnable fork of lark-cli that
|
||||
// installs a Rule permitting only docs/* and im/* read commands.
|
||||
// Any write command produces a structured command_denied envelope.
|
||||
// Any write command is rejected with the established Restrict policy
|
||||
// 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":"command_denied", ...}}
|
||||
// # {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
|
||||
//
|
||||
// ./readonly-cli config policy show
|
||||
// # shows the active Rule with source=plugin:readonly
|
||||
|
||||
@@ -36,3 +36,24 @@ 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)
|
||||
}
|
||||
|
||||
63
extension/platform/registrar_compat_test.go
Normal file
63
extension/platform/registrar_compat_test.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,9 @@ 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 Rule -- the resolver decides
|
||||
// which source wins (Plugin > yaml > none). This package only defines the
|
||||
// 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
|
||||
// shape; selection lives in internal/cmdpolicy.
|
||||
//
|
||||
// The four filter fields are joined by AND. See the engine's Evaluate for
|
||||
|
||||
122
extension/platform/skillsoverlay.go
Normal file
122
extension/platform/skillsoverlay.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// 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 }
|
||||
23
extension/platform/source_compat_test.go
Normal file
23
extension/platform/source_compat_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,16 @@ import (
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
byService = map[string]serviceAffordance{}
|
||||
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.
|
||||
@@ -33,7 +38,7 @@ func SetSource(fsys fs.FS) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
mdSource = fsys
|
||||
byService = map[string]map[string]json.RawMessage{}
|
||||
byService = map[string]serviceAffordance{}
|
||||
tried = map[string]bool{}
|
||||
}
|
||||
|
||||
@@ -47,27 +52,43 @@ func For(service, methodID string) (json.RawMessage, bool) {
|
||||
tried[service] = true
|
||||
byService[service] = loadService(service)
|
||||
}
|
||||
raw, ok := byService[service][methodID]
|
||||
raw, ok := byService[service].methods[methodID]
|
||||
return raw, ok && len(raw) > 0
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
if mdSource == nil {
|
||||
return nil
|
||||
return serviceAffordance{}
|
||||
}
|
||||
src, err := fs.ReadFile(mdSource, service+".md")
|
||||
if err != nil {
|
||||
return nil
|
||||
return serviceAffordance{}
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
for id, a := range parseDomainMD(src, commandFormResolver(service)) {
|
||||
parsed := parseDomainMD(src, commandFormResolver(service))
|
||||
for id, a := range parsed.methods {
|
||||
if b, err := json.Marshal(a); err == nil {
|
||||
m[id] = b
|
||||
}
|
||||
}
|
||||
return m
|
||||
return serviceAffordance{skill: parsed.skill, methods: m}
|
||||
}
|
||||
|
||||
// commandFormResolver maps a method's command-form heading ("user_mailbox.messages
|
||||
|
||||
@@ -68,6 +68,12 @@ 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
|
||||
@@ -75,7 +81,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["foo.bar"]
|
||||
a, ok := got.methods["foo.bar"]
|
||||
if !ok {
|
||||
t.Fatal("method not parsed")
|
||||
}
|
||||
@@ -96,10 +102,13 @@ func TestParseDomainMD_SkillsMerge(t *testing.T) {
|
||||
"## bar\ndoes bar.\n"
|
||||
got := parseDomainMD([]byte(md), nil)
|
||||
|
||||
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
|
||||
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" {
|
||||
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
|
||||
}
|
||||
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||
if a := got.methods["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
|
||||
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
|
||||
}
|
||||
}
|
||||
@@ -109,8 +118,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["+create"]; !ok {
|
||||
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
|
||||
if _, ok := got.methods["+create"]; !ok {
|
||||
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got.methods))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,11 +93,16 @@ 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) map[string]meta.Affordance {
|
||||
func parseDomainMD(src []byte, resolve func(string) string) parsedDomain {
|
||||
if resolve == nil {
|
||||
resolve = headingToKey
|
||||
}
|
||||
@@ -171,7 +176,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
case strings.HasPrefix(line, "# "):
|
||||
continue
|
||||
case strings.HasPrefix(t, "> skill:"):
|
||||
skill = strings.TrimSpace(t[len("> skill:"):])
|
||||
skill = strings.Trim(strings.TrimSpace(t[len("> skill:"):]), "`")
|
||||
continue
|
||||
case strings.HasPrefix(line, "### "):
|
||||
flushPending()
|
||||
@@ -220,5 +225,5 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
|
||||
}
|
||||
flushPending()
|
||||
assemble()
|
||||
return out
|
||||
return parsedDomain{skill: skill, methods: out}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -40,12 +41,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) *errs.AuthenticationError {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
func NewNeedUserAuthorizationError(userOpenID string) error {
|
||||
e := 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
|
||||
|
||||
55
internal/auth/hint_gate_test.go
Normal file
55
internal/auth/hint_gate_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -71,10 +72,13 @@ 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 {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
e := 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
|
||||
|
||||
53
internal/client/hint_gate_test.go
Normal file
53
internal/client/hint_gate_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,21 @@ 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
|
||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||
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)
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -61,8 +73,8 @@ func GetActive() *ActivePolicy {
|
||||
}
|
||||
|
||||
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
|
||||
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
|
||||
// value types so the struct copy already disjoints them.
|
||||
// each Rule's own slice fields. Source is a value type, so the struct copy
|
||||
// already disjoints it.
|
||||
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||
if in == nil {
|
||||
return nil
|
||||
@@ -81,6 +93,12 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package cmdpolicy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -124,6 +126,13 @@ 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)
|
||||
|
||||
@@ -27,3 +27,14 @@ 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
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.
|
||||
@@ -45,7 +46,18 @@ 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
|
||||
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)
|
||||
}
|
||||
|
||||
// ResolveFileIO resolves a FileIO instance using the current execution context.
|
||||
@@ -170,9 +182,14 @@ 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) {
|
||||
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)")
|
||||
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 nil
|
||||
}
|
||||
|
||||
@@ -28,6 +28,12 @@ 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:
|
||||
//
|
||||
@@ -49,10 +55,6 @@ 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()
|
||||
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -14,6 +14,7 @@ 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"
|
||||
)
|
||||
@@ -298,8 +299,10 @@ func RequireAuthForProfile(kc keychain.KeychainAccess, profileOverride string) (
|
||||
return nil, err
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
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 nil, recovery.Attach(
|
||||
errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"),
|
||||
recovery.UserAuthorization(),
|
||||
)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/recovery"
|
||||
)
|
||||
|
||||
// isMalformedConfigError reports whether a config load failure indicates a
|
||||
@@ -81,14 +82,24 @@ const (
|
||||
func NotConfiguredError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured").
|
||||
WithHint("%s", localInitHint)
|
||||
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,
|
||||
)
|
||||
}
|
||||
// Agent workspace: the workspace name appears only in the message, never
|
||||
// in the wire subtype, which stays not_configured.
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured,
|
||||
"%s context detected but lark-cli is not bound to it", ws.Display()).
|
||||
WithHint("%s", agentBindHint)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// reconfigureHint returns the workspace-aware "fix it from scratch" hint
|
||||
@@ -110,10 +121,20 @@ func reconfigureHint() string {
|
||||
func NoActiveProfileError() error {
|
||||
ws := CurrentWorkspace()
|
||||
if ws.IsLocal() {
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").
|
||||
WithHint("%s", localInitHint)
|
||||
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 in %s workspace", ws.Display()).
|
||||
WithHint("%s", agentBindHint)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
55
internal/core/notconfigured_hint_gate_test.go
Normal file
55
internal/core/notconfigured_hint_gate_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,10 @@ func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) e
|
||||
}
|
||||
switch oauthErr {
|
||||
case "invalid_client", "unauthorized_client":
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
typed := 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,
|
||||
|
||||
@@ -24,10 +24,9 @@ type Notice struct {
|
||||
Skill string `json:"skill,omitempty"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// 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 {
|
||||
var b strings.Builder
|
||||
b.WriteString(n.Command)
|
||||
b.WriteString(" is a pre-refactor compatibility alias")
|
||||
@@ -39,13 +38,20 @@ func (n *Notice) Message() string {
|
||||
if n.Skill != "" {
|
||||
b.WriteString("; update your ")
|
||||
b.WriteString(n.Skill)
|
||||
b.WriteString(" skill, run: lark-cli update")
|
||||
b.WriteString(" skill")
|
||||
} else {
|
||||
b.WriteString("; update your skill, run: lark-cli update")
|
||||
b.WriteString("; update your skill")
|
||||
}
|
||||
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]
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
package deprecation
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNoticeMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -36,6 +39,22 @@ 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) })
|
||||
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ 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
|
||||
@@ -231,27 +232,45 @@ 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) *errs.ConfigError {
|
||||
func buildConfigError(p errs.Problem) error {
|
||||
// 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).
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
hint := configRecoveryHint(p.Subtype)
|
||||
p.Hint = hint.String()
|
||||
return recovery.Annotate(&errs.ConfigError{Problem: p}, hint)
|
||||
}
|
||||
|
||||
// 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 "run `lark-cli config init` to set valid app_id and app_secret"
|
||||
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")
|
||||
case errs.SubtypeNotConfigured:
|
||||
return "run `lark-cli config init` to set up app_id and app_secret"
|
||||
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")
|
||||
case errs.SubtypeInvalidConfig:
|
||||
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
|
||||
return recovery.Join("; ",
|
||||
recovery.Text("check the config file for syntax errors"),
|
||||
recovery.Command(recovery.TargetConfigInit, "rerun `lark-cli config init` to reset"),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
return recovery.Join("")
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
@@ -272,8 +291,28 @@ func APIHint(subtype errs.Subtype) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) error {
|
||||
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"
|
||||
@@ -284,7 +323,8 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
||||
// 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).
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
hint := permissionRecoveryHint(missing, identity, p.Subtype, consoleURL)
|
||||
p.Hint = hint.String()
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
@@ -302,7 +342,7 @@ func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContex
|
||||
if p.Subtype == errs.SubtypeAppScopeNotApplied {
|
||||
permErr.ConsoleURL = consoleURL
|
||||
}
|
||||
return permErr
|
||||
return recovery.Annotate(permErr, hint)
|
||||
}
|
||||
|
||||
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
|
||||
@@ -363,33 +403,63 @@ 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 fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", 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 "the app developer must apply for the required scope(s) at the developer console"
|
||||
return recovery.Join("", recovery.Text(
|
||||
"the app developer must apply for the required scope(s) at the developer console"))
|
||||
case errs.SubtypeMissingScope:
|
||||
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, " "))
|
||||
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"))
|
||||
}
|
||||
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
|
||||
return recovery.UserAuthorization(missing...)
|
||||
case errs.SubtypeTokenScopeInsufficient:
|
||||
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
|
||||
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"),
|
||||
)
|
||||
case errs.SubtypeUserUnauthorized:
|
||||
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"
|
||||
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"),
|
||||
)
|
||||
case errs.SubtypeAppUnavailable:
|
||||
return "ask the tenant admin to check the app's install status in the Lark admin console"
|
||||
return recovery.Join("", recovery.Text(
|
||||
"ask the tenant admin to check the app's install status in the Lark admin console"))
|
||||
case errs.SubtypeAppDisabled:
|
||||
return "ask the tenant admin to re-enable the app in the Lark admin console"
|
||||
return recovery.Join("", recovery.Text(
|
||||
"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 fmt.Sprintf("check the resource owner has granted access to %s", who)
|
||||
return recovery.Join("", recovery.Text(
|
||||
fmt.Sprintf("check the resource owner has granted access to %s", who)))
|
||||
}
|
||||
return "check the calling identity has the required scope"
|
||||
return recovery.Join("", recovery.Text("check the calling identity has the required scope"))
|
||||
}
|
||||
|
||||
// liftErrorDetailValues collects the non-empty resp.error.details[].value reason
|
||||
|
||||
@@ -98,6 +98,15 @@ 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
|
||||
@@ -419,10 +428,7 @@ 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, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
pe := requirePermissionError(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)
|
||||
}
|
||||
@@ -431,10 +437,7 @@ 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, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
pe := requirePermissionError(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)
|
||||
}
|
||||
@@ -443,7 +446,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 := err.(*errs.PermissionError)
|
||||
pe := requirePermissionError(t, err)
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
||||
}
|
||||
@@ -459,13 +462,15 @@ func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
|
||||
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
|
||||
|
||||
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
|
||||
bot := requirePermissionError(t,
|
||||
errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc))
|
||||
if bot.ConsoleURL == "" {
|
||||
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
|
||||
}
|
||||
|
||||
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
|
||||
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
|
||||
user := requirePermissionError(t, errclass.BuildAPIError(
|
||||
missingScopeResp("docx:document"),
|
||||
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}))
|
||||
if user.ConsoleURL != "" {
|
||||
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
|
||||
}
|
||||
@@ -538,7 +543,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 := err.(*errs.PermissionError)
|
||||
pe := requirePermissionError(t, err)
|
||||
if pe.Identity != "user" {
|
||||
t.Errorf("default Identity should be \"user\"; got %q", pe.Identity)
|
||||
}
|
||||
@@ -550,7 +555,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 := err.(*errs.PermissionError)
|
||||
pe := requirePermissionError(t, err)
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
}
|
||||
@@ -573,7 +578,7 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
pe := requirePermissionError(t, err)
|
||||
if got, want := len(pe.MissingScopes), 2; got != want {
|
||||
t.Fatalf("MissingScopes len = %d, want %d (raw: %v)", got, want, pe.MissingScopes)
|
||||
}
|
||||
@@ -584,12 +589,9 @@ 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 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.
|
||||
// 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.
|
||||
//
|
||||
// One upstream-derived field is a documented exception: `code` (the Lark
|
||||
// API numeric code). The pre-flight check runs against a locally cached
|
||||
@@ -608,21 +610,12 @@ 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})
|
||||
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
|
||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||
}
|
||||
requirePermissionError(t, dispatcherErr)
|
||||
|
||||
// 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
|
||||
// Path B: the production constructor used by cmd/service's local
|
||||
// preflight. ConsoleURL is intentionally NOT set on either path for
|
||||
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
|
||||
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)
|
||||
directErr := errclass.NewMissingScopeError(brand, appID, identity, missing)
|
||||
|
||||
var bufA, bufB bytes.Buffer
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
||||
@@ -727,10 +720,9 @@ 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`. Both user and
|
||||
// bot identities route the same way because the recovery action is
|
||||
// user-initiated either way.
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
// this scope — recoverable by re-running `auth login`. An empty identity
|
||||
// retains the historical user default.
|
||||
for _, identity := range []string{"user", ""} {
|
||||
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)
|
||||
@@ -739,6 +731,9 @@ 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) {
|
||||
@@ -838,10 +833,7 @@ 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, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PermissionError, got %T", err)
|
||||
}
|
||||
pe := requirePermissionError(t, err)
|
||||
if pe.Subtype != tc.wantSubtype {
|
||||
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
|
||||
}
|
||||
@@ -938,9 +930,7 @@ func TestBuildAPIError_JSONNumberCode(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for json.Number-encoded code")
|
||||
}
|
||||
if _, ok := err.(*errs.PermissionError); !ok {
|
||||
t.Errorf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
requirePermissionError(t, err)
|
||||
}
|
||||
|
||||
// TestBuildAPIError_SecurityPolicyExtractsChallenge pins that policy responses
|
||||
|
||||
65
internal/errclass/hint_gate_test.go
Normal file
65
internal/errclass/hint_gate_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ 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 (
|
||||
@@ -54,6 +55,34 @@ 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,
|
||||
@@ -189,11 +218,10 @@ 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 Identity{
|
||||
return withCommandRecovery(Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}, recovery.TargetConfig, "run: lark-cli config --help")
|
||||
}
|
||||
if !cfg.CanBot() {
|
||||
return Identity{
|
||||
@@ -203,11 +231,10 @@ func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, v
|
||||
}
|
||||
}
|
||||
if cfg.SupportedIdentities == 0 && !credential.HasRealAppSecret(cfg.AppSecret) {
|
||||
return Identity{
|
||||
return withCommandRecovery(Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "Bot identity: not configured (missing app secret or bot token)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}, recovery.TargetConfig, "run: lark-cli config --help")
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
@@ -252,18 +279,16 @@ 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 Identity{
|
||||
return withCommandRecovery(Identity{
|
||||
Status: StatusNotConfigured,
|
||||
Message: "User identity: not configured (missing app config)",
|
||||
Hint: "run: lark-cli config --help",
|
||||
}
|
||||
}, recovery.TargetConfig, "run: lark-cli config --help")
|
||||
}
|
||||
if cfg.UserOpenId == "" {
|
||||
return Identity{
|
||||
return withCommandRecovery(Identity{
|
||||
Status: StatusMissing,
|
||||
Message: "User identity: missing (no user logged in)",
|
||||
Hint: "run: lark-cli auth login --help",
|
||||
}
|
||||
}, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
id := Identity{
|
||||
@@ -274,8 +299,7 @@ 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 + ")"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
return withCommandRecovery(id, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
fillTokenFields(&id, stored)
|
||||
@@ -291,41 +315,40 @@ func diagnoseUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig,
|
||||
default:
|
||||
id.Status = StatusMissing
|
||||
id.Message = "User identity: missing (refresh token expired)"
|
||||
id.Hint = "run: lark-cli auth login --help"
|
||||
return id
|
||||
return withCommandRecovery(id, recovery.TargetAuthLogin, "run: lark-cli auth login --help")
|
||||
}
|
||||
|
||||
if !verify {
|
||||
return id
|
||||
}
|
||||
|
||||
markVerifyFailed := func(reason, hint string) Identity {
|
||||
markVerifyFailed := func(reason, hint string, target recovery.Target) Identity {
|
||||
id.Status = StatusVerifyFailed
|
||||
id.Available = false
|
||||
id.Verified = boolPtr(false)
|
||||
id.Message = "User identity: verify failed: " + reason
|
||||
if hint != "" {
|
||||
id.Hint = hint
|
||||
id = withCommandRecovery(id, target, 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")
|
||||
return markVerifyFailed("token unusable: "+err.Error(), "run: lark-cli auth login --help", recovery.TargetAuthLogin)
|
||||
}
|
||||
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")
|
||||
return markVerifyFailed("server rejected token: "+err.Error(), "run: lark-cli auth login --help", recovery.TargetAuthLogin)
|
||||
}
|
||||
|
||||
id.Verified = boolPtr(true)
|
||||
|
||||
@@ -44,7 +44,8 @@ const (
|
||||
ReasonCapabilitiesPanic = "capabilities_panic"
|
||||
// ReasonInvalidCapability flags a plugin authoring error in
|
||||
// Capabilities() output -- e.g. a syntactically malformed
|
||||
// RequiredCLIVersion string. This is distinct from
|
||||
// RequiredCLIVersion string, or FailOpen paired with an actual
|
||||
// EmbeddedSkills contribution. 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.
|
||||
@@ -53,4 +54,12 @@ 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"
|
||||
)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -29,9 +30,10 @@ 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
|
||||
Plugins []PluginInfo
|
||||
Registry *hook.Registry
|
||||
PluginRules []cmdpolicy.PluginRule
|
||||
PluginSkills []skillpolicy.PluginSkill
|
||||
Plugins []PluginInfo
|
||||
}
|
||||
|
||||
// InstallAll runs every registered plugin through the staging
|
||||
@@ -71,8 +73,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. the consistency check between
|
||||
// Restricts and FailClosed failed).
|
||||
// cannot be trusted (e.g. a Restrict or EmbeddedSkills
|
||||
// contribution was paired with FailOpen).
|
||||
if isUntrustedConfigError(err) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,9 +94,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 an FailOpen declaration on
|
||||
// a misconfigured Restricts plugin would defeat the whole point of the
|
||||
// consistency check.
|
||||
// host MUST abort unconditionally; honouring a FailOpen declaration on
|
||||
// a misconfigured Restrict/EmbeddedSkills plugin would defeat the whole point
|
||||
// of the consistency check.
|
||||
func isUntrustedConfigError(err error) bool {
|
||||
var pi *PluginInstallError
|
||||
if !errors.As(err, &pi) {
|
||||
@@ -170,20 +172,28 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
||||
}
|
||||
|
||||
staging := newStagingRegistrar(name)
|
||||
if err := safeCallInstall(p, staging); err != nil {
|
||||
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 {
|
||||
// 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(err, &pi) {
|
||||
return err
|
||||
if errors.As(installErr, &pi) {
|
||||
return installErr
|
||||
}
|
||||
return &PluginInstallError{
|
||||
PluginName: name,
|
||||
ReasonCode: ReasonInstallFailed,
|
||||
Reason: "Install returned error",
|
||||
Cause: err,
|
||||
Cause: installErr,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +217,12 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user