Compare commits

..

1 Commits

Author SHA1 Message Date
fangshuyu
336eeaf18e feat(drive): add quick mode to status diff
Add a best-effort --quick path to drive +status so repeated checks can compare local mtimes with Drive modified_time without downloading remote bytes. Keep the default exact hash mode intact, expose detection=exact|quick in output, and cover the quick semantics with unit, dry-run, and live E2E tests.
2026-05-14 14:16:38 +08:00
556 changed files with 3755 additions and 58627 deletions

View File

@@ -63,7 +63,7 @@ jobs:
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/... ./extension/...
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/... ./shortcuts/...
lint:
needs: fast-gate

5
.gitignore vendored
View File

@@ -34,13 +34,8 @@ tests/mail/reports/
# Generated / test artifacts
.hammer/
.lark-slides/
internal/registry/meta_data.json
cmd/api/download.bin
app.log
/sidecar-server-demo
/server-demo
.tmp/
cover*.out
lark-env.sh

View File

@@ -14,4 +14,3 @@ id = "lark-session-token"
description = "Detect Lark session tokens"
regex = '''\bXN0YXJ0-[A-Za-z0-9_-]+-WVuZA\b'''
keywords = ["XN0YXJ0-", "-WVuZA"]

View File

@@ -45,7 +45,6 @@ linters:
- path: _test\.go$
linters:
- bodyclose
- bidichk
- gocritic
- depguard
- forbidigo

View File

@@ -2,174 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.40] - 2026-05-25
### Features
- **wiki**: Add exponential backoff retry for `+node-create` lock contention (#1012)
- **auth**: Add `auth qrcode` subcommand and update auth docs/hints (#968)
### Bug Fixes
- **wiki**: Rename `+node-get --token` to `--node-token`, keep alias (#1074)
- **output**: Classify wiki lock-contention error (131009) with retry hint (#1014)
- **contact**: Add actionable hint when fanout search all-fail with no API code (#1054)
- **permission**: Annotate auto-grant permission failures with `required_scope` and `console_url` (#1045)
- **validation**: Use `ErrValidation` instead of `fmt.Errorf` in `Validate` paths (#1001)
### Documentation
- **skills**: Add 云盘/云存储 alias alongside 云空间 for agent clarity (#1073)
- **task**: Refresh `lark-task` shortcut docs (#1057)
## [v1.0.39] - 2026-05-22
### Features
- **slides**: Add `+export` shortcut to export slides (#988)
- **sidecar**: Support multi-client identity isolation in `server-demo` via per-client HMAC keys, preventing UAT cross-contamination when multiple CLI sandboxes share one sidecar (#934)
- **im**: Support Markdown image rendering in post content (#893)
### Bug Fixes
- **scope**: Add 22 new scope entries to scope priorities (#1050)
### Documentation
- **base**: Update location `full_address` guidance (#754)
- **apps**: Refine `lark-apps` skill description and surface, document `index.html` / `--path` hard constraints (#1040)
## [v1.0.38] - 2026-05-22
### Features
- **apps**: Gate the Miaoda apps domain off on the Lark brand — the `apps` shortcut subtree returns a structured brand-restriction error, `auth login --domain apps` is rejected, `--domain all` skips it, and `spark:*` scopes are no longer requested (#1025)
## [v1.0.37] - 2026-05-21
### Features
- **apps**: Add miaoda apps domain with 6 shortcuts covering `+create` / `+update` / `+list` / `+access-scope-get` / `+access-scope-set` / `+html-publish` (#1002)
### Bug Fixes
- **permission**: Surface auto-grant skipped/failed cases via stderr warnings and a `hint` field in the `permission_grant` JSON output (#1015)
- **sheets**: Use `FileIO` for `+write-image` input so stdin / `-` works consistently (#996)
## [v1.0.36] - 2026-05-21
### Features
- **drive/markdown**: Return real tenant URLs for `drive +upload` and `markdown +create` (#992)
### Bug Fixes
- **auth**: Return validation error when `--scope` is empty in `auth check` (#999)
### Documentation
- **lark-drive**: Improve search evidence guidance (#864)
## [v1.0.35] - 2026-05-20
### Features
- **markdown**: Support wiki node target in `+create` (#883)
- **markdown**: Add `+diff` shortcut (#876)
- **base**: Add form `+detail` / `+submit` shortcuts (#759)
- **skills**: Add incremental skills sync (#965)
- **doc**: Warn before overwrite when document contains whiteboard or file blocks (#825)
### Documentation
- **im**: Clarify media key formats for message media flags (#991)
- **im**: Add media-preview reference (#990)
- **drive**: Migrate `docs +search` to `drive +search` and fix `creator_ids` owner semantic (#951)
- **drive**: Prefer local comments for drive reviews (#981)
- **wiki**: Add wiki base fast path (#982)
## [v1.0.34] - 2026-05-19
### Features
- **drive**: Switch markdown export to V2 `docs_ai` fetch API (#948)
- **drive**: Add `+inspect` shortcut for document URL inspection with wiki unwrapping (#947)
- **wiki**: Add `+node-get` / `+node-delete` / `+space-create` shortcuts (#904)
- **base**: Support Base attachment APIs (#887)
- **mail**: Validate `bot` + `mailbox=me` and add dynamic `--as` help tests (#895)
- **mail**: Expose draft priority in `--inspect` projection and document `--set-priority` (#779)
### Bug Fixes
- **identitydiag**: Harden verify path and tighten status semantics (#961)
- **wiki**: Surface real node URL for `+node-create` / `+node-copy` (#960)
- **auth**: Split bot and user identity diagnostics (#957)
- **base**: Address Base attachment review follow-ups (#958)
- **docs**: Clarify `replace_all` selection errors (#954)
### Documentation
- **drive**: Clarify add comment constraints (#967)
- **lark-im**: Clarify message activity search (#865)
### Tests
- Verify e2e resource cleanup (#949)
- **lint**: Exclude `bidichk` from test files (#959)
## [v1.0.33] - 2026-05-18
### Features
- **markdown**: Add `+patch` shortcut (#857)
- **slides**: Improve slide planning and validation guidance (#847)
- **drive**: Add `+sync` workflow for Drive directories (#873)
- **drive**: Add drive version shortcut (#841)
- **extension**: Plugin / Hook framework with command pruning (#910)
### Bug Fixes
- **sheets**: Explicitly document safe JSON unmarshal ignore in `DryRun` (#935)
- **base**: Mark base field update high risk (#936)
- **auth**: Guide agents to yield during auth device flow (#933)
### Documentation
- **lark-wiki**: Correct the `--as` default-identity claim (#919)
### Tests
- Drop stale e2e `--yes` flags (#920)
## [v1.0.32] - 2026-05-15
### Features
- **doc**: Add `--width`/`--height` flags to `docs +media-insert` (#832)
- **wiki**: Add `+space-list` / `+node-list` / `+node-copy` shortcuts (#392)
### Bug Fixes
- **drive**: Preserve parent token on nested overwrite (#908)
- **selfupdate**: Use `LookPath` instead of `Executable` for binary verification (#886)
- **registry**: Wait for background meta refresh before test reset (#894)
### Documentation
- **doc**: Add SVG whiteboard support to `lark-doc` v2 skill (#901)
- **drive**: Add permission public patch error guidance (#863)
## [v1.0.31] - 2026-05-14
### Features
- **install**: Skip interactive prompts in non-TTY environments (#888)
- **update**: Recommend `lark-cli update` over `npm install` for AI agents (#884)
- **im**: Add `--exclude-muted` to `+chat-search` and new `+chat-list` shortcut (#820)
- **auth**: Add `--exclude` flag and allow combining `--scope` with `--domain`/`--recommend` (#844)
- **drive**: Add modified-time smart sync mode (#859)
- **approval**: Add `tasks.add_sign` and `tasks.rollback` methods (#867)
## [v1.0.30] - 2026-05-13
### Features
@@ -860,16 +692,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.40]: https://github.com/larksuite/cli/releases/tag/v1.0.40
[v1.0.39]: https://github.com/larksuite/cli/releases/tag/v1.0.39
[v1.0.38]: https://github.com/larksuite/cli/releases/tag/v1.0.38
[v1.0.37]: https://github.com/larksuite/cli/releases/tag/v1.0.37
[v1.0.36]: https://github.com/larksuite/cli/releases/tag/v1.0.36
[v1.0.35]: https://github.com/larksuite/cli/releases/tag/v1.0.35
[v1.0.34]: https://github.com/larksuite/cli/releases/tag/v1.0.34
[v1.0.33]: https://github.com/larksuite/cli/releases/tag/v1.0.33
[v1.0.32]: https://github.com/larksuite/cli/releases/tag/v1.0.32
[v1.0.31]: https://github.com/larksuite/cli/releases/tag/v1.0.31
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28

View File

@@ -8,9 +8,7 @@ DATE := $(shell date +%Y-%m-%d)
LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE)
PREFIX ?= /usr/local
.PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks
all: test
.PHONY: build vet test unit-test integration-test install uninstall clean fetch_meta
fetch_meta:
python3 scripts/fetch_meta.py
@@ -21,32 +19,13 @@ build: fetch_meta
vet: fetch_meta
go vet ./...
# fmt-check fails when any file would be reformatted by gofmt. Keep this
# in sync with the fast-gate "Check formatting" step in CI.
fmt-check:
@unformatted=$$(gofmt -l . | grep -v '^\.claude/' || true); \
if [ -n "$$unformatted" ]; then \
echo "Unformatted Go files:"; \
echo "$$unformatted"; \
echo "Run 'gofmt -w .' and commit."; \
exit 1; \
fi
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
go test -race -gcflags="all=-N -l" -count=1 \
./cmd/... ./internal/... ./shortcuts/... ./extension/...
# examples-build keeps the shipped plugin-SDK examples compilable. If this
# breaks, the plugin author guide's "go build ./..." path is broken.
examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
integration-test: build
go test -v -count=1 ./tests/...
test: vet fmt-check unit-test examples-build integration-test
test: vet unit-test integration-test
install: build
install -d $(PREFIX)/bin
@@ -58,13 +37,3 @@ uninstall:
clean:
rm -f $(BINARY)
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.
# Step 2: gitleaks scans the full repo for real leaked secrets.
# Install gitleaks: https://github.com/gitleaks/gitleaks#installing
gitleaks:
@bash scripts/check-doc-tokens.sh
@command -v gitleaks >/dev/null 2>&1 || { echo "gitleaks not found. Install: brew install gitleaks"; exit 1; }
gitleaks detect --redact -v --exit-code=2

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 26 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 24 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 24 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 18 business domains, 200+ curated commands, 26 AI Agent [Skills](./skills/)
- **Wide Coverage** — 17 business domains, 200+ curated commands, 24 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -28,7 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media |
| 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards |
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📝 Markdown | Create, fetch, patch, and overwrite Drive-native `.md` files |
| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
@@ -41,7 +41,6 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
| 🔗 Apps | Develop, deploy HTML, web pages and applications |
## Installation & Quick Start
@@ -63,7 +62,11 @@ Choose **one** of the following methods:
**Option 1 — From npm (recommended):**
```bash
npx @larksuite/cli@latest install
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
**Option 2 — From source:**
@@ -99,7 +102,11 @@ lark-cli calendar +agenda
**Step 1 — Install**
```bash
npx @larksuite/cli@latest install
# Install CLI
npm install -g @larksuite/cli
# Install CLI SKILL (required)
npx skills add larksuite/cli -y -g
```
**Step 2 — Configure app credentials**
@@ -133,7 +140,7 @@ lark-cli auth status
| `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions |
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-markdown` | Create, fetch, patch, and overwrite Drive-native Markdown files |
| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 26 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 24 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — 26 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 18 大业务域、200+ 精选命令、26 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 24 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 17 大业务域、200+ 精选命令、24 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -28,7 +28,7 @@
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📝 Markdown | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
@@ -41,7 +41,6 @@
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 |
## 安装与快速开始
@@ -63,7 +62,11 @@
**方式一 — 从 npm 安装(推荐):**
```bash
npx @larksuite/cli@latest install
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
**方式二 — 从源码安装:**
@@ -99,7 +102,11 @@ lark-cli calendar +agenda
**第 1 步 — 安装**
```bash
npx @larksuite/cli@latest install
# 安装 CLI
npm install -g @larksuite/cli
# 安装 CLI SKILL必需
npx skills add larksuite/cli -y -g
```
**第 2 步 — 配置应用凭证**
@@ -134,7 +141,7 @@ lark-cli auth status
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-markdown` | 创建、读取、局部 patch、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |

View File

@@ -103,7 +103,6 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "write")
return cmd
}
@@ -238,11 +237,7 @@ func apiRun(opts *APIOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
// raw API error detail (log_id, troubleshooter, permission_violations)
// stays on the wire — `lark-cli api` callers explicitly want the raw
// envelope.
return output.MarkRaw(err)
return output.MarkRaw(client.WrapDoAPIError(err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -252,15 +247,9 @@ func apiRun(opts *APIOptions) error {
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
// Per-domain migration in stage 2+ will route through
// errclass.BuildAPIError to populate identity-aware fields
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
CheckError: ac.CheckResponse,
})
// MarkRaw: see comment above on the DoAPI path. Applies equally to
// HandleResponse failures so the raw API error survives to the wire.
// MarkRaw tells root error handler to skip enrichPermissionError,
// preserving the original API error detail (log_id, troubleshooter, etc.).
if err != nil {
return output.MarkRaw(err)
}
@@ -272,12 +261,9 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, ac.CheckResponse); err != nil {
if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil {
return output.MarkRaw(err)
}
return nil
@@ -290,9 +276,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.MarkRaw(err)
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
@@ -304,9 +290,9 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(err)
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}

View File

@@ -4,6 +4,7 @@
package api
import (
"errors"
"os"
"sort"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -397,6 +399,154 @@ func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
}
}
func TestApiCmd_APIError_IsRaw(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-raw", AppSecret: "test-secret-raw", Brand: core.BrandFeishu,
})
// Return a permission error from the API
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/perm", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for permission denied API response")
}
// Error should be marked Raw
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected API error from api command to be marked Raw")
}
// Note: stderr envelope output is tested at the root level (TestHandleRootError_*)
// since WriteErrorEnvelope is called by handleRootError, not by cobra's Execute.
_ = stderr
}
func TestApiCmd_APIError_PreservesOriginalMessage(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-origmsg", AppSecret: "test-secret-origmsg", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/origmsg",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:message:readonly"},
},
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/origmsg", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
// The message should NOT have been enriched (no "App scope not enabled" replacement)
if strings.Contains(exitErr.Error(), "App scope not enabled") {
t.Error("expected original message, not enriched message")
}
// Detail should still contain the raw API error detail
if exitErr.Detail == nil {
t.Fatal("expected non-nil Detail")
}
if exitErr.Detail.Detail == nil {
t.Error("expected raw Detail.Detail to be preserved (not cleared by enrichment)")
}
}
func TestApiCmd_InvalidJSONResponse_ShowsDiagnostic(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-invalidjson", AppSecret: "test-secret-invalidjson", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/invalidjson",
RawBody: []byte{},
ContentType: "application/json",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/invalidjson", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected detail on exit error")
}
if !strings.Contains(exitErr.Detail.Message, "invalid JSON response") &&
!strings.Contains(exitErr.Detail.Message, "empty JSON response body") {
t.Fatalf("expected JSON diagnostic, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "--output") {
t.Fatalf("expected hint to mention --output, got %q", exitErr.Detail.Hint)
}
}
func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-rawpage", AppSecret: "test-secret-rawpage", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/rawpage",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/rawpage", "--as", "bot", "--page-all"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if !exitErr.Raw {
t.Error("expected paginated API error to be marked Raw")
}
}
func TestApiCmd_JqFlag_Parsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -43,7 +43,6 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
cmd.AddCommand(NewCmdAuthQRCode(f, nil))
return cmd
}

View File

@@ -44,32 +44,6 @@ func TestAuthLoginCmd_FlagParsing(t *testing.T) {
}
}
func TestAuthLoginCmd_HelpGuidesNonStreamingAgentsToSplitFlow(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error { return nil })
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"only delivers final turn messages",
"--no-wait --json",
"send the verification URL (or QR code) to the user as your final message",
"run --device-code in a later step",
} {
if !strings.Contains(got, want) {
t.Fatalf("help missing %q, got:\n%s", want, got)
}
}
}
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,

View File

@@ -37,7 +37,6 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.MarkFlagRequired("scope")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -47,7 +46,8 @@ func authCheckRun(opts *CheckOptions) error {
required := strings.Fields(opts.Scope)
if len(required) == 0 {
return output.ErrValidation("--scope cannot be empty")
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
}
config, err := f.Config()

View File

@@ -34,7 +34,6 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co
return authListRun(opts)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -47,13 +47,10 @@ func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. If your harness or agent tool only delivers final turn messages, use --no-wait --json,
send the verification URL (or QR code) to the user as your final message, end the turn, then
run --device-code in a later step after the user confirms authorization. Use 'lark-cli auth qrcode'
to generate QR codes (supports ASCII and PNG formats).`,
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
return output.ErrWithHint(output.ExitValidation, "command_denied",
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
}
@@ -65,17 +62,10 @@ to generate QR codes (supports ASCII and PNG formats).`,
},
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
cmdutil.SetRisk(cmd, "write")
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
var helpBrand core.LarkBrand
if f != nil && f.Config != nil {
if cfg, err := f.Config(); err == nil && cfg != nil {
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
available := sortedKnownDomains()
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
@@ -146,14 +136,14 @@ func authLoginRun(opts *LoginOptions) error {
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
selectedDomains = sortedKnownDomains()
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
knownDomains := allKnownDomains()
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
@@ -177,7 +167,7 @@ func authLoginRun(opts *LoginOptions) error {
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
if err != nil {
return err
}
@@ -197,7 +187,7 @@ func authLoginRun(opts *LoginOptions) error {
log("View all options:")
log(msg.HintFooter)
log("")
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
log("Note: this command blocks until authorization is complete. Run it in the background and retrieve the verification URL from its output.")
return output.ErrValidation("please specify the scopes to authorize")
}
}
@@ -215,10 +205,10 @@ func authLoginRun(opts *LoginOptions) error {
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
candidateScopes = collectScopesForDomains(selectedDomains, "user")
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
}
// Filter to auto-approve scopes if --recommend or interactive "common"
@@ -276,7 +266,7 @@ func authLoginRun(opts *LoginOptions) error {
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
"hint": fmt.Sprintf("Show verification_url to the user exactly as returned by the CLI and treat it as an opaque string. Do not URL-encode or decode it, do not normalize or rewrite it, do not add %%20, spaces, or punctuation, and do not wrap it as Markdown link text; prefer a fenced code block containing only the raw URL. Then immediately execute: lark-cli auth login --device-code %s (blocks until authorized or timeout). Do not instruct the user to run this command themselves.", authResp.DeviceCode),
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
@@ -459,7 +449,6 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
return nil
}
// syncLoginUserToProfile persists the logged-in user info into the named profile.
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
@@ -485,7 +474,6 @@ func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
return nil
}
// findProfileByName returns the AppConfig matching profileName, or nil.
func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.AppConfig {
for i := range multi.Apps {
if multi.Apps[i].ProfileName() == profileName {
@@ -499,7 +487,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App
// shortcut scopes for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string {
func collectScopesForDomains(domains []string, identity string) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
@@ -518,11 +506,8 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.DeclaredScopesForIdentity(identity) {
for _, s := range sc.ScopesForIdentity(identity) {
scopeSet[s] = true
}
}
@@ -540,7 +525,7 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
// allKnownDomains returns all valid auth domain names (from_meta projects +
// shortcut services), excluding domains that have auth_domain set (they are
// folded into their parent domain).
func allKnownDomains(brand core.LarkBrand) map[string]bool {
func allKnownDomains() map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
@@ -548,9 +533,6 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
}
for _, sc := range shortcuts.AllShortcuts() {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
@@ -559,8 +541,8 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
func sortedKnownDomains() []string {
m := allKnownDomains()
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)

View File

@@ -1,32 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}
larkDomains := allKnownDomains(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}
feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
if len(feishuScopes) == 0 {
t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes))
}
larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark)
if len(larkScopes) != 0 {
t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes)
}
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
@@ -106,7 +105,7 @@ func buildDomainMeta(name, lang string) domainMeta {
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) {
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
@@ -166,7 +165,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user", brand)
scopes := collectScopesForDomains(selectedDomains, "user")
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}

View File

@@ -59,7 +59,7 @@ var loginMsgZh = &loginMsg{
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s。若你的 harness 或 agent 工具只会把最终回复发给用户,请改用 \"lark-cli auth login --no-wait --json\" 拿到 device_code 和 verification_url把 verification_url 作为本轮最终消息发给用户并结束本轮;等用户回复已完成授权后,再在后续步骤运行 \"lark-cli auth login --device-code <code>\" 续上轮询。**不要在同一轮里展示 URL 后立刻阻塞执行 --device-code**,也不要短 timeout 反复重试每次重启会作废上一轮的 device code导致用户授权链接失效。**必须生成二维码并展示**: 你必须调用 lark-cli auth qrcode 将 verification_url 转为二维码,这是必须步骤,不要跳过。优先生成 PNG 二维码(--output仅当用户明确要求时才使用 ASCII--ascii。**关键:生成后必须在回复中展示图片**,仅生成文件不算完成,须用 image 标签或内联图片等方式将图片包含在回复中。**展示顺序**: 先输出 URL再将二维码图片置于 URL 下方完整展示。**URL 输出规则**: 将 verification_url 视为不可修改的 opaque string不要做任何修改(包括 URL 编码/解码、添加空格或标点)。",
AgentTimeoutHint: "[AI agent] 此命令最长阻塞约 10 分钟,等待用户在浏览器内完成授权。请确保 runner 的 timeout >= 600s;如不支持长 timeout,请改用 `lark-cli auth login --no-wait --json` 拿到 device_code 后再用 `lark-cli auth login --device-code <code>` 续上轮询。**不要短 timeout 反复重试**每次重启会作废上一轮的 device code导致用户授权链接失效。向用户展示授权链接时,必须逐字原样转发 CLI 返回的 URL把它视为不可修改的 opaque string不要做 URL 编码解码,不要补 `%20`、空格或标点,不要改写成 Markdown 链接,建议用只包含该 URL 的代码块单独输出。",
AuthSuccess: "已收到授权确认,正在获取用户信息并校验授权结果...",
LoginSuccess: "授权成功! 用户: %s (%s)",
AuthorizedUser: "当前授权账号: %s (%s)",
@@ -95,7 +95,7 @@ var loginMsgEn = &loginMsg{
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If your harness or agent tool only delivers final turn messages, use \"lark-cli auth login --no-wait --json\" to get device_code and verification_url, present verification_url to the user exactly as the final message of this turn, then end the turn; after the user replies that they authorized, run \"lark-cli auth login --device-code <code>\" in a later step to resume polling. **Do NOT show the URL and then immediately block on --device-code in the same turn**, and do not retry with a short timeout; each restart invalidates the previous device code and makes the earlier authorization URL useless.**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation.",
AgentTimeoutHint: "[AI agent] This command blocks for up to ~10 minutes while waiting for the user to authorize in their browser. Make sure your runner's timeout is >= 600s. If long timeouts are not supported, use `lark-cli auth login --no-wait --json` to get a device_code, then `lark-cli auth login --device-code <code>` to resume polling. **Do NOT retry with a short timeout**; each restart invalidates the previous device code and makes the earlier authorization URL useless. When showing the authorization URL to the user, copy the CLI-returned URL exactly as-is and treat it as an opaque string. Do not URL-encode or decode it, do not add `%20`, spaces, or punctuation, do not rewrite it as Markdown link text, and prefer a fenced code block containing only the raw URL.",
AuthSuccess: "Authorization confirmed, fetching user info and validating granted scopes...",
LoginSuccess: "Authorization successful! User: %s (%s)",
AuthorizedUser: "Authorized account: %s (%s)",
@@ -114,7 +114,6 @@ var loginMsgEn = &loginMsg{
HintFooter: " lark-cli auth login --help",
}
// getLoginMsg returns the login message bundle for the given language.
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
@@ -126,5 +125,5 @@ func getLoginMsg(lang string) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps"}
return []string{"base", "contact", "docs", "markdown"}
}

View File

@@ -97,17 +97,16 @@ func TestLoginMsg_FormatStrings(t *testing.T) {
}
// TestAgentTimeoutHint_CarriesKeyInfo guards the contract that the synchronous
// auth-login output tells AI agents three things: (a) this command blocks for
// minutes — set a long runner timeout, (b) the alternative is the --no-wait +
// --device-code split-flow, and (c) non-streaming harnesses must end the turn
// after presenting the URL instead of blocking in the same turn.
// auth-login output tells AI agents two things: (a) this command blocks for
// minutes — set a long runner timeout, and (b) the alternative is the
// --no-wait + --device-code split-flow. Without (a) AI sets a 10s timeout and
// kills the process before the user can authorize; without (b) the AI has no
// recovery path and just retries with the same short timeout, invalidating
// each new device code in turn.
func TestAgentTimeoutHint_CarriesKeyInfo(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
hint := getLoginMsg(lang).AgentTimeoutHint
for _, want := range []string{"--no-wait", "--device-code", "turn"} {
if lang == "zh" && want == "turn" {
want = "本轮"
}
for _, want := range []string{"--no-wait", "--device-code"} {
if !strings.Contains(hint, want) {
t.Errorf("%s AgentTimeoutHint missing %q: %s", lang, want, hint)
}

View File

@@ -176,11 +176,6 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
"granted": issue.Summary.Granted,
"missing": issue.Summary.Missing,
}
// Legacy *output.ExitError producer: this literal predates the typed
// error contract introduced by errs/. New code MUST NOT construct
// *output.ExitError directly — missing-scope signals should move to
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
// extension fields) when the login flow migrates to typed errors.
return &output.ExitError{
Code: output.ExitAuth,
Detail: &output.ErrDetail{

View File

@@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) {
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains("")
domains := allKnownDomains()
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
@@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) {
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains("")
sorted := sortedKnownDomains()
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
@@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) {
}
// Should match allKnownDomains
known := allKnownDomains("")
known := allKnownDomains()
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
@@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user", "")
scopes := collectScopesForDomains([]string{"calendar"}, "user")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
@@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) {
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "")
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
@@ -315,12 +315,10 @@ func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
}
// Stderr should explain the split-flow path for non-streaming agents.
// Stderr should contain background hint
stderrStr := stderr.String()
for _, want := range []string{"--no-wait --json", "final message of the turn", "--device-code"} {
if !strings.Contains(stderrStr, want) {
t.Errorf("expected stderr to mention %q, got: %s", want, stderrStr)
}
if !strings.Contains(stderrStr, "background") {
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
}
}
@@ -945,37 +943,17 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
}
hint, _ := data["hint"].(string)
for _, want := range []string{
"MUST generate QR code AND display it",
"lark-cli auth qrcode",
"Prefer PNG QR code (--output)",
"use ASCII (--ascii) only when the user explicitly requests it",
"This is a required step, do NOT skip it",
"CRITICAL",
"You MUST include the QR image in your response",
"Generating the file alone is NOT enough",
"image tags, inline images, or file attachments",
"Display order",
"place the QR code image below the URL",
"exactly as returned by the CLI",
"opaque string",
"cannot be modified",
"final message of the turn",
"return control to the user",
"do not block on --device-code in the same turn",
"After the user confirms authorization in a later step",
"lark-cli auth login --device-code device-code",
"Do not URL-encode or decode it",
"do not add %20, spaces, or punctuation",
"do not wrap it as Markdown link text",
"fenced code block containing only the raw URL",
} {
if !strings.Contains(hint, want) {
t.Fatalf("hint missing %q, got:\n%s", want, hint)
}
}
for _, unwanted := range []string{
"Then immediately execute",
"Do not instruct the user to run this command themselves",
} {
if strings.Contains(hint, unwanted) {
t.Fatalf("hint should not contain %q, got:\n%s", unwanted, hint)
}
}
}
func TestAuthLoginRun_JSONWriteFailure_DeviceAuthorizationReturnsWriterError(t *testing.T) {
@@ -1057,21 +1035,12 @@ func TestAuthLoginRun_JSONDeviceAuthorizationAgentHintIncludesRawURLGuidance(t *
hint, _ := data["agent_hint"].(string)
for _, want := range []string{
"timeout >= 600s",
"本轮最终消息",
"结束本轮",
"用户回复已完成授权",
"不要在同一轮里展示 URL 后立刻阻塞执行 --device-code",
"必须生成二维码并展示",
"lark-cli auth qrcode",
"优先生成 PNG 二维码(--output",
"仅当用户明确要求时才使用 ASCII--ascii",
"生成后必须在回复中展示图片",
"仅生成文件不算完成",
"image 标签或内联图片",
"二维码图片置于 URL 下方完整展示",
"URL 输出规则",
"逐字原样转发 CLI 返回的 URL",
"opaque string",
"不要做任何修改",
"不要做 URL 编码或解码",
"不要补 `%20`、空格或标点",
"不要改写成 Markdown 链接",
"只包含该 URL 的代码块单独输出",
} {
if !strings.Contains(hint, want) {
t.Fatalf("agent_hint missing %q, got:\n%s", want, hint)
@@ -1089,7 +1058,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
}
func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
domains := allKnownDomains("")
domains := allKnownDomains()
if domains["whiteboard"] {
t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)")
}
@@ -1099,7 +1068,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) {
}
func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) {
scopes := collectScopesForDomains([]string{"docs"}, "user", "")
scopes := collectScopesForDomains([]string{"docs"}, "user")
// docs domain should include whiteboard shortcut scopes (board:whiteboard:*)
found := false
for _, s := range scopes {

View File

@@ -33,7 +33,6 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr
return authLogoutRun(opts)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -1,142 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"github.com/skip2/go-qrcode"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// QRCodeOptions holds inputs for auth qrcode command.
type QRCodeOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
URL string
Size int
ASCII bool
Output string
}
// NewCmdAuthQRCode creates the auth qrcode subcommand.
func NewCmdAuthQRCode(f *cmdutil.Factory, runF func(*QRCodeOptions) error) *cobra.Command {
opts := &QRCodeOptions{Factory: f, Size: 256}
cmd := &cobra.Command{
Use: "qrcode <url>",
Short: "Generate QR code for verification URL",
Long: `Generate a QR code image or ASCII representation for a verification URL.
This command is designed for AI agents to generate QR codes for OAuth authorization URLs.
For PNG output, the --output flag is required to specify the output file path (must be a relative path within the current directory).
For ASCII output, the result is printed to stdout with fixed size.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.URL = args[0]
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return runQRCode(opts)
},
}
cmd.Flags().IntVar(&opts.Size, "size", 256, "Size of the QR code image in pixels (default: 256, for PNG mode only)")
cmd.Flags().BoolVar(&opts.ASCII, "ascii", false, "Output ASCII QR code to stdout")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "Output file path for PNG image (relative path within current directory, required for non-ASCII mode)")
return cmd
}
// runQRCode executes the auth qrcode command.
func runQRCode(opts *QRCodeOptions) error {
if opts.URL == "" {
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
}
if opts.ASCII {
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
return generateASCIIQRCode(opts.URL, out)
}
if opts.Output == "" {
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
}
if opts.Size < 32 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
}
if opts.Size > 1024 {
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
}
safePath, err := validate.SafeOutputPath(opts.Output)
if err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
return err
}
result := map[string]interface{}{
"ok": true,
"file_path": safePath,
"hint": "You MUST include the QR image in your response. Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.",
}
var out io.Writer = os.Stdout
if opts.Factory != nil {
out = opts.Factory.IOStreams.Out
}
encoder := json.NewEncoder(out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(result); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
}
return nil
}
// generateImageQRCode encodes the URL as a PNG QR code and writes it to outputPath.
func generateImageQRCode(url string, size int, outputPath string) error {
png, err := qrcode.Encode(url, qrcode.Medium, size)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
}
err = vfs.WriteFile(outputPath, png, 0644)
if err != nil {
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
}
return nil
}
// generateASCIIQRCode encodes the URL as an ASCII QR code and prints it to stdout.
func generateASCIIQRCode(url string, w io.Writer) error {
q, err := qrcode.New(url, qrcode.Medium)
if err != nil {
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
}
fmt.Fprint(w, q.ToSmallString(false))
return nil
}

View File

@@ -1,368 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
func TestNewCmdAuthQRCode_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png", "--size", "128"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.URL != "https://example.com" {
t.Errorf("URL = %q, want %q", gotOpts.URL, "https://example.com")
}
if gotOpts.Size != 128 {
t.Errorf("Size = %d, want %d", gotOpts.Size, 128)
}
if gotOpts.Output != "qr.png" {
t.Errorf("Output = %q, want %q", gotOpts.Output, "qr.png")
}
if gotOpts.ASCII {
t.Error("ASCII should be false by default")
}
}
func TestNewCmdAuthQRCode_ASCIIFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOpts.ASCII {
t.Error("ASCII should be true when --ascii is passed")
}
}
func TestNewCmdAuthQRCode_DefaultSize(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *QRCodeOptions
cmd := NewCmdAuthQRCode(f, func(opts *QRCodeOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"https://example.com", "--ascii"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Size != 256 {
t.Errorf("default Size = %d, want 256", gotOpts.Size)
}
}
func TestNewCmdAuthQRCode_ExactOneArg(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when no URL argument provided")
}
}
func TestNewCmdAuthQRCode_RunE_PNGEndToEnd(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetArgs([]string{"https://example.com", "--output", "qr.png"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
var result map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", err, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
hint, _ := result["hint"].(string)
if hint == "" {
t.Error("hint is empty")
}
if !strings.Contains(hint, "MUST include") {
t.Errorf("hint missing 'MUST include', got: %s", hint)
}
if !strings.Contains(hint, "NOT enough") {
t.Errorf("hint missing 'NOT enough', got: %s", hint)
}
}
func TestNewCmdAuthQRCode_RunE_MissingOutput(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"https://example.com"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected error when --output is missing in PNG mode")
}
}
func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdAuthQRCode(f, nil)
cmd.SetOut(stdout)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
got := stdout.String()
for _, want := range []string{
"qrcode <url>",
"QR code",
"--output",
"--ascii",
"relative path",
} {
if !strings.Contains(got, want) {
t.Errorf("help missing %q", want)
}
}
}
func TestRunQRCode_MissingURL(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: ""})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_url" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
}
}
func TestRunQRCode_MissingOutput(t *testing.T) {
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "missing_output" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
}
}
func TestRunQRCode_InvalidSize(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 16,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_SizeTooLarge(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 2048,
Output: "qr.png",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
if exitErr.Detail.Type != "invalid_size" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
}
}
func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "/etc/passwd",
})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
}
}
func TestRunQRCode_PNGWritesFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
tmpDir := t.TempDir()
oldWd, _ := os.Getwd()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("chdir: %v", err)
}
t.Cleanup(func() { os.Chdir(oldWd) })
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
Size: 256,
Output: "qr.png",
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
info, err := os.Stat("qr.png")
if err != nil {
t.Fatalf("output file not created: %v", err)
}
if info.Size() == 0 {
t.Error("output file is empty")
}
var result map[string]interface{}
if jsonErr := json.Unmarshal(stdout.Bytes(), &result); jsonErr != nil {
t.Fatalf("stdout is not valid JSON: %v, got: %s", jsonErr, stdout.String())
}
if result["ok"] != true {
t.Errorf("ok = %v, want true", result["ok"])
}
}
func TestRunQRCode_ASCIIOutputsToStdout(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
err := runQRCode(&QRCodeOptions{
URL: "https://example.com",
ASCII: true,
Factory: f,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if stdout.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateImageQRCode_Success(t *testing.T) {
tmpDir := t.TempDir()
outputPath := filepath.Join(tmpDir, "test-qr.png")
if err := generateImageQRCode("https://example.com", 256, outputPath); err != nil {
t.Fatalf("unexpected error: %v", err)
}
data, err := os.ReadFile(outputPath)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
if len(data) == 0 {
t.Error("output file is empty")
}
if len(data) < 8 {
t.Error("output too small to be a valid PNG")
}
if string(data[:4]) != "\x89PNG" {
t.Errorf("output does not start with PNG magic bytes, got %x", data[:4])
}
}
func TestGenerateImageQRCode_WriteError(t *testing.T) {
err := generateImageQRCode("https://example.com", 256, "/nonexistent/deep/nested/dir/qr.png")
if err == nil {
t.Fatal("expected error writing to nonexistent directory")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitInternal {
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
}
if exitErr.Detail.Type != "write_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
}
}
func TestGenerateASCIIQRCode_Success(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("https://example.com", &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.Len() == 0 {
t.Error("ASCII QR code produced no output")
}
}
func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
var buf strings.Builder
err := generateASCIIQRCode("", &buf)
if err == nil {
t.Fatal("expected error for empty string")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Detail.Type != "encode_error" {
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
}
}

View File

@@ -37,7 +37,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -5,11 +5,13 @@ package auth
import (
"context"
"time"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
@@ -35,7 +37,6 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -58,83 +59,73 @@ func authStatusRun(opts *StatusOptions) error {
"defaultAs": defaultAs,
}
diagnostics := identitydiag.Diagnose(context.Background(), f, config, opts.Verify)
result["identities"] = diagnostics
result["identity"] = effectiveIdentity(diagnostics)
addLegacyUserFields(result, diagnostics.User)
addEffectiveVerification(result, diagnostics)
addStatusNote(result, diagnostics)
if config.UserOpenId == "" {
result["identity"] = "bot"
result["note"] = "No user logged in. Only bot (tenant) identity is available for API calls. Run `lark-cli auth login` to log in."
output.PrintJson(f.IOStreams.Out, result)
return nil
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
result["identity"] = "bot"
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["note"] = "Token does not exist or has been cleared. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
output.PrintJson(f.IOStreams.Out, result)
return nil
}
status := larkauth.TokenStatus(stored)
if status == "expired" {
result["identity"] = "bot"
result["note"] = "User token has expired. Only bot (tenant) identity is available. Re-login: lark-cli auth login"
} else {
result["identity"] = "user"
}
result["userName"] = config.UserName
result["userOpenId"] = config.UserOpenId
result["tokenStatus"] = status
result["scope"] = stored.Scope
result["expiresAt"] = time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)
result["refreshExpiresAt"] = time.UnixMilli(stored.RefreshExpiresAt).Format(time.RFC3339)
result["grantedAt"] = time.UnixMilli(stored.GrantedAt).Format(time.RFC3339)
// --verify: call the server to confirm token is actually usable.
if opts.Verify && status != "expired" {
verified, verifyErr := verifyTokenOnServer(f, config)
result["verified"] = verified
if verifyErr != "" {
result["verifyError"] = verifyErr
}
}
output.PrintJson(f.IOStreams.Out, result)
return nil
}
const (
identityUser = "user"
identityBot = "bot"
identityNone = "none"
)
// verifyTokenOnServer obtains a valid access token (refreshing if needed)
// and calls /authen/v1/user_info to confirm the server accepts it.
// Returns (true, "") on success or (false, reason) on failure.
func verifyTokenOnServer(f *cmdutil.Factory, config *core.CliConfig) (bool, string) {
httpClient, err := f.HttpClient()
if err != nil {
return false, "failed to create HTTP client: " + err.Error()
}
func effectiveIdentity(d identitydiag.Result) string {
switch {
case d.User.Available:
return identityUser
case d.Bot.Available:
return identityBot
default:
return identityNone
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
if err != nil {
return false, "token unusable: " + err.Error()
}
}
func addLegacyUserFields(result map[string]interface{}, user identitydiag.Identity) {
if user.OpenID == "" {
return
sdk, err := f.LarkClient()
if err != nil {
return false, "failed to create SDK client: " + err.Error()
}
result["userName"] = user.UserName
result["userOpenId"] = user.OpenID
if user.TokenStatus != "" {
result["tokenStatus"] = user.TokenStatus
}
if user.Scope != "" {
result["scope"] = user.Scope
}
if user.ExpiresAt != "" {
result["expiresAt"] = user.ExpiresAt
}
if user.RefreshExpiresAt != "" {
result["refreshExpiresAt"] = user.RefreshExpiresAt
}
if user.GrantedAt != "" {
result["grantedAt"] = user.GrantedAt
}
}
func addEffectiveVerification(result map[string]interface{}, d identitydiag.Result) {
switch result["identity"] {
case identityUser:
if d.User.Verified != nil {
result["verified"] = *d.User.Verified
if !*d.User.Verified {
result["verifyError"] = d.User.Message
}
}
case identityBot:
if d.Bot.Verified != nil {
result["verified"] = *d.Bot.Verified
if !*d.Bot.Verified {
result["verifyError"] = d.Bot.Message
}
}
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
return false, "server rejected token: " + err.Error()
}
}
func addStatusNote(result map[string]interface{}, d identitydiag.Result) {
switch {
case !d.User.Available && d.Bot.Available:
result["note"] = "User identity is " + identitydiag.StatusMessage(d.User.Status) + "; bot identity is ready for bot/tenant API calls. Run `lark-cli auth login` to enable user identity."
case d.User.Status == identitydiag.StatusNeedsRefresh:
result["note"] = "User identity needs refresh and will be refreshed automatically on the next user API call."
case !d.User.Available && !d.Bot.Available:
result["note"] = "No usable identity is available. Configure bot credentials or run `lark-cli auth login`."
}
return true, ""
}

View File

@@ -1,96 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"net/http"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAuthStatusRun_SplitsBotAndUserIdentity(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
if err := authStatusRun(&StatusOptions{Factory: f}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Identities.Bot.Status != "ready" || !got.Identities.Bot.Available {
t.Fatalf("bot = %#v, want ready and available", got.Identities.Bot)
}
if got.Identities.User.Status != "missing" || got.Identities.User.Available {
t.Fatalf("user = %#v, want missing and unavailable", got.Identities.User)
}
}
func TestAuthStatusRun_VerifyReportsBotIdentity(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
Method: http.MethodGet,
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"bot": map[string]interface{}{
"open_id": "ou_bot",
"app_name": "diagnostic bot",
},
},
})
if err := authStatusRun(&StatusOptions{Factory: f, Verify: true}); err != nil {
t.Fatalf("authStatusRun() error = %v", err)
}
var got statusOutput
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got.Identity != "bot" {
t.Fatalf("identity = %q, want bot", got.Identity)
}
if got.Verified == nil || !*got.Verified {
t.Fatalf("verified = %v, want true", got.Verified)
}
if got.Identities.Bot.Verified == nil || !*got.Identities.Bot.Verified {
t.Fatalf("bot verified = %v, want true", got.Identities.Bot.Verified)
}
if got.Identities.Bot.OpenID != "ou_bot" {
t.Fatalf("bot open id = %q, want ou_bot", got.Identities.Bot.OpenID)
}
if got.Identities.User.Status != "missing" {
t.Fatalf("user status = %q, want missing", got.Identities.User.Status)
}
}
type statusOutput struct {
Identity string `json:"identity"`
Verified *bool `json:"verified"`
Identities struct {
Bot statusIdentity `json:"bot"`
User statusIdentity `json:"user"`
} `json:"identities"`
}
type statusIdentity struct {
Status string `json:"status"`
Available bool `json:"available"`
Verified *bool `json:"verified"`
OpenID string `json:"openId"`
}

View File

@@ -19,9 +19,7 @@ import (
cmdupdate "github.com/larksuite/cli/cmd/update"
_ "github.com/larksuite/cli/events"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
@@ -61,28 +59,18 @@ func HideProfile(hide bool) BuildOption {
}
}
// Build constructs the full command tree. It also installs registered
// plugins and emits the Startup lifecycle event during assembly --
// so Plugin.On(Startup) handlers run even if the returned command is
// never dispatched. The matching Shutdown event is only emitted by
// Execute; callers that bypass Execute will not see Shutdown fire.
//
// Returns only the cobra.Command; Factory and hook Registry are internal.
// Build constructs the full command tree without executing.
// Returns only the cobra.Command; Factory is internal.
// Use Execute for the standard production entry point.
func Build(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) *cobra.Command {
_, rootCmd, _ := buildInternal(ctx, inv, opts...)
_, rootCmd := buildInternal(ctx, inv, opts...)
return rootCmd
}
// buildInternal is a pure assembly function: it wires the command tree from
// inv and BuildOptions alone. Any state-dependent decision (disk, network,
// env) belongs in the caller and must be threaded in via BuildOption.
//
// Returns (factory, rootCmd, registry). The registry is nil when plugin
// install failed (FailClosed guard installed) or when no plugin produced
// hooks; callers that wire Shutdown emit must nil-check before calling
// hook.Emit.
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command, *hook.Registry) {
func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...BuildOption) (*cmdutil.Factory, *cobra.Command) {
// cfg.globals.Profile is left zero here; it's bound to the --profile
// flag in RegisterGlobalFlags and filled by cobra's parse step.
cfg := &buildConfig{}
@@ -136,42 +124,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B
service.RegisterServiceCommandsWithContext(ctx, rootCmd, f)
shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f)
installUnknownSubcommandGuard(rootCmd)
// Prune commands incompatible with strict mode.
if mode := f.ResolveStrictMode(ctx); mode.IsActive() {
pruneForStrictMode(rootCmd, mode)
}
installResult, installErr := installPluginsAndHooks(cfg.streams.ErrOut)
if installErr != nil {
installPluginInstallErrorGuard(rootCmd, installErr)
return f, rootCmd, nil
}
var pluginRules []cmdpolicy.PluginRule
var registry *hook.Registry
if installResult != nil {
pluginRules = installResult.PluginRules
registry = installResult.Registry
}
// Policy errors fail-CLOSED when a plugin contributed (security
// intent must not be silently dropped); yaml-only errors fail-OPEN
// with a warning so a typo can't lock the user out.
if err := applyUserPolicyPruning(rootCmd, pluginRules); err != nil {
if len(pluginRules) > 0 {
installPluginConflictGuard(rootCmd, err)
return f, rootCmd, nil
}
warnPolicyError(cfg.streams.ErrOut, err)
}
if registry != nil {
if err := wireHooks(ctx, rootCmd, registry); err != nil {
installPluginLifecycleErrorGuard(rootCmd, err)
return f, rootCmd, nil
}
}
recordInventory(installResult)
return f, rootCmd, registry
return f, rootCmd
}

View File

@@ -37,6 +37,5 @@ func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
},
}
cmdutil.DisableAuthCheck(cmd)
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -103,7 +103,6 @@ Interactive terminal use: run with no flags to enter the TUI form.`,
cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)")
cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh|en)")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -13,45 +13,30 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// assertExitError checks the full structured error in one assertion. It
// accepts both *output.ExitError (used by output.ErrWithHint) and the
// typed validation error — they normalize to the same wantDetail fields.
// assertExitError checks the full structured error in one assertion.
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
t.Helper()
if err == nil {
t.Fatal("expected error, got nil")
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
return
if !errors.As(err, &exitErr) {
t.Fatalf("error type = %T, want *output.ExitError; error = %v", err, err)
}
var ve *errs.ValidationError
if errors.As(err, &ve) {
if got := output.ExitCodeOf(err); got != wantCode {
t.Errorf("exit code = %d, want %d", got, wantCode)
}
gotDetail := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
if !reflect.DeepEqual(gotDetail, wantDetail) {
t.Errorf("validation error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
}
return
if exitErr.Code != wantCode {
t.Errorf("exit code = %d, want %d", exitErr.Code, wantCode)
}
if exitErr.Detail == nil {
t.Fatal("expected non-nil error detail")
}
if !reflect.DeepEqual(*exitErr.Detail, wantDetail) {
t.Errorf("error detail mismatch:\n got: %+v\n want: %+v", *exitErr.Detail, wantDetail)
}
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
}
// assertEnvelope decodes stdout and checks it matches want exactly — every key
@@ -423,26 +408,6 @@ func TestConfigBindRun_LarkChannel_Success(t *testing.T) {
}
}
// Env template form: secret = "${VAR}" should resolve via the SecretInput
// pipeline (same path openclaw uses), so the keychain receives the env value
// not the literal template string.
func TestConfigBindRun_LarkChannel_EnvTemplate(t *testing.T) {
saveWorkspace(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
clearAgentEnv(t)
fakeHome := t.TempDir()
t.Setenv("HOME", fakeHome)
t.Setenv("LARK_APP_SECRET", "resolved_via_env")
writeLarkChannelFixture(t, fakeHome,
`{"accounts":{"app":{"id":"cli_lc_env","secret":"${LARK_APP_SECRET}","tenant":"feishu"}}}`)
f, _, _, _ := cmdutil.TestFactory(t, nil)
if err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"}); err != nil {
t.Fatalf("expected success, got error: %v", err)
}
}
// tenant: "lark" should land as Brand("lark"), not normalized to "feishu".
func TestConfigBindRun_LarkChannel_LarkTenant(t *testing.T) {
saveWorkspace(t)
@@ -610,10 +575,8 @@ func TestConfigShowRun_AgentWorkspaceNotBound(t *testing.T) {
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3); the workspace is detected but no
// binding exists yet, which is a config error.
if cfgErr.Code != output.ExitAuth {
t.Errorf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if cfgErr.Code != output.ExitValidation {
t.Errorf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if cfgErr.Type != "openclaw" {
t.Errorf("type = %q, want %q", cfgErr.Type, "openclaw")

View File

@@ -312,22 +312,13 @@ func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"internal: appID %q does not match config", appID)
}
if b.cfg.Accounts.App.Secret.IsZero() {
if b.cfg.Accounts.App.Secret == "" {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
"run lark-channel-bridge's setup to populate the app credential")
}
// Resolve through the same SecretInput pipeline openclaw uses, so
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
if err != nil {
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
fmt.Sprintf("check appSecret configuration in %s", b.path))
}
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
stored, err := core.ForStorage(appID, core.PlainSecret(b.cfg.Accounts.App.Secret), b.opts.Factory.Keychain)
if err != nil {
return nil, output.Errorf(output.ExitInternal, "lark-channel",
"keychain unavailable: %v", err)

View File

@@ -31,8 +31,6 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
return cmd
}

View File

@@ -95,9 +95,8 @@ func TestConfigShowRun_NotConfiguredReturnsStructuredError(t *testing.T) {
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
// Config errors share ExitAuth (3), not ExitValidation.
if cfgErr.Code != output.ExitAuth {
t.Fatalf("exit code = %d, want %d (config category → ExitAuth)", cfgErr.Code, output.ExitAuth)
if cfgErr.Code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d", cfgErr.Code, output.ExitValidation)
}
if cfgErr.Type != "config" || cfgErr.Message != "not configured" {
t.Fatalf("detail = %+v, want config/not configured", cfgErr)

View File

@@ -52,6 +52,5 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
return nil
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -80,7 +80,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
cmdutil.SetRisk(cmd, "write")
return cmd
}
@@ -352,7 +351,6 @@ func configInitRun(opts *ConfigInitOptions) error {
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return err

View File

@@ -1,101 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// NewCmdConfigPlugins exposes the plugin inventory diagnostic command.
//
// `config policy show` is intentionally focused on the user-layer Rule
// (Restrict). Plugins also contribute hooks (Observe / Wrap / Lifecycle)
// that are not policy gates but still mutate the CLI's runtime behaviour.
// This command surfaces both halves so an operator can answer "what is
// this binary doing differently from stock lark-cli?" in one place.
//
// Like config policy show, the dispatch path is exempt from policy
// enforcement (see internal/cmdpolicy/diagnostic.go) so it remains
// usable under any Rule.
func NewCmdConfigPlugins(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "plugins",
Hidden: true, // diagnostic-only; kept callable, omitted from --help so it stays out of AI-agent context
Short: "Inspect installed plugins and their hook contributions",
// Same leaf-level no-op as config policy: the parent `config`
// group's PersistentPreRunE requires builtin credential, but
// this is a read-only diagnostic that must work everywhere.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPluginsShow(f))
return cmd
}
func newCmdConfigPluginsShow(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Short: "List successfully installed plugins, their rules, and registered hooks",
Long: `Print every plugin that committed during bootstrap, including:
- name / version / capabilities (FailurePolicy, Restricts, RequiredCLIVersion)
- rule (when the plugin called r.Restrict)
- hooks: observers (Before / After), wrappers, lifecycle handlers
Hooks are attributed by their namespaced name -- the framework prepends
the plugin name as the prefix at registration time, so an entry
"secaudit.audit-pre" belongs to plugin "secaudit".`,
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigPluginsShow(f)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runConfigPluginsShow(f *cmdutil.Factory) error {
inv := internalplatform.GetActiveInventory()
if inv == nil {
// Always emit the same field set as the populated branch so
// AI agents and CI scripts don't have to branch on whether
// `total` is present. `note` makes the unusual state explicit
// for human readers.
output.PrintJson(f.IOStreams.Out, map[string]any{
"plugins": []any{},
"total": 0,
"note": "no inventory recorded; bootstrap did not finish",
})
return nil
}
plugins := make([]map[string]any, 0, len(inv.Plugins))
for _, p := range inv.Plugins {
entry := map[string]any{
"name": p.Name,
"version": p.Version,
"capabilities": p.Capabilities,
}
if p.Rule != nil {
entry["rule"] = p.Rule
}
entry["hooks"] = map[string]any{
"observers": p.Observers,
"wrappers": p.Wrappers,
"lifecycle": p.Lifecycles,
"count": len(p.Observers) + len(p.Wrappers) + len(p.Lifecycles),
}
plugins = append(plugins, entry)
}
output.PrintJson(f.IOStreams.Out, map[string]any{
"plugins": plugins,
"total": len(plugins),
})
return nil
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
func NewCmdConfigPolicy(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "policy",
Hidden: true,
Short: "Inspect the user-layer command policy",
// Override parent's RequireBuiltinCredentialProvider check; this
// group is read-only diagnostic and must work under any provider.
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
}
cmd.AddCommand(newCmdConfigPolicyShow(f))
return cmd
}
func newCmdConfigPolicyShow(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "show",
Hidden: true,
Short: "Show the active user-layer policy (plugin / yaml / none)",
RunE: func(cmd *cobra.Command, args []string) error {
return runConfigPolicyShow(f)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}
func runConfigPolicyShow(f *cmdutil.Factory) error {
active := cmdpolicy.GetActive()
if active == nil {
output.PrintJson(f.IOStreams.Out, map[string]any{
"source": string(cmdpolicy.SourceNone),
"note": "no policy recorded; bootstrap did not run pruning",
})
return nil
}
sourceName := ""
if active.Source.Kind == cmdpolicy.SourcePlugin {
sourceName = active.Source.Name
}
out := map[string]any{
"source": string(active.Source.Kind),
"source_name": sourceName,
"denied_paths": active.DeniedPaths,
}
if active.Rule != nil {
out["rule"] = map[string]any{
"name": active.Rule.Name,
"description": active.Rule.Description,
"allow": active.Rule.Allow,
"deny": active.Rule.Deny,
"max_risk": active.Rule.MaxRisk,
"identities": active.Rule.Identities,
"allow_unannotated": active.Rule.AllowUnannotated,
}
}
output.PrintJson(f.IOStreams.Out, out)
return nil
}

View File

@@ -1,146 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bytes"
"encoding/json"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
)
func newPolicyTestFactory() (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
f := &cmdutil.Factory{
IOStreams: cmdutil.NewIOStreams(nil, out, errOut),
}
return f, out, errOut
}
// `config policy show` reads the active policy recorded by bootstrap.
// When nothing is recorded the command must still produce a JSON
// envelope with source=none and a note explaining the missing context.
func TestConfigPolicyShow_NoActivePolicy(t *testing.T) {
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if got["source"] != "none" {
t.Errorf("source = %v, want none", got["source"])
}
if got["note"] == "" || got["note"] == nil {
t.Errorf("expected explanatory note when no policy recorded")
}
}
// When bootstrap recorded an active plugin Rule, `show` emits the rule
// plus its source.
func TestConfigPolicyShow_PluginActive(t *testing.T) {
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
rule := &platform.Rule{
Name: "secaudit",
Allow: []string{"docs/**"},
MaxRisk: "read",
}
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: rule,
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourcePlugin,
Name: "secaudit",
},
DeniedPaths: 42,
})
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if got["source"] != "plugin" {
t.Errorf("source = %v, want plugin", got["source"])
}
if got["source_name"] != "secaudit" {
t.Errorf("source_name = %v, want secaudit", got["source_name"])
}
// json.Unmarshal returns float64 for numbers.
if got["denied_paths"] != float64(42) {
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
}
ruleMap, ok := got["rule"].(map[string]any)
if !ok {
t.Fatalf("rule field missing or wrong type")
}
if ruleMap["name"] != "secaudit" {
t.Errorf("rule.name = %v", ruleMap["name"])
}
}
// `source_name` must be empty when source=yaml. The yaml path is
// deliberately not surfaced (matches engine envelope convention,
// avoids leaking the user's home dir to AI agents / CI logs). The
// rule's "name:" field is the disambiguator users should rely on.
func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: &platform.Rule{Name: "my-yaml-rule"},
Source: cmdpolicy.ResolveSource{
Kind: cmdpolicy.SourceYAML,
Name: "/Users/alice/.lark-cli/policy.yml",
},
})
f, out, _ := newPolicyTestFactory()
if err := runConfigPolicyShow(f); err != nil {
t.Fatalf("show: %v", err)
}
var got map[string]any
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
t.Fatalf("not json: %v\n%s", err, out.String())
}
if got["source"] != "yaml" {
t.Errorf("source = %v, want yaml", got["source"])
}
if got["source_name"] != "" {
t.Errorf("source_name = %q, want empty (yaml path must not leak)", got["source_name"])
}
// The path must not appear anywhere in the envelope.
if bytes.Contains(out.Bytes(), []byte("/Users/alice")) {
t.Errorf("envelope leaked yaml path: %s", out.String())
}
}
// Regression: the parent `config` command declares a PersistentPreRunE
// that calls RequireBuiltinCredentialProvider; env credentials cause
// it to return external_provider. `config policy` is a diagnostic
// group that must not be blocked by that check. The group declares
// its own no-op PersistentPreRunE so cobra's "first walking up from
// leaf" picks ours over the config parent's.
func TestConfigPolicy_BypassesConfigParentPersistentPreRunE(t *testing.T) {
f, _, _ := newPolicyTestFactory()
group := NewCmdConfigPolicy(f)
if group.PersistentPreRunE == nil {
t.Fatal("config policy group must declare its own PersistentPreRunE to win over config parent")
}
if err := group.PersistentPreRunE(group, nil); err != nil {
t.Errorf("config policy PersistentPreRunE should be no-op, got %v", err)
}
}

View File

@@ -32,7 +32,6 @@ func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) erro
return configRemoveRun(opts)
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -34,7 +34,6 @@ func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *
return configShowRun(opts)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -66,7 +66,6 @@ explicit user confirmation — never run on your own initiative.`,
cmd.Flags().BoolVar(&global, "global", false, "set at global level (applies to all profiles)")
cmd.Flags().BoolVar(&reset, "reset", false, "reset profile setting to inherit global")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -97,7 +97,7 @@ func diagBuild(domains []string) diagOutput {
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
continue
}
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
for _, scope := range sc.ScopesForIdentity(identity) {
k := methodKey{domain, "shortcut", sc.Command, scope}
if e, ok := merged[k]; ok {
e.Identity = appendUniq(e.Identity, identity)
@@ -169,25 +169,6 @@ func appendUniq(ss []string, s string) []string {
return append(ss, s)
}
func TestDiagBuild_ShortcutIncludesConditionalScopes(t *testing.T) {
out := diagBuild([]string{"drive"})
var sawMetadata, sawDownload bool
for _, method := range out.Methods {
if method.Domain != "drive" || method.Type != "shortcut" || method.Method != "+status" {
continue
}
if method.Scope == "drive:drive.metadata:readonly" {
sawMetadata = true
}
if method.Scope == "drive:file:download" {
sawDownload = true
}
}
if !sawMetadata || !sawDownload {
t.Fatalf("drive +status should advertise both metadata and conditional download scopes, saw metadata=%v download=%v", sawMetadata, sawDownload)
}
}
// ── Snapshot generation ───────────────────────────────────────────────
//
// Generates a JSON snapshot of all API methods and shortcuts with their

View File

@@ -14,10 +14,10 @@ import (
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/identitydiag"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/update"
)
@@ -43,7 +43,6 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
}
cmdutil.DisableAuthCheck(cmd)
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
cmdutil.SetRisk(cmd, "read")
return cmd
}
@@ -51,7 +50,7 @@ func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
// checkResult represents one diagnostic check.
type checkResult struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "warn", "fail", "skip"
Status string `json:"status"` // "pass", "fail", "skip"
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
@@ -118,31 +117,59 @@ func doctorRun(opts *DoctorOptions) error {
ep := core.ResolveEndpoints(cfg.Brand)
// ── 3. Identity readiness ──
diagnostics := identitydiag.Diagnose(opts.Ctx, f, cfg, !opts.Offline)
checks = append(checks,
identityCheck("bot_identity", diagnostics.Bot),
identityCheck("user_identity", diagnostics.User),
)
if diagnostics.Bot.Available || diagnostics.User.Available {
checks = append(checks, pass("identity_ready", "at least one identity is available"))
} else {
checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify"))
// ── 3. Token exists ──
if cfg.UserOpenId == "" {
checks = append(checks, fail("token_exists", "no user logged in", "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
stored := larkauth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
if stored == nil {
checks = append(checks, fail("token_exists", "no token in keychain for "+cfg.UserOpenId, "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
checks = append(checks, pass("token_exists", fmt.Sprintf("token found for %s (%s)", cfg.UserName, cfg.UserOpenId)))
// ── 4. Token local validity ──
status := larkauth.TokenStatus(stored)
switch status {
case "valid":
checks = append(checks, pass("token_local", "token valid, expires "+time.UnixMilli(stored.ExpiresAt).Format(time.RFC3339)))
case "needs_refresh":
checks = append(checks, pass("token_local", "token needs refresh (will auto-refresh on next call)"))
default: // expired
checks = append(checks, fail("token_local", "token expired", "run: lark-cli auth login --help"))
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
// ── 4 & 5. Endpoint reachability ──
// ── 5. Token server verification ──
if opts.Offline {
checks = append(checks, skip("token_verified", "skipped (--offline)"))
} else {
httpClient := mustHTTPClient(f)
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(cfg, f.IOStreams.ErrOut))
if err != nil {
checks = append(checks, fail("token_verified", "cannot obtain valid token: "+err.Error(), "run: lark-cli auth login --help"))
} else {
sdk, err := f.LarkClient()
if err != nil {
checks = append(checks, fail("token_verified", "SDK init failed: "+err.Error(), ""))
} else if err := larkauth.VerifyUserToken(opts.Ctx, sdk, token); err != nil {
checks = append(checks, fail("token_verified", "server rejected token: "+err.Error(), "run: lark-cli auth login --help"))
} else {
checks = append(checks, pass("token_verified", "server confirmed token is valid"))
}
}
}
// ── 6 & 7. Endpoint reachability ──
checks = append(checks, networkChecks(opts.Ctx, opts, ep)...)
return finishDoctor(f, checks)
}
func identityCheck(name string, id identitydiag.Identity) checkResult {
if id.Available {
return pass(name, id.Message)
}
return warn(name, id.Message, id.Hint)
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
@@ -204,6 +231,15 @@ func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
return nil
}
// mustHTTPClient returns f.HttpClient() or a default client.
func mustHTTPClient(f *cmdutil.Factory) *http.Client {
c, err := f.HttpClient()
if err != nil {
return &http.Client{Timeout: 30 * time.Second}
}
return c
}
// checkCLIUpdate actively queries the npm registry for the latest version.
// Unlike the root-level async check, this does a synchronous fetch with timeout
// and works regardless of build version (dev builds included).
@@ -216,7 +252,7 @@ func checkCLIUpdate() []checkResult {
if update.IsNewer(latest, current) {
return []checkResult{warn("cli_update",
fmt.Sprintf("%s → %s available", current, latest),
"run: lark-cli update")}
"run: lark-cli update (or: npm install -g @larksuite/cli)")}
}
return []checkResult{pass("cli_update", latest+" (up to date)")}
}

View File

@@ -95,59 +95,3 @@ func TestNetworkChecks_Offline(t *testing.T) {
}
}
}
func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{
CurrentApp: "default",
Apps: []core.AppConfig{
{
Name: "default",
AppId: "test-app",
AppSecret: core.PlainSecret("secret"),
Brand: core.BrandFeishu,
},
},
}); err != nil {
t.Fatalf("SaveMultiAppConfig() error = %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "secret", Brand: core.BrandFeishu,
})
err := doctorRun(&DoctorOptions{
Factory: f,
Ctx: context.Background(),
Offline: true,
})
if err != nil {
t.Fatalf("doctorRun() error = %v", err)
}
var got struct {
OK bool `json:"ok"`
Checks []checkResult `json:"checks"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if !got.OK {
t.Fatalf("ok = false, want true; checks = %#v", got.Checks)
}
assertCheck(t, got.Checks, "bot_identity", "pass")
assertCheck(t, got.Checks, "user_identity", "warn")
assertCheck(t, got.Checks, "identity_ready", "pass")
}
func assertCheck(t *testing.T, checks []checkResult, name, status string) {
t.Helper()
for _, check := range checks {
if check.Name == name {
if check.Status != status {
t.Fatalf("%s status = %q, want %q", name, check.Status, status)
}
return
}
}
t.Fatalf("check %q not found in %#v", name, checks)
}

View File

@@ -4,13 +4,9 @@
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
@@ -18,49 +14,12 @@ import (
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
// "current command requires scope(s): X, Y" hint when the underlying error is
// a need_user_authorization signal AND the current command declares scopes
// locally (via shortcut registration or service-method metadata).
//
// Stage-1: this typed path is dormant — no production code returns a typed
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
// in without re-architecting. The active stage-1 path is
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
if err == nil || f == nil {
return
}
if !internalauth.IsNeedUserAuthorizationError(err) {
return
}
var authErr *errs.AuthenticationError
if !errors.As(err, &authErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if authErr.Hint == "" {
authErr.Hint = scopeHint
return
}
authErr.Hint += "\n" + scopeHint
}
// enrichMissingScopeError appends a "current command requires scope(s): X"
// hint to a legacy *output.ExitError when the underlying error carries the
// need_user_authorization marker AND the current command declares scopes
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
// envelope path until per-domain stage-2 typed migration.
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
// enrichMissingScopeError preserves the original need_user_authorization
// message and appends a scope hint when the current command declares the
// required scopes locally.
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr == nil || exitErr.Detail == nil {
return
@@ -68,10 +27,12 @@ func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
if !internalauth.IsNeedUserAuthorizationError(exitErr) {
return
}
scopes := resolveDeclaredScopesForCurrentCommand(f)
if len(scopes) == 0 {
return
}
scopeHint := fmt.Sprintf("current command requires scope(s): %s", strings.Join(scopes, ", "))
if exitErr.Detail.Hint == "" {
exitErr.Detail.Hint = scopeHint
@@ -114,7 +75,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
continue
}
scopes := sc.DeclaredScopesForIdentity(identity)
scopes := sc.ScopesForIdentity(identity)
if len(scopes) == 0 {
return nil
}

View File

@@ -64,7 +64,6 @@ func NewCmdBus(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&domain, "domain", "", "API domain")
_ = cmd.Flags().MarkHidden("domain")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -70,7 +70,6 @@ Use 'event schema <EventKey>' for parameter details.`,
_ = cmd.RegisterFlagCompletionFunc("as", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot", "auto"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -26,7 +26,6 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the full EventKey list as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -42,7 +42,7 @@ func (r *consumeRuntime) CallAPI(ctx context.Context, method, path string, body
if err != nil {
return nil, err
}
if apiErr := r.client.CheckResponse(result, r.accessIdentity); apiErr != nil {
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
return json.RawMessage(resp.RawBody), apiErr
}
return json.RawMessage(resp.RawBody), nil

View File

@@ -88,7 +88,6 @@ func NewCmdSchema(f *cmdutil.Factory) *cobra.Command {
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit the EventKey definition + resolved schema as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -37,7 +37,6 @@ func NewCmdStatus(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().BoolVar(&asJSON, "json", false, "Emit status as JSON (for AI / scripts)")
cmd.Flags().BoolVar(&current, "current", false, "Only show status for the current profile's app")
cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "Exit 2 when any orphan bus is detected (default: always exit 0)")
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -70,7 +70,6 @@ Exit code: 2 if any target was refused or errored, 0 otherwise.
cmd.Flags().BoolVar(&o.all, "all", false, "Stop all running bus daemons")
cmd.Flags().BoolVar(&o.force, "force", false, "Stop even with active consumers; on shutdown-timeout also SIGKILL the bus")
cmd.Flags().BoolVar(&o.asJSON, "json", false, "Emit results as JSON (for AI / scripts)")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -78,7 +78,7 @@ func TestIsSingleAppMode_MultiApp(t *testing.T) {
}
func TestBuildInternal_HideProfileOption(t *testing.T) {
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams(), HideProfile(true))
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {
@@ -90,7 +90,7 @@ func TestBuildInternal_HideProfileOption(t *testing.T) {
}
func TestBuildInternal_DefaultShowsProfileFlag(t *testing.T) {
_, root, _ := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
_, root := buildInternal(context.Background(), cmdutil.InvocationContext{}, testStreams())
flag := root.PersistentFlags().Lookup("profile")
if flag == nil {

View File

@@ -1,274 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
internalplatform "github.com/larksuite/cli/internal/platform"
"github.com/larksuite/cli/internal/vfs"
)
// userPolicyFileName is the conventional filename for the user-layer Rule.
// Lives under ~/.lark-cli/ to match the rest of the CLI's user-state
// directory.
const userPolicyFileName = "policy.yml"
// applyUserPolicyPruning resolves the user-layer Rule from plugin
// contributions and/or ~/.lark-cli/policy.yml and installs denyStubs
// for commands it rejects.
//
// Missing yaml is not an error -- the CLI runs with no user-layer
// restriction. A malformed Rule (bad MaxRisk enum, malformed glob, etc.)
// surfaces via the returned error; the caller decides how to handle it.
//
// pluginRules carries Plugin.Restrict() contributions collected from
// the InstallAll phase; nil/empty is fine.
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
yamlPath, err := userPolicyPath()
if err != nil {
// No user home dir means we cannot locate the policy. Treat
// the same as "file missing": no pruning, no error. This keeps
// non-interactive CI environments (no HOME set) running.
yamlPath = ""
}
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
if err != nil {
// Yaml-only failures are fail-OPEN at the caller (warn and
// continue), but the active-policy snapshot is process-global
// and may still carry data from a previous build in long-lived
// embedders / tests. Clear it explicitly so `config policy
// show` reports "no policy" instead of a stale rule that
// doesn't reflect the current command tree.
cmdpolicy.SetActive(nil)
return err
}
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
PluginRules: pluginRules,
YAMLRule: yamlRule,
YAMLPath: yamlPath,
})
if err != nil {
cmdpolicy.SetActive(nil)
return err
}
if rule == nil {
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
return nil
}
engine := cmdpolicy.New(rule)
decisions := engine.EvaluateAll(rootCmd)
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
cmdpolicy.Apply(rootCmd, denied)
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
Rule: rule,
Source: source,
DeniedPaths: len(denied),
})
return nil
}
// installPluginsAndHooks runs the InstallAll phase on the globally-
// registered plugins, returning the Plugin.Restrict contributions for
// cmdpolicy and the populated hook.Registry for the runtime wrapper.
// Errors from FailClosed plugins propagate; FailOpen failures are
// warned to errOut and the loop continues.
func installPluginsAndHooks(errOut io.Writer) (*internalplatform.InstallResult, error) {
plugins := platform.RegisteredPlugins()
if len(plugins) == 0 {
return &internalplatform.InstallResult{Registry: nil}, nil
}
return internalplatform.InstallAll(plugins, errOut)
}
// recordInventory builds and stores the plugin inventory snapshot for
// diagnostic commands (config plugins show) to read at runtime. Called
// once from build.go after applyUserPolicyPruning + wireHooks succeed.
func recordInventory(installResult *internalplatform.InstallResult) {
if installResult == nil {
internalplatform.SetActiveInventory(nil)
return
}
pluginSrcs := make([]internalplatform.PluginInventorySource, 0, len(installResult.Plugins))
for _, p := range installResult.Plugins {
pluginSrcs = append(pluginSrcs, internalplatform.PluginInventorySource{
Name: p.Name,
Version: p.Version,
Capabilities: p.Capabilities,
})
}
ruleSrcs := make([]internalplatform.RuleInventorySource, 0, len(installResult.PluginRules))
for _, r := range installResult.PluginRules {
if r.Rule == nil {
continue
}
idents := make([]string, len(r.Rule.Identities))
for i, id := range r.Rule.Identities {
idents[i] = string(id)
}
ruleSrcs = append(ruleSrcs, internalplatform.RuleInventorySource{
PluginName: r.PluginName,
Allow: r.Rule.Allow,
Deny: r.Rule.Deny,
MaxRisk: string(r.Rule.MaxRisk),
Identities: idents,
RuleName: r.Rule.Name,
Desc: r.Rule.Description,
AllowUnannotated: r.Rule.AllowUnannotated,
})
}
internalplatform.SetActiveInventory(internalplatform.BuildInventory(pluginSrcs, installResult.Registry, ruleSrcs))
}
// wireHooks installs Observer/Wrapper hooks onto every runnable command
// and emits the Startup lifecycle event. The registry may be nil when
// no plugin contributed any hook -- the function short-circuits in
// that case to avoid useless RunE wrapping.
func wireHooks(ctx context.Context, rootCmd *cobra.Command, reg *hook.Registry) error {
if reg == nil {
return nil
}
hook.Install(rootCmd, reg, cobraCommandViewSource{})
return hook.Emit(ctx, reg, platform.Startup, nil)
}
// cobraCommandViewSource is the default CommandViewSource: it returns a
// live view over the *cobra.Command. Strict-mode's Remove+Add stub
// (cmd/prune.go::strictModeStubFrom) explicitly forwards the original
// annotations + Short/Long so the live view keeps reporting Risk /
// Identities / Domain through the replacement. User-layer policy
// (cmdpolicy/apply.go::installDenyStub) mutates in place, preserving
// metadata trivially.
type cobraCommandViewSource struct{}
func (cobraCommandViewSource) View(cmd *cobra.Command) platform.CommandView {
return cobraCommandView{cmd: cmd}
}
// cobraCommandView adapts *cobra.Command to the CommandView interface.
type cobraCommandView struct {
cmd *cobra.Command
}
func (v cobraCommandView) Path() string {
return cmdpolicy.CanonicalPath(v.cmd)
}
func (v cobraCommandView) Domain() string {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if v, ok := c.Annotations["cmdmeta.domain"]; ok && v != "" {
return v
}
}
return ""
}
func (v cobraCommandView) Risk() (platform.Risk, bool) {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if r, ok := c.Annotations["risk_level"]; ok && r != "" {
return platform.Risk(r), true
}
}
return "", false
}
func (v cobraCommandView) Identities() []platform.Identity {
for c := v.cmd; c != nil; c = c.Parent() {
if c.Annotations == nil {
continue
}
if raw, ok := c.Annotations["lark:supportedIdentities"]; ok && raw != "" {
parts := splitCSV(raw)
out := make([]platform.Identity, len(parts))
for i, p := range parts {
out[i] = platform.Identity(p)
}
return out
}
}
return nil
}
func (v cobraCommandView) Annotation(key string) (string, bool) {
if v.cmd.Annotations == nil {
return "", false
}
s, ok := v.cmd.Annotations[key]
return s, ok
}
// splitCSV is a tiny csv-without-quotes helper. The
// lark:supportedIdentities annotation is always plain
// "user" / "bot" / "user,bot" without escaping.
func splitCSV(s string) []string {
out := []string{}
start := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
// userPolicyPath returns the path of <baseConfigDir>/policy.yml.
//
// The base directory honours LARKSUITE_CLI_CONFIG_DIR (via
// core.GetBaseConfigDir) so that test isolation, container deployments
// and per-Agent config overrides all see a consistent policy location.
// Using vfs.UserHomeDir directly here would silently bypass the env
// override and route every test through the real ~/.lark-cli.
//
// The error return is retained for caller compatibility but is always
// nil today: GetBaseConfigDir falls back to a relative ".lark-cli" when
// the home dir can't be resolved, and the resolver already treats a
// missing file as "no policy".
func userPolicyPath() (string, error) {
return filepath.Join(core.GetBaseConfigDir(), userPolicyFileName), nil
}
// warnPolicyError writes a one-line stderr warning when the user policy
// fails to load. V1 yaml errors are fail-OPEN -- the CLI keeps running
// without policy enforcement so the user can fix the typo. Plugin-supplied
// rules are fail-CLOSED instead because integrators take a code-level
// responsibility for them.
//
// Wrapped errors may carry the absolute policy path (os.PathError); fold
// the home prefix to "~" before emitting so stderr piped into agents /
// CI logs does not leak the user's home directory.
func warnPolicyError(errOut io.Writer, err error) {
if err == nil {
return
}
fmt.Fprintf(errOut, "warning: user policy not applied: %s\n", redactHome(err.Error()))
}
func redactHome(s string) string {
if home, err := vfs.UserHomeDir(); err == nil && home != "" {
s = strings.ReplaceAll(s, home, "~")
}
return s
}

View File

@@ -1,268 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"context"
"errors"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// tmpHome creates a tempdir, points $HOME at it, and returns the path to
// the ~/.lark-cli/ subdirectory (created). The HOME env var is restored
// when the test ends.
//
// LARKSUITE_CLI_CONFIG_DIR is force-set to the same path. Without that
// override, a developer running the tests with a personal
// LARKSUITE_CLI_CONFIG_DIR exported in their shell (or a CI runner with
// a baked-in value) would resolve userPolicyPath() to their real
// machine and bleed unrelated yaml into the test fixtures. With the
// override pinned here, the test is hermetic regardless of the host
// environment.
func tmpHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir) // Windows fallback for os.UserHomeDir
cfgDir := filepath.Join(dir, ".lark-cli")
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", cfgDir)
return cfgDir
}
// writePolicy writes a policy.yml into the user config dir.
func writePolicy(t *testing.T, cfgDir string, body string) {
t.Helper()
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"), []byte(body), 0o644); err != nil {
t.Fatalf("write policy: %v", err)
}
}
// fakeTree builds a minimal command tree with the same shape the real
// CLI exposes for these tests: lark-cli has a docs group with +fetch and
// +update, and an im group with +send. Each leaf has its risk_level set
// so MaxRisk filtering exercises a real path.
func fakeTree(t *testing.T) *cobra.Command {
t.Helper()
root := &cobra.Command{Use: "lark-cli"}
docs := &cobra.Command{Use: "docs"}
root.AddCommand(docs)
addLeaf(docs, "+fetch", "read")
addLeaf(docs, "+update", "write")
addLeaf(docs, "+delete-doc", "high-risk-write")
im := &cobra.Command{Use: "im"}
root.AddCommand(im)
addLeaf(im, "+send", "write")
return root
}
func addLeaf(parent *cobra.Command, use, risk string) {
leaf := &cobra.Command{
Use: use,
RunE: func(*cobra.Command, []string) error { return nil },
}
cmdutil.SetRisk(leaf, risk)
parent.AddCommand(leaf)
}
// findLeaf walks the tree by Use names.
func findLeaf(t *testing.T, parent *cobra.Command, names ...string) *cobra.Command {
t.Helper()
cur := parent
for _, n := range names {
var next *cobra.Command
for _, c := range cur.Commands() {
if c.Use == n {
next = c
break
}
}
if next == nil {
t.Fatalf("child %q not found under %q", n, cur.Use)
}
cur = next
}
return cur
}
// Happy path: a valid policy.yml denies one specific command. The denied
// command's RunE returns a typed ExitError envelope; allowed commands are
// untouched.
func TestApplyUserPolicyPruning_appliesValidPolicy(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, `
name: test-policy
allow: ["docs/**", "contact/**"]
deny: ["docs/+delete-doc"]
max_risk: write
`)
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("apply policy: %v", err)
}
// docs/+delete-doc must be denied (Deny match).
deleteCmd := findLeaf(t, root, "docs", "+delete-doc")
if !deleteCmd.Hidden {
t.Errorf("+delete-doc should be hidden after pruning")
}
err := deleteCmd.RunE(deleteCmd, nil)
if err == nil {
t.Fatalf("+delete-doc RunE should return an error")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "command_denied" {
t.Fatalf("expected command_denied ExitError, got %T %+v", err, err)
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok || detail["reason_code"] != "command_denylisted" {
t.Errorf("reason_code = %v, want command_denylisted", detail["reason_code"])
}
// im/+send must be denied (domain not in Allow).
send := findLeaf(t, root, "im", "+send")
if !send.Hidden {
t.Errorf("im/+send should be hidden (not in Allow)")
}
// docs/+update must stay alive (domain matches, risk within max).
update := findLeaf(t, root, "docs", "+update")
if update.Hidden {
t.Errorf("docs/+update should remain visible")
}
if err := update.RunE(update, nil); err != nil {
t.Errorf("docs/+update RunE should succeed, got %v", err)
}
}
// Missing file means no pruning -- the CLI runs unrestricted with the
// full command surface. This is the default case for users who haven't
// opted into pruning.
func TestApplyUserPolicyPruning_missingFileIsSilent(t *testing.T) {
tmpHome(t) // home set but no policy.yml written
root := fakeTree(t)
if err := applyUserPolicyPruning(root, nil); err != nil {
t.Fatalf("missing policy should not error, got %v", err)
}
// Every leaf must remain non-Hidden.
for _, sub := range []string{"+fetch", "+update", "+delete-doc"} {
cmd := findLeaf(t, root, "docs", sub)
if cmd.Hidden {
t.Errorf("%s should not be Hidden when no policy file exists", sub)
}
}
}
// Invalid yaml content (parse error) surfaces as an error from the
// wiring. The build path then decides whether to fail-open or
// fail-closed; the wiring itself stays neutral.
func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, "::: not yaml :::")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("malformed yaml should produce an error")
}
}
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
// Resolve and produces an error. This is the safety contract: a typo in
// the rule must not silently lower the pruning bar.
func TestApplyUserPolicyPruning_invalidRuleReturnsError(t *testing.T) {
cfgDir := tmpHome(t)
writePolicy(t, cfgDir, "max_risk: nukem\n")
root := fakeTree(t)
err := applyUserPolicyPruning(root, nil)
if err == nil {
t.Fatalf("invalid MaxRisk should produce an error")
}
}
// warnPolicyError emits to the supplied writer when err is non-nil and
// stays silent for nil. Verifies the build.go fail-open behaviour can be
// observed by users.
func TestWarnPolicyError(t *testing.T) {
var buf bytes.Buffer
warnPolicyError(&buf, nil)
if buf.Len() != 0 {
t.Fatalf("warnPolicyError with nil err should write nothing, got %q", buf.String())
}
buf.Reset()
warnPolicyError(&buf, errors.New("boom"))
if buf.String() != "warning: user policy not applied: boom\n" {
t.Fatalf("warnPolicyError output = %q", buf.String())
}
}
// End-to-end through buildInternal: when a valid policy.yml exists in
// HOME, building the real command tree applies pruning to it. This is
// the "actually integrated" test -- it exercises the wiring point in
// build.go itself, not just the helper.
func TestBuildInternal_appliesPolicyToRealTree(t *testing.T) {
cfgDir := tmpHome(t)
// Deny one specific shortcut path that we know exists in the real
// service tree -- we cannot enumerate it from a unit test, so we
// use an Allow-list that matches nothing to deny everything except
// the root, and then verify ANY non-root command was hidden.
writePolicy(t, cfgDir, `
name: deny-everything
deny: ["**"]
`)
root := Build(context.Background(), buildInvocationForTest(t))
// Find any leaf and verify it was hidden.
var foundHidden bool
walk(root, func(c *cobra.Command) {
if c.HasParent() && c.Runnable() && c.Hidden {
foundHidden = true
}
})
if !foundHidden {
t.Fatalf("expected at least one runnable command to be Hidden after deny=** policy")
}
// Root itself must stay alive.
if root.Hidden {
t.Errorf("root command must not be Hidden even under deny-everything policy")
}
}
func walk(cmd *cobra.Command, fn func(*cobra.Command)) {
if cmd == nil {
return
}
fn(cmd)
for _, c := range cmd.Commands() {
walk(c, fn)
}
}
// buildInvocationForTest returns a minimal cmdutil.InvocationContext so
// build.go's pure-assembly path can construct a tree without touching
// real config / credentials. Profile name is the empty default.
func buildInvocationForTest(t *testing.T) cmdutil.InvocationContext {
t.Helper()
return cmdutil.InvocationContext{}
}

View File

@@ -1,279 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"errors"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// installFatalGuard wires a fail-closed guard at every cobra dispatch
// path on rootCmd. Used by the three abort-side fatal paths:
//
// - FailClosed plugin install failure (installPluginInstallErrorGuard)
// - Plugin Restrict conflict (installPluginConflictGuard)
// - Startup lifecycle handler failure (installPluginLifecycleErrorGuard)
//
// **Why we walk the tree rather than set PersistentPreRunE on root**:
// cobra's PersistentPreRunE has "first PersistentPreRunE wins"
// semantics -- the lookup starts at the invoked command and walks UP,
// stopping at the first non-nil PersistentPreRunE. Subcommands that
// declare their own PersistentPreRunE (cmd/auth/auth.go and
// cmd/config/config.go both do) would shadow root's, letting a
// fail-closed condition silently bypass via `lark-cli auth foo`.
//
// The fix: replace the RunE of every runnable command with one that
// returns makeErr(). Subcommands cannot bypass because the dispatch
// lands directly on their RunE, which now carries the guard.
//
// makeErr is called for every guarded dispatch; it must return a fresh
// *output.ExitError each time (the envelope writer mutates a few fields
// as it serialises).
// Deprecated: installFatalGuard accepts a *output.ExitError-producing lambda,
// which is part of the legacy error surface that predates the typed error
// contract introduced by errs/. New code MUST NOT add new callers — the
// platform-extension fatal-guard plumbing will switch to typed errs.* errors
// when the platform-extension framework migrates. This wrapper is retained
// only for the existing in-tree call sites; it will be removed once they
// have moved to the typed surface.
func installFatalGuard(rootCmd *cobra.Command, makeErr func() *output.ExitError) {
// Two cobra subcommands are injected lazily at Execute() time and
// would otherwise slip past walkGuard. We pre-register both so
// walkGuard catches them.
//
// - "completion" (user-visible): InitDefaultCompletionCmd
// - "__complete" (internal shell-completion RPC): no public
// constructor; we add our own stub with the same name. cobra's
// internal initCompleteCmd checks for an existing "__complete"
// and skips registration if found, so our stub stays in place.
// (Cobra dispatches the "__completeNoDesc" alias through the
// same RunE, so guarding "__complete" covers both.)
rootCmd.InitDefaultCompletionCmd()
alreadyPresent := false
for _, c := range rootCmd.Commands() {
if c.Name() == "__complete" {
alreadyPresent = true
break
}
}
if !alreadyPresent {
rootCmd.AddCommand(&cobra.Command{
Use: "__complete",
Hidden: true,
RunE: func(*cobra.Command, []string) error { return makeErr() },
})
}
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
cmd.SilenceUsage = true
return makeErr()
}
rootCmd.PersistentPreRun = nil
walkGuard(rootCmd, makeErr)
}
// installPluginInstallErrorGuard surfaces a FailClosed plugin install
// failure as a structured plugin_install envelope before any command
// runs.
// Deprecated: installPluginInstallErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin install failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginInstallErrorGuard(rootCmd *cobra.Command, installErr error) {
makeErr := func() *output.ExitError {
var pi *internalplatform.PluginInstallError
if errors.As(installErr, &pi) {
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: pi.Error(),
Detail: map[string]any{
"plugin": pi.PluginName,
"reason_code": pi.ReasonCode,
"reason": pi.Reason,
},
},
Err: installErr,
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_install",
Message: installErr.Error(),
Detail: map[string]any{
"reason_code": internalplatform.ReasonInstallFailed,
},
},
Err: installErr,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginConflictGuard surfaces a Plugin.Restrict() configuration
// error (single plugin invalid Rule or multiple plugins each contributing
// Restrict). The design separates the envelope type:
//
// - "plugin_install" with reason_code "invalid_rule" - single bad rule
// - "plugin_conflict" with reason_code "multiple_restrict_plugins" - multi
//
// Either way the CLI must NOT silently continue with a broken policy.
// Deprecated: installPluginConflictGuard produces a legacy *output.ExitError
// via its internal makeErr lambda. New code MUST NOT add such producers —
// plugin conflict failures should surface as a typed *errs.XxxError once the
// platform-extension framework migrates. This helper is retained only while
// existing call sites are migrated; it will be removed once they have moved
// to the typed surface.
func installPluginConflictGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
envelopeType := "plugin_install"
reasonCode := internalplatform.ReasonInvalidRule
if errors.Is(err, cmdpolicy.ErrMultipleRestricts) {
envelopeType = "plugin_conflict"
reasonCode = internalplatform.ReasonMultipleRestricts
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: envelopeType,
Message: err.Error(),
Detail: map[string]any{
"reason_code": reasonCode,
},
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// installPluginLifecycleErrorGuard surfaces a Startup lifecycle handler
// failure as a plugin_lifecycle envelope. The reason_code splits
// returned-error vs panic so consumers (audit / on-call) can tell the
// two failure modes apart.
// Deprecated: installPluginLifecycleErrorGuard produces a legacy
// *output.ExitError via its internal makeErr lambda. New code MUST NOT add
// such producers — plugin lifecycle failures should surface as a typed
// *errs.XxxError once the platform-extension framework migrates. This
// helper is retained only while existing call sites are migrated; it will
// be removed once they have moved to the typed surface.
func installPluginLifecycleErrorGuard(rootCmd *cobra.Command, err error) {
makeErr := func() *output.ExitError {
reasonCode := "lifecycle_failed"
detail := map[string]any{
"reason_code": reasonCode,
}
var le *hook.LifecycleError
if errors.As(err, &le) {
if le.Panic {
reasonCode = "lifecycle_panic"
}
detail = map[string]any{
"reason_code": reasonCode,
"hook_name": le.HookName,
"event": "startup",
}
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "plugin_lifecycle",
Message: err.Error(),
Detail: detail,
},
Err: err,
}
}
installFatalGuard(rootCmd, makeErr)
}
// walkGuard recurses through cmd's subtree and installs the guard at
// EVERY level cobra might dispatch to. The cobra execution order is:
//
// 1. PersistentPreRunE (looked up from leaf, walking up; "first wins")
// 2. PreRunE
// 3. RunE
// 4. PostRunE
// 5. PersistentPostRunE
//
// A subcommand that declares its own PersistentPreRunE (cmd/auth and
// cmd/config both do) would not only shadow root's PersistentPreRunE
// -- if that PreRunE itself returns an error (e.g. auth's
// external_provider check), the user sees THAT error instead of
// our plugin_install envelope, even if RunE was guarded.
//
// To close every dispatch hole we replace:
// - every command's PersistentPreRunE (including non-runnable groups)
// - every runnable command's PreRunE and RunE
//
// This way the very first non-nil step in cobra's chain is always our
// guard, regardless of which leaf the user invoked.
// Deprecated: walkGuard accepts a *output.ExitError-producing lambda, part
// of the legacy error surface that predates the typed error contract
// introduced by errs/. New code MUST NOT add new callers — the platform-
// extension guard plumbing will switch to typed errs.* errors when the
// platform-extension framework migrates. This wrapper is retained only for
// the existing in-tree call sites; it will be removed once they have moved
// to the typed surface.
func walkGuard(cmd *cobra.Command, makeErr func() *output.ExitError) {
if cmd == nil {
return
}
// PersistentPreRunE is the first step cobra runs (after Args /
// flag validation -- see below). Set it on every command (root
// included) so cobra's "first wins" walk-up always finds OUR
// PersistentPreRunE before hitting any subcommand's pre-existing
// one.
cmd.PersistentPreRunE = func(c *cobra.Command, args []string) error {
c.SilenceUsage = true
return makeErr()
}
cmd.PersistentPreRun = nil
// **Cobra dispatch order before PersistentPreRunE:**
// 1. ValidateArgs(cmd.Args) -- can return arg error
// 2. ParsePersistentFlags / ParseFlags -- can return flag error
// 3. Find legacyArgs check for unknown-command at root
// 4. PersistentPreRunE / PreRunE / RunE
// 5. Non-runnable groups fall through to help (PreRunE skipped)
//
// We neutralise each step:
// - Args = ArbitraryArgs -> ValidateArgs no-op. **Not nil**:
// cobra falls back to legacyArgs
// when Args==nil, which returns an
// unknown-command error during Find
// BEFORE PersistentPreRunE runs.
// ArbitraryArgs explicitly accepts
// everything, suppressing that path.
// - DisableFlagParsing -> ParseFlags skipped (and legacy
// "unknown flag" suppressed)
// - PreRunE / RunE on EVERY -> Even non-runnable groups now run
// command (not just leaves) the guard instead of showing help
//
// Setting RunE on a parent group flips Runnable() to true, so
// cobra dispatches to it (and our guard fires) rather than calling
// the help command on a "help-only" group.
cmd.Args = cobra.ArbitraryArgs
cmd.DisableFlagParsing = true
cmd.PreRunE = func(c *cobra.Command, args []string) error {
c.SilenceUsage = true
return makeErr()
}
cmd.PreRun = nil
cmd.RunE = func(*cobra.Command, []string) error { return makeErr() }
cmd.Run = nil
for _, c := range cmd.Commands() {
walkGuard(c, makeErr)
}
}

View File

@@ -1,208 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// failClosedAbortingPlugin returns a PluginInstallError on Install,
// declaring FailClosed so InstallAll surfaces the error.
type failClosedAbortingPlugin struct{}
func (failClosedAbortingPlugin) Name() string { return "policy" }
func (failClosedAbortingPlugin) Version() string { return "1.0.0" }
func (failClosedAbortingPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (failClosedAbortingPlugin) Install(platform.Registrar) error {
return errors.New("upstream policy server unreachable")
}
// When a FailClosed plugin fails to install, buildInternal must
// install a PersistentPreRunE that returns a structured *output.ExitError.
// The user must NEVER see a silent partial-install state.
//
// This pins the build.go fix for codex's NEW ISSUE about
// build.go demoting FailClosed errors to warnings.
func TestBuildInternal_failClosedAbortsCLI(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(failClosedAbortingPlugin{})
root := Build(context.Background(), buildInvocationForTest(t))
if root.PersistentPreRunE == nil {
t.Fatalf("FailClosed install error must wire a PersistentPreRunE that aborts subsequent commands")
}
err := root.PersistentPreRunE(root, nil)
checkGuardError(t, err)
// CRITICAL: subcommands that declare their own PersistentPreRunE
// (cmd/auth/auth.go and cmd/config/config.go both do) would
// shadow root's via cobra's "first wins" semantics if we only set
// root.PersistentPreRunE. Moreover, those subcommand PersistentPreRunE
// handlers may themselves return an error (e.g. auth's
// external_provider check at internal/cmdutil/factory.go:223),
// which would mask the plugin_install envelope even if RunE were
// guarded.
//
// The guard MUST therefore walk the tree and replace each command's
// PersistentPreRunE / PreRunE / RunE directly. This test pins
// that the bypass is closed.
auth := findChildByUse(t, root, "auth")
if auth == nil {
t.Skip("auth subcommand not present in build; cannot exercise bypass case")
}
// (a) auth's own PersistentPreRunE must be the guard, not the
// factory-checking handler that lived there before walkGuard ran.
if auth.PersistentPreRunE == nil {
t.Fatalf("auth.PersistentPreRunE must be guarded after walkGuard")
}
checkGuardError(t, auth.PersistentPreRunE(auth, nil))
// (b) A runnable leaf below auth also gets the guard on RunE. We
// match by RunE != nil (not just Runnable()) because the guard
// replaces RunE specifically — selecting a Run-only command and
// then calling leaf.RunE would nil-deref.
var leaf *cobra.Command
walk(auth, func(c *cobra.Command) {
if leaf != nil {
return
}
if c != auth && c.RunE != nil {
leaf = c
}
})
if leaf == nil {
t.Skip("no auth subcommand with RunE found")
}
checkGuardError(t, leaf.RunE(leaf, nil))
}
// checkGuardError asserts that err is the structured plugin_install
// ExitError the guard produces.
func checkGuardError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatalf("PersistentPreRunE must surface the install error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["plugin"] != "policy" {
t.Errorf("detail.plugin = %v, want policy", detail["plugin"])
}
if detail["reason_code"] != internalplatform.ReasonInstallFailed {
t.Errorf("detail.reason_code = %v, want install_failed", detail["reason_code"])
}
}
// findChildByUse helper.
func findChildByUse(t *testing.T, parent *cobra.Command, use string) *cobra.Command {
t.Helper()
for _, c := range parent.Commands() {
if c.Use == use {
return c
}
}
return nil
}
// namespacedWrap copy semantics: a plugin reusing a sentinel AbortError
// across two concurrent command invocations must produce two distinct
// HookName values on the wire. Mutation would interleave them.
//
// We exercise this by sharing one AbortError across two goroutines,
// each invoking through a different namespacedWrap; both observed
// errors must keep their own HookName.
func TestNamespacedWrap_doesNotMutateSharedAbortError(t *testing.T) {
shared := &platform.AbortError{HookName: "plugin-shared-name", Reason: "rejected"}
makeWrapper := func(name string) platform.Wrapper {
return func(next platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error { return shared }
}
}
reg := hook.NewRegistry()
reg.AddWrapper(hook.WrapperEntry{
Name: "p1.wrap", Selector: platform.All(), Fn: makeWrapper("p1.wrap"),
})
reg.AddWrapper(hook.WrapperEntry{
Name: "p2.wrap", Selector: platform.All(), Fn: makeWrapper("p2.wrap"),
})
// Drive matched wrappers separately to exercise both namespace paths.
matched := reg.MatchingWrappers(stubView{})
if len(matched) != 2 {
t.Fatalf("expected 2 matched wrappers, got %d", len(matched))
}
results := make([]string, 2)
var wg sync.WaitGroup
wg.Add(2)
for i, m := range matched {
go func() {
defer wg.Done()
err := m.Fn(func(context.Context, platform.Invocation) error { return nil })(
context.Background(), stubInvocation{})
if ab, ok := err.(*platform.AbortError); ok {
results[i] = ab.HookName
}
}()
}
wg.Wait()
// We are not using namespacedWrap directly here -- the test isolates
// the semantic by reading what each WrapperEntry's Fn returns.
// The real guarantee we depend on is the install-side namespacedWrap;
// see internal/hook/install.go for the production path. This test
// pins the sentinel-not-mutated invariant at the unit level: each
// Wrap returned the shared AbortError unchanged, so the production
// namespacedWrap can safely copy without touching the original.
if shared.HookName != "plugin-shared-name" {
t.Errorf("shared sentinel AbortError was mutated: HookName = %q", shared.HookName)
}
_ = results
}
// stubView for the wrap selector match.
type stubView struct{}
func (stubView) Path() string { return "x" }
func (stubView) Domain() string { return "" }
func (stubView) Risk() (platform.Risk, bool) { return "", false }
func (stubView) Identities() []platform.Identity { return nil }
func (stubView) Annotation(string) (string, bool) { return "", false }
// stubInvocation is the minimal platform.Invocation implementation
// used by tests that need to drive a Wrap without going through the
// full hook.Install pipeline.
type stubInvocation struct{}
func (stubInvocation) Cmd() platform.CommandView { return stubView{} }
func (stubInvocation) Args() []string { return nil }
func (stubInvocation) Started() time.Time { return time.Time{} }
func (stubInvocation) Err() error { return nil }
func (stubInvocation) DeniedByPolicy() bool { return false }
func (stubInvocation) DenialLayer() string { return "" }
func (stubInvocation) DenialPolicySource() string { return "" }

View File

@@ -1,684 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"context"
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
internalplatform "github.com/larksuite/cli/internal/platform"
)
// These integration tests exercise the Hook framework's plumbing
// (Plugin -> InstallAll -> Registry -> wireHooks -> RunE wrapper)
// against a SYNTHETIC command tree, not the real lark-cli shortcut
// tree. The synthetic tree keeps the test hermetic -- invoking real
// shortcuts requires a fully-populated Factory (HTTP, credentials,
// etc.) which is out of scope for a hook plumbing test.
//
// The e2e tests that go through Build() are kept thin (see
// TestBuildInternal_appliesPolicyToRealTree in policy_test.go); they
// assert plumbing existence (Hidden flag, etc.) without invoking
// shortcuts.
type fakeIntegrationPlugin struct {
name string
caps platform.Capabilities
rule *platform.Rule
beforeCount int64
afterCount int64
wrapCount int64
wrapDeniesWrite bool // when true, Wrap returns AbortError for risk=write
shutdownCalled int64
}
func (p *fakeIntegrationPlugin) Name() string { return p.name }
func (p *fakeIntegrationPlugin) Version() string { return "0.0.1" }
func (p *fakeIntegrationPlugin) Capabilities() platform.Capabilities { return p.caps }
func (p *fakeIntegrationPlugin) Install(r platform.Registrar) error {
if p.caps.Restricts && p.rule != nil {
r.Restrict(p.rule)
}
r.Observe(platform.Before, "audit-pre", platform.All(),
func(context.Context, platform.Invocation) {
atomic.AddInt64(&p.beforeCount, 1)
})
r.Observe(platform.After, "audit-post", platform.All(),
func(context.Context, platform.Invocation) {
atomic.AddInt64(&p.afterCount, 1)
})
r.Wrap("policy", platform.ByWrite(),
func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
atomic.AddInt64(&p.wrapCount, 1)
if p.wrapDeniesWrite {
return &platform.AbortError{
HookName: "policy",
Reason: "writes blocked by integration test plugin",
}
}
return next(ctx, inv)
}
})
r.On(platform.Shutdown, "flush",
func(context.Context, *platform.LifecycleContext) error {
atomic.AddInt64(&p.shutdownCalled, 1)
return nil
})
return nil
}
// syntheticTree builds a small command tree we own end-to-end. The leaf
// has risk=write so the Wrap's ByWrite() selector matches.
func syntheticTree() (*cobra.Command, *cobra.Command) {
root := &cobra.Command{Use: "lark-cli"}
group := &cobra.Command{Use: "docs"}
root.AddCommand(group)
leaf := &cobra.Command{
Use: "+write",
RunE: func(*cobra.Command, []string) error { return nil },
}
cmdutil.SetRisk(leaf, "write")
group.AddCommand(leaf)
return root, leaf
}
// End-to-end through the public install pipeline: register a plugin,
// run internalplatform.InstallAll (the same function buildInternal calls),
// wire hooks onto a synthetic tree, invoke the leaf, and confirm
// observers fired.
func TestPluginPipeline_observersWired(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "audit-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
}
platform.Register(plugin)
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
_ = leaf.RunE(leaf, nil)
if got := atomic.LoadInt64(&plugin.beforeCount); got != 1 {
t.Errorf("Before observer fired %d times, want 1", got)
}
if got := atomic.LoadInt64(&plugin.afterCount); got != 1 {
t.Errorf("After observer fired %d times, want 1", got)
}
if got := atomic.LoadInt64(&plugin.wrapCount); got != 1 {
t.Errorf("Wrap fired %d times (ByWrite matches risk=write), want 1", got)
}
}
// A Wrapper returning AbortError on a write command must surface as
// type="hook" in the envelope so the caller can parse the structured
// rejection.
func TestPluginPipeline_wrapAbortReachesEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "policy-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
wrapDeniesWrite: true,
}
platform.Register(plugin)
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
detail := exitErr.Detail.Detail.(map[string]any)
if detail["reason_code"] != "aborted" {
t.Errorf("detail.reason_code = %v, want aborted", detail["reason_code"])
}
if detail["hook_name"] != "policy-plugin.policy" {
t.Errorf("detail.hook_name = %v, want policy-plugin.policy", detail["hook_name"])
}
// errors.As must still reach the original AbortError so consumers
// can inspect the typed cause.
var ab *platform.AbortError
if !errors.As(err, &ab) {
t.Errorf("error chain should expose *platform.AbortError")
}
}
// Plugin.Restrict() contribution must reach the pruning resolver and
// take precedence over a yaml file (single-rule, plugin wins). This
// goes through the REAL Build() pipeline so the wiring between
// installPluginsAndHooks -> applyUserPolicyPruning -> cmdpolicy.Resolve
// is covered.
func TestPluginPipeline_restrictBeatsYaml(t *testing.T) {
cfgDir := tmpHome(t)
// yaml says allow everything; plugin says deny everything. Plugin
// should win and a command should be denied.
if err := os.WriteFile(filepath.Join(cfgDir, "policy.yml"),
[]byte("name: yaml-allow\nallow: [\"**\"]\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "restricter",
caps: platform.Capabilities{
Restricts: true,
FailurePolicy: platform.FailClosed,
},
rule: &platform.Rule{Name: "deny-all", Deny: []string{"**"}},
}
platform.Register(plugin)
root := Build(context.Background(), buildInvocationForTest(t))
// At least one runnable command must end up Hidden because of the
// plugin Restrict (yaml had been allow-all and would have left
// everything visible).
var foundHidden bool
walk(root, func(c *cobra.Command) {
if c.HasParent() && c.Runnable() && c.Hidden {
foundHidden = true
}
})
if !foundHidden {
t.Fatalf("plugin Restrict should have denied at least one command despite yaml allow-all")
}
}
// Denial-guard end-to-end: register a plugin with a Wrap that would
// SILENTLY suppress denial (return nil without calling next). After
// installing pruning (which marks a command as denied) and wiring
// hooks, calling the denied command must STILL produce the denial
// error -- the Wrap must never run on the denied path.
func TestPluginPipeline_denialGuardIntegrated(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
wrapCalled := false
plugin := &fakeIntegrationPlugin{
name: "policy-plugin",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
wrapDeniesWrite: false, // wrap would normally allow
}
// Override Wrap with a malicious behavior: return nil (silence the
// denial). We do this by wrapping the install: register a
// second Wrap that suppresses errors.
platform.Register(plugin)
// Add another plugin with a malicious wrap.
malicious := &mockMaliciousPlugin{
name: "malicious",
invokedFlag: &wrapCalled,
}
platform.Register(malicious)
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
// Simulate cmdpolicy.Apply marking leaf as denied.
leaf.Hidden = true
leaf.DisableFlagParsing = true
if leaf.Annotations == nil {
leaf.Annotations = map[string]string{}
}
leaf.Annotations["lark:policy_denied_layer"] = "policy"
leaf.Annotations["lark:policy_denied_source"] = "plugin:other"
denyStubCalled := false
leaf.RunE = func(*cobra.Command, []string) error {
denyStubCalled = true
return errors.New("CommandPruned (denyStub)")
}
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
err = leaf.RunE(leaf, nil)
if wrapCalled {
t.Errorf("denial guard violated: malicious Wrap ran on a denied command")
}
if !denyStubCalled {
t.Errorf("denyStub should run on the denial path even when a Wrap is registered")
}
if err == nil {
t.Errorf("denial error must propagate, got nil")
}
}
// mockMaliciousPlugin registers a Wrap that returns nil unconditionally
// -- exactly the kind of plugin the denial guard defends against.
type mockMaliciousPlugin struct {
name string
invokedFlag *bool
}
func (p *mockMaliciousPlugin) Name() string { return p.name }
func (p *mockMaliciousPlugin) Version() string { return "0.0.1" }
func (p *mockMaliciousPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailOpen}
}
func (p *mockMaliciousPlugin) Install(r platform.Registrar) error {
r.Wrap("hijack", platform.All(),
func(_ platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error {
if p.invokedFlag != nil {
*p.invokedFlag = true
}
return nil // silence everything
}
})
return nil
}
// Verifies buildInternal returns a non-nil *hook.Registry when a plugin
// is registered and Emit(Shutdown) on that registry fires the plugin's
// On(Shutdown) handler. This is the contract Execute relies on to fire
// Shutdown after rootCmd.Execute returns.
func TestBuildInternal_returnsRegistryForShutdownEmit(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
plugin := &fakeIntegrationPlugin{
name: "shutdown-test",
caps: platform.Capabilities{FailurePolicy: platform.FailOpen},
}
platform.Register(plugin)
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg == nil {
t.Fatalf("buildInternal returned nil registry; plugin's Shutdown handler is unreachable")
}
if err := hook.Emit(context.Background(), reg, platform.Shutdown, nil); err != nil {
t.Fatalf("Emit(Shutdown): %v", err)
}
if got := atomic.LoadInt64(&plugin.shutdownCalled); got != 1 {
t.Errorf("On(Shutdown) handler fired %d times, want 1", got)
}
}
// When plugin install fails (FailClosed), buildInternal returns nil
// registry. Execute must nil-check before calling Emit so we don't fault
// on the FailClosed bypass-guard path.
func TestBuildInternal_failClosedYieldsNilRegistry(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
// A plugin that fails install and is FailClosed -> InstallAll
// returns an error, buildInternal installs the guard and returns
// early with nil registry.
plugin := &failingPlugin{
name: "fail-closed",
caps: platform.Capabilities{FailurePolicy: platform.FailClosed},
err: errors.New("install failure simulated"),
}
platform.Register(plugin)
_, _, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("buildInternal returned non-nil registry on FailClosed install error")
}
}
type failingPlugin struct {
name string
caps platform.Capabilities
err error
}
func (p *failingPlugin) Name() string { return p.name }
func (p *failingPlugin) Version() string { return "0.0.1" }
func (p *failingPlugin) Capabilities() platform.Capabilities { return p.caps }
func (p *failingPlugin) Install(platform.Registrar) error { return p.err }
// === Plugin Restrict conflict guard ===
//
// Two plugins both calling r.Restrict must surface as a structured
// plugin_conflict envelope (reason_code multiple_restrict_plugins) at
// dispatch time, NOT as a silent stderr warning. Otherwise a
// safety-sensitive operator could miss that their policy never took
// effect.
func TestPluginConflictGuard_MultipleRestrictAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
rule := &platform.Rule{Name: "any", Allow: []string{"**"}}
platform.Register(&fakeIntegrationPlugin{
name: "plugin-a",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: rule,
})
platform.Register(&fakeIntegrationPlugin{
name: "plugin-b",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: rule,
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("conflict guard path should yield nil registry")
}
// Pick any leaf and verify it returns the structured envelope.
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_conflict" {
t.Errorf("envelope type = %q, want plugin_conflict", exitErr.Detail.Type)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "multiple_restrict_plugins" {
t.Errorf("reason_code = %v, want multiple_restrict_plugins", rc)
}
}
// Single plugin with an invalid Rule must surface as plugin_install /
// invalid_rule envelope (distinct error.type from multi-Restrict).
func TestPluginConflictGuard_InvalidRuleAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
// MaxRisk "nukem" is rejected by ValidateRule -> Resolve returns
// an error that is NOT ErrMultipleRestricts.
platform.Register(&fakeIntegrationPlugin{
name: "bad",
caps: platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed},
rule: &platform.Rule{Name: "bad", MaxRisk: "nukem"},
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("conflict guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
if leaf == nil {
t.Fatalf("no runnable leaf in command tree")
}
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_install" {
t.Errorf("envelope type = %q, want plugin_install", exitErr.Detail.Type)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "invalid_rule" {
t.Errorf("reason_code = %v, want invalid_rule", rc)
}
}
// === Startup lifecycle guard ===
//
// Plugin On(Startup) handler returning error must abort startup with
// a plugin_lifecycle envelope (reason_code lifecycle_failed). Silently
// continuing would leave the plugin's invariants violated while the
// rest of its hooks still fire.
func TestPluginLifecycleGuard_StartupErrorAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
platform.Register(&startupFailingPlugin{
name: "lc",
failErr: errors.New("backend unreachable"),
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("lifecycle guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "plugin_lifecycle" {
t.Errorf("envelope type = %q, want plugin_lifecycle", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "lifecycle_failed" {
t.Errorf("reason_code = %v, want lifecycle_failed", d["reason_code"])
}
if d["hook_name"] != "lc.start" {
t.Errorf("hook_name = %v, want lc.start", d["hook_name"])
}
}
// Same path but the handler panics -> reason_code lifecycle_panic.
func TestPluginLifecycleGuard_StartupPanicAbortsCLI(t *testing.T) {
tmpHome(t)
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
cmdpolicy.ResetActiveForTesting()
t.Cleanup(cmdpolicy.ResetActiveForTesting)
platform.Register(&startupFailingPlugin{
name: "lc",
doPanic: true,
panicMsg: "kaboom",
})
_, root, reg := buildInternal(context.Background(), buildInvocationForTest(t))
if reg != nil {
t.Errorf("lifecycle guard path should yield nil registry")
}
leaf := findRunnableLeaf(root)
err := leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if rc := exitErr.Detail.Detail.(map[string]any)["reason_code"]; rc != "lifecycle_panic" {
t.Errorf("reason_code = %v, want lifecycle_panic", rc)
}
}
type startupFailingPlugin struct {
name string
failErr error // when set, handler returns this
doPanic bool // when true, handler panics with panicMsg
panicMsg string
}
func (p *startupFailingPlugin) Name() string { return p.name }
func (p *startupFailingPlugin) Version() string { return "0.0.1" }
func (p *startupFailingPlugin) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (p *startupFailingPlugin) Install(r platform.Registrar) error {
r.On(platform.Startup, "start", func(context.Context, *platform.LifecycleContext) error {
if p.doPanic {
panic(p.panicMsg)
}
return p.failErr
})
return nil
}
// === Wrapper panic recovery ===
//
// A Wrapper that panics must NOT crash the process. The framework
// recovers and converts to a structured envelope:
//
// type="hook", reason_code="panic", hook_name=<namespaced>
func TestWrapperPanic_BecomesHookPanicEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&panickingWrapPlugin{name: "p"})
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("Wrapper panic must be recovered, but it escaped: %v", r)
}
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
if d["hook_name"] != "p.boom" {
t.Errorf("hook_name = %v, want p.boom (namespaced)", d["hook_name"])
}
}
type panickingWrapPlugin struct{ name string }
func (p *panickingWrapPlugin) Name() string { return p.name }
func (p *panickingWrapPlugin) Version() string { return "0.0.1" }
func (p *panickingWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
func (p *panickingWrapPlugin) Install(r platform.Registrar) error {
r.Wrap("boom", platform.All(),
func(_ platform.Handler) platform.Handler {
return func(context.Context, platform.Invocation) error {
panic("intentional panic for test")
}
})
return nil
}
// findRunnableLeaf walks the tree and returns the first command with a
// RunE so tests can synthesize a dispatch without going through cobra.
func findRunnableLeaf(c *cobra.Command) *cobra.Command {
if c.RunE != nil && c.HasParent() {
return c
}
for _, child := range c.Commands() {
if l := findRunnableLeaf(child); l != nil {
return l
}
}
return nil
}
// B2 regression: a plugin Wrapper whose FACTORY function (the
// `func(next Handler) Handler` itself) panics must not crash the
// process. The framework recovers and returns the same panic envelope
// it produces for runtime panics inside the inner Handler.
//
// Pre-fix code path: recoverWrap had `inner := w(next)` outside the
// deferred recover, so a factory panic escaped.
func TestWrapperFactoryPanic_BecomesHookPanicEnvelope(t *testing.T) {
platform.ResetForTesting()
t.Cleanup(platform.ResetForTesting)
platform.Register(&factoryPanicWrapPlugin{name: "fac"})
result, err := internalplatform.InstallAll(platform.RegisteredPlugins(), nil)
if err != nil {
t.Fatalf("InstallAll: %v", err)
}
root, leaf := syntheticTree()
if err := wireHooks(context.Background(), root, result.Registry); err != nil {
t.Fatalf("wireHooks: %v", err)
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("factory panic must be recovered, but it escaped: %v", r)
}
}()
err = leaf.RunE(leaf, nil)
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected *output.ExitError, got %T %+v", err, err)
}
if exitErr.Detail.Type != "hook" {
t.Errorf("envelope type = %q, want hook", exitErr.Detail.Type)
}
d := exitErr.Detail.Detail.(map[string]any)
if d["reason_code"] != "panic" {
t.Errorf("reason_code = %v, want panic", d["reason_code"])
}
if d["hook_name"] != "fac.bad-factory" {
t.Errorf("hook_name = %v, want fac.bad-factory (namespaced)", d["hook_name"])
}
}
type factoryPanicWrapPlugin struct{ name string }
func (p *factoryPanicWrapPlugin) Name() string { return p.name }
func (p *factoryPanicWrapPlugin) Version() string { return "0.0.1" }
func (p *factoryPanicWrapPlugin) Capabilities() platform.Capabilities { return platform.Capabilities{} }
func (p *factoryPanicWrapPlugin) Install(r platform.Registrar) error {
r.Wrap("bad-factory", platform.All(),
// The factory itself panics; the returned Handler is never reached.
func(_ platform.Handler) platform.Handler {
panic("factory blew up")
})
return nil
}

View File

@@ -45,7 +45,6 @@ func NewCmdProfileAdd(f *cmdutil.Factory) *cobra.Command {
_ = cmd.MarkFlagRequired("name")
_ = cmd.MarkFlagRequired("app-id")
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -34,7 +34,6 @@ func NewCmdProfileList(f *cmdutil.Factory) *cobra.Command {
return profileListRun(f)
},
}
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -28,7 +28,6 @@ func NewCmdProfileRemove(f *cmdutil.Factory) *cobra.Command {
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT remove profiles unless the user explicitly asks. This is destructive and clears all associated credentials.",
})
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -24,7 +24,6 @@ func NewCmdProfileRename(f *cmdutil.Factory) *cobra.Command {
return profileRenameRun(f, args[0], args[1])
},
}
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -27,7 +27,6 @@ func NewCmdProfileUse(f *cmdutil.Factory) *cobra.Command {
cmdutil.SetTips(cmd, []string{
"AI agents: Do NOT switch profiles unless the user explicitly asks.",
})
cmdutil.SetRisk(cmd, "write")
return cmd
}

View File

@@ -7,12 +7,10 @@ import (
"fmt"
"slices"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// pruneForStrictMode removes commands incompatible with the active strict mode.
@@ -45,80 +43,15 @@ func pruneIncompatible(parent *cobra.Command, mode core.StrictMode) {
}
func strictModeStubFrom(child *cobra.Command, mode core.StrictMode) *cobra.Command {
// The denial annotations let the hook layer's populateInvocationDenial
// recognise this command as denied, so the Wrap chain is physically
// isolated (wrapRunE takes the DeniedByPolicy branch and calls the
// stub RunE directly). Without these, a plugin Wrapper registered
// against platform.All() could intercept and silently swallow the
// strict-mode error -- breaking strict-mode's "hard boundary" contract.
//
// Args + PersistentPreRunE overrides mirror cmdpolicy/apply.go::installDenyStub:
//
// - Args=ArbitraryArgs: with DisableFlagParsing the user's flags
// look like positional args; the original child's Args validator
// (e.g. cobra.NoArgs) would fire BEFORE RunE and produce a
// cobra usage error instead of our strict_mode envelope.
//
// - PersistentPreRunE no-op: cmd/auth/auth.go declares a parent
// PersistentPreRunE that returns external_provider when env
// credentials are set. Cobra's "first wins walking up" would
// pick auth's instead of our denial. A leaf-level no-op makes
// cobra stop here and proceed to the wrapped RunE.
//
// strict-mode keeps its short Message + independent Hint and
// composes the shared detail.* / wrapped-CommandDeniedError shape
// by hand; BuildDenialError would override Message with the
// CommandDeniedError.Error() long form.
stubMessage := fmt.Sprintf(
"strict mode is %q, only %s-identity commands are available",
mode, mode.ForcedIdentity())
const stubHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
denial := cmdpolicy.Denial{
Layer: cmdpolicy.LayerStrictMode,
PolicySource: "strict-mode",
ReasonCode: "identity_not_supported",
Reason: stubMessage,
}
// Preserve the original command's annotations (risk_level,
// lark:supportedIdentities, cmdmeta.domain, ...) and help text so
// audit / compliance observers can still see what was denied.
// Stamp the denial annotations on top.
annotations := make(map[string]string, len(child.Annotations)+2)
for k, v := range child.Annotations {
annotations[k] = v
}
annotations[cmdpolicy.AnnotationDenialLayer] = cmdpolicy.LayerStrictMode
annotations[cmdpolicy.AnnotationDenialSource] = "strict-mode"
return &cobra.Command{
Use: child.Use,
Aliases: append([]string(nil), child.Aliases...),
Short: child.Short,
Long: child.Long,
Hidden: true,
DisableFlagParsing: true,
Args: cobra.ArbitraryArgs,
Annotations: annotations,
PersistentPreRunE: func(c *cobra.Command, _ []string) error {
c.SilenceUsage = true
return nil
},
RunE: func(c *cobra.Command, _ []string) error {
cd := cmdpolicy.CommandDeniedFromDenial(cmdpolicy.CanonicalPath(c), denial)
// Legacy *output.ExitError producer: this literal predates the
// typed error contract introduced by errs/. New denial sites MUST
// NOT construct *output.ExitError directly — they should return a
// typed *errs.XxxError once the cmdpolicy framework migrates.
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "command_denied",
Message: stubMessage,
Hint: stubHint,
Detail: cmdpolicy.DenialDetailMap(cd),
},
Err: cd,
}
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrWithHint(output.ExitValidation, "strict_mode",
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
},
}
}

View File

@@ -4,15 +4,11 @@
package cmd
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -202,176 +198,3 @@ func TestPruneForStrictMode_User_DirectBotShortcutReturnsStrictMode(t *testing.T
t.Fatalf("unexpected error: %v", err)
}
}
// Regression for codex C13: a strict-mode stub whose PARENT declares
// a PersistentPreRunE (e.g. cmd/auth/auth.go's external_provider
// check on env credentials) must surface the strict_mode envelope,
// not the parent's error. Cobra's "first PersistentPreRunE wins
// walking up from leaf" semantics will pick the parent's unless the
// stub itself carries its own.
//
// Fix: strictModeStubFrom installs a no-op PersistentPreRunE so cobra
// stops at the stub and proceeds to its RunE.
func TestStrictModeStub_BypassesParentPersistentPreRunE(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login stub should exist after StrictModeBot")
}
if stub.PersistentPreRunE == nil {
t.Fatal("strict-mode stub must declare PersistentPreRunE on leaf")
}
if err := stub.PersistentPreRunE(stub, nil); err != nil {
t.Errorf("strict-mode stub PersistentPreRunE should be no-op, got %v", err)
}
}
// Regression for codex H13: strict-mode stub must accept arbitrary
// positional args. With DisableFlagParsing=true, a user passing
// `auth login --scope ...` looks like 4 positional args; the original
// cobra.Args validator would surface a usage error BEFORE strict-mode
// stub's RunE.
func TestStrictModeStub_BypassesArgsValidator(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "auth", "login")
if stub == nil {
t.Fatal("auth/login stub should exist after StrictModeBot")
}
if stub.Args == nil {
t.Fatal("strict-mode stub must declare Args validator")
}
if err := stub.Args(stub, []string{"--scope", "im.message", "--profile", "default"}); err != nil {
t.Errorf("strict-mode stub Args should accept flag-like args, got %v", err)
}
}
// Pins the strict-mode envelope shape: structured detail.* / wrapped
// CommandDeniedError for external agents, AND the historical short
// Message + independent Hint for existing consumers.
func TestStrictModeStub_StructuredEnvelope(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "im", "+search")
if stub == nil {
t.Fatalf("expected im/+search stub")
}
err := stub.RunE(stub, nil)
if err == nil {
t.Fatalf("strict-mode stub RunE should return error")
}
var ee *output.ExitError
if !errors.As(err, &ee) {
t.Fatalf("err is not *output.ExitError: %T", err)
}
if ee.Detail == nil {
t.Fatalf("ExitError.Detail is nil; envelope writer cannot emit JSON")
}
if ee.Detail.Type != "command_denied" {
t.Errorf("Detail.Type = %q, want command_denied", ee.Detail.Type)
}
dm, ok := ee.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("Detail.Detail = %T, want map[string]any", ee.Detail.Detail)
}
if got, _ := dm["layer"].(string); got != cmdpolicy.LayerStrictMode {
t.Errorf("Detail.Detail[layer] = %q, want %q", got, cmdpolicy.LayerStrictMode)
}
if got, _ := dm["reason_code"].(string); got != "identity_not_supported" {
t.Errorf("Detail.Detail[reason_code] = %q, want identity_not_supported", got)
}
if got, _ := dm["policy_source"].(string); got != "strict-mode" {
t.Errorf("Detail.Detail[policy_source] = %q, want strict-mode", got)
}
var cd *platform.CommandDeniedError
if !errors.As(err, &cd) {
t.Fatalf("err does not unwrap to *platform.CommandDeniedError")
}
if cd.Layer != cmdpolicy.LayerStrictMode {
t.Errorf("CommandDeniedError.Layer = %q, want %q", cd.Layer, cmdpolicy.LayerStrictMode)
}
if cd.ReasonCode != "identity_not_supported" {
t.Errorf("CommandDeniedError.ReasonCode = %q, want identity_not_supported", cd.ReasonCode)
}
if !strings.Contains(cd.Reason, `strict mode is "bot"`) {
t.Errorf("CommandDeniedError.Reason = %q, want substring 'strict mode is \"bot\"'", cd.Reason)
}
if ee.Detail.Message != `strict mode is "bot", only bot-identity commands are available` {
t.Errorf("Detail.Message = %q, want short historical form", ee.Detail.Message)
}
if !strings.HasPrefix(ee.Detail.Hint, "if the user explicitly wants to switch policy") {
t.Errorf("Detail.Hint = %q, want historical hint", ee.Detail.Hint)
}
}
// strictModeStubFrom must write the denial annotations so the hook
// layer's populateInvocationDenial recognises the command as denied
// and physically isolates the Wrap chain. Without this, a plugin
// Wrapper registered against platform.All() could intercept the stub
// and silently return nil, swallowing the strict-mode error.
func TestStrictModeStub_HasDenialAnnotation(t *testing.T) {
root := newTestTree()
pruneForStrictMode(root, core.StrictModeBot)
// im/+search is user-only -> replaced by a stub in StrictModeBot.
stub := findCmd(root, "im", "+search")
if stub == nil {
t.Fatalf("expected im/+search stub to exist")
}
got := stub.Annotations[cmdpolicy.AnnotationDenialLayer]
if got != cmdpolicy.LayerStrictMode {
t.Errorf("stub annotation %q = %q, want %q",
cmdpolicy.AnnotationDenialLayer, got, cmdpolicy.LayerStrictMode)
}
if src := stub.Annotations[cmdpolicy.AnnotationDenialSource]; src != "strict-mode" {
t.Errorf("stub annotation %q = %q, want %q",
cmdpolicy.AnnotationDenialSource, src, "strict-mode")
}
}
// Audit / compliance observers fire even for strict-mode-denied commands
// and rely on CommandView.Risk() / Identities() / etc. The stub must
// carry the original command's annotations so those accessors keep
// returning meaningful values; the Short/Long are preserved so `--help`
// on a denied command still describes the original intent (parity with
// cmdpolicy/apply.go::installDenyStub).
func TestStrictModeStub_PreservesOriginalMetadata(t *testing.T) {
root := &cobra.Command{Use: "root"}
svc := &cobra.Command{Use: "im"}
root.AddCommand(svc)
userOnly := &cobra.Command{
Use: "+search",
Short: "search messages",
Long: "Search across IM history.",
RunE: func(*cobra.Command, []string) error { return nil },
}
cmdutil.SetSupportedIdentities(userOnly, []string{"user"})
cmdutil.SetRisk(userOnly, "read")
svc.AddCommand(userOnly)
pruneForStrictMode(root, core.StrictModeBot)
stub := findCmd(root, "im", "+search")
if stub == nil {
t.Fatalf("expected im/+search stub")
}
if got := stub.Annotations["risk_level"]; got != "read" {
t.Errorf("stub risk_level = %q, want %q (lost in replacement)", got, "read")
}
if got := stub.Annotations["lark:supportedIdentities"]; got != "user" {
t.Errorf("stub supportedIdentities = %q, want %q", got, "user")
}
if stub.Short != "search messages" {
t.Errorf("stub Short = %q, want preserved Short", stub.Short)
}
if stub.Long != "Search across IM history." {
t.Errorf("stub Long = %q, want preserved Long", stub.Long)
}
// Denial stamps must still be present.
if stub.Annotations[cmdpolicy.AnnotationDenialLayer] != cmdpolicy.LayerStrictMode {
t.Errorf("denial annotation overwritten or missing")
}
}

View File

@@ -10,18 +10,14 @@ import (
"errors"
"fmt"
"io"
"net/url"
"os"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/platform"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdpolicy"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/hook"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/skillscheck"
@@ -92,9 +88,8 @@ func Execute() int {
}
configureFlagCompletions(os.Args)
ctx := context.Background()
f, rootCmd, reg := buildInternal(
ctx, inv,
f, rootCmd := buildInternal(
context.Background(), inv,
WithIO(os.Stdin, os.Stdout, os.Stderr),
HideProfile(isSingleAppMode()),
)
@@ -104,18 +99,8 @@ func Execute() int {
setupNotices()
}
runErr := rootCmd.Execute()
// Fire Shutdown lifecycle hooks regardless of run outcome.
// emitShutdown imposes a 2s total deadline and never propagates handler
// errors (Emit's documented Shutdown contract), so it cannot block exit
// or alter the user-visible exit code.
if reg != nil && !isCompletionCommand(os.Args) {
_ = hook.Emit(ctx, reg, platform.Shutdown, runErr)
}
if runErr != nil {
return handleRootError(f, runErr)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
@@ -155,7 +140,6 @@ func setupNotices() {
"current": info.Current,
"latest": info.Latest,
"message": info.Message(),
"command": "lark-cli update",
}
}
if stale := skillscheck.GetPending(); stale != nil {
@@ -163,7 +147,6 @@ func setupNotices() {
"current": stale.Current,
"target": stale.Target,
"message": stale.Message(),
"command": "lark-cli update",
}
}
if len(notice) == 0 {
@@ -174,17 +157,11 @@ func setupNotices() {
}
// isCompletionCommand returns true if args indicate a shell completion request.
// Update notifications and Shutdown lifecycle emits must be suppressed for
// these to avoid corrupting machine-parseable completion output and to avoid
// firing plugin Shutdown handlers on every Tab keystroke.
//
// Cobra dispatches BOTH "__complete" and its alias "__completeNoDesc" through
// the same hidden subcommand (see cobra/completions.go ShellCompRequestCmd /
// ShellCompNoDescRequestCmd). Check both, otherwise bash/zsh completion
// (which often uses NoDesc) silently bypasses the gate.
// Update notifications must be suppressed for these to avoid corrupting
// machine-parseable completion output.
func isCompletionCommand(args []string) bool {
for _, arg := range args {
if arg == "completion" || arg == "__complete" || arg == "__completeNoDesc" {
if arg == "completion" || arg == "__complete" {
return true
}
}
@@ -199,52 +176,22 @@ func configureFlagCompletions(args []string) {
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
//
// Dispatch order:
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
// render via the typed envelope writer, which lifts extension fields
// (missing_scopes, console_url, ...) to the top level. Routed by
// errs.CategoryOf via ExitCodeOf.
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
// this path so existing wire shapes are preserved byte-for-byte until
// per-domain typed migration in stage 2+.
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// SecurityPolicyError keeps the legacy custom envelope (string codes,
// challenge_url, retryable) and exit code 1 — its wire shape predates the
// typed taxonomy and downstream OAuth/policy consumers depend on it.
// The taxonomy migration for this category is deferred to a later PR.
var spErr *errs.SecurityPolicyError
// SecurityPolicyError uses a custom envelope format (string codes, challenge_url, retryable)
// that differs from the standard ErrDetail, so it's handled separately.
var spErr *internalauth.SecurityPolicyError
if errors.As(err, &spErr) {
writeSecurityPolicyError(errOut, spErr)
return 1
}
// *core.ConfigError flows raw to the legacy envelope path in stage 1
// (asExitError → output.ErrWithHint). Typed migration via
// errcompat.PromoteConfigError happens in stage 2+.
// When the typed error is a need_user_authorization signal, fold in the
// current command's declared scopes as a Hint so the user/AI sees the
// concrete scope(s) to re-auth with. The hint is computed on the fly from
// local shortcut/service metadata — it never depends on server state.
applyNeedAuthorizationHint(f, err)
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
return output.ExitCodeOf(err)
}
// All other structured errors normalize to ExitError.
if exitErr := asExitError(err); exitErr != nil {
if !exitErr.Raw {
// Raw errors (e.g. from `api` command via output.MarkRaw)
// preserve the original API error detail; skip enrichment
// which would clear it.
// Raw errors (e.g. from `api` command) preserve the original API
// error detail; skip enrichment which would clear it.
enrichMissingScopeError(f, exitErr)
enrichPermissionError(f, exitErr)
}
@@ -252,21 +199,35 @@ func handleRootError(f *cmdutil.Factory, err error) int {
return exitErr.Code
}
// Cobra errors (required flags, unknown commands, etc.)
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// writeSecurityPolicyError writes the security-policy-specific JSON envelope.
// This wire format intentionally differs from the typed envelope writer: it
// uses string codes ("challenge_required"/"access_denied"), a "auth_error"
// type literal, and a top-level "retryable" field — the shape OAuth/policy
// consumers have been parsing since before the typed taxonomy existed.
func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// writeSecurityPolicyError writes the security-policy-specific JSON envelope to w.
// This format intentionally differs from the standard ErrDetail envelope:
// it uses string codes ("challenge_required"/"access_denied") and extra fields
// (retryable, challenge_url) for machine-readable policy error handling.
func writeSecurityPolicyError(w io.Writer, spErr *internalauth.SecurityPolicyError) {
var codeStr string
switch spErr.Subtype {
case errs.SubtypeChallengeRequired:
switch spErr.Code {
case internalauth.LarkErrBlockByPolicyTryAuth:
codeStr = "challenge_required"
case errs.SubtypeAccessDenied:
case internalauth.LarkErrBlockByPolicy:
codeStr = "access_denied"
default:
codeStr = strconv.Itoa(spErr.Code)
@@ -281,8 +242,8 @@ func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
if spErr.ChallengeURL != "" {
errData["challenge_url"] = spErr.ChallengeURL
}
if spErr.Hint != "" {
errData["hint"] = spErr.Hint
if spErr.CLIHint != "" {
errData["hint"] = spErr.CLIHint
}
env := map[string]interface{}{"ok": false, "error": errData}
@@ -291,100 +252,15 @@ func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
encoder := json.NewEncoder(buffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if encErr := encoder.Encode(env); encErr != nil {
err := encoder.Encode(env)
if err != nil {
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
return
}
fmt.Fprint(w, buffer.String())
}
// asExitError converts known structured error types to *output.ExitError.
// Returns nil for unrecognized errors (e.g. cobra flag errors).
//
// Deprecated: legacy *output.ExitError bridge; removed after typed migration.
func asExitError(err error) *output.ExitError {
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
return output.ErrWithHint(cfgErr.Code, cfgErr.Type, cfgErr.Message, cfgErr.Hint)
}
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return exitErr
}
return nil
}
// installUnknownSubcommandGuard replaces cobra's silent help fallback on
// group commands (no Run/RunE) with an unknown_subcommand error.
//
// IMPORTANT: every command modified here is also tagged with
// cmdpolicy.AnnotationPureGroup so the user-layer policy engine
// continues to treat the command as a pure parent group. Without the
// tag, the RunE injection here would flip Runnable()=true and a user
// rule like `max_risk: read` would deny every `<group> --help` call
// with reason_code=risk_not_annotated.
func installUnknownSubcommandGuard(cmd *cobra.Command) {
if cmd.HasSubCommands() && cmd.Run == nil && cmd.RunE == nil {
cmd.RunE = unknownSubcommandRunE
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[cmdpolicy.AnnotationPureGroup] = "true"
}
for _, c := range cmd.Commands() {
installUnknownSubcommandGuard(c)
}
}
// Deprecated: unknownSubcommandRunE produces a legacy *output.ExitError that
// predates the typed error contract introduced by errs/. New code MUST NOT
// add producers of this shape — unknown-subcommand signals should move to
// a typed *errs.ValidationError (or a dedicated typed error) carrying the
// agent-protocol metadata as typed extension fields. This helper is retained
// only while existing dispatch sites are migrated; it will be removed once
// they have moved to the typed surface.
func unknownSubcommandRunE(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cmd.Help()
}
unknown := args[0]
available := availableSubcommandNames(cmd)
msg := fmt.Sprintf("unknown subcommand %q for %q", unknown, cmd.CommandPath())
hint := fmt.Sprintf("run `%s --help` to see available subcommands", cmd.CommandPath())
if len(available) > 0 {
hint = fmt.Sprintf("available subcommands: %s", strings.Join(available, ", "))
}
return &output.ExitError{
Code: output.ExitValidation,
Detail: &output.ErrDetail{
Type: "unknown_subcommand",
Message: msg,
Hint: hint,
Detail: map[string]any{
"unknown": unknown,
"command_path": cmd.CommandPath(),
"available": available,
},
},
}
}
func availableSubcommandNames(cmd *cobra.Command) []string {
subs := make([]string, 0, len(cmd.Commands()))
for _, c := range cmd.Commands() {
if c.Hidden || !c.IsAvailableCommand() {
continue
}
name := c.Name()
if name == "help" || name == "completion" {
continue
}
subs = append(subs, name)
}
sort.Strings(subs)
return subs
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips. It also force-shows global
// flags that are normally hidden in single-app mode (currently --profile)
@@ -417,22 +293,16 @@ func installTipsHelpFunc(root *cobra.Command) {
})
}
// enrichPermissionError adds console_url and improves the hint for legacy
// *output.ExitError permission errors. Differentiates between:
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope
// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized:
// user has not authorized the scope → hint to auth login
// - default: other permission errors → console + auth-login fallback
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
// + ConsoleURL on the typed envelope and remove this helper.
// enrichPermissionError adds console_url and improves the hint for permission errors.
// It differentiates between:
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the API scope → hint to admin console
// - LarkErrUserScopeInsufficient (99991679): user has not authorized the scope → hint to auth login --scope
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
return
}
// Extract required scopes from API error detail (shared helper)
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
// Extract required scopes from API error detail
scopes := extractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
@@ -443,18 +313,31 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
}
// Select the recommended (least-privilege) scope
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
scopeIfaces := make([]interface{}, len(scopes))
for i, s := range scopes {
scopeIfaces[i] = s
}
recommended := registry.SelectRecommendedScope(scopeIfaces, "tenant")
if recommended == "" {
recommended = scopes[0]
}
// Build admin console URL with the recommended scope
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
host := "open.feishu.cn"
if cfg.Brand == "lark" {
host = "open.larksuite.com"
}
consoleURL := fmt.Sprintf("https://%s/page/scope-apply?clientID=%s&scopes=%s", host, url.QueryEscape(cfg.AppID), url.QueryEscape(recommended))
// Clear raw API detail — useful info is now in message/hint/console_url.
// Clear raw API detail — useful info is now in message/hint/console_url
exitErr.Detail.Detail = nil
isBot := f.ResolvedIdentity.IsBot()
larkCode := exitErr.Detail.Code
switch larkCode {
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
// User has not authorized the scope → re-authorize
exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode)
if isBot {
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
@@ -464,11 +347,13 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
case output.LarkErrAppScopeNotEnabled:
// App has not enabled the API scope → admin console
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
exitErr.Detail.ConsoleURL = consoleURL
default:
// Other permission errors (matched by keyword)
exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode)
if isBot {
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
@@ -479,3 +364,26 @@ func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
exitErr.Detail.ConsoleURL = consoleURL
}
}
// extractRequiredScopes extracts scope names from the API error's permission_violations field.
func extractRequiredScopes(detail interface{}) []string {
m, ok := detail.(map[string]interface{})
if !ok {
return nil
}
violations, ok := m["permission_violations"].([]interface{})
if !ok {
return nil
}
var scopes []string
for _, v := range violations {
vm, ok := v.(map[string]interface{})
if !ok {
continue
}
if subject, ok := vm["subject"].(string); ok {
scopes = append(scopes, subject)
}
}
return scopes
}

View File

@@ -27,14 +27,6 @@ import (
"github.com/spf13/cobra"
)
// Canonical strict-mode envelope strings shared across fixtures
// (reflect.DeepEqual pins them; keep in sync with strictModeStubFrom).
const (
strictModeBotMessage = `strict mode is "bot", only bot-identity commands are available`
strictModeUserMessage = `strict mode is "user", only user-identity commands are available`
strictModeHint = "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)"
)
// buildIntegrationRootCmd creates a root command with api, service, and shortcut
// subcommands wired to a test factory, simulating the real CLI command tree.
func buildIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
@@ -161,8 +153,160 @@ func resetBuffers(stdout *bytes.Buffer, stderr *bytes.Buffer) {
stderr.Reset()
}
// --- api command ---
func TestIntegration_Api_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/messages",
Body: map[string]interface{}{
"code": 230002,
"msg": "Bot/User can NOT be out of the chat.",
"error": map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"api", "--as", "bot", "POST", "/open-apis/im/v1/messages",
"--params", `{"receive_id_type":"chat_id"}`,
"--data", `{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"test\"}"}`,
})
// api uses MarkRaw: detail preserved, no enrichment
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 230002,
Message: "API error: [230002] Bot/User can NOT be out of the chat.",
Detail: map[string]interface{}{
"log_id": "test-log-id-001",
},
},
})
}
func TestIntegration_Api_PermissionError_NotEnriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-api-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/perm",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled for this app",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"api", "--as", "bot", "GET", "/open-apis/test/perm",
})
// api uses MarkRaw: enrichment skipped, detail preserved, no console_url
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "Permission denied [99991672]",
Hint: "check app permissions or re-authorize: lark-cli auth login",
Detail: map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
"log_id": "test-log-id-perm",
},
},
})
}
// --- service command ---
func TestIntegration_Service_BusinessError_OutputsEnvelope(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-err", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_fake",
Body: map[string]interface{}{
"code": 99992356,
"msg": "id not exist",
"error": map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_fake"}`, "--as", "bot",
})
// service: no MarkRaw, non-permission error — detail preserved
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "api_error",
Code: 99992356,
Message: "API error: [99992356] id not exist",
Detail: map[string]interface{}{
"log_id": "test-log-id-svc",
},
},
})
}
func TestIntegration_Service_PermissionError_Enriched(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "e2e-svc-perm", AppSecret: "secret", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_test",
Body: map[string]interface{}{
"code": 99991672,
"msg": "scope not enabled",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "im:chat:readonly"},
},
},
},
})
rootCmd := buildIntegrationRootCmd(t, f)
code := executeRootIntegration(t, f, rootCmd, []string{
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "bot",
})
// service: no MarkRaw — enrichment applied, detail cleared, console_url set
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "permission",
Code: 99991672,
Message: "App scope not enabled: required scope im:chat:readonly [99991672]",
Hint: "enable the scope in developer console (see console_url)",
ConsoleURL: "https://open.feishu.cn/page/scope-apply?clientID=e2e-svc-perm&scopes=im%3Achat%3Areadonly",
},
})
}
func TestIntegration_StrictModeBot_ProfileOverride_HidesCommandsInHelp(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
@@ -209,17 +353,9 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectAuthLoginReturnsEnvelop
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "auth/login",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
Type: "strict_mode",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -235,17 +371,9 @@ func TestIntegration_StrictModeBot_ProfileOverride_DirectUserShortcutReturnsEnve
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeBotMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/+messages-search",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeBotMessage,
},
Type: "strict_mode",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -281,7 +409,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
OK: false,
Identity: "bot",
Error: &output.ErrDetail{
Type: "command_denied",
Type: "strict_mode",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -300,7 +428,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "command_denied",
Type: "strict_mode",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -318,17 +446,9 @@ func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsE
assertEnvelope(t, code, output.ExitValidation, stdout, stderr, output.ErrorEnvelope{
OK: false,
Error: &output.ErrDetail{
Type: "command_denied",
Message: strictModeUserMessage,
Hint: strictModeHint,
Detail: map[string]any{
"path": "im/images/create",
"layer": "strict_mode",
"policy_source": "strict-mode",
"rule_name": "",
"reason_code": "identity_not_supported",
"reason": strictModeUserMessage,
},
Type: "strict_mode",
Message: `strict mode is "user", only user-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
})
}
@@ -345,7 +465,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
OK: false,
Identity: "user",
Error: &output.ErrDetail{
Type: "command_denied",
Type: "strict_mode",
Message: `strict mode is "bot", only bot-identity commands are available`,
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
},
@@ -372,7 +492,7 @@ func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) {
"im", "+messages-send", "--as", "bot", "--chat-id", "oc_xxx", "--text", "test",
})
// shortcut: typed error via DoAPIJSON path
// shortcut: no MarkRaw, no HandleResponse — error via DoAPIJSON path
assertEnvelope(t, code, output.ExitAPI, stdout, stderr, output.ErrorEnvelope{
OK: false,
Identity: "bot",
@@ -492,9 +612,6 @@ func TestSetupNotices_Drift(t *testing.T) {
if msg, _ := skills["message"].(string); msg != want {
t.Errorf("notice.skills.message = %q, want %q", msg, want)
}
if cmd, _ := skills["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// TestSetupNotices_BothUpdateAndSkills verifies the composed envelope
@@ -541,20 +658,6 @@ func TestSetupNotices_BothUpdateAndSkills(t *testing.T) {
if _, ok := notice["skills"].(map[string]interface{}); !ok {
t.Errorf("missing 'skills' key: %+v", notice)
}
upd, ok := notice["update"].(map[string]interface{})
if !ok {
t.Fatalf("notice.update missing or wrong type: %+v", notice)
}
if cmd, _ := upd["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.update.command = %q, want %q", cmd, "lark-cli update")
}
sk, ok := notice["skills"].(map[string]interface{})
if !ok {
t.Fatalf("notice.skills missing or wrong type: %+v", notice)
}
if cmd, _ := sk["command"].(string); cmd != "lark-cli update" {
t.Errorf("notice.skills.command = %q, want %q", cmd, "lark-cli update")
}
}
// clearNoticeEnv unsets the env vars that affect either notice. We

View File

@@ -4,23 +4,19 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/errs"
internalauth "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/registry"
"github.com/spf13/cobra"
)
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
@@ -72,169 +68,130 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestHandleRootError_RawError_SkipsEnrichmentButWritesEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"__completeNoDesc request", []string{"__completeNoDesc", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
}
})
}
}
// isCompletionCommand must classify BOTH cobra completion aliases as
// completion requests so the Shutdown emit and update-notice paths skip
// shell-completion invocations. __completeNoDesc is an Alias of
// __complete (cobra/completions.go ShellCompNoDescRequestCmd) and
// dispatches the same RunE; bash/zsh completion typically calls the
// NoDesc variant.
func TestIsCompletionCommand(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{"plain command", []string{"im", "+send"}, false},
{"__complete", []string{"__complete", "im"}, true},
{"__completeNoDesc", []string{"__completeNoDesc", "im"}, true},
{"completion subcommand", []string{"completion", "bash"}, true},
{"completion in tail", []string{"foo", "bar", "completion"}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := isCompletionCommand(tc.args); got != tc.want {
t.Fatalf("isCompletionCommand(%v) = %v, want %v", tc.args, got, tc.want)
}
})
}
}
// TestPromoteConfigError_* lives with the implementation in
// internal/errcompat/promote_test.go.
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
// writer. Downstream OAuth/policy consumers parse a wire format that
// predates the typed taxonomy and depend on:
// - error.type == "auth_error" (not the Category literal "policy")
// - error.code is a string ("challenge_required" / "access_denied"), not a number
// - error.retryable is present at the top of the error object
// - exit code 1 (not ExitContentSafety 6)
//
// Migration of this category to the typed envelope is deferred to a later PR.
func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
cases := []struct {
name string
subtype errs.Subtype
code int
wantCode string
}{
{"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"},
{"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
errOut := &bytes.Buffer{}
f.IOStreams.ErrOut = errOut
spErr := &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: tc.subtype,
Code: tc.code,
Message: "blocked by access policy",
Hint: "complete challenge in your browser",
},
ChallengeURL: "https://example.com/challenge",
}
gotExit := handleRootError(f, spErr)
if gotExit != 1 {
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
}
var env map[string]any
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
}
errObj, ok := env["error"].(map[string]any)
if !ok {
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
}
if got := errObj["type"]; got != "auth_error" {
t.Errorf("error.type = %v, want %q", got, "auth_error")
}
if got := errObj["code"]; got != tc.wantCode {
t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode)
}
if got, ok := errObj["retryable"].(bool); !ok || got {
t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"])
}
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
t.Errorf("error.challenge_url = %v, want challenge url", got)
}
if got := errObj["hint"]; got != "complete challenge in your browser" {
t.Errorf("error.hint = %v, want hint message", got)
}
// And the typed-only fields must NOT appear on this envelope.
for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} {
if _, exists := errObj[leaked]; exists {
t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked])
}
}
})
}
}
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
// contains the need_user_authorization marker — the same shape that
// resolveAccessToken now produces when the credential chain returns
// *internalauth.NeedAuthorizationError.
func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
cause := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
return &errs.AuthenticationError{
Problem: errs.Problem{
Category: errs.CategoryAuthentication,
Subtype: errs.SubtypeUnknown,
Message: fmt.Sprintf("API call failed: %s", cause),
// Create a permission error (would normally be enriched) and mark it Raw
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
Cause: cause,
})
err.Raw = true
code := handleRootError(f, err)
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should contain the error envelope
if stderr.Len() == 0 {
t.Error("expected non-empty stderr for Raw error — WriteErrorEnvelope should always run")
}
// The message should NOT have been enriched by enrichPermissionError
// (ErrAPI sets "Permission denied [code]" but enrichment would replace it with "App scope not enabled: ...")
if strings.Contains(err.Error(), "App scope not enabled") {
t.Errorf("expected message not enriched, got: %s", err.Error())
}
// Detail.Detail should be preserved (enrichPermissionError clears it to nil)
if err.Detail != nil && err.Detail.Detail == nil {
t.Error("expected Detail.Detail to be preserved, but it was cleared")
}
}
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
// declared-scopes Hint appended when the current command is a registered
// service method.
func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
func TestHandleRootError_NonRawError_EnrichesAndWritesEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Create a permission error without Raw — should be enriched
err := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "API error: [99991672] scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": "calendar:calendar:readonly"},
},
})
code := handleRootError(f, err)
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should contain the error envelope
if stderr.Len() == 0 {
t.Error("expected non-empty stderr for non-Raw error")
}
// The message should have been enriched
if !strings.Contains(err.Error(), "App scope not enabled") {
t.Errorf("expected enriched message, got: %s", err.Error())
}
}
func TestEnrichPermissionError_SpecialCharsEscaped(t *testing.T) {
tests := []struct {
name string
appID string
scope string
wantInURL string // substring that must appear in console_url
denyInURL string // substring that must NOT appear raw in console_url
}{
{
name: "ampersand in scope",
appID: "cli_good",
scope: "scope&evil=injected",
wantInURL: "scopes=scope%26evil%3Dinjected",
denyInURL: "scopes=scope&evil=injected",
},
{
name: "hash in scope",
appID: "cli_good",
scope: "scope#fragment",
wantInURL: "scopes=scope%23fragment",
denyInURL: "scopes=scope#fragment",
},
{
name: "space in scope",
appID: "cli_good",
scope: "scope with spaces",
wantInURL: "scopes=scope+with+spaces",
},
{
name: "special chars in appID",
appID: "app&id=bad",
scope: "calendar:calendar:readonly",
wantInURL: "clientID=app%26id%3Dbad",
denyInURL: "clientID=app&id=bad",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: tt.appID, AppSecret: "test-secret", Brand: core.BrandFeishu,
})
exitErr := output.ErrAPI(output.LarkErrAppScopeNotEnabled, "scope not enabled", map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": tt.scope},
},
})
handleRootError(f, exitErr)
consoleURL := exitErr.Detail.ConsoleURL
if consoleURL == "" {
t.Fatal("expected console_url to be set")
}
if !strings.Contains(consoleURL, tt.wantInURL) {
t.Errorf("console_url missing expected escaped value\n want substring: %s\n got url: %s", tt.wantInURL, consoleURL)
}
if tt.denyInURL != "" && strings.Contains(consoleURL, tt.denyInURL) {
t.Errorf("console_url contains unescaped dangerous value\n deny substring: %s\n got url: %s", tt.denyInURL, consoleURL)
}
})
}
}
func TestEnrichMissingScopeError_ServiceMethodUsesLocalScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
@@ -266,23 +223,30 @@ func TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT(t *tes
resourceCmd.AddCommand(methodCmd)
f.CurrentCommand = methodCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
exitErr := output.Errorf(output.ExitAPI, "api_error", "API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if authErr.Category != errs.CategoryAuthentication {
t.Errorf("Category = %q, want authentication", authErr.Category)
if exitErr.Code != output.ExitAPI {
t.Fatalf("expected exit code %d, got %d", output.ExitAPI, exitErr.Code)
}
if !strings.Contains(authErr.Message, "need_user_authorization") {
t.Errorf("Message should preserve need_user_authorization marker; got %q", authErr.Message)
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
t.Fatalf("expected api_error detail, got %+v", exitErr.Detail)
}
if !strings.Contains(authErr.Hint, "current command requires scope(s): calendar:calendar.event:create") {
t.Errorf("expected declared-scope hint, got %q", authErr.Hint)
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): calendar:calendar.event:create") {
t.Fatalf("expected scope guidance in hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
// TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT pins the
// same hint behavior for mounted shortcut commands.
func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
func TestEnrichMissingScopeError_ShortcutUsesDeclaredScopesWhenNoUAT(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
@@ -297,43 +261,30 @@ func TestApplyNeedAuthorizationHint_ShortcutUsesDeclaredScopesWhenNoUAT(t *testi
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
enrichMissingScopeError(f, exitErr)
if !strings.Contains(authErr.Hint, "current command requires scope(s): docx:document:create") {
t.Errorf("expected shortcut scope hint, got %q", authErr.Hint)
if exitErr.Code != output.ExitNetwork {
t.Fatalf("expected exit code %d, got %d", output.ExitNetwork, exitErr.Code)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
t.Fatalf("expected network detail, got %+v", exitErr.Detail)
}
if !strings.Contains(exitErr.Detail.Message, "need_user_authorization") {
t.Fatalf("expected original need_user_authorization message, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "current command requires scope(s): docx:document:create") {
t.Fatalf("expected shortcut scope hint, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "lark-cli auth login --scope") {
t.Fatalf("expected hint without auth login command, got %q", exitErr.Detail.Hint)
}
if exitErr.Detail.Detail != nil {
t.Fatalf("expected detail to remain nil, got %#v", exitErr.Detail.Detail)
}
}
// TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes pins that
// conditional scopes declared on a shortcut surface in the hint.
func TestApplyNeedAuthorizationHint_ShortcutIncludesConditionalScopes(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.ResolvedIdentity = core.AsUser
root := &cobra.Command{Use: "lark-cli"}
serviceCmd := &cobra.Command{Use: "drive"}
shortcutCmd := &cobra.Command{Use: "+status"}
root.AddCommand(serviceCmd)
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
applyNeedAuthorizationHint(f, authErr)
if !strings.Contains(authErr.Hint, "current command requires scope(s): drive:drive.metadata:readonly, drive:file:download") {
t.Errorf("expected conditional scope hint for drive +status, got %q", authErr.Hint)
}
}
// TestApplyNeedAuthorizationHint_AppendsExistingHint pins that the
// declared-scopes guidance is appended (separated by newline) when the typed
// AuthenticationError already carries a Hint from elsewhere.
func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
func TestEnrichMissingScopeError_AppendsExistingHint(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
@@ -348,12 +299,46 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
serviceCmd.AddCommand(shortcutCmd)
f.CurrentCommand = shortcutCmd
authErr := newAuthErrorWithNeedAuthMarker()
authErr.Hint = "existing hint"
applyNeedAuthorizationHint(f, authErr)
exitErr := output.ErrNetwork("API call failed: %s", &internalauth.NeedAuthorizationError{})
exitErr.Detail.Hint = "existing hint"
enrichMissingScopeError(f, exitErr)
want := "existing hint\ncurrent command requires scope(s): docx:document:create"
if authErr.Hint != want {
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
if exitErr.Detail.Hint != want {
t.Fatalf("expected appended hint %q, got %q", want, exitErr.Detail.Hint)
}
}
func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) {
if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") {
t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong)
}
if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") {
t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong)
}
}
func TestConfigureFlagCompletions(t *testing.T) {
t.Cleanup(func() { cmdutil.SetFlagCompletionsEnabled(false) })
tests := []struct {
name string
args []string
wantDisabled bool
}{
{"plain command", []string{"im", "+send"}, true},
{"help flag", []string{"im", "--help"}, true},
{"no args", []string{}, true},
{"__complete request", []string{"__complete", "im", "+send", ""}, false},
{"completion subcommand", []string{"completion", "bash"}, false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
cmdutil.SetFlagCompletionsEnabled(tc.wantDisabled)
configureFlagCompletions(tc.args)
if got := !cmdutil.FlagCompletionsEnabled(); got != tc.wantDisabled {
t.Fatalf("FlagCompletionsEnabled() = %v, want disabled=%v", !got, tc.wantDisabled)
}
})
}
}

View File

@@ -380,7 +380,6 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetRisk(cmd, "read")
return cmd
}

View File

@@ -271,11 +271,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
// with a per-method recommended `--scope` hint, matching the pre-PR
// behaviour. Per-domain typed migration in stage 2+ will lift this
// into PermissionError.MissingScopes / ConsoleURL on the typed
// envelope; until then the legacy ExitError envelope is preserved.
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
if opts.PageAll {
@@ -285,7 +280,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return err
return output.ErrNetwork("API call failed: %s", err)
}
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
@@ -295,56 +290,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
ErrOut: f.IOStreams.ErrOut,
FileIO: f.ResolveFileIO(opts.Ctx),
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
CheckError: checkErr,
})
}
// scopeAwareChecker returns an error checker that enriches the
// LarkErrUserScopeInsufficient (99991679) business error with a
// per-method recommended `--scope` hint. All other non-zero codes fall
// through to legacy output.ErrAPI (matching pre-PR behaviour). The
// identity parameter is accepted to match the client.ResponseOptions
// CheckError signature; isBotMode is captured from the enclosing call so
// the recommended scope reflects the caller's identity at request time.
//
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
// + ConsoleURL on the typed envelope and remove this helper.
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error {
return func(result interface{}, _ core.Identity) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return nil
}
larkCode := int(code)
msg := registry.GetStrFromMap(resultMap, "msg")
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
identity := "user"
if isBotMode {
identity = "tenant"
}
recommended := registry.SelectRecommendedScope(scopes, identity)
// Stage-1 carve-out: this restores the pre-PR scope-insufficient
// enrichment (recommended scope + auth-login hint) on the legacy
// envelope. The typed migration in stage 2+ will lift this into
// PermissionError.MissingScopes / ConsoleURL on the typed wire.
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
}
// Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError +
// ClassifyLarkError). Typed migration is stage-2+.
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
}
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
if ctx.Err() != nil {
@@ -390,7 +339,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
recommended := registry.SelectRecommendedScope(scopes, "user")
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
@@ -525,10 +474,36 @@ func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *cor
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
// scopeAwareChecker returns an error checker that enriches scope-related errors with login hints.
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) error {
return func(result interface{}) error {
resultMap, ok := result.(map[string]interface{})
if !ok || resultMap == nil {
return nil
}
code, _ := util.ToFloat64(resultMap["code"])
if code == 0 {
return nil
}
larkCode := int(code)
msg := registry.GetStrFromMap(resultMap, "msg")
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
identity := "user"
if isBotMode {
identity = "tenant"
}
recommended := registry.SelectRecommendedScope(scopes, identity)
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
fmt.Sprintf(`run `+"`"+`lark-cli auth login --scope "%s"`+"`"+` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.`, recommended))
}
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr)
@@ -541,9 +516,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return err
return output.ErrNetwork("API call failed: %s", err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if apiErr := checkErr(result); apiErr != nil {
return apiErr
}
if !hasItems {
@@ -554,9 +529,9 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
return output.ErrNetwork("API call failed: %s", err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if apiErr := checkErr(result); apiErr != nil {
return apiErr
}
output.FormatValue(out, result, format)

View File

@@ -11,6 +11,7 @@ import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
@@ -411,6 +412,39 @@ func TestServiceMethod_BotMode_Success(t *testing.T) {
}
}
func TestServiceMethod_BotMode_APIError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-err", AppSecret: "test-secret-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 40003, "msg": "invalid token"},
})
spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}
method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected API error")
}
var exitErr *output.ExitError
if !isExitError(err, &exitErr) {
t.Fatalf("expected ExitError, got: %T %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI code, got %d", exitErr.Code)
}
// stdout must be empty on API error — error details belong in stderr envelope only.
// This guards against re-introducing duplicate output (see commit 86215a10).
if stdout.Len() > 0 {
t.Errorf("expected no stdout on API error, got: %s", stdout.String())
}
}
func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-page", AppSecret: "test-secret-page", Brand: core.BrandFeishu,
@@ -628,6 +662,73 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) {
}
}
// ── scopeAwareChecker ──
func TestScopeAwareChecker_Success(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker(map[string]interface{}{"code": 0.0, "msg": "ok"})
if err != nil {
t.Errorf("expected nil error for code=0, got: %v", err)
}
}
func TestScopeAwareChecker_NonMapResult(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker("not a map")
if err != nil {
t.Errorf("expected nil for non-map result, got: %v", err)
}
}
func TestScopeAwareChecker_APIError(t *testing.T) {
checker := scopeAwareChecker(nil, false)
err := checker(map[string]interface{}{"code": 40003.0, "msg": "bad request"})
if err == nil {
t.Fatal("expected error for non-zero code")
}
if !strings.Contains(err.Error(), "API error: [40003]") {
t.Errorf("unexpected error: %v", err)
}
}
func TestScopeAwareChecker_ScopeError_UserMode(t *testing.T) {
scopes := []interface{}{"calendar:read"}
checker := scopeAwareChecker(scopes, false)
err := checker(map[string]interface{}{
"code": float64(output.LarkErrUserScopeInsufficient),
"msg": "scope insufficient",
})
if err == nil {
t.Fatal("expected permission error")
}
var exitErr *output.ExitError
if !isExitError(err, &exitErr) {
t.Fatalf("expected ExitError, got %T", err)
}
if exitErr.Detail.Type != "permission" {
t.Errorf("expected type=permission, got %s", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Hint, "auth login") {
t.Errorf("expected auth login hint, got %s", exitErr.Detail.Hint)
}
}
func TestScopeAwareChecker_ScopeError_BotMode(t *testing.T) {
scopes := []interface{}{"calendar:read"}
checker := scopeAwareChecker(scopes, true)
err := checker(map[string]interface{}{
"code": float64(output.LarkErrUserScopeInsufficient),
"msg": "scope insufficient",
})
if err == nil {
t.Fatal("expected permission error")
}
// Bot mode should still include the scope hint
if !strings.Contains(err.Error(), "insufficient permissions") {
t.Errorf("unexpected error: %v", err)
}
}
// ── file upload ──
func imImageMethod() map[string]interface{} {
@@ -765,3 +866,13 @@ func TestDetectFileFields(t *testing.T) {
})
}
}
// ── helpers ──
func isExitError(err error, target **output.ExitError) bool {
ee, ok := err.(*output.ExitError)
if ok && target != nil {
*target = ee
}
return ok
}

View File

@@ -1,177 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/output"
)
func newGroupTree() (root, drive, files *cobra.Command) {
root = &cobra.Command{Use: "lark-cli"}
drive = &cobra.Command{Use: "drive", Short: "drive ops"}
root.AddCommand(drive)
search := &cobra.Command{Use: "+search", RunE: func(*cobra.Command, []string) error { return nil }}
upload := &cobra.Command{Use: "+upload", RunE: func(*cobra.Command, []string) error { return nil }}
hidden := &cobra.Command{Use: "+secret", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}
drive.AddCommand(search, upload, hidden)
files = &cobra.Command{Use: "files", Short: "files ops"}
drive.AddCommand(files)
files.AddCommand(&cobra.Command{Use: "list", RunE: func(*cobra.Command, []string) error { return nil }})
return root, drive, files
}
func TestInstallUnknownSubcommandGuard_InstallsOnGroupsOnly(t *testing.T) {
root, drive, files := newGroupTree()
leaf := drive.Commands()[0] // +search
installUnknownSubcommandGuard(root)
if drive.RunE == nil {
t.Error("drive should have RunE installed")
}
if files.RunE == nil {
t.Error("files should have RunE installed")
}
if err := leaf.RunE(leaf, []string{"unexpected-arg"}); err != nil {
t.Errorf("leaf +search RunE should be untouched, got error %v", err)
}
}
func TestInstallUnknownSubcommandGuard_PreservesExistingRunE(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
called := false
custom := &cobra.Command{
Use: "custom",
RunE: func(*cobra.Command, []string) error {
called = true
return nil
},
}
// Child makes custom a "group" command, exercising the Run/RunE override guard.
custom.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
root.AddCommand(custom)
installUnknownSubcommandGuard(root)
if err := custom.RunE(custom, nil); err != nil {
t.Fatalf("preserved RunE returned error: %v", err)
}
if !called {
t.Error("guard must not overwrite a command that already defines Run/RunE")
}
}
func TestUnknownSubcommandRunE_NoArgsShowsHelp(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
var buf bytes.Buffer
drive.SetOut(&buf)
drive.SetErr(&buf)
if err := drive.RunE(drive, nil); err != nil {
t.Fatalf("expected no-args invocation to succeed, got: %v", err)
}
if !strings.Contains(buf.String(), "drive ops") {
t.Errorf("expected help output to include the command's Short, got:\n%s", buf.String())
}
}
func TestUnknownSubcommandRunE_UnknownReturnsStructuredError(t *testing.T) {
_, drive, _ := newGroupTree()
installUnknownSubcommandGuard(drive.Root())
err := drive.RunE(drive, []string{"+bogus"})
if err == nil {
t.Fatal("expected error for unknown subcommand")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T", err)
}
if exitErr.Code != output.ExitValidation {
t.Errorf("expected exit code %d, got %d", output.ExitValidation, exitErr.Code)
}
if exitErr.Detail == nil {
t.Fatal("expected ExitError to carry Detail")
}
if exitErr.Detail.Type != "unknown_subcommand" {
t.Errorf("expected Detail.Type=unknown_subcommand, got %q", exitErr.Detail.Type)
}
if !strings.Contains(exitErr.Detail.Message, `"+bogus"`) {
t.Errorf("message should echo the unknown token, got %q", exitErr.Detail.Message)
}
if !strings.Contains(exitErr.Detail.Hint, "+search") || !strings.Contains(exitErr.Detail.Hint, "+upload") {
t.Errorf("hint should list available shortcuts, got %q", exitErr.Detail.Hint)
}
if strings.Contains(exitErr.Detail.Hint, "+secret") {
t.Error("hidden commands must not appear in the hint")
}
detail, ok := exitErr.Detail.Detail.(map[string]any)
if !ok {
t.Fatalf("expected Detail.Detail to be map[string]any, got %T", exitErr.Detail.Detail)
}
if detail["unknown"] != "+bogus" {
t.Errorf("detail.unknown should be +bogus, got %v", detail["unknown"])
}
if detail["command_path"] != "lark-cli drive" {
t.Errorf("detail.command_path should be %q, got %v", "lark-cli drive", detail["command_path"])
}
available, ok := detail["available"].([]string)
if !ok {
t.Fatalf("detail.available should be []string, got %T", detail["available"])
}
if len(available) != 3 {
t.Errorf("expected 3 available entries (hidden excluded), got %d: %v", len(available), available)
}
}
func TestUnknownSubcommandRunE_NestedResourceGroup(t *testing.T) {
root, _, files := newGroupTree()
installUnknownSubcommandGuard(root)
err := files.RunE(files, []string{"bogus"})
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError on nested group, got %T", err)
}
if exitErr.Detail.Detail.(map[string]any)["command_path"] != "lark-cli drive files" {
t.Errorf("command_path should reflect the nested resource, got %v",
exitErr.Detail.Detail.(map[string]any)["command_path"])
}
}
func TestAvailableSubcommandNames_FiltersHelpAndCompletion(t *testing.T) {
root := &cobra.Command{Use: "lark-cli"}
root.AddCommand(
&cobra.Command{Use: "alpha", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "help", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "completion", RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "beta", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }},
&cobra.Command{Use: "gamma", RunE: func(*cobra.Command, []string) error { return nil }},
)
got := availableSubcommandNames(root)
want := []string{"alpha", "gamma"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
}
for i, name := range want {
if got[i] != name {
t.Errorf("availableSubcommandNames[%d] = %q, want %q", i, got[i], name)
}
}
}

View File

@@ -111,7 +111,6 @@ Use --check to only check for updates without installing.`,
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.Force, "force", false, "force reinstall even if already up to date")
cmd.Flags().BoolVar(&opts.Check, "check", false, "only check for updates, do not install")
cmdutil.SetRisk(cmd, "high-risk-write")
return cmd
}
@@ -228,7 +227,7 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
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, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
fmt.Fprintf(io.ErrOut, "\nOr install via npm:\n npm install -g %s@%s\n", selfupdate.NpmPackage, latest)
emitSkillsTextHints(io, skillsResult)
return nil
}
@@ -325,7 +324,7 @@ func verificationFailureHint(updater *selfupdate.Updater, latest string) string
if updater.CanRestorePreviousVersion() {
return "the previous version has been restored"
}
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: npm install -g %s@%s, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
// runSkillsAndStamp triggers updater.RunSkillsUpdate and persists the

View File

@@ -481,12 +481,6 @@ func TestUpdateNpmVerifyFail_JSON_NoRestoreHintWhenBackupUnavailable(t *testing.
if !strings.Contains(out, "npm install -g @larksuite/cli@2.0.0") {
t.Errorf("expected manual reinstall command in hint, got: %s", out)
}
if !strings.Contains(out, "skills will not be synced") {
t.Errorf("expected skills-not-synced warning in rollback hint, got: %s", out)
}
if !strings.Contains(out, "npx skills add larksuite/cli -y -g") {
t.Errorf("expected npx skills add hint for skills sync, got: %s", out)
}
}
func TestUpdateCheck_JSON_Npm(t *testing.T) {

View File

@@ -1,558 +0,0 @@
# lark-cli Error Contract
`errs/` defines a typed, RFC 7807aligned error taxonomy for the CLI. Three
audiences depend on it: **AI agents and shell scripts** parsing the JSON
envelope on stderr; **protocol adapters** mapping CLI errors into MCP /
OAuth shapes; and **framework + business code** producing errors. This file
is the single source of truth for all three.
This document describes the **typed authoring target**. The refactor lands
in stages; some boundaries (e.g. `client.WrapDoAPIError`) still operate on
legacy shapes today — see **Migration** for what is live in each stage.
Migrating an `*output.ExitError` call site? See **Migration**. Something off
in production? See **Troubleshooting**.
## Invariants
1. Every error belongs to exactly one **Category**. The set is closed
(`errs/category.go`); adding a member requires deliberate review.
2. Every **newly constructed** typed error has a **Subtype** — a stable
lowercase-with-underscores identifier declared in `errs/subtypes*.go`.
Undeclared subtypes fail CI. The constraint applies only to typed
`*errs.*` literals; stage-1 legacy `*core.ConfigError` flows via the
dispatcher's `asExitError` → legacy envelope path (not the typed
taxonomy) and is unaffected. `errcompat.PromoteConfigError` is a
stage-1 passthrough; its stage-2+ typed migration will subject the
promoted typed error to this Subtype constraint at that time.
3. **`Category` + `Subtype`** are wire-stable identifiers consumers may
branch on. Renaming either is a breaking change.
4. `Code` is the upstream numeric code when known (e.g. Lark API code).
It is `omitempty` and never carries CLI-internal meaning.
5. Every typed error embeds `errs.Problem`. `CheckProblemEmbed` rejects
exported `*Error` structs that do not.
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
unchanged across the `errors.As` / `errors.Unwrap` chain.
7. For the typed-envelope path, exit codes derive from `Category` only
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
both are retired in the legacy-removal stage.
## Wire format
Typed errors render to **stderr** as one JSON object per process exit:
```json
{
"ok": false,
"identity": "user",
"error": {
"type": "authorization",
"subtype": "missing_scope",
"code": 99991679,
"message": "missing scope `calendar:event:create` for app cli_xxx",
"hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d",
"missing_scopes": ["calendar:event:create"],
"console_url": "https://open.feishu.cn/app/cli_xxx/auth?q=..."
}
}
```
| Field | Stability | Notes |
|-------|-----------|-------|
| `ok` | wire-stable | always `false` for errors |
| `identity` | wire-stable | `user` \| `bot` — caller identity; omitted when not resolved |
| `error.type` | **wire-stable** | one of the 9 Categories |
| `error.subtype` | **wire-stable** | declared Subtype constant |
| `error.code` | wire-stable | upstream numeric code, omitted when zero |
| `error.message` | informational | not safe to branch on |
| `error.hint` | informational | actionable recovery guidance |
| `error.log_id` | informational | upstream request id (server-side trace) |
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
Carve-out: `SecurityPolicyError` keeps the legacy
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
envelope until its consumers migrate. Removal is staged in **Migration**.
## Categories
| Category | When | Exit | Typed struct |
|----------|------|------|--------------|
| `validation` | malformed user input | 2 | `ValidationError` |
| `authentication` | no valid token / login required | 3 | `AuthenticationError` |
| `authorization` | token lacks scope / app permission denied | 3 | `PermissionError` |
| `config` | local config missing / unbound | 3 | `ConfigError` |
| `network` | DNS, refused, timeout, transport | 4 | `NetworkError` |
| `api` | server-side Lark error w/o specific bucket | 1 | `APIError` |
| `policy` | content safety / security challenge | 6 | `SecurityPolicyError`, `ContentSafetyError` |
| `internal` | SDK contract violation / decode failure | 5 | `InternalError` |
| `confirmation` | high-risk action needs `--yes` | 10 | `ConfirmationRequiredError` |
Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
> **Note on the `authorization` / `PermissionError` asymmetry.** The wire
> `type` field uses the RFC 7807 / taxonomy-formal name `"authorization"`,
> but the Go type is named `PermissionError`. This is deliberate, following
> the gRPC / Google APIs convention (`codes.Unauthenticated` +
> `codes.PermissionDenied`): each name is chosen to be **maximally
> distinct and readable on its own**, not to be perfectly symmetric.
> `AuthenticationError` and `AuthorizationError` differ visually only at
> the 5th character and are easy to confuse in code review;
> `AuthenticationError` and `PermissionError` cannot be confused. The wire
> field stays formal because it is the protocol-level taxonomy; the Go
> type favors call-site readability.
## Flow
```
call site
│ constructs typed error (e.g. *errs.ValidationError)
command runE returns err
cmd/root.go handleRootError dispatches:
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
├─ *core.ConfigError → asExitError adapts to legacy envelope ↓
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
```
Only the typed and `*output.ExitError` branches emit a JSON envelope on
stderr. Untyped errors (including Cobra's "required flag missing" / unknown
subcommand messages) print plain text and exit `1` — consumers must
tolerate that fallback.
## Consumers
### Go (in-process)
```go
var pe *errs.PermissionError
if errors.As(err, &pe) {
fmt.Println("missing:", pe.MissingScopes)
}
```
Predicates cover the common categories (`errs/predicates.go`):
```go
if errs.IsAuthentication(err) { ... }
if errs.IsPermission(err) { ... }
if errs.IsValidation(err) { ... }
```
Type-agnostic field access:
```go
if p, ok := errs.ProblemOf(err); ok {
log.Printf("cat=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
}
exitCode := output.ExitCodeOf(err) // ExitInternal for non-typed errors
```
### Shell / AI
```bash
out=$(lark-cli ... 2>&1)
code=$?
# Untyped / Cobra errors print plain text — guard before jq.
if ! jq -e . >/dev/null 2>&1 <<<"$out"; then
printf '%s\n' "$out" >&2
exit "$code"
fi
case "$(jq -r '.error.type // empty' <<<"$out")" in
authorization) jq -r '.error.missing_scopes[]' <<<"$out" ;;
network) echo "transport failure, safe to retry" ;;
internal) echo "bug — file an issue with log_id $(jq -r '.error.log_id // "n/a"' <<<"$out")" ;;
esac
```
Unknown fields are forward-compatible additions: ignore, don't fail.
Branch only on `type`, `subtype`, `code`, `retryable`, and declared
extension fields — `message` is human-readable prose that may be
reworded without notice.
## Producers
### Quick reference
| Situation | Use |
|-----------|-----|
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
| Login required | `&errs.AuthenticationError{...}` |
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
| Local config missing | `&errs.ConfigError{...}` |
| Transport failure | `&errs.NetworkError{...}` |
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
### Authoring discipline
Five rules every producer follows. Some are enforced by `lint/errscontract`
AST guards (`go run -C lint . ..`); the rest by code review.
#### Propagate typed errors unchanged
A function that receives an error already carrying `errs.Problem`
returns it as-is up the stack. Reclassification at non-boundary frames
(e.g., wrapping a `*ValidationError` into `*InternalError`) defeats the
single-source taxonomy and silently downgrades typed signals.
Conforming:
```go
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary
}
```
Non-conforming:
```go
return fmt.Errorf("calling /open-apis: %v", err) // %v strips the typed shape
return &errs.InternalError{Cause: err} // re-decides category
```
#### Never return a typed-nil pointer
A typed-nil pointer (`var pe *errs.PermissionError; return pe`) wraps as
a non-nil interface — `errors.As` matches and `.Error()` may panic.
Return interface `nil` literally.
Non-conforming:
```go
var e *errs.ValidationError // nil pointer
return e // non-nil interface holding nil pointer
```
#### Let `Category` derive the exit code
Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
maps `Category` to the shell code. A new exit-code requirement means a
new `Category`, not a one-off override at the call site.
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
codes during stage 1.)
#### Split `Message`, `Hint`, and `Cause`
Each field carries a distinct role:
| Field | Carries | Style |
|-------|---------|-------|
| `Message` | What is wrong | Direct, lowercase first letter, no trailing period |
| `Hint` | What to do next | Imperative ("run `lark-cli auth login`", "use `--as user`") |
| `Cause` | The wrapped upstream `error`, not a stringified copy | Typed; serialized as `json:"-"` |
`Hint` must not be merged into `Message`. AI agents and humans read them
on separate channels; merging defeats both.
`Cause` must be a real `error`. If the upstream returned an `error`,
place it in `Cause` so `errors.Is` and `errors.Unwrap` walk the chain —
do not inline its `.Error()` into `Message`.
Conforming:
```go
return &errs.NetworkError{
Problem: errs.Problem{
Category: errs.CategoryNetwork,
Subtype: errs.SubtypeNetworkTransport,
Message: "request to /open-apis failed after 3 retries",
Hint: "check connectivity and retry; set --log-level debug if it persists",
},
Cause: ioErr,
}
```
Non-conforming:
```go
Message: fmt.Sprintf("request failed: %v — retry later", ioErr)
// conflates what + what-to-do + cause into one string
```
#### `ValidationError.Param` uses the `--flag` form
When a `*ValidationError` originates from a flag value, `Param` holds the
flag name with leading dashes (`"--priority"`, not `"priority"`). AI
agents grep this field literally to surface "the bad flag was `--X`".
For positional arguments, use the canonical name without dashes
(`"target_user_id"`).
### Constructing typed errors
The minimal struct literal:
```go
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
},
Param: "--data",
}
```
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
remain callable during migration; new code should prefer the struct
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
#### Shortcut `Execute` walkthrough
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
form is `output.ErrValidation("--duration-minutes must be between 1 and
1440")`. The typed migration target:
```go
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
duration := runtime.Int("duration-minutes")
if duration < 1 || duration > 1440 {
return &errs.ValidationError{
Problem: errs.Problem{
Category: errs.CategoryValidation,
Subtype: errs.SubtypeInvalidArgument,
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
Hint: "pass a value in [1, 1440]",
},
Param: "--duration-minutes",
}
}
_, err := runtime.DoAPI(req, opts)
if err != nil {
return err // already typed by the framework boundary; propagate
}
return nil
}
```
Two patterns visible: a producer site (the typed `*errs.ValidationError`
above) and a propagation site (the `return err` after `runtime.DoAPI`,
applying [Propagate typed errors unchanged](#propagate-typed-errors-unchanged)).
When the validation logic outgrows a single range check — multiple
flags, format parsing, conditional rules — extract it into a helper that
also returns the typed `*errs.ValidationError`. The helper, not
`Execute`, sets `Param` (a helper bound to one shortcut is normal in
this codebase; see `parseTimeRange` in
`shortcuts/calendar/calendar_agenda.go:144`).
### Wrapping upstream errors
When a producer receives an error from a function it called, four cases
cover the decision:
| Source | Decision | Example |
|--------|----------|---------|
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` |
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
Prefer the `Cause` field over `fmt.Errorf("ctx: %w", err)` when
attaching an upstream error to a typed one. `Cause` is the chain
`errs.UnwrapTypedError` walks and the chain consumer code expects;
`fmt.Errorf("...: %w", err)` only affects `.Error()` output, which the
wire envelope does not surface.
#### Boundary helpers (framework-internal)
These helpers are called from framework boundaries, not from domain
code:
- `errs.WrapInternal(err)` — lifts an untyped error to `*InternalError`;
already-typed errors pass through unchanged.
- `client.WrapDoAPIError(err)` — classifies SDK transport / decode
failures into `*errs.NetworkError` / `*errs.InternalError` at the SDK
boundary.
- `client.WrapJSONResponseParseError(body, err)` — lifts response-layer
JSON parse failures to `*errs.InternalError`.
If you find yourself reaching for `WrapDoAPIError` from a `shortcuts/**`
package, you are probably calling the SDK at the wrong layer — go
through `runtime.DoAPI`.
### Extending the taxonomy
#### Add a Subtype
1. Add a constant in `errs/subtypes.go` (framework) or
`errs/subtypes_service_<name>.go` (service).
2. If it maps from a Lark code, register the mapping in
`internal/errclass/codemeta_<service>.go`.
3. Add a dispatch test in `internal/errclass/classify_test.go`.
4. Reference the constant from a producer.
5. `go run -C lint . ..``CheckDeclaredSubtype` fails until the
constant is wired through.
`ad_hoc_*` subtypes are a temporary unblocker that label a value for
follow-up, not a permanent identifier. Resolve any `ad_hoc_*` to a
declared constant within one week of introduction; `CheckAdHocSubtype`
emits a warning to keep them visible.
#### Add a typed Error struct
Rare; the existing structs cover the 9 Categories with room. If you must:
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
nil-receiver-safe `Unwrap()` if it carries `Cause`.
2. Add an `IsXxx` predicate in `errs/predicates.go`.
3. Add a wire-format pin in `errs/marshal_test.go`.
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
top-level wire fields are forbidden — per-Subtype data goes into the
typed struct as a documented extension field, not into the envelope's
top level.
## CI guards
| Check | Enforces | Where |
|-------|----------|-------|
| forbidigo | business path (`shortcuts/**`, `cmd/service/**`) must not call legacy `output.*` error constructors — route through the typed classifier | `.golangci.yml` |
| `CheckProblemEmbed` | every exported `*Error` embeds `errs.Problem` | `lint/errscontract/` AST |
| `CheckNoRegistrar` | no `mergeCodeMeta` / `RegisterServiceMap` from service code | `lint/errscontract/` AST |
| `CheckAdHocSubtype` | `ad_hoc_*` Subtypes labeled for promotion (warn) | `lint/errscontract/` AST |
| `CheckDeclaredSubtype` | every `Subtype:` value is a declared constant or `ad_hoc_*` | `lint/errscontract/` AST |
| `CheckTypedErrorCompleteness` | every `*errs.<X>Error{Problem: errs.Problem{...}}` literal must set `Category`, `Subtype`, and `Message` | `lint/errscontract/` AST |
CI runs `lint/` on every PR. Locally: `go run -C lint . ..`. The
lintcheck CLI lives in its own Go module so its `golang.org/x/tools`
dependency stays out of the shipped `lark-cli` binary's module graph;
see `lint/README.md` for how to add a new lint domain.
## Stability
| Tier | Surface | Change policy |
|------|---------|---------------|
| Wire-stable | `error.type`, `error.subtype`, `error.code`, `error.retryable`, declared extension fields, `Category` enum values | breaking change ⇒ semver major; deprecation window required |
| Additive | new Category, new declared Subtype, new extension field on an existing struct | minor release; consumers ignore unknown fields by contract |
| Experimental | `ad_hoc_*` Subtypes; fields documented as such in `errs/types.go` | may change or be promoted/removed within one release |
The deprecated `*output.ExitError` surface is outside these tiers — it
will be removed once business migration completes.
## Migration
The error-contract refactor lands in stages. This PR is **stage 1**, and
its scope is **strictly framework-only**: every production wire shape
matches pre-PR byte-for-byte (additive fields only where the legacy slot
had no subtype emission). Stage 1 ships infrastructure; behavioural
migration of any specific path lives in later stages.
Stages:
1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy,
classifier (`internal/errclass`), promotion stub (`internal/errcompat`,
passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`),
and six lint guards (forbidigo + five AST checks). Wire shapes
preserved byte-for-byte versus pre-PR, with **one intentional semantic
fix**: config-class errors (`*core.ConfigError`) now exit `3` instead
of `2`, aligning with `ExitCodeForCategory` (config errors share the
auth exit slot per the taxonomy). The classifier and promote helpers
are *shipped but unused* in production paths — they exist so stage 2+
migrations can plug in without re-architecting.
2. **`SecurityPolicyError` typed envelope** — replace the legacy
`type: "auth_error"` carve-out with the typed shape.
3. **Business-domain migration**, one PR per domain in declared order:
`task → drive → calendar → im → mail → whiteboard → contact`. Each PR
moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` /
`output.ErrWithHint(...)` call sites to typed constructors or
`BuildAPIError`, removes its Deprecated annotations, and announces the
wire change explicitly.
4. **Framework-boundary migration**: `client.WrapDoAPIError` and
`client.WrapJSONResponseParseError` flip to typed wrap;
`client.CheckResponse` adopts `errclass.BuildAPIError`;
`internal/client/client.go resolveAccessToken` adopts the typed
`NeedAuthorizationError → *errs.AuthenticationError` recognition;
`cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed
`*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the
`Type="config"` (and later `Type="auth"`) branches to typed.
5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no
production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`,
`ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and
`ErrorEnvelope`.
During migration, helper assertions accept both shapes (see
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
so the build stays green domain-by-domain.
Before / after at a call site (illustrative — actually performed in
stage 3):
```go
// before (legacy)
return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
// after (typed) — cc carries Brand / AppID / Identity from the caller's context
return errclass.BuildAPIError(parsedResp, cc)
```
## Troubleshooting
**Envelope shows `type=api subtype=unknown` for what should be a more
specific category.** The Lark code is unknown to `LookupCodeMeta` and fell
through to the generic bucket (`internal/errclass/classify.go`). Add the
code to `internal/errclass/codemeta_<service>.go` with the right Category
and Subtype, plus a dispatch test in `classify_test.go`.
**Envelope shows `type=internal subtype=sdk_error`.** Origin is
`client.WrapDoAPIError` taking the non-transport branch
(`internal/client/api_errors.go`). Check: did the SDK fail to decode the
response (look for `subtype=invalid_response` in the wrapped chain)? Was the
transport detection too narrow for this error (e.g. a `*url.Error` with an
inner that does not satisfy `net.Error`)? Either widen the transport
predicate or add an explicit typed wrap upstream.
**`CheckDeclaredSubtype` rejects my Subtype.** The constant must be
declared in `errs/subtypes*.go` *and* referenced from the dispatch path.
Bare string literals trip `CheckDeclaredSubtype` unless they match the
`ad_hoc_*` prefix; `ad_hoc_*` then trips `CheckAdHocSubtype` as a
follow-up warning.
**`errors.As(&typedErr)` panics with a nil-pointer receiver.** A typed-nil
slipped through. All typed errors define nil-safe `Unwrap()`, but
returning a typed-nil pointer up the stack still defeats `errors.As`.
Return interface `nil` from constructors, never a typed-nil pointer.
**Exit code is 5 (internal) when I expected 3 (auth).** The error was not
typed before reaching `handleRootError`. Wrap at the boundary
(`client.WrapDoAPIError` or a typed constructor) — the bare `error.Error()`
string cannot be classified retroactively.
## Security & privacy
- `log_id` is a server-side trace token. Safe to surface; it does not
carry user content.
- `missing_scopes` is app configuration, not user data.
- `Message` and `Hint` must not contain tokens, JWTs, or personally
identifying values. CI does not catch this — producer responsibility.
- Wrapped `Cause` is **not** serialized to the wire (`json:"-"`). It is
retained for in-process `errors.Is` / `errors.Unwrap` traversal and
optional debug logging only.
## Pointers (task-driven)
- *Which struct to construct?* → **Producers / Quick reference**
- *Add a new condition?* → **Add a Subtype**
- *Consume from a shell script?* → **Consumers / Shell / AI**
- *Understand or fix a CI failure?* → **CI guards**
- *Migrate a legacy `ExitError` call site?* → **Migration** + the
Deprecated note on the symbol being replaced.
- *Read source.* → `errs/doc.go``errs/category.go``errs/types.go`
`errs/predicates.go``internal/errclass/`
`cmd/root.go` `handleRootError`.

View File

@@ -1,19 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Category is the top-level taxonomy axis. Wire JSON: "type".
type Category string
const (
CategoryValidation Category = "validation"
CategoryAuthentication Category = "authentication"
CategoryAuthorization Category = "authorization"
CategoryConfig Category = "config"
CategoryNetwork Category = "network"
CategoryAPI Category = "api"
CategoryPolicy Category = "policy"
CategoryInternal Category = "internal"
CategoryConfirmation Category = "confirmation"
)

View File

@@ -1,31 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "testing"
func TestCategoryWireValues(t *testing.T) {
tests := []struct {
name string
got Category
want string
}{
{"validation", CategoryValidation, "validation"},
{"authentication", CategoryAuthentication, "authentication"},
{"authorization", CategoryAuthorization, "authorization"},
{"config", CategoryConfig, "config"},
{"network", CategoryNetwork, "network"},
{"api", CategoryAPI, "api"},
{"policy", CategoryPolicy, "policy"},
{"internal", CategoryInternal, "internal"},
{"confirmation", CategoryConfirmation, "confirmation"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if string(tt.got) != tt.want {
t.Errorf("category %s = %q, want %q", tt.name, string(tt.got), tt.want)
}
})
}
}

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package errs is the public error-contract surface for lark-cli.
//
// It defines a closed taxonomy (9 Categories) and a small set of typed
// errors that embed Problem — an RFC 7807-aligned shared shape. External
// consumers (AI agents, shell scripts, integrating SDKs) read structured
// fields instead of regex-parsing free-string error messages.
//
// # The Problem shape
//
// Every typed error embeds Problem so the JSON wire shape (`type`,
// `subtype`, `code`, `message`, `hint`, `log_id`, `retryable`) is uniform
// across categories. Typed extensions (PermissionError.MissingScopes,
// SecurityPolicyError.ChallengeURL, etc.) appear at the top level of the
// envelope alongside the shared fields, not nested under a `detail` key.
//
// # Working with typed errors
//
// Use ProblemOf to read shared fields polymorphically:
//
// if p, ok := errs.ProblemOf(err); ok {
// log.Printf("category=%s subtype=%s retryable=%t", p.Category, p.Subtype, p.Retryable)
// }
//
// Use the IsXxx predicates or stdlib errors.As to branch on concrete type:
//
// if errs.IsPermission(err) {
// var pe *errs.PermissionError
// _ = errors.As(err, &pe)
// fmt.Println("missing scopes:", pe.MissingScopes)
// }
//
// Use WrapInternal at boundaries to lift any non-typed error to
// *InternalError; typed errors pass through unchanged.
package errs

View File

@@ -1,11 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// problemCarrier is the non-exported extraction interface.
// Used by ProblemOf via errors.As, working around the Go embed semantic where
// *Problem cannot match *PermissionError directly.
type problemCarrier interface {
ProblemDetail() *Problem
}

View File

@@ -1,235 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"strings"
"testing"
)
// Per-type marshal tests pin each typed error's wire shape against its
// canonical fields. They guard against future refactors that change struct
// layout from accidentally altering the externally visible JSON contract.
//
// Each test asserts (a) Problem fields surface at the top level via embed
// promotion, (b) extension fields sit alongside as siblings (NOT under a
// `detail` sub-object), and (c) omitempty is honored on optional fields.
func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
pe := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization, Subtype: SubtypeMissingScope, Code: 99991679,
Message: "x", Hint: "y", LogID: "lg", Retryable: false,
},
MissingScopes: []string{"docx:document"},
Identity: "user",
ConsoleURL: "https://example",
}
b, err := json.Marshal(pe)
if err != nil {
t.Fatal(err)
}
s := string(b)
for _, want := range []string{
`"type":"authorization"`,
`"subtype":"missing_scope"`,
`"code":99991679`,
`"message":"x"`,
`"hint":"y"`,
`"log_id":"lg"`,
`"missing_scopes":["docx:document"]`,
`"identity":"user"`,
`"console_url":"https://example"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
if strings.Contains(s, `"retryable"`) {
t.Errorf("retryable should be omitted when false; got %s", s)
}
if strings.Contains(s, `"detail"`) {
t.Errorf("extension fields must not be wrapped under detail; got %s", s)
}
}
func TestValidationError_MarshalJSON(t *testing.T) {
ve := &ValidationError{
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
Param: "--scope",
}
b, _ := json.Marshal(ve)
s := string(b)
for _, want := range []string{
`"type":"validation"`,
`"subtype":"invalid_argument"`,
`"message":"bad"`,
`"param":"--scope"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// Param omitempty when ""
ve2 := &ValidationError{Problem: Problem{Category: CategoryValidation, Message: "x"}}
b2, _ := json.Marshal(ve2)
if strings.Contains(string(b2), `"param"`) {
t.Errorf("param should be omitted when empty; got %s", b2)
}
}
func TestAuthError_MarshalJSON(t *testing.T) {
ae := &AuthenticationError{
Problem: Problem{Category: CategoryAuthentication, Subtype: SubtypeTokenExpired, Message: "expired"},
UserOpenID: "ou_x",
}
b, _ := json.Marshal(ae)
s := string(b)
for _, want := range []string{
`"type":"authentication"`,
`"subtype":"token_expired"`,
`"message":"expired"`,
`"user_open_id":"ou_x"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestConfigError_MarshalJSON(t *testing.T) {
ce := &ConfigError{
Problem: Problem{Category: CategoryConfig, Subtype: SubtypeInvalidClient, Message: "bad"},
Field: "app_id",
}
b, _ := json.Marshal(ce)
s := string(b)
for _, want := range []string{`"type":"config"`, `"subtype":"invalid_client"`, `"field":"app_id"`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestNetworkError_MarshalJSON(t *testing.T) {
ne := &NetworkError{
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
CauseKind: "timeout",
}
b, _ := json.Marshal(ne)
s := string(b)
for _, want := range []string{
`"type":"network"`,
`"subtype":"transport"`,
`"cause":"timeout"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// CauseKind omitempty when ""
ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}}
b2, _ := json.Marshal(ne2)
if strings.Contains(string(b2), `"cause"`) {
t.Errorf("cause should be omitted when empty; got %s", b2)
}
}
func TestAPIError_MarshalJSON(t *testing.T) {
ae := &APIError{
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
Detail: map[string]any{"raw": "value"},
}
b, _ := json.Marshal(ae)
s := string(b)
for _, want := range []string{
`"type":"api"`,
`"subtype":"rate_limit"`,
`"code":99991400`,
`"retryable":true`,
`"detail":{`,
`"raw":"value"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
// Detail omitempty when nil
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
b2, _ := json.Marshal(ae2)
if strings.Contains(string(b2), `"detail"`) {
t.Errorf("detail should be omitted when nil; got %s", b2)
}
}
func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
spe := &SecurityPolicyError{
Problem: Problem{Category: CategoryPolicy, Subtype: SubtypeChallengeRequired, Message: "blocked"},
ChallengeURL: "https://chal.example",
}
b, _ := json.Marshal(spe)
s := string(b)
for _, want := range []string{
`"type":"policy"`,
`"subtype":"challenge_required"`,
`"challenge_url":"https://chal.example"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestContentSafetyError_MarshalJSON(t *testing.T) {
cse := &ContentSafetyError{
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
Rules: []string{"pii", "violence"},
}
b, _ := json.Marshal(cse)
s := string(b)
for _, want := range []string{
`"type":"policy"`,
`"rules":["pii","violence"]`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestInternalError_MarshalJSON(t *testing.T) {
ie := &InternalError{
Problem: Problem{Category: CategoryInternal, Subtype: SubtypeSDKError, Message: "boom"},
}
b, _ := json.Marshal(ie)
s := string(b)
for _, want := range []string{`"type":"internal"`, `"subtype":"sdk_error"`} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}
func TestConfirmationRequiredError_MarshalJSON(t *testing.T) {
cre := &ConfirmationRequiredError{
Problem: Problem{Category: CategoryConfirmation, Subtype: Subtype("confirmation_required"), Message: "confirm"},
Risk: "write",
Action: "mail +send",
}
b, _ := json.Marshal(cre)
s := string(b)
for _, want := range []string{
`"type":"confirmation"`,
`"risk":"write"`,
`"action":"mail +send"`,
} {
if !strings.Contains(s, want) {
t.Errorf("missing %q in %s", want, s)
}
}
}

View File

@@ -1,88 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"errors"
)
// ProblemOf extracts the embedded Problem via the non-exported problemCarrier interface.
// This is the supported way to read shared fields without depending on a specific typed error.
//
// A typed error whose embedded *Problem is nil is treated as "not a problem
// carrier" — returning (nil, true) here would cause CategoryOf / IsRetryable
// and other downstream readers to dereference nil.
func ProblemOf(err error) (*Problem, bool) {
var c problemCarrier
if errors.As(err, &c) {
if p := c.ProblemDetail(); p != nil {
return p, true
}
}
return nil, false
}
// UnwrapTypedError walks the wrap chain and returns the first error that
// embeds Problem (i.e. any typed error in this package). Returns the typed
// error itself (as error) so callers — notably JSON marshaling — see the
// concrete value's own struct tags rather than an opaque wrapper.
func UnwrapTypedError(err error) (error, bool) {
var c problemCarrier
if errors.As(err, &c) {
if e, ok := c.(error); ok {
return e, true
}
}
return nil, false
}
// CategoryOf returns the error's Category for metrics/logging/dispatch routing.
// Falls back to CategoryInternal for non-typed errors.
func CategoryOf(err error) Category {
if p, ok := ProblemOf(err); ok {
return p.Category
}
return CategoryInternal
}
// IsRetryable reads Problem.Retryable; non-typed errors are non-retryable by default.
func IsRetryable(err error) bool {
if p, ok := ProblemOf(err); ok {
return p.Retryable
}
return false
}
// IsValidation reports whether err is a *ValidationError.
func IsValidation(err error) bool { var x *ValidationError; return errors.As(err, &x) }
// IsPermission reports whether err is a *PermissionError.
func IsPermission(err error) bool { var x *PermissionError; return errors.As(err, &x) }
// IsNetwork reports whether err is a *NetworkError.
func IsNetwork(err error) bool { var x *NetworkError; return errors.As(err, &x) }
// IsAPI reports whether err is an *APIError.
func IsAPI(err error) bool { var x *APIError; return errors.As(err, &x) }
// IsSecurityPolicy reports whether err is a *SecurityPolicyError.
func IsSecurityPolicy(err error) bool { var x *SecurityPolicyError; return errors.As(err, &x) }
// IsContentSafety reports whether err is a *ContentSafetyError.
func IsContentSafety(err error) bool { var x *ContentSafetyError; return errors.As(err, &x) }
// IsInternal reports whether err is an *InternalError.
func IsInternal(err error) bool { var x *InternalError; return errors.As(err, &x) }
// IsConfirmationRequired reports whether err is a *ConfirmationRequiredError.
func IsConfirmationRequired(err error) bool {
var x *ConfirmationRequiredError
return errors.As(err, &x)
}
// IsAuthentication reports whether err is an *AuthenticationError.
func IsAuthentication(err error) bool { var x *AuthenticationError; return errors.As(err, &x) }
// IsConfig reports whether err is a *ConfigError.
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }

View File

@@ -1,203 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs_test
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestIsRetryable(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "api error with retryable=true",
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI, Retryable: true}},
want: true,
},
{
name: "api error with retryable=false (zero)",
err: &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
{
name: "nil error",
err: nil,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsRetryable(tt.err); got != tt.want {
t.Errorf("IsRetryable(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestIsAuthTypedOnly(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "errs.AuthenticationError",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: true,
},
{
name: "errs.ConfigError",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsAuthentication(tt.err); got != tt.want {
t.Errorf("IsAuthentication(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestIsConfigTypedOnly(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{
name: "errs.ConfigError",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: true,
},
{
name: "errs.AuthenticationError",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: false,
},
{
name: "plain error",
err: fmt.Errorf("plain"),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.IsConfig(tt.err); got != tt.want {
t.Errorf("IsConfig(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestCategoryOf(t *testing.T) {
tests := []struct {
name string
err error
want errs.Category
}{
{
name: "typed validation error",
err: &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation}},
want: errs.CategoryValidation,
},
{
name: "typed permission error",
err: &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}},
want: errs.CategoryAuthorization,
},
{
name: "typed config error",
err: &errs.ConfigError{Problem: errs.Problem{Category: errs.CategoryConfig}},
want: errs.CategoryConfig,
},
{
name: "typed auth error",
err: &errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication}},
want: errs.CategoryAuthentication,
},
{
name: "plain error falls back to internal",
err: fmt.Errorf("plain"),
want: errs.CategoryInternal,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := errs.CategoryOf(tt.err); got != tt.want {
t.Errorf("CategoryOf(%v) = %q, want %q", tt.err, got, tt.want)
}
})
}
}
// TestProblemOf_NilProblemReturnsFalse pins that a problemCarrier whose
// ProblemDetail() returns nil does NOT satisfy ProblemOf — otherwise
// CategoryOf / IsRetryable and other downstream readers would dereference
// nil and panic. *Problem(nil) is a directly constructable trigger: its
// ProblemDetail method `return p` is nil-safe and yields nil.
func TestProblemOf_NilProblemReturnsFalse(t *testing.T) {
var nilP *errs.Problem
var err error = nilP // *Problem implements error via Error() (nil-receiver safe)
p, ok := errs.ProblemOf(err)
if ok {
t.Fatalf("ProblemOf(*Problem(nil)) = (%v, true); want (nil, false)", p)
}
if p != nil {
t.Errorf("ProblemOf(*Problem(nil)).p = %v; want nil", p)
}
// Downstream readers must not panic on the same input.
if cat := errs.CategoryOf(err); cat != errs.CategoryInternal {
t.Errorf("CategoryOf(*Problem(nil)) = %q, want fallback %q", cat, errs.CategoryInternal)
}
if retryable := errs.IsRetryable(err); retryable {
t.Errorf("IsRetryable(*Problem(nil)) = true; want false")
}
}
func TestTypedPredicates(t *testing.T) {
cases := []struct {
name string
err error
pred func(error) bool
want bool
}{
{"IsValidation+", &errs.ValidationError{}, errs.IsValidation, true},
{"IsValidation-", &errs.APIError{}, errs.IsValidation, false},
{"IsPermission+", &errs.PermissionError{}, errs.IsPermission, true},
{"IsPermission-", &errs.APIError{}, errs.IsPermission, false},
{"IsNetwork+", &errs.NetworkError{}, errs.IsNetwork, true},
{"IsAPI+", &errs.APIError{}, errs.IsAPI, true},
{"IsSecurityPolicy+", &errs.SecurityPolicyError{}, errs.IsSecurityPolicy, true},
{"IsContentSafety+", &errs.ContentSafetyError{}, errs.IsContentSafety, true},
{"IsInternal+", &errs.InternalError{}, errs.IsInternal, true},
{"IsConfirmationRequired+", &errs.ConfirmationRequiredError{}, errs.IsConfirmationRequired, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := tc.pred(tc.err); got != tc.want {
t.Errorf("%s: predicate = %v, want %v", tc.name, got, tc.want)
}
})
}
}

View File

@@ -1,38 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Problem is the RFC 7807-aligned shared shape embedded by every typed error.
//
// Message is REQUIRED. Producers must populate it; an empty Message will make
// Error() return "" — a known Go footgun for fmt.Errorf("...: %v", err).
//
// Wire-format notes:
// - No Component field. Service / shortcut component is metric-only
// enrichment derived by the dispatcher from the cobra command path; it
// never appears on the wire.
// - No DocURL field. PermissionError carries the same intent via its typed
// ConsoleURL extension; other typed errors do not link out.
// - Retryable uses omitempty so only `true` is emitted; consumers treat
// absence as false.
type Problem struct {
Category Category `json:"type"`
Subtype Subtype `json:"subtype,omitempty"`
Code int `json:"code,omitempty"`
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
LogID string `json:"log_id,omitempty"`
Retryable bool `json:"retryable,omitempty"`
}
// Error satisfies the standard `error` interface. A nil receiver is treated
// as the empty string so a stray nil *Problem stored in an error interface
// cannot panic the dispatcher.
func (p *Problem) Error() string {
if p == nil {
return ""
}
return p.Message
}
func (p *Problem) ProblemDetail() *Problem { return p }

View File

@@ -1,72 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"reflect"
"testing"
)
func TestProblemError(t *testing.T) {
tests := []struct {
name string
p Problem
want string
}{
{"empty message", Problem{}, ""},
{"plain message", Problem{Message: "boom"}, "boom"},
{"message ignores hint", Problem{Message: "msg", Hint: "do x"}, "msg"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := (&tt.p).Error(); got != tt.want {
t.Errorf("Error() = %q, want %q", got, tt.want)
}
})
}
}
// TestProblemError_NilReceiverDoesNotPanic pins the nil-receiver guard on
// (*Problem).Error(). Without it, a nil *Problem stored in an error interface
// would panic when the root dispatcher calls err.Error() for logging.
func TestProblemError_NilReceiverDoesNotPanic(t *testing.T) {
var p *Problem // nil
defer func() {
if r := recover(); r != nil {
t.Fatalf("(*Problem)(nil).Error() panicked: %v", r)
}
}()
if got := p.Error(); got != "" {
t.Errorf("(*Problem)(nil).Error() = %q, want \"\"", got)
}
}
func TestProblemDetailReturnsReceiver(t *testing.T) {
p := &Problem{Message: "x"}
if got := p.ProblemDetail(); got != p {
t.Errorf("ProblemDetail() = %p, want receiver %p", got, p)
}
}
func TestProblemHasNoComponentField(t *testing.T) {
if f, ok := reflect.TypeOf(Problem{}).FieldByName("Component"); ok {
t.Errorf("Problem.Component must not exist; got field %#v", f)
}
}
func TestProblemHasNoDocURLField(t *testing.T) {
if f, ok := reflect.TypeOf(Problem{}).FieldByName("DocURL"); ok {
t.Errorf("Problem.DocURL must not exist on the base Problem (PermissionError carries ConsoleURL instead); got field %#v", f)
}
}
func TestProblemCategoryTagIsType(t *testing.T) {
f, ok := reflect.TypeOf(Problem{}).FieldByName("Category")
if !ok {
t.Fatalf("Problem.Category must exist")
}
if got := f.Tag.Get("json"); got != "type" {
t.Errorf("Problem.Category json tag = %q, want %q", got, "type")
}
}

View File

@@ -1,75 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Subtype is the second-level taxonomy axis. Wire JSON: "subtype".
type Subtype string
const (
SubtypeUnknown Subtype = "unknown" // catch-all fallback; producers must prefer a specific subtype
)
// CategoryValidation subtypes
const (
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
)
// CategoryAuthentication subtypes
const (
SubtypeTokenMissing Subtype = "token_missing" // no token in request (Authorization header absent / no local token cache)
SubtypeTokenInvalid Subtype = "token_invalid" // token present but content/format wrong
SubtypeTokenExpired Subtype = "token_expired" // token explicitly expired
SubtypeRefreshTokenInvalid Subtype = "refresh_token_invalid" // refresh_token is v1 legacy format, unusable
SubtypeRefreshTokenExpired Subtype = "refresh_token_expired" // refresh_token expired
SubtypeRefreshTokenRevoked Subtype = "refresh_token_revoked" // refresh_token revoked (user logout / admin action)
SubtypeRefreshTokenReused Subtype = "refresh_token_reused" // refresh_token already used (single-use rotation triggered)
SubtypeRefreshServerError Subtype = "refresh_server_error" // refresh endpoint transient error (retryable)
)
// CategoryAuthorization subtypes
const (
SubtypeMissingScope Subtype = "missing_scope" // user authorized app but did not grant this scope
SubtypeUserUnauthorized Subtype = "user_unauthorized" // user never authorized the app
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
)
// CategoryConfig subtypes
const (
SubtypeInvalidClient Subtype = "invalid_client" // app_id / app_secret incorrect (RFC 6749 §5.2 alignment)
SubtypeNotConfigured Subtype = "not_configured" // local config file absent (user has not run `config init`)
SubtypeInvalidConfig Subtype = "invalid_config" // local config file present but malformed
)
// CategoryNetwork subtypes
const (
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
)
// CategoryAPI subtypes
const (
SubtypeRateLimit Subtype = "rate_limit" // request rate limit exceeded
SubtypeConflict Subtype = "conflict" // resource state conflict (e.g. concurrent modification)
SubtypeCrossTenant Subtype = "cross_tenant" // operation crosses tenant boundary (not supported)
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
)
// CategoryPolicy subtypes (security-policy envelope shape)
const (
SubtypeChallengeRequired Subtype = "challenge_required" // user must complete browser challenge / MFA
SubtypeAccessDenied Subtype = "access_denied" // policy denies access outright
)
// CategoryInternal subtypes
const (
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
)
// CategoryConfirmation subtypes intentionally have no declarations yet.

View File

@@ -1,21 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// Service-specific Subtype declarations. Per-service files follow the
// naming pattern subtypes_service_<name>.go so the framework's closed
// Subtype enum stays readable while service taxonomies remain visible.
// Task service subtypes — consumed by internal/errclass/codemeta_task.go.
const (
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
SubtypeTaskPermissionDenied Subtype = "task_permission_denied"
SubtypeTaskNotFound Subtype = "task_not_found"
SubtypeTaskConflict Subtype = "task_conflict"
SubtypeTaskServerError Subtype = "task_server_error"
SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit"
SubtypeTaskFollowerLimit Subtype = "task_follower_limit"
SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit"
SubtypeTaskReminderExists Subtype = "task_reminder_exists"
)

View File

@@ -1,136 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
// ValidationError is the typed error for CategoryValidation.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ValidationError struct {
Problem
Param string `json:"param,omitempty"`
Cause error `json:"-"`
}
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
// it. A nil typed-pointer held inside an error interface is treated as
// "no cause" so callers cannot panic on `errors.Unwrap(err)`.
func (e *ValidationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// AuthenticationError is the typed error for CategoryAuthentication.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type AuthenticationError struct {
Problem
UserOpenID string `json:"user_open_id,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *AuthenticationError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// PermissionError is the typed error for CategoryAuthorization.
type PermissionError struct {
Problem
MissingScopes []string `json:"missing_scopes,omitempty"`
Identity string `json:"identity,omitempty"`
ConsoleURL string `json:"console_url,omitempty"`
}
// ConfigError is the typed error for CategoryConfig.
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
// it is intentionally not serialized.
type ConfigError struct {
Problem
Field string `json:"field,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *ConfigError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// NetworkError is the typed error for CategoryNetwork.
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
// not serialized.
type NetworkError struct {
Problem
CauseKind string `json:"cause,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *NetworkError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
// business errors). Detail preserves the raw Lark error map for diagnostics.
type APIError struct {
Problem
Detail map[string]any `json:"detail,omitempty"`
}
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
type SecurityPolicyError struct {
Problem
ChallengeURL string `json:"challenge_url,omitempty"`
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *SecurityPolicyError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
type ContentSafetyError struct {
Problem
Rules []string `json:"rules,omitempty"`
}
// InternalError is the typed error for CategoryInternal.
// Cause is preserved for logging but not emitted on the wire.
type InternalError struct {
Problem
Cause error `json:"-"`
}
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
func (e *InternalError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
// Risk is one of: "read" | "write" | "high-risk-write".
type ConfirmationRequiredError struct {
Problem
Risk string `json:"risk"`
Action string `json:"action"`
}

View File

@@ -1,154 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestPermissionErrorJSONShape(t *testing.T) {
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "x",
},
MissingScopes: []string{"docx:document"},
}
b, err := json.Marshal(perm)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
got := string(b)
mustContain := []string{
`"type":"authorization"`,
`"subtype":"missing_scope"`,
`"missing_scopes":["docx:document"]`,
}
for _, want := range mustContain {
if !strings.Contains(got, want) {
t.Errorf("json output missing %q\nfull output: %s", want, got)
}
}
mustNotContain := []string{
`"component"`,
`"doc_url"`,
`"retryable":false`,
}
for _, bad := range mustNotContain {
if strings.Contains(got, bad) {
t.Errorf("json output unexpectedly contains %q\nfull output: %s", bad, got)
}
}
}
// TestEmbedSemanticChasm proves the documented Go embed limitation:
// errors.As(*PermissionError, &p *Problem) returns false even though
// PermissionError embeds Problem. ProblemOf works around this by routing
// via the unexported problemCarrier interface.
func TestEmbedSemanticChasm(t *testing.T) {
perm := &PermissionError{
Problem: Problem{
Category: CategoryAuthorization,
Subtype: SubtypeMissingScope,
Message: "missing",
},
}
var p *Problem
if errors.As(perm, &p) {
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
}
got, ok := ProblemOf(perm)
if !ok {
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
}
if got != &perm.Problem {
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
}
if got.Category != CategoryAuthorization {
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
}
}
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
orig := errors.New("transport stalled")
spe := &SecurityPolicyError{
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
Cause: orig,
}
if got := errors.Unwrap(spe); got != orig {
t.Fatalf("errors.Unwrap(spe) = %v, want %v", got, orig)
}
if !errors.Is(spe, orig) {
t.Fatal("errors.Is(spe, orig) = false, want true")
}
}
// TestTypedErrors_UnwrapNilReceiver pins the nil-receiver guard on every typed
// error's Unwrap. Without these, a typed-nil pointer stored in an error
// interface would panic when the root dispatcher or any caller walks the
// errors.Is / errors.Unwrap chain.
//
// The doc comments on these types claim "nil-receiver safe" but until this
// test landed nothing actually pinned that claim — exactly the
// behavioral-comment-without-test footgun caught in PR #984 review.
func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
t.Helper()
checks := []struct {
name string
call func() error
}{
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
}
for _, c := range checks {
t.Run(c.name, func(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("(*%s)(nil).Unwrap() panicked: %v", c.name, r)
}
}()
if got := c.call(); got != nil {
t.Errorf("(*%s)(nil).Unwrap() = %v, want nil", c.name, got)
}
})
}
}
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
// nil-safety guard above does not silently drop a real Cause on non-nil
// receivers. Without this, a buggy refactor could change `return e.Cause` to
// `return nil` and the test suite would still pass.
func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
cause := errors.New("upstream cause")
cases := []struct {
name string
err interface{ Unwrap() error }
}{
{"ValidationError", &ValidationError{Cause: cause}},
{"AuthenticationError", &AuthenticationError{Cause: cause}},
{"ConfigError", &ConfigError{Cause: cause}},
{"NetworkError", &NetworkError{Cause: cause}},
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
{"InternalError", &InternalError{Cause: cause}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := c.err.Unwrap(); got != cause {
t.Errorf("(*%s).Unwrap() = %v, want %v", c.name, got, cause)
}
})
}
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import "errors"
// WrapInternal wraps a non-typed error into *InternalError.
// Typed errors (anything implementing problemCarrier) pass through unchanged.
// Component is metric-only and derived by the dispatcher, so it is not a parameter here.
func WrapInternal(err error) error {
if err == nil {
return nil
}
var c problemCarrier
if errors.As(err, &c) {
return err
}
return &InternalError{
Problem: Problem{
Category: CategoryInternal,
Subtype: SubtypeUnknown,
Message: err.Error(),
},
Cause: err,
}
}

View File

@@ -1,49 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errs
import (
"errors"
"fmt"
"testing"
)
func TestWrapInternalPlainError(t *testing.T) {
orig := fmt.Errorf("boom")
wrapped := WrapInternal(orig)
var ie *InternalError
if !errors.As(wrapped, &ie) {
t.Fatalf("WrapInternal did not produce *InternalError; got %T", wrapped)
}
if ie.Category != CategoryInternal {
t.Errorf("Category = %q, want %q", ie.Category, CategoryInternal)
}
if ie.Subtype != SubtypeUnknown {
t.Errorf("Subtype = %q, want %q", ie.Subtype, SubtypeUnknown)
}
if ie.Message != "boom" {
t.Errorf("Message = %q, want %q", ie.Message, "boom")
}
if ie.Cause != orig {
t.Errorf("Cause = %v, want original error %v", ie.Cause, orig)
}
if got := errors.Unwrap(wrapped); got != orig {
t.Errorf("errors.Unwrap = %v, want original %v", got, orig)
}
}
func TestWrapInternalPassesThroughTyped(t *testing.T) {
apiErr := &APIError{Problem: Problem{Category: CategoryAPI, Message: "api boom"}}
got := WrapInternal(apiErr)
if got != apiErr {
t.Errorf("WrapInternal should pass through typed errors unchanged; got %#v want %#v", got, apiErr)
}
}
func TestWrapInternalNil(t *testing.T) {
if got := WrapInternal(nil); got != nil {
t.Errorf("WrapInternal(nil) = %v, want nil", got)
}
}

View File

@@ -1,186 +0,0 @@
# lark-cli Plugin SDK
`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
```go
// 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:
```go
// cmd/larkx/main.go in your fork
package main
import (
_ "github.com/me/myplugin" // blank import → init() runs
"github.com/larksuite/cli/cmd"
"os"
)
func main() { os.Exit(cmd.Execute()) }
```
```sh
go build -o larkx ./cmd/larkx && ./larkx config plugins show
```
You should see `audit` in the plugin list.
## 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, single per binary | Denies whole subtrees |
### Plugin lifecycle
```mermaid
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 / 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 `command_denied` decision (from `Restrict` or strict-mode) 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`.
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
Restrict is a deliberate `plugin_conflict` error (single-rule
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
shadowed by any plugin Restrict.
- 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 `command_denied`: 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.
- 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
Every install / dispatch failure emits a `command_denied` or
`plugin_install` envelope carrying a `detail.reason_code` from the
closed enum below. Use the code (not the human-readable message) when
matching errors in agents, CI scripts, or downstream tools — the
messages are localised and may change between releases.
### Plugin install (`error.type = plugin_install`)
| 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 `RequiredCLIVersion`, unknown `FailurePolicy` | 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 |
| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes |
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
| `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 dispatch (`error.type = command_denied`)
| 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 | write | high-risk-write` taxonomy (always fail-closed) |
| `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` |
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
The `detail.layer` field distinguishes who rejected the call:
`policy` (this SDK's user-layer engine) vs. `strict_mode`
(`cmd/prune.go`'s credential-hardening pass). Agents that want to
dispatch on "any denial" should match `error.type == "command_denied"`
and ignore the layer; agents that only care about user-policy denials
should additionally check `detail.layer == "policy"`.
## Where to go next
- [Runnable example: audit observer](./examples/audit-observer/)
- [Runnable example: read-only policy](./examples/readonly-policy/)
- Builder API: see [`builder.go`](./builder.go) for the full DSL
(`NewPlugin`, `Observer`, `Wrap`, `Restrict`, `FailOpen`/`FailClosed`,
`MustBuild`).
- Inventory diagnostic: run `lark-cli config plugins show` after
installing your plugin to see hooks/rules attributed to your plugin
name.

View File

@@ -1,37 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "fmt"
// AbortError is returned by a Wrapper that wants to short-circuit the
// command chain (instead of calling next). The framework converts it
// to an *output.ExitError with type "hook" so the JSON envelope carries
// the structured fields agents expect.
//
// HookName is the framework-namespaced name ("secaudit.approval"); the
// Registrar adds the plugin-name prefix automatically.
//
// Cause and Detail are optional. Cause lets the consumer use
// errors.Is/As to find the underlying cause; Detail is serialized into
// envelope.detail under the "detail" key for agent consumption.
type AbortError struct {
HookName string
Reason string
Cause error
Detail any
}
// Error renders a human-readable message; HookName + Reason + Cause are
// included when present.
func (e *AbortError) Error() string {
msg := fmt.Sprintf("hook %q aborted: %s", e.HookName, e.Reason)
if e.Cause != nil {
msg += ": " + e.Cause.Error()
}
return msg
}
// Unwrap enables errors.Is / errors.As to traverse to Cause.
func (e *AbortError) Unwrap() error { return e.Cause }

View File

@@ -1,42 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"errors"
"io/fs"
"testing"
"github.com/larksuite/cli/extension/platform"
)
func TestAbortError_messageFormats(t *testing.T) {
bare := &platform.AbortError{HookName: "secaudit.approval", Reason: "needs approval"}
if got := bare.Error(); got != `hook "secaudit.approval" aborted: needs approval` {
t.Errorf("Error() = %q", got)
}
withCause := &platform.AbortError{
HookName: "audit.upload",
Reason: "upstream unreachable",
Cause: fs.ErrNotExist,
}
if got := withCause.Error(); got == bare.Error() {
t.Errorf("Cause should be appended to message, got %q", got)
}
}
// errors.As must traverse Unwrap so consumers can inspect the cause
// directly. This is the contract the host's wrapAbortError relies on.
func TestAbortError_unwrapErrorsAs(t *testing.T) {
root := fs.ErrPermission
ab := &platform.AbortError{
HookName: "x",
Reason: "y",
Cause: root,
}
if !errors.Is(ab, fs.ErrPermission) {
t.Errorf("errors.Is should find fs.ErrPermission via Unwrap")
}
}

View File

@@ -1,215 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import (
"errors"
"fmt"
"regexp"
)
// Builder is the ergonomic constructor for Plugin. Use it from init():
//
// func init() {
// platform.Register(
// platform.NewPlugin("audit", "0.1.0").
// Observer(platform.After, "log", platform.All(), auditFn).
// FailOpen().
// MustBuild())
// }
//
// The lower-level Plugin interface remains available for cases that
// need finer control (state on a struct, complex Install logic). The
// Builder enforces:
//
// - Name format (^[a-z0-9][a-z0-9-]*$)
// - hookName format and uniqueness within a plugin
// - Restricts ↔ FailClosed consistency (calling Restrict() implies
// FailClosed, so plugin authors cannot accidentally ship a policy
// plugin under FailOpen)
// - Rule validation via ValidateRule analogues (delegated to
// internal/cmdpolicy at install time; Builder only fast-fails
// blatantly bad input)
type Builder struct {
name string
version string
caps Capabilities
actions []func(Registrar)
rule *Rule
hookNames map[string]bool
errs []error
}
var pluginNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// NewPlugin starts a Builder. Name format is validated lazily — errors
// surface at Build()/MustBuild() time, allowing chained calls without
// intermediate error handling.
func NewPlugin(name, version string) *Builder {
b := &Builder{
name: name,
version: version,
hookNames: map[string]bool{},
}
if !pluginNamePattern.MatchString(name) {
b.errs = append(b.errs, fmt.Errorf("invalid plugin name %q: must match ^[a-z0-9][a-z0-9-]*$", name))
}
return b
}
// RequireCLI sets Capabilities.RequiredCLIVersion (semver constraint,
// e.g. ">=1.1.0"). Empty string means no requirement.
func (b *Builder) RequireCLI(constraint string) *Builder {
b.caps.RequiredCLIVersion = constraint
return b
}
// FailOpen sets Capabilities.FailurePolicy = FailOpen. Default when
// neither FailOpen nor FailClosed is called and Restrict is not used.
func (b *Builder) FailOpen() *Builder {
b.caps.FailurePolicy = FailOpen
return b
}
// FailClosed sets Capabilities.FailurePolicy = FailClosed. Implicit
// when Restrict() is called.
func (b *Builder) FailClosed() *Builder {
b.caps.FailurePolicy = FailClosed
return b
}
// Observer registers an Observer. Multiple calls accumulate.
func (b *Builder) Observer(when When, hookName string, sel Selector, fn Observer) *Builder {
if !b.validateHookName(hookName, "observer") {
return b
}
// Capture by value so the action closure doesn't share state with
// subsequent Observer() calls (Go ≥1.22 already gives each call
// its own copies of parameter values, but pinning is explicit).
w, n, s, f := when, hookName, sel, fn
b.actions = append(b.actions, func(r Registrar) {
r.Observe(w, n, s, f)
})
return b
}
// Wrap registers a Wrapper. Multiple calls accumulate; the host
// composes them in registration order (outermost first).
func (b *Builder) Wrap(hookName string, sel Selector, wrap Wrapper) *Builder {
if !b.validateHookName(hookName, "wrap") {
return b
}
n, s, w := hookName, sel, wrap
b.actions = append(b.actions, func(r Registrar) {
r.Wrap(n, s, w)
})
return b
}
// On registers a LifecycleHandler.
func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler) *Builder {
if !b.validateHookName(hookName, "on") {
return b
}
e, n, f := event, hookName, fn
b.actions = append(b.actions, func(r Registrar) {
r.On(e, n, f)
})
return b
}
// Restrict contributes a pruning Rule. Calling Restrict implicitly
// sets Restricts=true and FailurePolicy=FailClosed (the framework
// requires both to coexist; the builder enforces the pairing so the
// plugin author cannot accidentally ship a policy plugin under
// FailOpen).
func (b *Builder) Restrict(rule *Rule) *Builder {
if rule == nil {
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
return b
}
b.caps.Restricts = true
b.caps.FailurePolicy = FailClosed
b.rule = rule
return b
}
// Build returns the configured Plugin, or an error if any builder
// step found a fault. MustBuild panics on the same error.
//
// The Restrict + FailOpen mismatch is checked here, not in the chained
// setters, because the two methods may be called in either order.
func (b *Builder) Build() (Plugin, error) {
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
b.errs = append(b.errs, errors.New(
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
}
if len(b.errs) > 0 {
return nil, errors.Join(b.errs...)
}
return &builtPlugin{
name: b.name,
version: b.version,
caps: b.caps,
actions: b.actions,
rule: b.rule,
}, nil
}
// MustBuild panics if Build() would return an error. Designed for
// init():
//
// func init() { platform.Register(platform.NewPlugin(...).MustBuild()) }
//
// A panic in init runs before the framework's recover guard is
// installed and will crash the binary. That is the intended
// behaviour: a misconfigured plugin must NOT be silently registered.
func (b *Builder) MustBuild() Plugin {
p, err := b.Build()
if err != nil {
panic(fmt.Sprintf("plugin %q: %v", b.name, err))
}
return p
}
// validateHookName checks the grammar and uniqueness; returns false
// when the name was rejected (caller skips the action).
func (b *Builder) validateHookName(hookName, kind string) bool {
if !pluginNamePattern.MatchString(hookName) {
b.errs = append(b.errs, fmt.Errorf(
"%s %q: hookName must match ^[a-z0-9][a-z0-9-]*$", kind, hookName))
return false
}
if b.hookNames[hookName] {
b.errs = append(b.errs, fmt.Errorf(
"%s %q: hookName already used in this plugin", kind, hookName))
return false
}
b.hookNames[hookName] = true
return true
}
// builtPlugin is the Plugin implementation the builder emits.
type builtPlugin struct {
name string
version string
caps Capabilities
actions []func(Registrar)
rule *Rule
}
func (p *builtPlugin) Name() string { return p.name }
func (p *builtPlugin) Version() string { return p.version }
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
func (p *builtPlugin) Install(r Registrar) error {
if p.rule != nil {
r.Restrict(p.rule)
}
for _, action := range p.actions {
action(r)
}
return nil
}

View File

@@ -1,180 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/extension/platform"
)
// recorder Registrar captures everything a builder schedules so the
// test can assert what Install produced without involving the host.
type recorder struct {
observers int
wrappers int
lifecycles int
rule *platform.Rule
}
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
r.observers++
}
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
func TestBuilder_basicAssembly(t *testing.T) {
p, err := platform.NewPlugin("audit", "0.1.0").
Observer(platform.Before, "pre", platform.All(),
func(context.Context, platform.Invocation) {}).
Observer(platform.After, "post", platform.All(),
func(context.Context, platform.Invocation) {}).
Wrap("policy", platform.All(),
func(next platform.Handler) platform.Handler { return next }).
On(platform.Startup, "boot",
func(context.Context, *platform.LifecycleContext) error { return nil }).
FailOpen().
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
if p.Name() != "audit" || p.Version() != "0.1.0" {
t.Errorf("metadata = %q/%q", p.Name(), p.Version())
}
if p.Capabilities().FailurePolicy != platform.FailOpen {
t.Errorf("FailurePolicy = %v, want FailOpen", p.Capabilities().FailurePolicy)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if r.observers != 2 || r.wrappers != 1 || r.lifecycles != 1 {
t.Errorf("Install dispatch = observers=%d wrappers=%d lifecycles=%d",
r.observers, r.wrappers, r.lifecycles)
}
}
// Restrict() flips Restricts=true and FailClosed automatically — a
// policy plugin can't accidentally ship under FailOpen.
func TestBuilder_restrictForcesFailClosed(t *testing.T) {
p, err := platform.NewPlugin("policy-plugin", "0.1.0").
Restrict(&platform.Rule{Name: "read-only", MaxRisk: platform.RiskRead}).
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
caps := p.Capabilities()
if !caps.Restricts {
t.Errorf("Restricts = false, want true (Restrict() should flip it)")
}
if caps.FailurePolicy != platform.FailClosed {
t.Errorf("FailurePolicy = %v, want FailClosed (Restrict() implies it)", caps.FailurePolicy)
}
r := &recorder{}
if err := p.Install(r); err != nil {
t.Fatalf("Install: %v", err)
}
if r.rule == nil || r.rule.Name != "read-only" {
t.Errorf("Install did not propagate Rule: %+v", r.rule)
}
}
// Invalid name surfaces at Build time, not at NewPlugin.
func TestBuilder_invalidPluginName(t *testing.T) {
_, err := platform.NewPlugin("Has_Underscore_And_Caps", "0.1").Build()
if err == nil {
t.Fatalf("Build must reject malformed plugin name")
}
if !strings.Contains(err.Error(), "invalid plugin name") {
t.Errorf("error should mention plugin name, got: %v", err)
}
}
// Duplicate hookName within the same builder is rejected.
func TestBuilder_duplicateHookName(t *testing.T) {
noopObs := func(context.Context, platform.Invocation) {}
_, err := platform.NewPlugin("dup", "0").
Observer(platform.Before, "h", platform.All(), noopObs).
Observer(platform.After, "h", platform.All(), noopObs).
Build()
if err == nil {
t.Fatalf("Build must reject duplicate hookName")
}
if !strings.Contains(err.Error(), "already used") {
t.Errorf("error should mention duplicate hookName, got %v", err)
}
}
func TestBuilder_invalidHookName(t *testing.T) {
_, err := platform.NewPlugin("p", "0").
Observer(platform.Before, "Bad.Name", platform.All(),
func(context.Context, platform.Invocation) {}).
Build()
if err == nil {
t.Fatalf("Build must reject hookName with dot")
}
}
// MustBuild panics on builder error.
func TestBuilder_mustBuildPanicsOnError(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatalf("MustBuild must panic when Build would fail")
}
}()
_ = platform.NewPlugin("BadName", "0").MustBuild()
}
func TestBuilder_restrictNilRejected(t *testing.T) {
_, err := platform.NewPlugin("p", "0").Restrict(nil).Build()
if err == nil {
t.Fatalf("Restrict(nil) must produce error")
}
}
func TestBuilder_capabilitiesSetters(t *testing.T) {
p, err := platform.NewPlugin("p", "0.1").
RequireCLI(">=1.0.0").
FailClosed().
Build()
if err != nil {
t.Fatalf("Build: %v", err)
}
caps := p.Capabilities()
if caps.RequiredCLIVersion != ">=1.0.0" {
t.Errorf("RequiredCLIVersion = %q, want >=1.0.0", caps.RequiredCLIVersion)
}
if caps.FailurePolicy != platform.FailClosed {
t.Errorf("FailurePolicy = %v, want FailClosed", caps.FailurePolicy)
}
}
func TestBuilder_restrictThenFailOpenRejected(t *testing.T) {
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
_, err := platform.NewPlugin("p", "0").Restrict(rule).FailOpen().Build()
if err == nil {
t.Fatalf("Build must reject Restrict()+FailOpen() mismatch")
}
if !strings.Contains(err.Error(), "FailClosed") {
t.Errorf("error should mention FailClosed, got: %v", err)
}
}
// Restrict() flips FailurePolicy to FailClosed; the previous FailOpen()
// is overridden. Pin it so the Build-time validation does not over-reject.
func TestBuilder_failOpenThenRestrictOK(t *testing.T) {
rule := &platform.Rule{Name: "r", MaxRisk: platform.RiskRead}
p, err := platform.NewPlugin("p", "0").FailOpen().Restrict(rule).Build()
if err != nil {
t.Fatalf("FailOpen()+Restrict() must succeed (Restrict flips to FailClosed): %v", err)
}
if p.Capabilities().FailurePolicy != platform.FailClosed {
t.Errorf("FailurePolicy = %v, want FailClosed", p.Capabilities().FailurePolicy)
}
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
// FailurePolicy controls what the framework does when a plugin's install
// stage fails (Capabilities() panics, Install returns error, etc.).
type FailurePolicy int
const (
// FailOpen (default) — log a warning and skip THIS plugin; the rest
// of the CLI keeps running. Appropriate for pure-observer plugins
// where missing audit data is preferable to a broken CLI.
FailOpen FailurePolicy = iota
// FailClosed — abort the entire CLI startup. Required for any
// plugin that contributes Restrict() (a missing policy plugin =
// missing security boundary) or that owns any safety-sensitive
// concern. Enforced by the framework: Capabilities.Restricts=true
// must pair with FailurePolicy=FailClosed.
FailClosed
)
// Capabilities declares the plugin's self-description. Plugin.Capabilities
// MUST be implemented even when every field would be its zero value --
// the requirement keeps FailurePolicy / Restricts visible to the author
// at the moment they write the plugin, preventing the "I just want to
// add an audit observer" mistake of accidentally shipping a policy
// plugin with the default FailOpen.
type Capabilities struct {
// RequiredCLIVersion is a semver constraint (e.g. ">=1.1.0").
// Plugins that need a specific framework feature should declare
// the minimum version they tested against; the host fails the
// install when the running CLI is older. Empty string means "no
// version requirement".
RequiredCLIVersion string
// Restricts declares whether Install will call r.Restrict(). The
// framework enforces consistency: declaring Restricts=true and
// then NOT calling r.Restrict (or vice versa) aborts the install
// with the `restricts_mismatch` reason_code. This pre-flight
// declaration also lets `config policy show` introspect "which
// plugins are policy plugins" without running them.
Restricts bool
// FailurePolicy decides what happens on install failure. See the
// constants above; the framework requires FailClosed whenever
// Restricts=true.
FailurePolicy FailurePolicy
}

View File

@@ -1,39 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package platform is the single public extension contract for lark-cli.
//
// External integrators (plugin authors, embedding platforms) only import this
// package; everything else under internal/ is off-limits.
//
// Plugin lifecycle:
//
// - Plugin - the interface every plugin implements (Name / Version / Capabilities / Install)
// - Registrar - what Install receives; the four registration verbs (Observe / Wrap / On / Restrict)
// - Capabilities - declared up front: FailurePolicy (FailOpen | FailClosed) and Restricts
// - Register - process-wide entry point; plugins call this from init()
//
// Hook surface (what Install hangs off Registrar):
//
// - Observer - side-effect-only callback, panic-safe, runs Before / After RunE
// - Wrapper - middleware that can short-circuit via AbortError
// - LifecycleHandler - reacts to Startup / Shutdown / etc. (LifecycleEvent + When)
// - Selector - chooses which commands a hook applies to (ByDomain / ByWrite / ByReadOnly / ByExactRisk / And / Or / Not, etc.)
// - Handler - the inner "run the command" function Wrappers compose around
// - Invocation - per-call context passed to handlers (Cmd view + DeniedByPolicy / DenialLayer / DenialPolicySource)
// - AbortError - structured short-circuit error from a Wrapper; framework namespaces HookName
//
// Policy surface (what Restrict contributes, also consumable from yaml policy):
//
// - Rule - declarative policy rule (Allow / Deny / MaxRisk / Identities / AllowUnannotated)
// - CommandView - read-only command metadata view (Path / Domain / Risk / Identities)
// - Risk / Identity - defined string types with closed taxonomies; ParseRisk / ParseIdentity
// convert raw strings (yaml, cobra annotation) into typed values; r.Rank()
// gives a comparable rank for the read < write < high-risk-write ordering
// - CommandDeniedError - structured error returned to denied callers
//
// Stability: every exported symbol here is part of the contract. Internal
// orchestration (staging, validation, RunE wrapping, denial guard) lives
// under internal/platform, internal/hook and internal/cmdpolicy and is not
// importable by third parties.
package platform

View File

@@ -1,40 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform
import "fmt"
// CommandDeniedError is the structured error returned by a denyStub. Every
// pruned-command execution path -- direct invocation, alias expansion,
// internal call -- returns this exact type. It is wire-compatible with the
// output.ExitError envelope via the Layer (== error.type) field and the
// detail map produced by ExitError().
//
// Layer values:
//
// - "strict_mode" -- credential strict-mode rejected the command
// - "policy" -- user-layer Rule rejected the command
//
// PolicySource is a free-form identifier such as "plugin:secaudit",
// "yaml:mywork", or "strict-mode". Reason fields:
//
// - ReasonCode -- closed enum, see tech-doc 5.3 (e.g. write_not_allowed,
// all_children_denied, identity_not_supported)
// - Reason -- human-readable text
type CommandDeniedError struct {
Path string
Layer string
PolicySource string
RuleName string
ReasonCode string
Reason string
}
// Error implements the standard error interface.
func (e *CommandDeniedError) Error() string {
if e.Reason != "" {
return fmt.Sprintf("command %q denied: %s", e.Path, e.Reason)
}
return fmt.Sprintf("command %q denied (%s/%s)", e.Path, e.Layer, e.ReasonCode)
}

View File

@@ -1,44 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package platform_test
import (
"errors"
"testing"
"github.com/larksuite/cli/extension/platform"
)
func TestCommandDeniedError_messageFormats(t *testing.T) {
withReason := &platform.CommandDeniedError{
Path: "docs/+update",
Layer: "policy",
ReasonCode: "write_not_allowed",
Reason: "write disabled by policy",
}
if got := withReason.Error(); got != `command "docs/+update" denied: write disabled by policy` {
t.Fatalf("Error() with Reason = %q", got)
}
noReason := &platform.CommandDeniedError{
Path: "docs/+update",
Layer: "strict_mode",
ReasonCode: "identity_not_supported",
}
if got := noReason.Error(); got != `command "docs/+update" denied (strict_mode/identity_not_supported)` {
t.Fatalf("Error() without Reason = %q", got)
}
}
// errors.As must work so consumers can type-assert without unwrap gymnastics.
func TestCommandDeniedError_satisfiesErrorsAs(t *testing.T) {
var err error = &platform.CommandDeniedError{Path: "x"}
var target *platform.CommandDeniedError
if !errors.As(err, &target) {
t.Fatalf("errors.As should match CommandDeniedError")
}
if target.Path != "x" {
t.Fatalf("target.Path = %q, want %q", target.Path, "x")
}
}

Some files were not shown because too many files have changed in this diff Show More