lark-cli Plugin SDK

August 5, 2026 · View on GitHub

extension/platform is the in-process plugin SDK for lark-cli. Plugins compile into a fork of the lark-cli binary via a blank import; there is no .so loading, no RPC, no subprocess isolation. A plugin shares the binary's address space and lifecycle.

5-minute hello world

// myplugin/audit.go
package myplugin

import (
    "context"
    "log"

    "github.com/larksuite/cli/extension/platform"
)

func init() {
    platform.Register(
        platform.NewPlugin("audit", "0.1.0").
            Observer(platform.After, "log-cmd", platform.All(),
                func(ctx context.Context, inv platform.Invocation) {
                    log.Printf("cmd=%s err=%v", inv.Cmd().Path(), inv.Err())
                }).
            FailOpen().
            MustBuild())
}

Wire into a fork:

// cmd/larkx/main.go in your fork
package main

import (
    "os"

    _ "github.com/me/myplugin"  // blank import → init() runs

    "github.com/larksuite/cli/cmd"
)

func main() {
    os.Exit(cmd.Execute())
}
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:

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

HookFiresCan block?
ObserverBefore / After each commandNo (fire-and-forget audit)
WrapAround each command's RunEYes (return *AbortError)
On(Startup/Shutdown)Process lifecycleN/A
Restrict(Rule)Bootstrap-time, ≥1 per pluginDenies whole subtrees
EmbeddedSkills(SkillsOverlay)Bootstrap-time, ≤1 per pluginBuild-integrity (fail-closed)

Plugin lifecycle

sequenceDiagram
    participant Host as lark-cli (host)
    participant SDK as platform (SDK)
    participant Plugin as your plugin

    Note over Host,Plugin: Process start (before main)
    Plugin->>Plugin: init() (via blank import)
    Plugin->>SDK: Register(plugin)

    Note over Host,Plugin: Bootstrap (host main)
    Host->>SDK: RegisteredPlugins()
    SDK-->>Host: snapshot in registration order
    Host->>SDK: InstallAll()
    SDK->>Plugin: Capabilities()
    SDK->>Plugin: Install(Registrar)
    Plugin->>SDK: Observe / Wrap / Restrict / EmbeddedSkills / On(Startup,Shutdown)
    SDK->>Plugin: On(Startup) fire

    Note over Host,Plugin: Each command dispatch
    Host->>SDK: hook chain (in registration order)
    SDK->>Plugin: Observer Before
    SDK->>Plugin: Wrap (around RunE)
    SDK->>Plugin: Observer After

    Note over Host,Plugin: Process exit
    Host->>SDK: Emit(Shutdown)
    SDK->>Plugin: On(Shutdown) fire

A rule or strict-mode denial bypasses the Wrap chain entirely — observers still fire so audit plugins see the rejected dispatch.

Safety contract (read this)

  • A plugin calling Restrict() MUST declare FailClosed. The Builder flips it automatically; the lower-level Plugin interface rejects the mismatch with restricts_mismatch.

  • A plugin may call Restrict() more than once; each call adds one scoped Rule and the engine combines them with OR — a command is allowed when it satisfies every axis (allow / deny / max_risk / identities) of at least one rule. Note a rule's deny is scoped to that rule only and cannot veto another rule's allow. Only ONE plugin per binary may contribute rules, though: two DISTINCT plugins each calling Restrict() is a deliberate multiple_restrict_plugins error (single-owner assumption — an independent plugin must not be able to 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:

    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:

    os.Exit(cmd.ExecuteWithOptions(
        cmd.ConcealRestrictedCommands(
            cmd.UnavailableMessage("capability not shipped by this distribution"),
        ),
    ))
    
  • config policy show / config plugins show stay executable under any plugin policy (hidden from help when their domain is denied) so an operator can still inspect the rule that locked the build. A concealed distribution can remove those escape hatches with the host-side cmd.HidePolicyDiagnostics() presentation option.

  • The Wrap factory runs once per command dispatch, not at install time. Long-lived state (clients, caches, metrics counters) must live on the Plugin struct or in package-level variables.

  • Plugins cannot suppress a denied dispatch: the framework physically isolates denied commands from the Wrap chain (Observers still fire).

  • Commands missing a risk_level annotation are denied by default when a Rule is active. Set Rule.AllowUnannotated = true (or allow_unannotated: true in yaml) to opt out during gradual adoption. With several rules this is per-rule: an unannotated command is allowed as long as one rule that opts in also grants it.

  • Risk annotation typos (e.g. "wrtie") are always denied with risk_invalid plus a "did you mean" suggestion. AllowUnannotated does NOT bypass this — typo is a code bug, not a missing annotation.

reason_code reference

Install and rule evaluation keep a closed reason_code taxonomy for operator diagnostics and in-process errors. The established Restrict presentation includes the reason code in the error hint. A distribution that explicitly enables command concealment replaces that wire presentation with validation/command_unavailable.

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_codeWhen it firesHonours FailurePolicy?
invalid_plugin_namePlugin.Name() doesn't match ^[a-z0-9][a-z0-9-]*$No — always aborts
plugin_name_panicPlugin.Name() panickedNo — always aborts
duplicate_plugin_nameTwo plugins return the same Name()No — always aborts
capabilities_panicPlugin.Capabilities() panickedYes
invalid_capabilityCapabilities malformed: bad version/policy, or EmbeddedSkills contributed under FailOpenNo — always aborts
capability_unmetCurrent CLI version doesn't satisfy RequiredCLIVersionYes
restricts_mismatchRestricts=true without FailClosed, or Restricts flag inconsistent w/ InstallNo — always aborts
invalid_hook_nameHook name contains . or doesn't match the plugin namespaceYes
duplicate_hook_nameSame hook name registered twice within a pluginYes
invalid_hook_registrationHook factory returns nil / Wrap chain re-entry / etc.Yes
invalid_ruleRule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity)Yes
multiple_restrict_pluginsTwo or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules)Yes
invalid_skills_overlayRegistration fault (nil / duplicate call), or invalid selection/content/reference remapRegistration honours policy; composition always aborts
multiple_skills_overlay_pluginsTwo or more DISTINCT plugins each contributed a SkillsOverlay (only one may own skill content)No — always aborts (dispatch guard)
install_failedPlugin.Install returned a non-nil errorYes
install_panicPlugin.Install panickedYes

"No — always aborts" entries are treated as untrusted-config errors: the host can't honour the plugin's declared FailurePolicy because the declaration itself is suspect (e.g. an invalid_capability plugin might also be lying about being FailOpen).

Command rule evaluation (internal/operator diagnostics)

reason_codeMeaning
risk_not_annotatedCommand has no risk_level annotation, and the active Rule does not set allow_unannotated: true
risk_invalidCommand's risk_level is a typo / not in the `read
command_denylistedCommand path matched the active Rule's deny glob
domain_not_allowedActive Rule has a non-empty allow list and the command path did not match any glob
write_not_allowedCommand risk is write / high-risk-write and exceeds Rule max_risk
risk_too_highCommand risk exceeds Rule max_risk but is not a write (reserved for future risk levels)
identity_mismatchCommand's supportedIdentities does not intersect Rule identities
no_matching_ruleSeveral 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_deniedAggregate stub installed on a parent group because every live child was denied

These codes remain available to in-process hosts through the wrapped *platform.CommandDeniedError cause. Operator commands expose the active rule and shipped-tree summary. Agents consuming a host that explicitly enabled concealment should match error.type == "validation" and error.subtype == "command_unavailable" instead of branching on a rule-specific reason. The canonical validation/command_unavailable contract defines its exit code, wire fields, and consumer behavior.

Where to go next