Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ func buildInternalWithConfig(ctx context.Context, inv cmdutil.InvocationContext,
// mechanically unchanged.
var hasConcealedCommands bool
runtime.surface, hasConcealedCommands = applyDistributionPresentation(rootCmd, cfg.presentation, denied)
rootCmd.SetUsageTemplate(rewrittenRootUsageTemplate(runtime.surface))

// Resolve skill assets and canonical references before installing hooks.
// A declared customization is a build-integrity boundary: failure must
Expand Down
5 changes: 3 additions & 2 deletions cmd/event/console_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

"github.com/larksuite/cli/internal/core"
eventlib "github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/urlrewrite"
)

// Landing-page contract for the scan-to-enable deep link, verified against the
Expand Down Expand Up @@ -73,13 +74,13 @@
return "", err
}
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded), nil
return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s&addons=%s", host, addonsLandingPath, addonsClientIDParam, appID, encoded)), nil
}

// consoleLandingURL is the bare landing page (no addons) — fallback when encoding fails.
func consoleLandingURL(brand core.LarkBrand, appID string) string {
host := core.ResolveEndpoints(brand).Open
return fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID)
return urlrewrite.Rewrite(fmt.Sprintf("%s%s?%s=%s", host, addonsLandingPath, addonsClientIDParam, appID))

Check warning on line 83 in cmd/event/console_url.go

View check run for this annotation

Codecov / codecov/patch

cmd/event/console_url.go#L83

Added line #L83 was not covered by tests
}

// addonsHintURL returns the scan URL, degrading to the bare landing page on encode error.
Expand Down
15 changes: 14 additions & 1 deletion cmd/root_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
package cmd

import (
"fmt"
"strings"

"github.com/larksuite/cli/internal/surface"
"github.com/larksuite/cli/internal/urlrewrite"
)

// rootHelpFragment is one framework-owned root-help fragment. A fragment with
Expand Down Expand Up @@ -142,13 +144,24 @@ Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://
var rootUsageTemplate = renderRootUsageTemplate(nil)

func renderRootUsageTemplate(plan *surface.Plan) string {
return renderRootUsageTemplateWithSkillsURL(plan, "https://github.com/larksuite/cli#agent-skills")
}

func renderRootUsageTemplateWithSkillsURL(plan *surface.Plan, skillsURL string) 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.WriteString(fmt.Sprintf(`{{if not .HasParent}}

Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — %s{{end}}`, skillsURL))
}
b.WriteByte('\n')
return b.String()
}

func rewrittenRootUsageTemplate(plan *surface.Plan) string {
return renderRootUsageTemplateWithSkillsURL(plan,
urlrewrite.Rewrite("https://github.com/larksuite/cli#agent-skills"))
}
13 changes: 13 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package cmd

import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
Expand All @@ -28,6 +29,7 @@ import (
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/surface"
testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite"
)

// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
Expand Down Expand Up @@ -90,6 +92,17 @@ func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
}
}

func TestBuildRewritesRootSkillsHelpURLAfterProviderRegistration(t *testing.T) {
testurlrewrite.Register(t, func(rawURL string) string {
return strings.Replace(rawURL, "github.com", "mirror.example.test", 1)
})

_, root, _ := buildInternal(context.Background(), buildInvocationForTest(t), WithoutPlugins())
if got := root.UsageTemplate(); !strings.Contains(got, "https://mirror.example.test/larksuite/cli#agent-skills") {
t.Fatalf("root help URL was not rewritten:\n%s", got)
}
}

func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })

Expand Down
57 changes: 40 additions & 17 deletions cmd/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package cmdupdate

import (
"context"
"fmt"
stdio "io"
"runtime"
Expand All @@ -19,6 +20,7 @@
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update"
"github.com/larksuite/cli/internal/urlrewrite"
)

const (
Expand Down Expand Up @@ -114,7 +116,7 @@

The skill name "lark-suite" is reserved for CLI-managed suite layout.`,
RunE: func(cmd *cobra.Command, args []string) error {
return updateRun(opts)
return updateRunWithContext(cmd.Context(), opts)
},
}
cmdutil.DisableAuthCheck(cmd)
Expand All @@ -128,6 +130,10 @@
}

func updateRun(opts *UpdateOptions) error {
return updateRunWithContext(nil, opts)
}

func updateRunWithContext(ctx context.Context, opts *UpdateOptions) error {
io := opts.Factory.IOStreams
if _, err := skillscheck.ParseLayout(opts.SkillsLayout); err != nil {
return reportError(opts, io, "validation",
Expand Down Expand Up @@ -189,6 +195,18 @@
return doAutoUpdate(opts, io, cur, latest, detect, updater)
}

type presentationURLs struct {
release string
changelog string
}

func resolvePresentationURLs(latest string) presentationURLs {
return presentationURLs{
release: urlrewrite.Rewrite(releaseURL(latest)),
changelog: urlrewrite.Rewrite(changelogURL()),
}
}

// resolveSkillsBrand returns the skills-source brand: resolved config first,
// then the active profile's raw config entry (the brand is not a secret; a
// locked keychain must not flip the source), then the default with a notice.
Expand Down Expand Up @@ -230,21 +248,22 @@
}

func reportCheckResult(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, canAutoUpdate bool) error {
urls := resolvePresentationURLs(latest)
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "current_version": cur,
"latest_version": latest, "action": "update_available",
"auto_update": canAutoUpdate,
"message": fmt.Sprintf("lark-cli %s %s %s available", cur, symArrow(), latest),
"url": releaseURL(latest), "changelog": changelogURL(),
"url": urls.release, "changelog": urls.changelog,
}
applySkillsStatus(out, cur)
output.PrintJson(io.Out, out)
return nil
}
fmt.Fprintf(io.ErrOut, "Update available: %s %s %s\n", cur, symArrow(), latest)
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog)
if canAutoUpdate {
fmt.Fprintf(io.ErrOut, "\nRun `lark-cli update` to install.\n")
} else {
Expand All @@ -254,14 +273,15 @@
}

func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
urls := resolvePresentationURLs(latest)
skillsResult := runSkillsAndState(updater, io, cur, opts.Force, opts.SkillsLayout)
reason := detect.ManualReason()
if opts.JSON {
out := map[string]interface{}{
"ok": true, "previous_version": cur, "latest_version": latest,
"action": "manual_required",
"message": fmt.Sprintf("Automatic update unavailable: %s (path: %s)", reason, detect.ResolvedPath),
"url": releaseURL(latest), "changelog": changelogURL(),
"url": urls.release, "changelog": urls.changelog,
}
applySkillsResult(out, skillsResult)
if err := reportSkillsFailureWithFields(opts, io, skillsResult, out); err != nil {
Expand All @@ -272,8 +292,8 @@
}
fmt.Fprintf(io.ErrOut, "Automatic update unavailable: %s (path: %s).\n\n", reason, detect.ResolvedPath)
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, " Release: %s\n", urls.release)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog)
if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
Expand All @@ -287,6 +307,7 @@
}

func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
urls := resolvePresentationURLs(latest)
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
Expand All @@ -308,12 +329,13 @@
if npmResult.Err != nil {
restore()
combined := npmResult.CombinedOutput()
hint := permissionHint(combined, pm)
if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined, pm),
"hint": hint,
},
})
return output.ErrBare(output.ExitAPI)
Expand All @@ -325,7 +347,7 @@
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
}
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined, pm); hint != "" {
if hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint)
}
return output.ErrBare(output.ExitAPI)
Expand Down Expand Up @@ -355,12 +377,12 @@
"previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s, but skills update failed", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
"url": urls.release, "changelog": urls.changelog,
}
applySkillsResult(fields, skillsResult)
if !opts.JSON {
fmt.Fprintf(io.ErrOut, "\n%s lark-cli binary updated from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog)
}
return reportSkillsFailureWithFields(opts, io, skillsResult, fields)
}
Expand All @@ -370,15 +392,15 @@
"ok": true, "previous_version": cur, "current_version": latest,
"latest_version": latest, "action": "updated",
"message": fmt.Sprintf("lark-cli updated from %s to %s", cur, latest),
"url": releaseURL(latest), "changelog": changelogURL(),
"url": urls.release, "changelog": urls.changelog,
}
applySkillsResult(result, skillsResult)
output.PrintJson(io.Out, result)
return nil
}

fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", urls.changelog)
if skillsResult != nil {
skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
Expand All @@ -395,19 +417,20 @@
return ""
}
if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see " + urlrewrite.Rewrite("https://pnpm.io/pnpm-cli")
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: " + urlrewrite.Rewrite("https://docs.npmjs.com/resolving-eacces-permissions-errors")
}

func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
release := urlrewrite.Rewrite(releaseURL(latest))
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release)

Check warning on line 431 in cmd/update/update.go

View check run for this annotation

Codecov / codecov/patch

cmd/update/update.go#L431

Added line #L431 was not covered by tests
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, release)
}

func runSkillsAndState(updater *selfupdate.Updater, io *cmdutil.IOStreams, stateVersion string, force bool, requestedLayout string) *skillscheck.SyncResult {
Expand Down
14 changes: 13 additions & 1 deletion cmd/update/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/selfupdate"
"github.com/larksuite/cli/internal/skillscheck"
testurlrewrite "github.com/larksuite/cli/internal/testutil/urlrewrite"
)

const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
Expand Down Expand Up @@ -907,6 +908,17 @@ func TestReleaseURL(t *testing.T) {
}
}

func TestResolvePresentationURLsRewrites(t *testing.T) {
testurlrewrite.Register(t, func(rawURL string) string {
return strings.Replace(rawURL, "github.com", "mirror.example.test", 1)
})

got := resolvePresentationURLs("2.0.0")
if got.release != "https://mirror.example.test/larksuite/cli/releases/tag/v2.0.0" || got.changelog != "https://mirror.example.test/larksuite/cli/blob/main/CHANGELOG.md" {
t.Fatalf("resolvePresentationURLs() = %#v", got)
}
}

func TestPermissionHint(t *testing.T) {
origOS := currentOS
defer func() { currentOS = origOS }()
Expand All @@ -922,7 +934,7 @@ func TestPermissionHint(t *testing.T) {
}

// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
pnpmHint := permissionHint("EACCES: permission denied, access 'pnpm-home'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
Expand Down
8 changes: 7 additions & 1 deletion extension/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ Main extension points:
| Package | Extension point | What it does |
| ------- | --------------- | ------------ |
| [`credential/`](./credential/) | **Credential** | Bring your own credential source: database, Vault, config center… |
| [`transport/`](./transport/) | **Transport** | Intercept every HTTP request: inject headers, rewrite targets, logging & monitoring |
| [`transport/`](./transport/) | **Transport** | Intercept HTTP requests and rewrite CLI-owned network, presentation, and child-process URLs |
| [`platform/`](./platform/) | **Restrict · Observer · Wrap · On** | Command allow/deny rules, audit hooks, onion-style middleware (approval gates, rate limiting), process lifecycle — see the [Plugin SDK README](./platform/README.md) |

📖 Full guide: [Embed lark-cli in your Agent](https://open.larksuite.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent) ([中文](https://open.larkoffice.com/document/mcp_open_tools/feishu-cli/embed-feishu-cli-in-agent))

The transport registry has one process-wide owner. Register the aggregate
provider during `init`, before constructing or executing the CLI. URL rewriting
runs before the request interceptor and also covers CLI-owned presentation URLs
and URLs passed to child processes. `ScopedProvider` limits only the request
interceptor.
10 changes: 7 additions & 3 deletions extension/transport/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ var (
provider Provider
)

// Register registers a transport Provider.
// Later registrations override earlier ones.
// Typically called from init() via blank import.
// Register sets the process-wide transport Provider.
//
// Integrations that need multiple capabilities compose them in one Provider
// and register it during init, before command construction or execution. Later
// registrations replace the earlier Provider for backward compatibility;
// changing the Provider while the CLI is running is unsupported because
// clients may already hold a resolved interceptor or URL rewriter.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
Expand Down
Loading
Loading