Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions affordance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ replace the Go tips (not merged), so keep tips in one place.
## Notes

- Write plain prose; the only convention is wrapping command references in `[[ ]]`.
- Treat the lead as decision context, not a second command description. The
shortcut or method description stays canonical; omit the lead when it would
only restate that description.
- Keep it concise and high-signal — don't restate field/flag names, id types, or
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
Expand Down
56 changes: 56 additions & 0 deletions affordance/docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# docs
> skill: lark-doc

## +create
Create a new Lark document from DocxXML or Markdown, optionally in a folder or Wiki node.

### Tips
- Match `--doc-format` to `--content`: XML is the default for rich DocxXML; use `--doc-format markdown` for Markdown input.
- Before authoring `--content`, read the matching XML or Markdown guide under Related skills when available, unless already read. For XML, use only documented DocxXML tags.
- For multiline `--content`, prefer `@file` or `-` (stdin) to avoid shell-escaping damage.

### Skills
- lark-doc/references/lark-doc-create.md
- lark-doc/references/lark-doc-xml.md
- lark-doc/references/lark-doc-md.md

## +fetch
Read an entire Lark document, or limit the result to an outline, section, block range, or keyword match.

### Skills
- lark-doc/references/lark-doc-fetch.md

## +update
Apply targeted text or block edits, append content, or deliberately replace an entire Lark document.

### Tips
- Prefer `str_replace` or `block_*` commands for targeted edits. Use `overwrite` only when replacing the entire document is intended; it can discard unrelated rich content.
- Before a `block_*` edit, fetch the target with `lark-cli docs +fetch --detail with-ids` and a narrow `--scope`; refetch after structural changes before reusing block IDs.
- Before authoring `--content`, read the matching XML or Markdown guide under Related skills when available, unless already read. For XML, use only documented DocxXML tags.
- Match `--doc-format` to `--content`; for multiline content, prefer `@file` or `-` (stdin).

### Skills
- lark-doc/references/lark-doc-update.md
- lark-doc/references/lark-doc-xml.md
- lark-doc/references/lark-doc-md.md

## +history-list

### Skills
- lark-doc/references/lark-doc-history.md

## +history-revert

### Prerequisites
- `history_version_id` from [[+history-list]]

### Skills
- lark-doc/references/lark-doc-history.md

## +history-revert-status

### Prerequisites
- `task_id` from [[+history-revert]]

### Skills
- lark-doc/references/lark-doc-history.md
17 changes: 14 additions & 3 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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
}
Expand Down
17 changes: 15 additions & 2 deletions cmd/auth/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{
Expand All @@ -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)
},
}

Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
70 changes: 70 additions & 0 deletions cmd/auth/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ package auth
import (
"encoding/json"
"errors"
"strings"
"testing"
"time"

larkauth "github.com/larksuite/cli/internal/auth"
"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"
)

Expand Down Expand Up @@ -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"])
}
}
23 changes: 20 additions & 3 deletions cmd/auth/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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{
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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
}

Expand Down
48 changes: 46 additions & 2 deletions cmd/auth/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Loading
Loading