# 自研 Agent 接入飞书 CLI

## 适用场景

飞书 CLI 内置了一套**扩展机制**，让你在不修改 CLI 源码的情况下，替换或增强 CLI 的核心行为。

如果你是企业内部开发者或 ISV，希望**在自己的 Agent 或应用中直接内嵌飞书 CLI 能力**，并需要：

- 从自己的凭证系统（数据库、Vault、配置中心）获取 AppID / Token 等

- 对所有 HTTP 请求统一加监控、日志，或改写请求目标等

- 给 Agent 划能力边界，只让它用特定身份（用户或应用）调用特定命令

- 统一审计所有命令的执行情况

- 写类命令接审批 / 限流后才放行

- 进程启动时初始化资源、退出前刷盘清理

你只需要：写一个 Go 包，实现指定接口，在 wrapper main 里 import 它，重新编译——就得到一个**行为完全不同的增强版 CLI**。

![image.png](//sf3-cn.feishucdn.com/obj/open-platform-opendoc/ccf93a98714162eedafdc334d43ef8ef_OhYIAp7xxW.png?height=2854&lazyload=true&maxWidth=800&width=3228)

飞书 CLI 提供六个扩展点，分为**基础能力扩展**和**命令行为扩展**两大类。基础能力扩展改变 CLI 底层的凭证获取和网络通信方式；命令行为扩展控制命令的可用范围、运行时行为和进程生命周期。

|扩展点|分层定位|核心能力|使用场景|
|---|---|---|---|
|**Credential**|基础能力 · 凭证层|自定义凭证来源，让 CLI 从数据库、Vault、配置中心等任意来源获取 AppID / Token|内嵌 CLI 到自有系统，需要对接企业凭证管理|
|**Transport**|基础能力 · 网络层|拦截 CLI 每个 HTTP 请求，在请求前后注入 Header、改写目标、记录日志、上报监控|统一网络监控、日志审计、请求路由改写|
|**Restrict**|命令行为 · 裁剪层|声明命令白名单 / 黑名单 / 风险上限 / 身份限定，启动时将不合规命令改写为桩|给 Agent 划能力边界，只暴露安全的命令子集|
|**Observer**|命令行为 · Hook 层|命令前/后旁观，只看不拦；panic 安全，不影响命令执行|审计日志、埋点、命令监控（含被拒命令）|
|**Wrap**|命令行为 · Hook 层|洋葱模式中间件，可在命令前后做事，也可直接拦截（返回 AbortError）|审批拦截、限流、运行时 token 注入|
|**On**|命令行为 · 生命周期层|进程级 Startup / Shutdown 处理器，启动时初始化、退出前清理|初始化连接池、退出前刷审计日志|

## 扩展 1：Credential — 凭证来源扩展

### 它是什么

Credential 扩展让你**自定义凭证的来源**。CLI 默认会从环境变量（`LARKSUITE_CLI_APP_ID`、`LARKSUITE_CLI_APP_SECRET`、`LARKSUITE_CLI_USER_ACCESS_TOKEN`、`LARKSUITE_CLI_TENANT_ACCESS_TOKEN`）或本地 keychain 读取凭证。通过注册自定义 Provider，你可以让 CLI 从数据库、Vault、配置中心等任意来源获取凭证。本文为了直观，就采取从本地读取凭证来进行演示。

### 怎么用

#### 第 1 步：创建项目

```bash
mkdir my-cli && cd my-cli
go mod init my-cli
```

#### 第 2 步：编写 Provider

创建 `mycred/mycred.go`，实现 `credential.Provider` 接口：

```Go
package mycred

import (
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "net/http"

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

const (
    appID     = "cli_aXXXXXXXXXXXXXXXX" // 替换为真实 AppID
    appSecret = "XXXXXXXXXXXXXXXXXXXXXXXX" // 替换为真实 AppSecret
)

type Provider struct{}

func (p *Provider) Name() string { return "mycred" }

// ResolveAccount 返回应用凭证
// 返回 &Account{} → 命中，CLI 用这组凭证
// 返回 nil, nil   → 跳过，交给下一个 Provider
// 返回 nil, err   → 报错，终止
func (p *Provider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
    // 这里替换成你自己的逻辑：查数据库、调 Vault、读配置中心...
    return &credential.Account{
        AppID:     appID,
        AppSecret: appSecret,
        Brand:     credential.BrandFeishu,
        DefaultAs: credential.IdentityUser,
    }, nil
}

// ResolveToken 返回访问令牌
// 重要：一旦你的 Provider 接管了 Account，Token 也由你负责。
// 返回 nil, nil 不会 fallback 到默认换 Token 逻辑，而是直接报错。
func (p *Provider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
    switch req.Type {
    case credential.TokenTypeTAT:
        // bot 身份：用 AppID + AppSecret 向飞书换取 tenant_access_token
        token, err := exchangeTenantAccessToken(appID, appSecret)
        if err != nil {
            return nil, err
        }
        return &credential.Token{Value: token, Source: "mycred:tat"}, nil

case credential.TokenTypeUAT:
        // user 身份：从任意来源获取 user_access_token
        // 例如：从数据库查询、从 Redis 缓存读取、从 OAuth 回调获取、
        // 从配置文件读取，甚至直接写死一个用于测试...
        uat := getUserAccessToken()
        return &credential.Token{Value: uat, Source: "mycred:uat"}, nil
    }
    return nil, nil
}

// getUserAccessToken 从你的来源获取 UAT
// 这里你可以对接任何存储：数据库、Redis、文件、环境变量等
func getUserAccessToken() string {
    // 示例：从环境变量获取
    // return os.Getenv("MY_USER_ACCESS_TOKEN")

// 示例：从数据库查询
    // return db.Query("SELECT uat FROM tokens WHERE user_id = ?", userID)

// 示例：写死一个用于本地测试（从 lark-cli auth login 后获取）
    return "u-XXXXXXXX"
}

// exchangeTenantAccessToken 用 AppID + AppSecret 向飞书换取 tenant_access_token
func exchangeTenantAccessToken(appID, appSecret string) (string, error) {
    body, _ := json.Marshal(map[string]string{
        "app_id":     appID,
        "app_secret": appSecret,
    })
    resp, err := http.Post(
        //此处以feishu Brand作为演示，如果为LARK品牌host切换为：open.larksuite.com
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        "application/json", bytes.NewReader(body),
    )
    if err != nil {
        return "", fmt.Errorf("exchange tat: %w", err)
    }
    defer resp.Body.Close()

var result struct {
        Code              int    `json:"code"`
        TenantAccessToken string `json:"tenant_access_token"`
    }
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return "", err
    }
    if result.Code != 0 {
        return "", fmt.Errorf("exchange tat failed, code=%d", result.Code)
    }
    return result.TenantAccessToken, nil
}

func init() {
    credential.Register(&Provider{})
}

```

#### 第 3 步：编写 wrapper main

创建 `main.go`：

```Go
package main

import (
    "os"
    "github.com/larksuite/cli/cmd"

_ "my-cli/mycred"  // blank import 触发 init() → 注册你的 Provider
)

func main() {
    os.Exit(cmd.Execute())
}

```

#### 第 4 步：编译验证

```Bash
go mod tidy
go build -o my-lark-cli .

# 用 --dry-run 验证凭证来源（不会真正发请求）
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run

```

输出：

```JSON
{
  "appId": "cli_a1234567890",
  "as": "user"
}

```

`appId` 显示的是你写死的值，说明自定义 Provider 生效了。

### 原理

Credential 采用**链式数组**存储多个 Provider，按注册顺序依次查询，第一个返回非 nil 的胜出：

```Plain Text
import 顺序:  mycred → env
注册结果:     providers = [mycred, env]

CLI 需要凭证时:
  ┌─ mycred.ResolveAccount()
  │   查到了? → 返回 Account → 命中! 后面全跳过
  │   没查到? → 返回 nil    → 继续
  │
  ├─ env.ResolveAccount()
  │   环境变量有? → 返回 Account → 命中
  │   没有?      → 返回 nil    → 继续
  │
  └─ defaultAcct (keychain 兜底)

```

**延迟执行**：Provider 在启动时只是注册，不会立即被调用。只有当用户执行的命令真正需要凭证时，才会触发链式遍历。结果通过 `sync.Once` 缓存，整个进程只解析一次。

#### 核心源码

|文件|作用|
|---|---|
|`extension/credential/types.go`|Provider 接口、Account / Token 类型定义|
|`extension/credential/registry.go`|`Register()` 追加到全局数组，`Providers()` 返回快照|
|`extension/credential/env/env.go`|内置实现：从环境变量读取凭证|

## 扩展 2：Transport — HTTP 请求拦截扩展

### 它是什么

Transport 扩展让你**拦截 CLI 发出的每一个 HTTP 请求**。你可以在请求发出前修改 Header、改写请求目标（Host / URL）、打印日志，在请求完成后记录耗时、上报监控。

### 怎么用

#### 第 1 步：编写 Interceptor

创建 `tracing/tracing.go`，实现 `transport.Provider` 和 `transport.Interceptor` 接口：

```Go
package tracing

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "time"

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

type Provider struct{}

func (p *Provider) Name() string { return "tracing" }

func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor {
    return &tracingInterceptor{}
}

type tracingInterceptor struct{}

// PreRoundTrip 在每个请求发出前被调用
// 返回的函数在请求完成后被调用
func (i *tracingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
    // ===== 请求前 =====
    start := time.Now()
    fmt.Fprintf(os.Stderr, "🚀 请求开始: %s %s\n", req.Method, req.URL)

// ===== 请求后（返回的闭包）=====
    return func(resp *http.Response, err error) {
        elapsed := time.Since(start)
        if err != nil {
            fmt.Fprintf(os.Stderr, "❌ 请求失败: %s %s err=%v (耗时 %v)\n",
                req.Method, req.URL, err, elapsed)
        } else {
            fmt.Fprintf(os.Stderr, "✅ 请求完成: %s %s 状态码=%d (耗时 %v)\n",
                req.Method, req.URL, resp.StatusCode, elapsed)
        }
    }
}

func init() {
    transport.Register(&Provider{})
}

```

#### 第 2 步：在 main\.go 中引入

```Go
package main

import (
    "os"
    "github.com/larksuite/cli/cmd"

_ "my-cli/tracing"  // blank import 注册 Transport Interceptor
    _ "github.com/larksuite/cli/extension/credential/env"
)

func main() {
    os.Exit(cmd.Execute())
}

```

#### 第 3 步：编译验证

```Bash
go build -o my-lark-cli .

```

验证 UAT（user 身份）：

```Bash
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user

```

输出：

```Plain Text
🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET https://open.feishu.cn/open-apis/authen/v1/user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }

```

验证 TAT（bot 身份）：

```Bash
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as bot

```

输出：

```Plain Text
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET https://open.feishu.cn/open-apis/calendar/v4/calendars 状态码=200 (耗时 736ms)
{ "code": 0, "data": { ... } }

```

### 原理

Transport 采用**单 Provider** 模式，注册后会被包装为一个 HTTP 中间件，插入到 CLI 的请求链中：

```Plain Text
CLI 发 HTTP 请求
      │
      ▼
你的 PreRoundTrip(req)         ← 请求前: 加 Header / 改写 Host / 打日志 / 开始计时
      │  返回一个 postFn
      ▼
CLI 内置 transport 链           ← Retry → SecurityHeader → 实际网络请求
      │
      ▼
postFn(resp, err)              ← 请求后: 记录耗时 / 上报监控

```

CLI 在构建 HTTP Client 时，会调用 `wrapWithExtension()` 把你的 Interceptor 包装成中间件。之后 CLI 发出的**每一个请求**（包括换 Token、调业务 API）都会经过这个中间件。

**安全保障**：CLI 内置安全层会统一重写基础安全 Header（如 `X-Cli-*`、`User-Agent`）。`Authorization` 不属于这层统一覆盖：它由请求构建阶段设置；如果发生跨 host redirect，会在 redirect 策略中被剥离。因此你可以添加自定义 Header，但**不要依赖** Transport 扩展去覆盖凭证头。

#### 核心源码

|文件|作用|
|---|---|
|`extension/transport/types.go`|Provider / Interceptor 接口定义|
|`extension/transport/registry.go`|`Register()` 注册单个 Provider（last\-write\-wins）|

## 扩展 3：Restrict — 命令裁剪（启动期）

### 它是什么

Restrict 让你声明一条或多条"裁剪规则"（`Rule`）：哪些命令能用、哪些不能、最高到什么风险、限定什么身份。框架启动时把整棵命令树过一遍——**不符合规则的命令被改写成桩**：从 help 里隐藏，一旦执行就返回 `command_denied`。对命令分发来说它们还是合法命令，只是一跑就报错。

### 怎么用

```Go
platform.NewPlugin("secaudit", "1.0.0").
    Restrict(&platform.Rule{
        Name:    "agent-docs-readonly",
        Allow:   []string{"docs/**"}, // glob，"/" 是分隔符，"**" 匹配多段
        MaxRisk: "read",              // 风险上限：read | write | high-risk-write
    }).
    MustBuild()

```

编译后可以查看当前生效的裁剪规则：

```Plain Text
$ ./my-lark-cli config policy show
{
  "source": "plugin",
  "source_name": "secaudit",
  "rules": [{"name": "agent-docs-readonly", "allow": ["docs/**"], "max_risk": "read", "allow_unannotated": false}],
  "denied_paths": 258
}

```

#### Rule 的字段

|字段|含义|例子|
|---|---|---|
|`Name`|规则名（必填）。`config policy show` 输出和 envelope 的 `rule_name` 会带上它，方便排查|`"agent-docs-readonly"`|
|`Description`|可选描述，仅供 `config policy show` 展示|`"Agent 只读 docs 命令"`|
|`Allow`|白名单 glob，命中即通过；空表示"不限制路径"|`["docs/**", "im/+messages-send"]`|
|`Deny`|黑名单 glob，命中即拒绝（优先于 Allow）|`["**/+*-delete"]`|
|`MaxRisk`|风险等级上限（read / write / high\-risk\-write）。也可用常量 `platform.RiskRead` / `RiskWrite` / `RiskHighRiskWrite`|`"read"` 排除所有写类命令|
|`Identities`|限定身份白名单（user / bot）。空表示不按身份过滤|`[]platform.Identity{"user"}` 排除 bot 类命令|
|`AllowUnannotated`|是否放行未标注 risk\_level 的命令。**默认 false（fail\-closed）**：未标注命令直接拒并报 `risk_not_annotated`；改 true 则这类命令跳过 MaxRisk 检查|`true`（把第三方 plugin 的老命令平滑接入）|

**多条规则怎么组合（容易踩坑）：**

- **单条 Rule 内**的四个维度（Allow / Deny / MaxRisk / Identities）是 **AND**：必须同时满足才放行。

- **多条 Rule 之间**是 **OR**：命中**任意一条**的全部维度即放行。同一个 plugin 可以多次调 `Restrict`，每条是一个独立授权。

- 每条 Rule 的 `Deny` 只在**本条内**生效，不能否决另一条的 Allow。要全局禁某命令，得在每条 Rule 里都 Deny（或都不 Allow）。

- **只接受一个 plugin 贡献规则**：启动期发现 2 个以上不同 plugin 都贡献了 Rule，直接 abort 整个 CLI（`plugin_conflict` / `multiple_restrict_plugins`），不做静默合并。

- `~/.lark-cli/policy.yml` 也能写规则,但优先级最低:只要程序里有插件自带了规则,这个 yaml 就不会被加载。

#### 多条 Rule 示例

不同命令组要不同 risk / 身份：

```Go
platform.NewPlugin("secaudit", "1.0.0").
    Restrict(&platform.Rule{ // docs 只读、限 user 身份
        Name:       "docs-readonly",
        Allow:      []string{"docs/**"},
        MaxRisk:    platform.RiskRead,
        Identities: []platform.Identity{"user"},
    }).
    Restrict(&platform.Rule{ // im 可写、user+bot 都允许
        Name:       "im-write",
        Allow:      []string{"im/**"},
        MaxRisk:    platform.RiskWrite,
        Identities: []platform.Identity{"user", "bot"},
    }).
    MustBuild()

```

命令命中任意一条的全部维度即放行；全都没命中时 envelope 报 `no_matching_rule`（只有一条 Rule 时仍报各自具体的 reason\_code）。

### 诊断：命令为什么被拒？

两个只读、不调任何 API 的诊断命令，排查时先用它们：

|命令|关注|数据|
|---|---|---|
|`config policy show`|"当前生效**裁剪规则**是啥"|Rule \+ denied\_paths \+ source|
|`config plugins show`|"都装了哪些 plugin、各自贡献啥"|完整 plugin 清单 \+ 各自 hook 列表|

#### config policy show

查看 CLI 当前生效的裁剪规则、来源和被裁路径数。适用于：排查"为什么我的命令被拒"、验证 plugin 是否成功贡献了 Rule、检查用户 yaml 是否被 plugin 影子覆盖。

```Plain Text
$ ./my-cli config policy show
{
  "source": "plugin",
  "source_name": "secaudit",
  "rules": [{
    "name": "agent-docs-readonly",
    "description": "",
    "allow": ["docs/**"],
    "deny": null,
    "max_risk": "read",
    "identities": null,
    "allow_unannotated": false
  }],
  "denied_paths": 258
}

```

|字段|含义|
|---|---|
|`source`|当前生效来源：`"plugin"` / `"yaml"` / `"none"`|
|`source_name`|来源标签：仅当 `source` 为 plugin 时是插件名；yaml 与 none 来源下为空\(定位 yaml 配置路径请查 `~/.lark-cli/policy.yml`\)|
|`rules`|已决议、已落地的 Rule 列表（单条时数组长度为 1）|
|`denied_paths`|被裁的命令路径总数（含父组聚合）|

#### config plugins show

列出**所有成功装载的 plugin** 及其完整贡献：能力声明、贡献的 Rule、注册的 Hook（Observer / Wrapper / Lifecycle）。适用于：拿到别人编译的二进制想知道"到底装了啥"、排查"为啥我的命令前后多了一堆日志"、审计某 plugin 注册了哪些 Wrapper / Lifecycle、验证 plugin 是否完整安装。

```Plain Text
$ ./my-cli config plugins show
{
  "plugins": [
    {
      "name": "secaudit",
      "version": "1.0.0",
      "capabilities": { "restricts": true, "failure_policy": "FailClosed" },
      "rules": [{
        "name": "agent-docs-readonly",
        "allow": ["docs/**"], "deny": null,
        "max_risk": "read", "identities": null, "allow_unannotated": false
      }],
      "hooks": {
        "count": 5,
        "observers": [
          { "name": "secaudit.audit-pre",  "when": "Before" },
          { "name": "secaudit.audit-post", "when": "After"  }
        ],
        "wrappers": [ { "name": "secaudit.approval" } ],
        "lifecycle": [
          { "name": "secaudit.init",  "event": "Startup"  },
          { "name": "secaudit.flush", "event": "Shutdown" }
        ]
      }
    }
  ],
  "total": 1
}

```

**如何辨认 hook 归属：** 框架在注册时把 hookName 自动加上 plugin 名前缀，分隔符是 "\."。所以 `secaudit.boot` 就是 `secaudit` plugin 注册的 `boot` hook。plugin 名禁用 "\."，分隔无歧义。

## 扩展 4：Observer — 旁观 Hook（只看不拦）

### 它是什么

Observer 让你注册一个"观察者"，命令执行**前**（`Before`）或**后**（`After`）跑你的代码。它不参与主流程、**不能拦命令**，**它出错或 panic 都不影响命令本身，**常用于审计、埋点、监控。

### 怎么用

```Go
platform.NewPlugin("secaudit", "1.0.0").
    // 命令运行前：记审计
    Observer(platform.Before, "audit-pre", platform.All(),
        func(ctx context.Context, inv platform.Invocation) {
            log.Printf("pre  %s denied=%v", inv.Cmd().Path(), inv.DeniedByPolicy())
        }).
    // 命令运行后：记结果
    Observer(platform.After, "audit-post", platform.All(),
        func(ctx context.Context, inv platform.Invocation) {
            log.Printf("post %s err=%v", inv.Cmd().Path(), inv.Err())
        }).
    FailOpen().
    MustBuild()

```

#### Selector：决定 Hook 在哪些命令上生效

`platform.All()` 是"所有命令"。还有按维度筛选的 Selector：

```Go
platform.ByDomain("docs")     // docs 域所有命令
platform.ByExactRisk("write") // 风险等级精确为 write 的命令
platform.ByWrite()            // 风险为 write 或 high-risk-write
platform.ByReadOnly()         // 风险为 read 的命令
platform.ByIdentity("bot")    // 限定身份

```

用 `.And()` / `.Or()` / `.Not()` 组合：

```Go
// im 域，但排除 +chats-list
platform.ByDomain("im").And(platform.ByCommandPath("im/+chats-list").Not())

// docs 或 im 域（任一命中即可）
platform.ByDomain("docs").Or(platform.ByDomain("im"))

```

#### Observer 的三条边界

- **不能短路**：返回值会被丢弃，改变不了命令是否执行。

- **panic 安全**：框架用 `recover` 包着，Observer panic 不会冒到外层。

- **被裁命令也跑**：`inv.DeniedByPolicy()` 为 true 时也会触发（这是 Observer 跟 Wrapper 最大的不同），方便审计被拒命令。

#### Invocation：Observer 和 Wrap 的运行期上下文

|方法|返回 / 含义|
|---|---|
|`Cmd() CommandView`|命令视图，可调 `.Path()` / `.Risk()` / `.Identities()` / `.Domain()`|
|`Args() []string`|本次调用的位置参数（不含 flag）|
|`Started() time.Time`|命令开始时间，可在 After 里算耗时|
|`Err() error`|仅 After 有意义：命令 RunE 的返回错误（含 AbortError）|
|`DeniedByPolicy() bool`|是否被裁（user\-layer Rule 或 strict\-mode 都算）|
|`DenialLayer() string`|裁的来源：`"policy"` / `"strict_mode"`；没被裁时为空|
|`DenialPolicySource() string`|policy 层 Rule 的来源标签：`plugin:<name>` / `yaml`|

## 扩展 5：Wrap — 中间件 Hook（能拦能改）

### 它是什么

Wrap 是"洋葱模式"中间件，像洋葱一样包住命令的真正逻辑。你拿到一个 `next` 函数，在调用 `next` 之前 / 之后做事，也可以直接拦下（返回 AbortError）。常用于审批、限流、注入 token。

Wrap 链按注册顺序左→右组合，**外层先跑**。

### 怎么用

经典场景：**写类命令必须过审批**。

```Go
.Wrap("approval", platform.ByWrite(),
    func(next platform.Handler) platform.Handler {
        return func(ctx context.Context, inv platform.Invocation) error {
            if !approvalClient.Check(ctx, inv.Cmd().Path()) {
                return &platform.AbortError{
                    HookName: "approval",
                    Reason:   "write requires approval",
                }
            }
            return next(ctx, inv)
        }
    })

```

效果：

```JSON
{
  "ok": false,
  "error": {
    "type": "hook",
    "message": "hook \"secaudit.approval\" aborted: write requires approval",
    "detail": {
      "hook_name": "secaudit.approval",
      "reason": "write requires approval",
      "reason_code": "aborted"
    }
  }
}

```

### 多个 Wrapper 的组合顺序

注册顺序左→右，外层先跑：

```Go
.Wrap("logging",   All(),     loggingWrapper).
.Wrap("approval",  ByWrite(), approvalWrapper).
.Wrap("ratelimit", All(),     ratelimitWrapper)

```

```Plain Text
logging → approval → ratelimit → 原 RunE

```

## Observer vs Wrap：到底用哪个？

两者长得像（都靠 `Invocation` 拿上下文），但语义差很远。一句话：**只想"看"用 ****Observer****，要"拦 / 改"用 Wrap。**

|维度|Observer（旁观）|Wrapper（中间件）|
|---|---|---|
|能拦下命令吗|不能，返回值被丢弃|**能**，返回 `AbortError` 即短路|
|能改命令是否执行吗|不能|**能**|
|panic 怎么处理|框架 recover，不影响命令|框架 recover，不污染外层|
|被裁（denied）的命令|**照样跑**（方便审计被拒命令）|不跑|
|触发时机|Before / After 两个点|包在命令真正逻辑外面（洋葱）|
|典型用途|审计、埋点、监控|审批、限流、token 注入|

## 扩展 6：On — 生命周期（进程级）

### 它是什么

On 注册进程级生命周期处理器：

- **Startup**：CLI 启动期、所有 plugin 装好、命令树定型后**只跑一次**。返错或 panic = **整树罢工**（fail\-closed）。

- **Shutdown**：`cmd.Execute` 返回前跑。**2 秒硬超时**，错误**只记录不上抛****。**

常用于初始化连接池、退出前刷盘。

### 怎么用

```Go
.On(platform.Startup, "init",
    func(ctx context.Context, lc *platform.LifecycleContext) error {
        return auditBuf.Open(ctx) // 启动期：加载审计 buffer
    }).
.On(platform.Shutdown, "flush",
    func(ctx context.Context, lc *platform.LifecycleContext) error {
        return auditBuf.Flush(ctx) // 退出前：刷盘
    })

```

**Startup 失败的影响：** 任何命令都会拿到下面这个 envelope，直到把 Startup 错误修复。

```JSON
{
  "ok": false,
  "error": {
    "type": "plugin_lifecycle",
    "message": "lifecycle hook \"secaudit.init\" failed: connect: connection refused",
    "detail": {
      "reason_code": "lifecycle_failed",
      "hook_name": "secaudit.init",
      "event": "startup"
    }
  }
}

```

#### LifecycleContext 字段

|字段|含义|
|---|---|
|`Event LifecycleEvent`|当前事件：`Startup` / `Shutdown`。一个 handler 注册多个 event 时用来分流|
|`Err error`|仅 Shutdown 有意义：`cmd.Execute()` 返回的命令错误。可据此区分"正常退出"与"异常退出"|

## 扩展 1 及扩展 2 完整示例

将 Credential \+ Transport 两个基础扩展组合在一起的 wrapper main：

```Go
package main

import (
    "os"

"github.com/larksuite/cli/cmd" 

// 自定义扩展（写在前面 = 凭证优先级更高）
    _ "my-cli/mycred"    // 自定义凭证来源
    _ "my-cli/tracing"   // HTTP 请求拦截

// 原版 CLI 兜底
    _ "github.com/larksuite/cli/extension/credential/env"
)

func main() {
    os.Exit(cmd.Execute())
}

```

**对比原版 CLI 的 main\.go**，只多了 2 行 import。业务代码 `cmd.Execute()` 完全不变，编译出来就是一个功能完整的增强版 CLI。

### 项目结构

```Plain Text
my-cli/
├── go.mod             # require github.com/larksuite/cli v1.0.6
├── main.go            # Wrapper main
├── mycred/
│   └── mycred.go      # Credential: 自定义凭证来源
└── tracing/
    └── tracing.go     # Transport: HTTP 请求拦截

```

### 构建和运行

```Bash
mkdir my-cli && cd my-cli
go mod init my-cli
# 编写 main.go、mycred/mycred.go、tracing/tracing.go（见上方代码）
go get github.com/larksuite/cli@v1.0.6
go mod tidy
go build -o my-lark-cli .

# 验证凭证扩展（--dry-run 不发真实请求，只看用了哪组凭证）
./my-lark-cli api GET /open-apis/calendar/v4/calendars --dry-run

```

输出：

```JSON
{
  "appId": "cli_aXXXXXXXXXXXXXXXX",
  "as": "user"
}

```

```Bash
# 验证请求拦截（发送真实请求，查看日志输出）
./my-lark-cli api GET /open-apis/calendar/v4/calendars --as user

```

输出：

```Plain Text
🚀 请求开始: GET https://open.feishu.cn/open-apis/authen/v1/user_info
✅ 请求完成: GET .../user_info 状态码=200 (耗时 644ms)
🚀 请求开始: GET https://open.feishu.cn/open-apis/calendar/v4/calendars
✅ 请求完成: GET .../calendars 状态码=200 (耗时 1070ms)
{ "code": 0, "data": { "calendar_list": [...] } }

```

两个扩展同时生效：Credential 提供了凭证和 Token，Transport 拦截并记录了每个请求。

## 附录 A：进阶能力\-更精细化的 hook 能力

前面 Restrict / Observer / Wrap / On 都用 Builder 写法。Builder 其实是对下面底层接口的封装。**需要在结构体上挂依赖（审批客户端、连接池）、或 Install 逻辑复杂时**，直接实现接口更清晰。每个 plugin 必须实现 4 个方法：

```Go
type Plugin interface {
    Name() string               // 全局唯一，正则 ^[a-z0-9][a-z0-9-]*$，禁用 "."
    Version() string            // 任意字符串，仅用于诊断
    Capabilities() Capabilities // 安全声明
    Install(r Registrar) error  // 启动期被调一次，在这里通过 r 注册具体能力
}

```

`Capabilities` 是**安全契约**，每次写新 plugin 必须明确填写：

```Go
type Capabilities struct {
    Restricts          bool          // 我会不会调 r.Restrict（命令裁剪）？默认 false
    FailurePolicy      FailurePolicy // FailOpen（装失败就跳过）/ FailClosed（装失败整树 abort）
    RequiredCLIVersion string        // 例如 ">=1.0.0"；不满足按 FailurePolicy 处理
}

```

**硬性约束：** `Restricts=true` **必须**配 `FailurePolicy=FailClosed`。框架一致性检查不通过会**无条件 abort**（即使声明 FailOpen）——安全相关的 plugin 不应该被 FailOpen 静默跳过。
**用 Builder 就不用操心这些**：`NewPlugin(...).Restrict(...)` 会自动把 `Restricts=true` 和 `FailClosed` 配齐。

**Builder vs 接口实现，怎么选：**

- 简单 plugin（只挂 Observer / Wrap）→ 用 **Builder**，一行一个能力，零样板

- 持有外部依赖（审批客户端、连接池）→ 实现 **interface** 让依赖落在 struct 字段上更清晰

Builder 还提供 `FailOpen()` / `FailClosed()` 显式 setter：只挂 Observer / Wrap 不调 Restrict 的 plugin 默认 `FailOpen`，但你可能想强制 `FailClosed`（plugin 装失败整树罢工，保证审计不丢）。一旦调过 `Restrict()`，Builder 会强制 `FailClosed`，再调 `FailOpen()` 会在 `Build()` 阶段报错。

## 附录 B：错误对照表

所有命令拒绝都走 `command_denied`,`detail.layer` 区分来源——`policy` 为用户层 Rule,`strict_mode` 为内置 strict\-mode 裁剪。

|错误类型|错误码|含义|怎么修|
|---|---|---|---|
|`plugin_install`|`restricts_mismatch`|Restricts=true 但 FailurePolicy≠FailClosed|改成 `FailurePolicy: platform.FailClosed`|
|`plugin_install`|`invalid_capability`<br>|FailurePolicy 不是 FailOpen/FailClosed,或 RequiredCLIVersion 格式非法|用合法枚举值;version 用单段 semver\(≤3 段\)|
|`plugin_install`|`invalid_plugin_name`|Name 不符合 `^[a-z0-9][a-z0-9-]*$`|改名\(小写、连字符、不含点\)|
|`plugin_install`|`duplicate_plugin_name`|两个 plugin 用了同一个 Name|改名让其中一个唯一|
|`plugin_install`|`plugin_name_panic`|Plugin\.Name\(\) 在调用中 panic|排查 Name\(\);不要做 I/O 或访问可能 nil 的字段|
|`plugin_install`|`capabilities_panic`|Plugin\.Capabilities\(\) panic|同上|
|`plugin_install`|`capability_unmet`|RequiredCLIVersion 不满足|升级 CLI 或放宽约束|
|`plugin_install`|`install_panic`|Install 里 panic 了|排查 Install 函数体|
|`plugin_install`|`install_failed`|Install 返回非 nil 的 error|看 envelope detail 里包裹的原始 error|
|`plugin_install`|`invalid_rule`|Rule 字段非法\(如 max\_risk 不在枚举内、glob 解析失败\)|校验 Rule 字段;MaxRisk 用 read/write/high\-risk\-write|
|`plugin_install`|`invalid_hook_name`|hookName 不符合 `^[a-z0-9][a-z0-9-]*$`\(不能含 "\."\)|改名;框架自动加 plugin 名前缀|
|`plugin_install`|`duplicate_hook_name`|同一 plugin 注册了两个同名 hook|改名让其唯一|
|`plugin_install`|`invalid_hook_registration`|Wrapper factory 返回 nil 或 selector 不合法|确保 Wrapper 构造返回非 nil Handler|
|`plugin_conflict`|`multiple_restrict_plugins`|2\+ 不同 plugin 都贡献 Rule\(同一 plugin 多条不算冲突\)|只保留一个贡献 Rule 的 plugin|
|`plugin_lifecycle`|`lifecycle_failed` / `lifecycle_panic`|Startup handler 返错 / panic|排查 Startup 逻辑|
|`command_denied`|`domain_not_allowed`|命令不在 Allow 里|加进 Allow 或换条命令|
|`command_denied`|`command_denylisted`|命令命中 Deny 列表|从 Deny 移除或换命令|
|`command_denied`|`write_not_allowed`|写类命令风险超过 MaxRisk|提高 MaxRisk 或不用该命令|
|`command_denied`|`risk_too_high`|命令风险超过 MaxRisk\(非写类,保留位\)|提高 MaxRisk|
|`command_denied`|`risk_not_annotated`|命令未标注 risk\_level,Rule 默认 fail\-closed。**注**:内置命令已全部标注\(commit `5d9d438`\),该错误主要出现在第三方 plugin 注册的命令|给命令补 risk\_level 注解,或 Rule 设 AllowUnannotated=true|
|`command_denied`|`risk_invalid`|risk\_level 拼错\(如 wrtie\),不在枚举内|改成合法枚举\(错误信息含 did\-you\-mean 提示\)|
|`command_denied`|`identity_mismatch`|命令身份与 Rule\.Identities 不交集|放宽 Identities 或换合适身份|
|`command_denied`|`no_matching_rule`|注册了多条 Rule,命令一条都没命中\(单条时仍报各自具体 reason\_code\)|放宽某条 Rule,或新增一条覆盖该命令的 Rule|
|`command_denied`|`identity_not_supported`|strict\-mode 层裁命令:命令支持的身份与当前 strict\-mode 不交集|调整 `config strict-mode` 或选支持当前身份的命令|
|`command_denied`|`all_children_denied`|父组下所有子命令全部被拒,父节点也自动被裁\(聚合\)|放宽该域的 Allow 或允许部分子命令|
|`command_denied`|`mixed_children_strict_mode`|父组下部分子命令被 strict\-mode 裁;父节点聚合显示,`detail.layer = strict_mode`|看具体子命令的拒绝详情;通常是 strict\-mode 配置问题|
|`command_denied`|`mixed_children_policy`|父组下部分子命令被 user\-layer policy 裁;父节点聚合显示,`detail.layer = policy`|放宽该域的 Allow / Deny 或允许特定子命令|
|`hook`|`aborted`<br>|Wrap 体内显式返回 `*AbortError` 短路|看 envelope detail 的 `hook_name` \+ `reason`|
|`hook`|`panic`|Wrap 体内 panic 被框架兜底捕获|修复 Wrapper 代码;框架已 recover,不会污染外层|

