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
| Hook | Fires | Can block? |
|---|---|---|
Observer | Before / After each command | No (fire-and-forget audit) |
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
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 declareFailClosed. The Builder flips it automatically; the lower-levelPlugininterface rejects the mismatch withrestricts_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'sdenyis 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 callingRestrict()is a deliberatemultiple_restrict_pluginserror (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 underrules:) is shadowed by any plugin Restrict. -
A plugin may call
EmbeddedSkills()at most once to customize the embedded skill tree —Allowkeeps only the listed skills (the allow-list counterpart ofRule.Allow, so a CLI upgrade cannot widen the build;Removewins overAllow, andOverlayentries are exempt),Removedrops skills,Overlayadds/replaces ones, or swap the wholeBase— layered over the host-provided base skill tree. The repository's root lark-cli binary wires its default incontent_embed.go; an external fork main must callcmd.SetEmbeddedSkillContentas shown above (unless its plugin suppliesBase) and should wirecmd.SetEmbeddedAffordanceContentfor command guidance.EmbeddedSkills()impliesFailClosed: it declares distribution assets, and silently falling back could republish content the distribution explicitly removed or replaced. Removing a skill drops itsskills readcontent and every framework-owned structured help block that depends on it; it does NOT disable matching commands (useRestrict()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; useEmbeddedSkillswhen both must be trimmed.ReferenceRemapscan 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 deliberatemultiple_skills_overlay_pluginserror. The top-level skill set and each skill's owning FS are snapshotted during CLI build; files inside an owned skill directory remain live. BothBaseandOverlaymust contain only valid skill directories withSKILL.md. -
A command denied by a Rule is hidden from normal command discovery and returns
validation/failed_preconditionwith its policy source, rule, and reason code in the recovery hint. This is the establishedRestrictcontract for both plugin and yaml sources. A distribution that wants plugin-restricted commands to look absent must opt in from its wrapper main withcmd.ExecuteWithOptions(cmd.ConcealRestrictedCommands(...)); presentation is a host choice, not part ofRuleorCapabilities. 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 showstay 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-sidecmd.HidePolicyDiagnostics()presentation option. -
The
Wrapfactory 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_levelannotation are denied by default when a Rule is active. SetRule.AllowUnannotated = true(orallow_unannotated: truein 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 withrisk_invalidplus a "did you mean" suggestion.AllowUnannotateddoes 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_code | When it fires | Honours FailurePolicy? |
|---|---|---|
invalid_plugin_name | Plugin.Name() doesn't match ^[a-z0-9][a-z0-9-]*$ | No — always aborts |
plugin_name_panic | Plugin.Name() panicked | No — always aborts |
duplicate_plugin_name | Two plugins return the same Name() | No — always aborts |
capabilities_panic | Plugin.Capabilities() panicked | Yes |
invalid_capability | Capabilities malformed: bad version/policy, or EmbeddedSkills contributed under FailOpen | No — always aborts |
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 |
duplicate_hook_name | Same hook name registered twice within a plugin | Yes |
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 |
"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_code | Meaning |
|---|---|
risk_not_annotated | Command has no risk_level annotation, and the active Rule does not set allow_unannotated: true |
risk_invalid | Command's risk_level is a typo / not in the `read |
command_denylisted | Command path matched the active Rule's deny glob |
domain_not_allowed | Active Rule has a non-empty allow list and the command path did not match any glob |
write_not_allowed | Command risk is write / high-risk-write and exceeds Rule max_risk |
risk_too_high | Command risk exceeds Rule max_risk but is not a write (reserved for future risk levels) |
identity_mismatch | Command's supportedIdentities does not intersect Rule identities |
no_matching_rule | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
aggregate_all_denied | Aggregate stub installed on a parent group because every live child was denied |
These codes remain available to in-process hosts through the wrapped
*platform.CommandDeniedError cause. Operator commands expose the active
rule and shipped-tree summary. Agents consuming a host that explicitly
enabled concealment should match error.type == "validation" and
error.subtype == "command_unavailable" instead of branching on a
rule-specific reason. The canonical
validation/command_unavailable contract
defines its exit code, wire fields, and consumer behavior.
Where to go next
- Runnable example: audit observer
- Runnable example: read-only policy
- Builder API: see
builder.gofor the full DSL (NewPlugin,Observer,Wrap,Restrict,EmbeddedSkills,FailOpen/FailClosed,MustBuild). - Inventory diagnostic: run
lark-cli config plugins showafter installing your plugin to see hooks/rules attributed to your plugin name.