feat: open-source lark-cli — the official CLI for Lark/Feishu

Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf
This commit is contained in:
梁硕
2026-03-28 10:36:25 +08:00
commit 83dfb068ad
643 changed files with 101763 additions and 0 deletions

36
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Coverage
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
codecov:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: go test -coverprofile=coverage.txt -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
with:
files: coverage.txt
token: ${{ secrets.CODECOV_TOKEN }}

72
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Lint
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
staticcheck:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Run staticcheck
uses: dominikh/staticcheck-action@9716614d4101e79b4340dd97b10e54d68234e431 # v1
with:
install-go: false
golangci-lint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Run golangci-lint
uses: golangci/golangci-lint-action@55c2c1448f86e01eaae002a5a3a9624417608d84 # v6
with:
version: latest
vet:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta_data.json
run: python3 scripts/fetch_meta.py
- name: Run go vet
run: go vet ./...

35
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: read
jobs:
goreleaser:
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
fetch-depth: 0
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6
with:
version: '~> v2'
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
unit-test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: '1.23'
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.x'
- name: Fetch meta data
run: python3 scripts/fetch_meta.py
- name: Run tests
run: go test -v -race -count=1 -timeout=30s ./cmd/... ./internal/... ./shortcuts/...

32
.gitignore vendored Normal file
View File

@@ -0,0 +1,32 @@
# Build output
/lark-cli
.cache/
dist/
bin/
# Node
node_modules/
# OS
.DS_Store
Thumbs.db
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# Go
docs/ref
docs/
vendor/
#test
test_scripts/
tests/mail/reports/
/log/

40
.goreleaser.yml Normal file
View File

@@ -0,0 +1,40 @@
version: 2
before:
hooks:
- python3 scripts/fetch_meta.py
builds:
- binary: lark-cli
env:
- CGO_ENABLED=0
ldflags:
- -s -w -X github.com/larksuite/cli/internal/build.Version={{ .Version }} -X github.com/larksuite/cli/internal/build.Date={{ .Date }}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
archives:
- name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
format: zip
files:
- README.md
- LICENSE
- CHANGELOG.md
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
- '^chore:'

57
CHANGELOG.md Normal file
View File

@@ -0,0 +1,57 @@
# Changelog
All notable changes to this project will be documented in this file.
## [v1.0.0] - 2026-03-28
### Initial Release
The first open-source release of **Lark CLI** — the official command-line interface for [Lark/Feishu](https://www.larksuite.com/).
### Features
#### Core Commands
- **`lark api`** — Make arbitrary Lark Open API calls directly from the terminal with flexible parameter support.
- **`lark auth`** — Complete OAuth authentication flow, including interactive login, logout, token status, and scope management.
- **`lark config`** — Manage CLI configuration, including `init` for guided setup and `default-as` for switching contexts.
- **`lark schema`** — Inspect available API services and resource schemas.
- **`lark doctor`** — Run diagnostic checks on CLI configuration and environment.
- **`lark completion`** — Generate shell completion scripts for Bash, Zsh, Fish, and PowerShell.
#### Service Shortcuts
Built-in shortcuts for commonly used Lark APIs, enabling concise commands like `lark im send` or `lark drive upload`:
- **IM (Messaging)** — Send messages, manage chats, and more.
- **Drive** — Upload, download, and manage cloud documents.
- **Docs** — Work with Lark documents.
- **Sheets** — Interact with spreadsheets.
- **Base (Bitable)** — Manage multi-dimensional tables.
- **Calendar** — Create and manage calendar events.
- **Mail** — Send and manage emails.
- **Contact** — Look up users and departments.
- **Task** — Create and manage tasks.
- **Event** — Subscribe to and manage event callbacks.
- **VC (Video Conference)** — Manage meetings.
- **Whiteboard** — Interact with whiteboards.
#### AI Agent Skills
Bundled AI agent skills for intelligent assistance:
- `lark-im`, `lark-doc`, `lark-drive`, `lark-sheets`, `lark-base`, `lark-calendar`, `lark-mail`, `lark-contact`, `lark-task`, `lark-event`, `lark-vc`, `lark-whiteboard`, `lark-wiki`, `lark-minutes`
- `lark-openapi-explorer` — Explore and discover Lark APIs interactively.
- `lark-skill-maker` — Create custom AI skills.
- `lark-workflow-meeting-summary` — Automated meeting summary workflow.
- `lark-workflow-standup-report` — Automated standup report workflow.
- `lark-shared` — Shared skill utilities.
#### Developer Experience
- Cross-platform support (macOS, Linux, Windows) via GoReleaser.
- Shell completion for Bash, Zsh, Fish, and PowerShell.
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.0]: https://github.com/larksuite/cli/releases/tag/v1.0.0

28
CLA.md Normal file
View File

@@ -0,0 +1,28 @@
> Thank you for your interest in open source projects hosted or managed by ByteDance Ltd. and/or its Affiliates ("**ByteDance**") . In order to clarify the intellectual property license granted with Contributions from any person or entity, ByteDance must have a Contributor License Agreement ("**CLA**") on file that has been signed by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of ByteDance and its users; it does not change your rights to use your own Contributions for any other purpose.
> If you are an individual making a submission on your own behalf, you should accept the Individual Contributor License Agreement. If you are making a submission on behalf of a legal entity (the “**Corporation**”), you should sign the separation Corporate Contributor License Agreement.
**ByteDance Individual Contributor License Agreement v1.** **1**
By clicking “Accept” on this page, You accept and agree to the following terms and conditions for Your present and future Contributions submitted to ByteDance. Except for the license granted herein to ByteDance and recipients of software distributed by ByteDance, You reserve all right, title, and interest in and to Your Contributions.
1.Definitions.
"Affiliate" shall mean an entity that Controls, is Controlled by, or is under common Control with You or ByteDance, respectively (but only as long as such Control exists).
"Control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to ByteDance for inclusion in, or documentation of, any of the products owned or managed by ByteDance (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to ByteDance or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, ByteDance for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
"You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with ByteDance. For the avoidance of doubt, the Corporation making a Contribution and all of its Affiliates are considered to be a single Contributor and this CLA shall apply to Contributions Submitted by the Corporation or any of its Affiliates.
2.Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works.
3.Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to ByteDance and to recipients of software distributed by ByteDance a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4.You represent that you are legally entitled to grant the above license. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, that your employer has waived such rights for your Contributions to ByteDance, or that your employer has executed a separate Corporate CLA with ByteDance.
5.You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others). You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
6.You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON- INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7.Should You wish to submit work that is not Your original creation, You may submit it to ByteDance separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
8.You agree to notify ByteDance of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
9.You agree that contributions to Projects and information about contributions may be maintained indefinitely and disclosed publicly, including Your name and other information that You submit with your submission.
10.This Agreement is the entire agreement and understanding between the parties, and supersedes any and all prior agreements, understandings or communications, written or oral, between the parties relating to the subject matter hereof. This Agreement may be assigned by ByteDance.
[ByteDance Corporate Contributor License Agreement v1.1](./ByteDance_Corporate_Contributor_License_Agreement_v1.1.pdf)
This version of the Contributor License Agreement allows a legal entity (the “Corporation”) to submit Contributions to the applicable project.
ByteDance Corporate Contributor License Agreement v1.1.pdf
A person authorized to sign legal documents on behalf of your employer (usually a VP or higher) must sign the Contributor Agreement on behalf of the employer.
If you have not already signed this agreement, please complete and sign, then scan and email a pdf file of this Agreement to opensource-cla@bytedance.com. Please read this document carefully before signing and keep a copy for your records.
If you need to update your CLA, please email <opensource-cla@bytedance.com> from the email address associated with your individual or corporate information.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Lark Technologies Pte. Ltd.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

39
Makefile Normal file
View File

@@ -0,0 +1,39 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
BINARY := lark-cli
MODULE := github.com/larksuite/cli
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
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: build vet test unit-test integration-test install uninstall clean fetch_meta
fetch_meta:
python3 scripts/fetch_meta.py
build: fetch_meta
go build -trimpath -ldflags "$(LDFLAGS)" -o $(BINARY) .
vet: fetch_meta
go vet ./...
unit-test: fetch_meta
go test -race -gcflags="all=-N -l" -count=1 ./cmd/... ./internal/... ./shortcuts/...
integration-test: build
go test -v -count=1 ./tests/...
test: vet unit-test integration-test
install: build
install -d $(PREFIX)/bin
install -m755 $(BINARY) $(PREFIX)/bin/$(BINARY)
@echo "OK: $(PREFIX)/bin/$(BINARY) ($(VERSION))"
uninstall:
rm -f $(PREFIX)/bin/$(BINARY)
clean:
rm -f $(BINARY)

268
README.md Normal file
View File

@@ -0,0 +1,268 @@
# lark-cli
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/)
[中文版](./README.zh.md) | [English](./README.md)
A command-line tool for [Lark/Feishu](https://www.larksuite.com/) Open Platform — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 19 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** — 19 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 11 business domains, 200+ curated commands, 19 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
- **Secure & Controllable** — Input injection protection, terminal output sanitization, OS-native keychain credential storage
- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → API Commands (platform-synced) → Raw API (full coverage), choose the right granularity
## Features
| Category | Capabilities |
| ------------- | ----------------------------------------------------------------------------------- |
| 📅 Calendar | View agenda, create events, invite attendees, check free/busy status, time suggestions |
| 💬 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 |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
## Installation & Quick Start
### Requirements
Before you start, make sure you have:
- Node.js (`npm`/`npx`)
- Go `v1.23`+ and Python 3 (only required for building from source)
### Quick Start (Human Users)
> **Tip:** If you have an AI Agent, you can hand this README to it and let the AI handle installation and setup — jump to [Quick Start (AI Agent)](#quick-start-ai-agent).
#### Install CLI
**From npm (recommended):**
```bash
npm install -g @larksuite/cli
```
**From source:**
```bash
make install
```
#### Install AI Agent Skills
[Skills](./skills/) are structured instruction documents that enable AI Agents to use this CLI:
```bash
# Install all skills to current directory
npx skills add larksuite/cli -y
# Install all skills globally
npx skills add larksuite/cli -y -g
```
#### Configure & Use
```bash
# 1. Configure app credentials (one-time, interactive guided setup)
lark-cli config init
# 2. Log in (--recommend auto-selects commonly used scopes)
lark-cli auth login --recommend
# 3. Start using
lark-cli calendar +agenda
```
## Quick Start (AI Agent)
> The following steps are for AI Agents. Some steps require the user to complete actions in a browser.
```bash
# 1. Install CLI
npm install -g @larksuite/cli
# 2. Install Skills (enables AI Agent to use this CLI)
npx skills add larksuite/cli --all -y
# 3. Configure app credentials
# Important: run this command in the background. It will output an authorization URL — extract it and send it to the user. The command exits automatically after the user completes the setup in browser.
lark-cli config init --new
# 4. Login
# Same as above: run in the background, extract the authorization URL and send it to the user.
lark-cli auth login --recommend
# 5. Verify
lark-cli auth status
```
## Agent Skills
| Skill | Description |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) |
| `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions |
| `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-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
| `lark-contact` | Search users by name/email/phone, get user profiles |
| `lark-wiki` | Knowledge spaces, nodes, documents |
| `lark-event` | Real-time event subscriptions (WebSocket), regex routing & agent-friendly format |
| `lark-vc` | Search meeting records, query meeting minutes (summary, todos, transcript) |
| `lark-whiteboard` | Whiteboard/chart DSL rendering |
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |
## Authentication
| Command | Description |
| ------------- | -------------------------------------------------------------- |
| `auth login` | OAuth login with interactive selection or CLI flags for scopes |
| `auth logout` | Sign out and remove stored credentials |
| `auth status` | Show current login status and granted scopes |
| `auth check` | Verify a specific scope (exit 0 = ok, 1 = missing) |
| `auth scopes` | List all available scopes for the app |
| `auth list` | List all authenticated users |
```bash
# Interactive login (TUI guides domain and permission level selection)
lark-cli auth login
# Filter by domain
lark-cli auth login --domain calendar,task
# Recommended auto-approval scopes
lark-cli auth login --recommend
# Exact scope
lark-cli auth login --scope "calendar:calendar:readonly"
# Agent mode: return verification URL immediately, non-blocking
lark-cli auth login --domain calendar --no-wait
# Resume polling later
lark-cli auth login --device-code <DEVICE_CODE>
# Identity switching: execute commands as user or bot
lark-cli calendar +agenda --as user
lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello"
```
## Three-Layer Command System
The CLI provides three levels of granularity, covering everything from quick operations to fully custom API calls:
### 1. Shortcuts
Prefixed with `+`, designed to be friendly for both humans and AI, with smart defaults, table output, and dry-run previews.
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "Weekly Report" --markdown "# Progress\n- Completed feature X"
```
Run `lark-cli <service> --help` to see all shortcut commands.
### 2. API Commands
Auto-generated from Lark OAPI metadata, curated through evaluation and quality gates — 100+ commands mapped 1:1 to platform endpoints.
```bash
lark-cli calendar calendars list
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
```
### 3. Raw API Calls
Call any Lark Open Platform endpoint directly, covering 2500+ APIs.
```bash
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
```
## Advanced Usage
### Output Formats
```bash
--format json # Full JSON response (default)
--format pretty # Human-friendly formatted output
--format table # Readable table
--format ndjson # Newline-delimited JSON (for piping)
--format csv # Comma-separated values
```
### Pagination
```bash
--page-all # Auto-paginate through all pages
--page-limit 5 # Max 5 pages
--page-delay 500 # 500ms between page requests
```
### Dry Run
For commands that may have side effects, preview the request with --dry-run first:
```bash
lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run
```
### Schema Introspection
Use schema to inspect any API method's parameters, request body, response structure, supported identities, and scopes:
```bash
lark-cli schema
lark-cli schema calendar.events.instance_view
lark-cli schema im.messages.delete
```
## Security & Risk Warnings (Read Before Use)
This tool can be invoked by AI Agents to automate operations on the Lark/Feishu Open Platform, and carries inherent risks such as model hallucinations, unpredictable execution, and prompt injection. After you authorize Lark/Feishu permissions, the AI Agent will act under your user identity within the authorized scope, which may lead to high-risk consequences such as leakage of sensitive data or unauthorized operations. Please use with caution.
To reduce these risks, the tool enables default security protections at multiple layers. However, these risks still exist. We strongly recommend that you do not proactively modify any default security settings; once relevant restrictions are relaxed, the risks will increase significantly, and you will bear the consequences.
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Contributing
Community contributions are welcome! If you find a bug or have feature suggestions, please submit an [Issue](https://github.com/larksuite/cli/issues) or [Pull Request](https://github.com/larksuite/cli/pulls).
For major changes, we recommend discussing with us first via an Issue.
## License
This project is licensed under the **MIT License**.
When running, it calls Lark/Feishu Open Platform APIs. To use these APIs, you must comply with the following agreements and privacy policies:
- [Feishu User Terms of Service](https://www.feishu.cn/terms)
- [Feishu Privacy Policy](https://www.feishu.cn/privacy)
- [Feishu Open Platform App Service Provider Security Management Specifications](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications)
- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service)
- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy)

269
README.zh.md Normal file
View File

@@ -0,0 +1,269 @@
# lark-cli
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/go-%3E%3D1.23-blue.svg)](https://go.dev/)
[中文版](./README.zh.md) | [English](./README.md)
飞书/Lark 开放平台命令行工具 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 19 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 11 大业务域、200+ 精选命令、 19 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
- **安全可控** — 输入防注入、终端输出净化、OS 原生密钥链存储凭证
- **三层调用架构** — 快捷命令(人机友好)→ API 命令(平台同步)→ 通用调用(全 API 覆盖),按需选择粒度
## 功能
| 类别 | 能力 |
| ------------- | --------------------------------------------------------------------------- |
| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 |
| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 |
| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 |
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理多维表格、字段、记录、视图、仪表盘,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
## 安装与快速开始
### 环境要求
开始之前,请确保具备以下条件:
- Node.js`npm`/`npx`
- Go `v1.23`+ 和 Python 3仅源码构建需要
### 快速开始(人类用户)
> **Tip:** 如果你拥有 AI Agent可以直接把本 README 丢给它,让 AI 帮你完成安装和配置 — 跳转到[快速开始AI Agent](#快速开始ai-agent)查看。
#### 安装 CLI
**从 npm 安装(推荐):**
```bash
npm install -g @larksuite/cli
```
**从源码安装:**
```bash
make install
```
#### 安装 AI Agent Skills
[Skills](./skills/) 是结构化的指令文档,使 AI Agent 能够使用本 CLI
```bash
# 安装所有 skills 到当前目录
npx skills add larksuite/cli -y
# 安装所有 skills 到全局
npx skills add larksuite/cli -y -g
```
#### 配置与使用
```bash
# 1. 配置应用凭证(仅需一次,交互式引导完成)
lark-cli config init
# 2. 登录授权(--recommend 自动选择常用权限)
lark-cli auth login --recommend
# 3. 开始使用
lark-cli calendar +agenda
```
### 快速开始AI Agent
> 以下步骤面向 AI Agent部分步骤需要用户在浏览器中配合完成。
```bash
# 1. 安装 CLI
npm install -g @larksuite/cli
# 2. 安装 Skills使 AI Agent 能够使用本 CLI
npx skills add larksuite/cli --all -y
# 3. 配置应用凭证
# 重要:在后台运行此命令,命令会输出一个授权链接,提取该链接并发送给用户,用户在浏览器中完成配置后命令会自动退出。
lark-cli config init --new
# 4. 登录
# 同上,后台运行,提取授权链接发给用户
lark-cli auth login --recommend
# 5. 验证
lark-cli auth status
```
## Agent Skills
| Skill | 说明 |
| --------------------------------- | ----------------------------------------------------------------------------- |
| `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) |
| `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 |
| `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 |
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
| `lark-contact` | 按姓名/邮箱/手机号搜索用户,获取用户信息 |
| `lark-wiki` | 知识空间、节点、文档 |
| `lark-event` | 实时事件订阅WebSocket支持正则路由与 Agent 友好格式 |
| `lark-vc` | 搜索会议记录、查询会议纪要产物(总结、待办、逐字稿) |
| `lark-whiteboard` | 画板/图表 DSL 渲染 |
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
## 认证
| 命令 | 说明 |
| --------------- | -------------------------------------------------- |
| `auth login` | OAuth 登录,支持交互式选择或命令行参数指定 scope |
| `auth logout` | 登出并删除已存储的凭证 |
| `auth status` | 查看当前登录状态和已授权的 scope |
| `auth check` | 校验指定 scopeexit 0 = 有权限1 = 缺失) |
| `auth scopes` | 列出应用的所有可用 scope |
| `auth list` | 列出所有已认证的用户 |
```bash
# 交互式登录TUI 引导选择业务域和权限级别)
lark-cli auth login
# 按域筛选
lark-cli auth login --domain calendar,task
# 推荐的自动审批 scopes
lark-cli auth login --recommend
# 精确 scope
lark-cli auth login --scope "calendar:calendar:readonly"
# Agent 模式:立即返回验证 URL不阻塞
lark-cli auth login --domain calendar --no-wait
# 稍后恢复轮询
lark-cli auth login --device-code <DEVICE_CODE>
# 身份切换:以用户或机器人身份执行命令
lark-cli calendar +agenda --as user
lark-cli im +messages-send --as bot --chat-id "oc_xxx" --text "Hello"
```
## 三层命令调用
CLI 提供三种粒度的调用方式,覆盖从快速操作到完全自定义的全部场景:
### 1. 快捷命令Shortcuts
`+` 为前缀,对人类与 AI 友好化封装,内置智能默认值、表格输出和 dry-run 预览。
```bash
lark-cli calendar +agenda
lark-cli im +messages-send --chat-id "oc_xxx" --text "Hello"
lark-cli docs +create --title "周报" --markdown "# 本周进展\n- 完成了 X 功能"
```
运行 `lark-cli <service> --help` 查看所有快捷命令。
### 2. API 命令
从飞书 OAPI 元数据自动生成经过评测与准入筛选100+ 精选命令与平台端点一一对应。
```bash
lark-cli calendar calendars list
lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}'
```
### 3. 通用 API 调用
直接调用任意飞书开放平台端点,覆盖 2500+ API。
```bash
lark-cli api GET /open-apis/calendar/v4/calendars
lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_id"}' --body '{"receive_id":"oc_xxx","msg_type":"text","content":"{\"text\":\"Hello\"}"}'
```
## 进阶用法
### 输出格式
```bash
--format json # 完整 JSON 响应(默认)
--format pretty # 人性化格式输出
--format table # 易读表格
--format ndjson # 换行分隔 JSON适合管道处理
--format csv # 逗号分隔值
```
### 分页
```bash
--page-all # 自动翻页获取所有数据
--page-limit 5 # 最多获取 5 页
--page-delay 500 # 每页请求间隔 500ms
```
### Dry Run
对可能产生副作用的命令,建议先用 --dry-run 预览请求:
```bash
lark-cli im +messages-send --chat-id oc_xxx --text "hello" --dry-run
```
### Schema 自省
使用 schema 查看任意 API 方法的参数、请求体、响应结构、支持身份和 scopes
```bash
lark-cli schema
lark-cli schema calendar.events.instance_view
lark-cli schema im.messages.delete
```
## 安全与风险提示(使用前必读)
本工具可供 AI Agent 调用以自动化操作飞书/Lark 开放平台存在模型幻觉、执行不可控、提示词注入等固有风险授权飞书权限后AI Agent 将以您的用户身份在授权范围内执行操作,可能导致敏感数据泄露、越权操作等高风险后果,请您谨慎操作和使用。
为降低上述风险,工具已在多个层面启用默认安全保护,但上述风险仍然存在。我们强烈建议不要主动修改任何默认安全配置;一旦放开相关限制,上述风险将显著提高,由此产生的后果需由您自行承担。
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## 贡献
欢迎社区贡献!如果你发现 bug 或有功能建议,请提交 [Issue](https://github.com/larksuite/cli/issues) 或 [Pull Request](https://github.com/larksuite/cli/pulls)。
对于较大的改动,建议先通过 Issue 与我们讨论。
## 许可证
本项目基于 **MIT 许可证** 开源。
该软件运行时会调用 Lark/飞书开放平台的 API使用这些 API 需要遵守如下协议和隐私政策:
- [飞书用户服务协议](https://www.feishu.cn/terms)
- [飞书隐私政策](https://www.feishu.cn/privacy)
- [飞书开放平台独立软件服务商安全管理运营规范](https://open.feishu.cn/document/uAjLw4CM/uMzNwEjLzcDMx4yM3ATM/management-practice/app-service-provider-security-management-specifications)
- [Lark User Terms of Service](https://www.larksuite.com/user-terms-of-service)
- [Lark Privacy Policy](https://www.larksuite.com/privacy-policy)

9
build.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
set -euo pipefail
cd "$(dirname "$0")"
python3 scripts/fetch_meta.py
VERSION=$(git describe --tags --always --dirty 2>/dev/null || echo dev)
go build -ldflags "-s -w -X github.com/larksuite/cli/internal/build.Version=${VERSION} -X github.com/larksuite/cli/internal/build.Date=$(date +%Y-%m-%d)" -o lark-cli .
echo "OK: ./lark-cli (${VERSION})"

247
cmd/api/api.go Normal file
View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"context"
"encoding/json"
"fmt"
"io"
"regexp"
"strings"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
)
// APIOptions holds all inputs for the api command.
type APIOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
// Positional args
Method string
Path string
// Flags
Params string
Data string
As core.Identity
Output string
PageAll bool
PageSize int
PageLimit int
PageDelay int
Format string
DryRun bool
}
func parseJsonOpt(input, label string) (map[string]interface{}, error) {
if input == "" {
return nil, nil
}
var result map[string]interface{}
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}
var urlPrefixRe = regexp.MustCompile(`https?://[^/]+(/open-apis/.+)`)
func normalisePath(raw string) string {
if matches := urlPrefixRe.FindStringSubmatch(raw); len(matches) > 1 {
raw = matches[1]
} else if !strings.HasPrefix(raw, "/open-apis/") {
raw = "/open-apis/" + strings.TrimPrefix(raw, "/")
}
return validate.StripQueryFragment(raw)
}
// NewCmdApi creates the api command. If runF is non-nil it is called instead of apiRun (test hook).
func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
opts := &APIOptions{Factory: f}
var asStr string
cmd := &cobra.Command{
Use: "api <method> <path>",
Short: "Generic Lark API requests",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Method = strings.ToUpper(args[0])
opts.Path = args[1]
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
if runF != nil {
return runF(opts)
}
return apiRun(opts)
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "query parameters JSON")
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageSize, "page-size", 0, "page size (0 = use API default)")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) == 0 {
return []string{"GET", "POST", "PUT", "PATCH", "DELETE"}, cobra.ShellCompDirectiveNoFileComp
}
return nil, cobra.ShellCompDirectiveNoFileComp
}
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
// buildAPIRequest validates flags and builds a RawApiRequest.
func buildAPIRequest(opts *APIOptions) (client.RawApiRequest, error) {
params, err := parseJsonOpt(opts.Params, "--params")
if err != nil {
return client.RawApiRequest{}, err
}
if params == nil {
params = map[string]interface{}{}
}
var data interface{}
if opts.Data != "" {
data, err = parseJsonOpt(opts.Data, "--data")
if err != nil {
return client.RawApiRequest{}, err
}
}
if opts.PageSize > 0 {
params["page_size"] = opts.PageSize
}
request := client.RawApiRequest{
Method: opts.Method,
URL: normalisePath(opts.Path),
Params: params,
Data: data,
As: opts.As,
}
// WithFileDownload tells the SDK to skip CodeError parsing on 200 OK.
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
return request, nil
}
func apiRun(opts *APIOptions) error {
f := opts.Factory
opts.As = f.ResolveAs(opts.Cmd, opts.As)
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
request, err := buildAPIRequest(opts)
if err != nil {
return err
}
config, err := f.ResolveConfig(opts.As)
if err != nil {
return err
}
if opts.DryRun {
return apiDryRun(f, request, config, opts.Format)
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
return err
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
}
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
err = client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
Out: out,
ErrOut: f.IOStreams.ErrOut,
})
// MarkRaw tells root error handler that the API response was already written
// to stdout, so it should skip the stderr error envelope. Only apply when
// HandleResponse actually wrote output (i.e. returned a business/API error
// after printing JSON to stdout). Non-JSON HTTP errors (e.g. 404 text/plain)
// produce no stdout output and need the envelope.
if err != nil && client.IsJSONContentType(resp.Header.Get("Content-Type")) {
return output.MarkRaw(err)
}
return err
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error {
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.MarkRaw(output.ErrNetwork("API call failed: %v", err))
}
if apiErr := client.CheckLarkResponse(result); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return output.MarkRaw(apiErr)
}
output.FormatValue(out, result, format)
return nil
}
}

558
cmd/api/api_test.go Normal file
View File

@@ -0,0 +1,558 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package api
import (
"errors"
"sort"
"strings"
"testing"
"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"
)
func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Method != "GET" {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
if gotOpts.Path != "/open-apis/test" {
t.Errorf("expected path /open-apis/test, got %s", gotOpts.Path)
}
if gotOpts.As != core.AsBot {
t.Errorf("expected as=bot, got %s", gotOpts.As)
}
if !gotOpts.DryRun {
t.Error("expected DryRun=true")
}
}
func TestApiCmd_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "Dry Run") {
t.Error("expected dry run output")
}
if !strings.Contains(output, "/open-apis/test") {
t.Error("expected path in dry run output")
}
}
func TestApiCmd_BotMode(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-test-token",
"expire": 7200,
},
})
// Register API endpoint stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/test",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Error("expected 'success' in output")
}
}
func TestApiCmd_MissingArgs(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute()
if err == nil {
t.Error("expected error for missing args")
}
}
func TestApiCmd_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute()
if err == nil {
t.Error("expected validation error for invalid JSON")
}
}
func TestApiValidArgsFunction(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdApi(f, nil)
fn := cmd.ValidArgsFunction
tests := []struct {
name string
args []string
toComplete string
wantComps []string
wantDir cobra.ShellCompDirective
}{
{
name: "no args returns HTTP methods",
args: []string{},
toComplete: "",
wantComps: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
wantDir: cobra.ShellCompDirectiveNoFileComp,
},
{
name: "one arg returns nil with NoFileComp",
args: []string{"GET"},
toComplete: "",
wantComps: nil,
wantDir: cobra.ShellCompDirectiveNoFileComp,
},
{
name: "two args returns nil with NoFileComp",
args: []string{"GET", "/path"},
toComplete: "",
wantComps: nil,
wantDir: cobra.ShellCompDirectiveNoFileComp,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
comps, dir := fn(cmd, tt.args, tt.toComplete)
if dir != tt.wantDir {
t.Errorf("directive = %d, want %d", dir, tt.wantDir)
}
if tt.wantComps == nil {
if comps != nil {
t.Errorf("completions = %v, want nil", comps)
}
return
}
sort.Strings(comps)
sort.Strings(tt.wantComps)
if len(comps) != len(tt.wantComps) {
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
return
}
for i := range comps {
if comps[i] != tt.wantComps[i] {
t.Errorf("completions = %v, want %v", comps, tt.wantComps)
break
}
}
})
}
}
func TestApiCmd_PageLimitDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"GET", "/open-apis/test"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.PageLimit != 10 {
t.Errorf("expected default PageLimit=10, got %d", gotOpts.PageLimit)
}
}
func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return apiRun(opts)
})
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--page-all", "--output", "file.bin"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --output + --page-all conflict")
}
if gotOpts != nil && !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("expected 'mutually exclusive' error, got: %v", err)
}
}
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-bin", "expire": 7200,
},
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/drive/v1/files/xxx/download",
RawBody: []byte("fake-binary-content"),
ContentType: "application/octet-stream",
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "binary response detected") {
t.Error("expected binary response hint in stderr")
}
if !strings.Contains(stdout.String(), "saved_path") {
t.Error("expected saved_path in output")
}
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pa1", "expire": 7200,
},
})
// Register a non-batch API that returns scalar data (no array field)
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users/u123",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"user_id": "u123",
"name": "Test User",
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should print fallback warning to stderr
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
t.Error("expected fallback warning in stderr")
}
if !strings.Contains(stderr.String(), "falling back to json") {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
if !strings.Contains(stdout.String(), "u123") {
t.Error("expected user_id in JSON output")
}
}
func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall-err", AppSecret: "test-secret-pageall-err", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-err", "expire": 7200,
},
})
// Non-batch API that returns a business error (code != 0)
reg.Register(&httpmock.Stub{
URL: "/open-apis/im/v1/chats/oc_xxx/announcement",
Body: map[string]interface{}{
"code": 230001, "msg": "no permission",
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute()
// Should return an error
if err == nil {
t.Fatal("expected an error for non-zero code")
}
// Should still output the response body so user can see the error details
if !strings.Contains(stdout.String(), "230001") {
t.Errorf("expected error response in stdout, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "no permission") {
t.Errorf("expected error message in stdout, got: %s", stdout.String())
}
}
func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall2", AppSecret: "test-secret-pageall2", Brand: core.BrandFeishu,
})
// Register tenant_access_token stub (unique app credentials => new token request)
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-pa2", "expire": 7200,
},
})
// Register a batch API that returns an array field
reg.Register(&httpmock.Stub{
URL: "/open-apis/contact/v3/users",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
"has_more": false,
},
},
})
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should NOT print fallback warning
if strings.Contains(stderr.String(), "warning: this API does not return a list") {
t.Error("expected no fallback warning for batch API")
}
// Should stream ndjson items
if !strings.Contains(stdout.String(), `"id"`) {
t.Error("expected streamed items in output")
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string
raw string
want string
}{
{"plain path", "/open-apis/test", "/open-apis/test"},
{"with query", "/open-apis/test?admin=true", "/open-apis/test"},
{"with fragment", "/open-apis/test#section", "/open-apis/test"},
{"with both", "/open-apis/test?a=1#frag", "/open-apis/test"},
{"full URL with query", "https://open.feishu.cn/open-apis/foo?bar=1", "/open-apis/foo"},
{"short path with query", "contact/v3/users?page_size=50", "/open-apis/contact/v3/users"},
} {
t.Run(tt.name, func(t *testing.T) {
got := normalisePath(tt.raw)
if got != tt.want {
t.Errorf("normalisePath(%q) = %q, want %q", tt.raw, got, tt.want)
}
})
}
}
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,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-raw", "expire": 7200,
},
})
// 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")
}
// stderr should NOT contain an error envelope (identity line is OK)
if strings.Contains(stderr.String(), `"ok"`) {
t.Error("expected no JSON error envelope on stderr for Raw API error")
}
}
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/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-origmsg", "expire": 7200,
},
})
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_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/auth/v3/tenant_access_token/internal",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test-token-rawpage", "expire": 7200,
},
})
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_MethodUppercase(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"post", "/test"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Method != "POST" {
t.Errorf("expected method POST (uppercased), got %s", gotOpts.Method)
}
}

143
cmd/auth/auth.go Normal file
View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
// NewCmdAuth creates the auth command with subcommands.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(NewCmdAuthStatus(f, nil))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
cmd.AddCommand(NewCmdAuthList(f, nil))
cmd.AddCommand(NewCmdAuthCheck(f, nil))
return cmd
}
// userInfoResponse is the API response for /open-apis/authen/v1/user_info.
type userInfoResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
Name string `json:"name"`
} `json:"data"`
}
// getUserInfo fetches the current user's OpenID and name using the given access token.
func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (openId, name string, err error) {
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/authen/v1/user_info",
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
}, larkcore.WithUserAccessToken(accessToken))
if err != nil {
return "", "", err
}
var resp userInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return "", "", fmt.Errorf("failed to parse user info: %v", err)
}
if resp.Code != 0 {
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
}
if resp.Data.OpenID == "" {
return "", "", fmt.Errorf("failed to get user info: missing open_id in response")
}
name = resp.Data.Name
if name == "" {
name = "(unknown)"
}
return resp.Data.OpenID, name, nil
}
// appInfo contains application information (owner, scopes).
type appInfo struct {
OwnerOpenId string
UserScopes []string
}
// appInfoResponse is the API response for /open-apis/application/v6/applications/:app_id.
type appInfoResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
App struct {
Owner struct {
OwnerID string `json:"owner_id"`
} `json:"owner"`
CreatorID string `json:"creator_id"`
Scopes []struct {
Scope string `json:"scope"`
TokenTypes []string `json:"token_types"`
} `json:"scopes"`
} `json:"app"`
} `json:"data"`
}
// getAppInfo queries app info from the Lark API.
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
sdk, err := f.LarkClient()
if err != nil {
return nil, err
}
queryParams := make(larkcore.QueryParams)
queryParams.Set("lang", "zh_cn")
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/application/v6/applications/" + appId,
QueryParams: queryParams,
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant},
})
if err != nil {
return nil, err
}
var resp appInfoResponse
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return nil, fmt.Errorf("failed to parse response: %v", err)
}
if resp.Code != 0 {
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
}
app := resp.Data.App
ownerOpenId := app.Owner.OwnerID
if ownerOpenId == "" {
ownerOpenId = app.CreatorID
}
var userScopes []string
for _, s := range app.Scopes {
if s.Scope == "" || !slices.Contains(s.TokenTypes, "user") {
continue
}
userScopes = append(userScopes, s.Scope)
}
return &appInfo{OwnerOpenId: ownerOpenId, UserScopes: userScopes}, nil
}

233
cmd/auth/auth_test.go Normal file
View File

@@ -0,0 +1,233 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
)
func TestAuthLoginCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *LoginOptions
cmd := NewCmdAuthLogin(f, func(opts *LoginOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Scope != "calendar:calendar:read" {
t.Errorf("expected scope calendar:calendar:read, got %s", gotOpts.Scope)
}
if !gotOpts.JSON {
t.Error("expected JSON=true")
}
}
func TestAuthCheckCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *CheckOptions
cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--scope", "calendar:calendar:read drive:drive:read"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Scope != "calendar:calendar:read drive:drive:read" {
t.Errorf("expected scope string, got %s", gotOpts.Scope)
}
}
func TestAuthLogoutCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *LogoutOptions
cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
}
func TestAuthListCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ListOptions
cmd := NewCmdAuthList(f, func(opts *ListOptions) error {
gotOpts = opts
return nil
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
}
func TestAuthStatusCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
}
func TestAuthStatusCmd_VerifyFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *StatusOptions
cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--verify"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if !gotOpts.Verify {
t.Error("expected Verify=true when --verify flag is passed")
}
}
func TestDomainFlagCompletion(t *testing.T) {
allDomains := registry.ListFromMetaProjects()
tests := []struct {
name string
toComplete string
wantContains []string
wantExclude []string
}{
{
name: "empty returns all domains",
toComplete: "",
wantContains: allDomains,
},
{
name: "partial match",
toComplete: "cal",
wantContains: []string{"calendar"},
wantExclude: []string{"bitable", "drive", "task"},
},
{
name: "comma prefix completes second value",
toComplete: "calendar,",
wantContains: func() []string {
var out []string
for _, d := range allDomains {
out = append(out, "calendar,"+d)
}
return out
}(),
},
{
name: "comma with partial second value",
toComplete: "calendar,ta",
wantContains: []string{"calendar,task"},
wantExclude: []string{"calendar,bitable", "calendar,drive"},
},
{
name: "no match returns empty",
toComplete: "xxx",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
comps := completeDomain(tt.toComplete)
sort.Strings(comps)
for _, want := range tt.wantContains {
found := false
for _, c := range comps {
if c == want {
found = true
break
}
}
if !found {
t.Errorf("completions %v missing expected %q", comps, want)
}
}
for _, exclude := range tt.wantExclude {
for _, c := range comps {
if c == exclude {
t.Errorf("completions %v should not contain %q", comps, exclude)
}
}
}
// Verify no completion contains trailing comma artifacts
for _, c := range comps {
if strings.HasSuffix(c, ",") {
t.Errorf("completion %q should not end with comma", c)
}
}
})
}
}
func TestAuthScopesCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ScopesOptions
cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--format", "json"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Format != "json" {
t.Errorf("expected format json, got %s", gotOpts.Format)
}
}

90
cmd/auth/check.go Normal file
View File

@@ -0,0 +1,90 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"strings"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// CheckOptions holds all inputs for auth check.
type CheckOptions struct {
Factory *cmdutil.Factory
Scope string
}
// NewCmdAuthCheck creates the auth check subcommand.
func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Command {
opts := &CheckOptions{Factory: f}
cmd := &cobra.Command{
Use: "check",
Short: "Check if current token has specified scopes",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return authCheckRun(opts)
},
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)")
cmd.MarkFlagRequired("scope")
return cmd
}
func authCheckRun(opts *CheckOptions) error {
f := opts.Factory
required := strings.Fields(opts.Scope)
if len(required) == 0 {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": true, "granted": []string{}, "missing": []string{}})
return nil
}
config, err := f.Config()
if err != nil {
return err
}
if config.UserOpenId == "" {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "not_logged_in", "missing": required})
return output.ErrBare(1)
}
stored := larkauth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"ok": false, "error": "no_token", "missing": required})
return output.ErrBare(1)
}
missing := larkauth.MissingScopes(stored.Scope, required)
missingSet := make(map[string]bool, len(missing))
for _, s := range missing {
missingSet[s] = true
}
var granted []string
for _, s := range required {
if !missingSet[s] {
granted = append(granted, s)
}
}
ok := len(missing) == 0
result := map[string]interface{}{"ok": ok, "granted": granted, "missing": missing}
if len(missing) > 0 {
result["suggestion"] = fmt.Sprintf(`lark-cli auth login --scope "%s"`, strings.Join(missing, " "))
}
output.PrintJson(f.IOStreams.Out, result)
if !ok {
return output.ErrBare(1)
}
return nil
}

71
cmd/auth/list.go Normal file
View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// ListOptions holds all inputs for auth list.
type ListOptions struct {
Factory *cmdutil.Factory
}
// NewCmdAuthList creates the auth list subcommand.
func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{Factory: f}
cmd := &cobra.Command{
Use: "list",
Short: "List all logged-in users",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return authListRun(opts)
},
}
return cmd
}
func authListRun(opts *ListOptions) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "Not configured yet. Run `lark-cli config init` to initialize.")
return nil
}
app := multi.Apps[0]
if len(app.Users) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.")
return nil
}
var items []map[string]interface{}
for _, u := range app.Users {
stored := larkauth.GetStoredToken(app.AppId, u.UserOpenId)
status := "no_token"
if stored != nil {
status = larkauth.TokenStatus(stored)
}
items = append(items, map[string]interface{}{
"userName": u.UserName,
"userOpenId": u.UserOpenId,
"appId": app.AppId,
"tokenStatus": status,
})
}
output.PrintJson(f.IOStreams.Out, items)
return nil
}

475
cmd/auth/login.go Normal file
View File

@@ -0,0 +1,475 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
larkauth "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/larksuite/cli/shortcuts"
"github.com/larksuite/cli/shortcuts/common"
)
// LoginOptions holds all inputs for auth login.
type LoginOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
JSON bool
Scope string
Recommend bool
Domains []string
NoWait bool
DeviceCode string
}
// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
opts := &LoginOptions{Factory: f}
cmd := &cobra.Command{
Use: "login",
Short: "Device Flow authorization login",
Long: `Device Flow authorization login.
For AI agents: this command blocks until the user completes authorization in the
browser. Run it in the background and retrieve the verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return authLoginRun(opts)
},
}
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
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().BoolVar(&opts.JSON, "json", false, "structured JSON output")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
_ = cmd.RegisterFlagCompletionFunc("domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
// completeDomain returns completions for comma-separated domain values.
func completeDomain(toComplete string) []string {
allDomains := registry.ListFromMetaProjects()
parts := strings.Split(toComplete, ",")
prefix := parts[len(parts)-1]
base := strings.Join(parts[:len(parts)-1], ",")
var completions []string
for _, d := range allDomains {
if strings.HasPrefix(d, prefix) {
if base == "" {
completions = append(completions, d)
} else {
completions = append(completions, base+","+d)
}
}
}
return completions
}
func authLoginRun(opts *LoginOptions) error {
f := opts.Factory
config, err := f.Config()
if err != nil {
return err
}
// Determine UI language from saved config
lang := "zh"
if multi, _ := core.LoadMultiAppConfig(); multi != nil && len(multi.Apps) > 0 {
lang = multi.Apps[0].Lang
}
msg := getLoginMsg(lang)
log := func(format string, a ...interface{}) {
if !opts.JSON {
fmt.Fprintf(f.IOStreams.ErrOut, format+"\n", a...)
}
}
// --device-code: resume polling from a previous --no-wait call
if opts.DeviceCode != "" {
return authLoginPollDeviceCode(opts, config, msg, log)
}
selectedDomains := opts.Domains
scopeLevel := "" // "common" or "all" (from interactive mode)
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
domainSet := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
domainSet[p] = true
}
for _, sc := range shortcuts.AllShortcuts() {
domainSet[sc.Service] = true
}
selectedDomains = make([]string, 0, len(domainSet))
for d := range domainSet {
selectedDomains = append(selectedDomains, d)
}
sort.Strings(selectedDomains)
break
}
}
// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains()
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
}
available := make([]string, 0, len(knownDomains))
for k := range knownDomains {
available = append(available, k)
}
sort.Strings(available)
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
}
}
}
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
if err != nil {
return err
}
if result == nil {
return output.ErrValidation("no login options selected")
}
selectedDomains = result.Domains
scopeLevel = result.ScopeLevel
} else {
log(msg.HintHeader)
log("Common options:")
log(msg.HintCommon1)
log(msg.HintCommon2)
log(msg.HintCommon3)
log(msg.HintCommon4)
log("")
log("View all options:")
log(msg.HintFooter)
log("")
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")
}
}
finalScope := opts.Scope
// Resolve scopes from domain/permission filters
if len(selectedDomains) > 0 || opts.Recommend {
if opts.Scope != "" {
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
}
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user")
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user")
}
// Filter to auto-approve scopes if --recommend or interactive "common"
if opts.Recommend || scopeLevel == "common" {
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
}
if len(candidateScopes) == 0 {
return output.ErrValidation("no matching scopes found, check domain/scope options")
}
finalScope = strings.Join(candidateScopes, " ")
}
// Step 1: Request device authorization
httpClient, err := f.HttpClient()
if err != nil {
return err
}
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
if err != nil {
return output.ErrAuth("device authorization failed: %v", err)
}
// --no-wait: return immediately with device code and URL
if opts.NoWait {
b, _ := json.Marshal(map[string]interface{}{
"verification_url": authResp.VerificationUriComplete,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"hint": fmt.Sprintf("Show verification_url to user, 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),
})
fmt.Fprintln(f.IOStreams.Out, string(b))
return nil
}
// Step 2: Show user code and verification URL
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "device_authorization",
"verification_uri": authResp.VerificationUri,
"verification_uri_complete": authResp.VerificationUriComplete,
"user_code": authResp.UserCode,
"expires_in": authResp.ExpiresIn,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
} else {
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", authResp.VerificationUriComplete)
}
// Step 3: Poll for token
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if !result.OK {
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "authorization_failed",
"error": result.Message,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
return output.ErrBare(output.ExitAuth)
}
return output.ErrAuth("authorization failed: %s", result.Message)
}
// Step 6: Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return output.ErrAuth("failed to get user info: %v", err)
}
// Step 7: Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
UserOpenId: openId,
AppId: config.AppID,
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
ExpiresAt: now + int64(result.Token.ExpiresIn)*1000,
RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000,
Scope: result.Token.Scope,
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
// Step 8: Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
if multi != nil && len(multi.Apps) > 0 {
app := &multi.Apps[0]
for _, oldUser := range app.Users {
if oldUser.UserOpenId != openId {
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
}
}
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
}
if opts.JSON {
b, _ := json.Marshal(map[string]interface{}{
"event": "authorization_complete",
"user_open_id": openId,
"user_name": userName,
"scope": result.Token.Scope,
})
fmt.Fprintln(f.IOStreams.Out, string(b))
} else {
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
if result.Token.Scope != "" {
fmt.Fprintf(f.IOStreams.ErrOut, msg.GrantedScopes, result.Token.Scope)
}
}
return nil
}
// authLoginPollDeviceCode resumes the device flow by polling with a device code
// obtained from a previous --no-wait call.
func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *loginMsg, log func(string, ...interface{})) error {
f := opts.Factory
httpClient, err := f.HttpClient()
if err != nil {
return err
}
log(msg.WaitingAuth)
result := larkauth.PollDeviceToken(opts.Ctx, httpClient, config.AppID, config.AppSecret, config.Brand,
opts.DeviceCode, 5, 180, f.IOStreams.ErrOut)
if !result.OK {
return output.ErrAuth("authorization failed: %s", result.Message)
}
if result.Token == nil {
return output.ErrAuth("authorization succeeded but no token returned")
}
// Get user info
log(msg.AuthSuccess)
sdk, err := f.LarkClient()
if err != nil {
return output.ErrAuth("failed to get SDK: %v", err)
}
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
if err != nil {
return output.ErrAuth("failed to get user info: %v", err)
}
// Store token
now := time.Now().UnixMilli()
storedToken := &larkauth.StoredUAToken{
UserOpenId: openId,
AppId: config.AppID,
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
ExpiresAt: now + int64(result.Token.ExpiresIn)*1000,
RefreshExpiresAt: now + int64(result.Token.RefreshExpiresIn)*1000,
Scope: result.Token.Scope,
GrantedAt: now,
}
if err := larkauth.SetStoredToken(storedToken); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
}
// Update config — overwrite Users to single user, clean old tokens
multi, _ := core.LoadMultiAppConfig()
if multi != nil && len(multi.Apps) > 0 {
app := &multi.Apps[0]
for _, oldUser := range app.Users {
if oldUser.UserOpenId != openId {
larkauth.RemoveStoredToken(config.AppID, oldUser.UserOpenId)
}
}
app.Users = []core.AppUser{{UserOpenId: openId, UserName: userName}}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.LoginSuccess, userName, openId))
return nil
}
// collectScopesForDomains collects API scopes (from from_meta projects) and
// shortcut scopes for the given domain names.
func collectScopesForDomains(domains []string, identity string) []string {
scopeSet := make(map[string]bool)
// 1. API scopes from from_meta projects
for _, s := range registry.CollectScopesForProjects(domains, identity) {
scopeSet[s] = true
}
// 2. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
domainSet := make(map[string]bool, len(domains))
for _, d := range domains {
domainSet[d] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
for _, s := range sc.ScopesForIdentity(identity) {
scopeSet[s] = true
}
}
}
// 3. Deduplicate and sort
result := make([]string, 0, len(scopeSet))
for s := range scopeSet {
result = append(result, s)
}
sort.Strings(result)
return result
}
// allKnownDomains returns all valid domain names (from_meta projects + shortcut services).
func allKnownDomains() map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
domains[p] = true
}
for _, sc := range shortcuts.AllShortcuts() {
domains[sc.Service] = true
}
return domains
}
// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains() []string {
m := allKnownDomains()
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)
}
sort.Strings(domains)
return domains
}
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
// Empty AuthTypes defaults to ["user"].
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
authTypes := sc.AuthTypes
if len(authTypes) == 0 {
authTypes = []string{"user"}
}
for _, t := range authTypes {
if t == identity {
return true
}
}
return false
}
// suggestDomain finds the best "did you mean" match for an unknown domain.
func suggestDomain(input string, known map[string]bool) string {
// Check common cases: prefix match or input is a substring
for k := range known {
if strings.HasPrefix(k, input) || strings.HasPrefix(input, k) {
return k
}
}
return ""
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
)
// domainMeta describes a domain for the interactive selector.
type domainMeta struct {
Name string
Title string
Description string
}
// interactiveResult holds the user's selections from the interactive form.
type interactiveResult struct {
Domains []string
ScopeLevel string // "common" or "all"
}
// getDomainMetadata returns metadata for all known domains, sorted by name.
func getDomainMetadata(lang string) []domainMeta {
seen := make(map[string]bool)
var domains []domainMeta
// 1. Domains from from_meta projects
for _, project := range registry.ListFromMetaProjects() {
dm := buildDomainMeta(project, lang)
domains = append(domains, dm)
seen[project] = true
}
// 2. Shortcut-only domains
shortcutOnlyNames := getShortcutOnlyDomainNames()
for _, name := range shortcutOnlyNames {
if !seen[name] {
dm := buildDomainMeta(name, lang)
domains = append(domains, dm)
seen[name] = true
}
}
// 3. Auto-discover remaining shortcut services that are listed as shortcut-only domains
shortcutOnlySet := make(map[string]bool)
for _, n := range shortcutOnlyNames {
shortcutOnlySet[n] = true
}
for _, sc := range shortcuts.AllShortcuts() {
if !seen[sc.Service] {
if shortcutOnlySet[sc.Service] {
dm := buildDomainMeta(sc.Service, lang)
domains = append(domains, dm)
}
seen[sc.Service] = true
}
}
sort.Slice(domains, func(i, j int) bool {
return domains[i].Name < domains[j].Name
})
return domains
}
// buildDomainMeta constructs a domainMeta for a given service name and language.
// It reads from the service_descriptions.json config first, falling back to
// from_meta spec fields if not found.
func buildDomainMeta(name, lang string) domainMeta {
title := registry.GetServiceTitle(name, lang)
desc := registry.GetServiceDetailDescription(name, lang)
if title != "" || desc != "" {
return domainMeta{
Name: name,
Title: title,
Description: desc,
}
}
// Fallback: read from from_meta spec (legacy)
meta := registry.LoadFromMeta(name)
dm := domainMeta{Name: name}
if meta != nil {
if t, ok := meta["title"].(string); ok {
dm.Title = t
}
if d, ok := meta["description"].(string); ok {
dm.Description = d
}
}
return dm
}
// runInteractiveLogin shows an interactive TUI form for domain and permission selection.
func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) {
allDomains := getDomainMetadata(lang)
// Build multi-select options
options := make([]huh.Option[string], len(allDomains))
for i, dm := range allDomains {
var label string
switch {
case dm.Title != "" && dm.Description != "":
label = fmt.Sprintf("%-12s %s - %s", dm.Name, dm.Title, dm.Description)
case dm.Title != "":
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Title)
default:
label = fmt.Sprintf("%-12s %s", dm.Name, dm.Description)
}
options[i] = huh.NewOption(label, dm.Name)
}
var selectedDomains []string
var permLevel string
// Phase 1a: domain selection
// Phase 1b: permission level (shown after domain selection completes)
form1 := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title(msg.SelectDomains).
Description(msg.DomainHint).
Options(options...).
Value(&selectedDomains).
Validate(func(s []string) error {
if len(s) == 0 {
return fmt.Errorf(msg.ErrNoDomain)
}
return nil
}),
),
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.PermLevel).
Options(
huh.NewOption(msg.PermCommon, "common"),
huh.NewOption(msg.PermAll, "all"),
).
Value(&permLevel),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form1.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if len(selectedDomains) == 0 {
return nil, output.ErrValidation("no domains selected")
}
// Compute scope summary
scopes := collectScopesForDomains(selectedDomains, "user")
if permLevel == "common" {
scopes = registry.FilterAutoApproveScopes(scopes)
}
// Print summary
permLabel := msg.PermAllLabel
if permLevel == "common" {
permLabel = msg.PermCommonLabel
}
fmt.Fprintf(ios.ErrOut, msg.Summary)
fmt.Fprintf(ios.ErrOut, msg.SummaryDomains, strings.Join(selectedDomains, ", "))
fmt.Fprintf(ios.ErrOut, msg.SummaryPerm, permLabel)
scopePreview := strings.Join(scopes, ", ")
if len(scopePreview) > 80 {
scopePreview = strings.Join(scopes[:3], ", ") + ", ..."
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,
}, nil
}

108
cmd/auth/login_messages.go Normal file
View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
type loginMsg struct {
// Interactive UI (login_interactive.go)
SelectDomains string
DomainHint string
PermLevel string
PermCommon string
PermAll string
Summary string
SummaryDomains string
SummaryPerm string
SummaryScopes string
PermAllLabel string
PermCommonLabel string
ErrNoDomain string
ConfirmAuth string
// Non-interactive prompts (login.go)
OpenURL string
WaitingAuth string
AuthSuccess string
LoginSuccess string
GrantedScopes string
// Non-interactive hint (no flags)
HintHeader string
HintCommon1 string
HintCommon2 string
HintCommon3 string
HintCommon4 string
HintFooter string
}
var loginMsgZh = &loginMsg{
SelectDomains: "选择要授权的业务域",
DomainHint: "空格=选择, 回车=确认",
PermLevel: "权限类型",
PermCommon: "常用权限",
PermAll: "全部权限",
Summary: "\n摘要:\n",
SummaryDomains: " 域: %s\n",
SummaryPerm: " 权限: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "全部权限",
PermCommonLabel: "常用权限",
ErrNoDomain: "请至少选择一个业务域",
ConfirmAuth: "确认授权?",
OpenURL: "在浏览器中打开以下链接进行认证:\n\n",
WaitingAuth: "等待用户授权...",
AuthSuccess: "授权成功,正在获取用户信息...",
LoginSuccess: "登录成功! 用户: %s (%s)",
GrantedScopes: " 已授权 scopes: %s\n",
HintHeader: "请指定要授权的权限:\n",
HintCommon1: " --recommend 授权推荐权限",
HintCommon2: " --domain all 授权所有已知域的权限",
HintCommon3: " --domain calendar,task 授权日历和任务域的权限",
HintCommon4: " --domain calendar --recommend 授权日历域的推荐权限",
HintFooter: " lark-cli auth login --help",
}
var loginMsgEn = &loginMsg{
SelectDomains: "Select domains to authorize",
DomainHint: "Space=toggle, Enter=confirm",
PermLevel: "Permission level",
PermCommon: "Common scopes",
PermAll: "All scopes",
Summary: "\nSummary:\n",
SummaryDomains: " Domains: %s\n",
SummaryPerm: " Level: %s\n",
SummaryScopes: " Scopes (%d): %s\n\n",
PermAllLabel: "All scopes",
PermCommonLabel: "Common scopes",
ErrNoDomain: "please select at least one domain",
ConfirmAuth: "Confirm authorization?",
OpenURL: "Open this URL in your browser to authenticate:\n\n",
WaitingAuth: "Waiting for user authorization...",
AuthSuccess: "Authorization successful, fetching user info...",
LoginSuccess: "Login successful! User: %s (%s)",
GrantedScopes: " Granted scopes: %s\n",
HintHeader: "Please specify the scopes to authorize:\n",
HintCommon1: " --recommend authorize recommended scopes",
HintCommon2: " --domain all authorize all known domain scopes",
HintCommon3: " --domain calendar,task authorize calendar and task scopes",
HintCommon4: " --domain calendar --recommend authorize calendar recommended scopes",
HintFooter: " lark-cli auth login --help",
}
func getLoginMsg(lang string) *loginMsg {
if lang == "en" {
return loginMsgEn
}
return loginMsgZh
}
// getShortcutOnlyDomainNames returns domain names that exist only as shortcuts
// (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json.
func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs"}
}

View File

@@ -0,0 +1,96 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"reflect"
"testing"
)
func TestGetLoginMsg_Zh(t *testing.T) {
msg := getLoginMsg("zh")
if msg != loginMsgZh {
t.Error("expected zh message set")
}
if msg.SelectDomains != "选择要授权的业务域" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
func TestGetLoginMsg_En(t *testing.T) {
msg := getLoginMsg("en")
if msg != loginMsgEn {
t.Error("expected en message set")
}
if msg.SelectDomains != "Select domains to authorize" {
t.Errorf("unexpected SelectDomains: %s", msg.SelectDomains)
}
}
func TestGetLoginMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getLoginMsg(lang)
if msg != loginMsgZh {
t.Errorf("getLoginMsg(%q) should default to zh", lang)
}
}
}
func TestLoginMsgZh_AllFieldsNonEmpty(t *testing.T) {
assertLoginMsgAllFieldsNonEmpty(t, loginMsgZh, "zh")
}
func TestLoginMsgEn_AllFieldsNonEmpty(t *testing.T) {
assertLoginMsgAllFieldsNonEmpty(t, loginMsgEn, "en")
}
func assertLoginMsgAllFieldsNonEmpty(t *testing.T, msg *loginMsg, label string) {
t.Helper()
v := reflect.ValueOf(*msg)
typ := v.Type()
for i := 0; i < v.NumField(); i++ {
field := typ.Field(i)
val := v.Field(i).String()
if val == "" {
t.Errorf("%s.%s is empty", label, field.Name)
}
}
}
func TestLoginMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
msg := getLoginMsg(lang)
// LoginSuccess should contain two %s placeholders (userName, openId)
got := fmt.Sprintf(msg.LoginSuccess, "testuser", "ou_123")
if got == msg.LoginSuccess {
t.Errorf("%s LoginSuccess has no format verb", lang)
}
// GrantedScopes should contain %s
got = fmt.Sprintf(msg.GrantedScopes, "scope1 scope2")
if got == msg.GrantedScopes {
t.Errorf("%s GrantedScopes has no format verb", lang)
}
// SummaryDomains should contain %s
got = fmt.Sprintf(msg.SummaryDomains, "calendar, task")
if got == msg.SummaryDomains {
t.Errorf("%s SummaryDomains has no format verb", lang)
}
// SummaryPerm should contain %s
got = fmt.Sprintf(msg.SummaryPerm, "all")
if got == msg.SummaryPerm {
t.Errorf("%s SummaryPerm has no format verb", lang)
}
// SummaryScopes should contain %d and %s
got = fmt.Sprintf(msg.SummaryScopes, 5, "a, b, c")
if got == msg.SummaryScopes {
t.Errorf("%s SummaryScopes has no format verb", lang)
}
}
}

292
cmd/auth/login_test.go Normal file
View File

@@ -0,0 +1,292 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"sort"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSuggestDomain_PrefixMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
"task": true,
"drive": true,
"im": true,
}
// Input is prefix of known domain
if s := suggestDomain("cal", known); s != "calendar" {
t.Errorf("expected 'calendar', got %q", s)
}
// Known domain is prefix of input
if s := suggestDomain("calendar_extra", known); s != "calendar" {
t.Errorf("expected 'calendar', got %q", s)
}
}
func TestSuggestDomain_NoMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
"task": true,
}
if s := suggestDomain("zzz", known); s != "" {
t.Errorf("expected empty suggestion, got %q", s)
}
}
func TestSuggestDomain_ExactMatch(t *testing.T) {
known := map[string]bool{
"calendar": true,
}
// Exact match: input is prefix of known AND known is prefix of input
if s := suggestDomain("calendar", known); s != "calendar" {
t.Errorf("expected 'calendar', got %q", s)
}
}
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
// Empty AuthTypes defaults to ["user"]
sc := common.Shortcut{AuthTypes: nil}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected default to support 'user'")
}
if shortcutSupportsIdentity(sc, "bot") {
t.Error("expected default to NOT support 'bot'")
}
}
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
if !shortcutSupportsIdentity(sc, "user") {
t.Error("expected to support 'user'")
}
if !shortcutSupportsIdentity(sc, "bot") {
t.Error("expected to support 'bot'")
}
if shortcutSupportsIdentity(sc, "tenant") {
t.Error("expected to NOT support 'tenant'")
}
}
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
sc := common.Shortcut{AuthTypes: []string{"bot"}}
if shortcutSupportsIdentity(sc, "user") {
t.Error("expected bot-only to NOT support 'user'")
}
if !shortcutSupportsIdentity(sc, "bot") {
t.Error("expected bot-only to support 'bot'")
}
}
func TestCompleteDomain(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
t.Skip("no from_meta data available")
}
// Complete from empty prefix
completions := completeDomain("")
if len(completions) == 0 {
t.Fatal("expected completions for empty prefix")
}
// All completions should match from_meta projects
if len(completions) != len(projects) {
t.Errorf("expected %d completions, got %d", len(projects), len(completions))
}
// Complete with partial prefix
completions = completeDomain("cal")
for _, c := range completions {
if c != "calendar" && c[:3] != "cal" {
t.Errorf("unexpected completion %q for prefix 'cal'", c)
}
}
}
func TestCompleteDomain_CommaSeparated(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
t.Skip("no from_meta data available")
}
// After a comma, should complete the next segment
completions := completeDomain("calendar,")
for _, c := range completions {
if c[:9] != "calendar," {
t.Errorf("expected 'calendar,' prefix, got %q", c)
}
}
}
func TestAllKnownDomains(t *testing.T) {
domains := allKnownDomains()
if len(domains) == 0 {
t.Fatal("expected non-empty known domains")
}
// Should include from_meta projects
for _, p := range registry.ListFromMetaProjects() {
if !domains[p] {
t.Errorf("expected from_meta project %q in known domains", p)
}
}
}
func TestSortedKnownDomains(t *testing.T) {
sorted := sortedKnownDomains()
if len(sorted) == 0 {
t.Fatal("expected non-empty sorted domains")
}
if !sort.StringsAreSorted(sorted) {
t.Error("expected sorted result")
}
// Should match allKnownDomains
known := allKnownDomains()
if len(sorted) != len(known) {
t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known))
}
}
func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) {
for _, name := range getShortcutOnlyDomainNames() {
zhDesc := registry.GetServiceDescription(name, "zh")
enDesc := registry.GetServiceDescription(name, "en")
if zhDesc == "" {
t.Errorf("missing zh description for shortcut-only domain %q", name)
}
if enDesc == "" {
t.Errorf("missing en description for shortcut-only domain %q", name)
}
}
}
func TestCollectScopesForDomains(t *testing.T) {
projects := registry.ListFromMetaProjects()
if len(projects) == 0 {
t.Skip("no from_meta data available")
}
scopes := collectScopesForDomains([]string{"calendar"}, "user")
if len(scopes) == 0 {
t.Fatal("expected non-empty scopes for calendar domain")
}
// Should be sorted
if !sort.StringsAreSorted(scopes) {
t.Error("expected sorted result")
}
// Should include at least the API scopes
apiScopes := registry.CollectScopesForProjects([]string{"calendar"}, "user")
for _, s := range apiScopes {
found := false
for _, cs := range scopes {
if cs == s {
found = true
break
}
}
if !found {
t.Errorf("API scope %q missing from collectScopesForDomains result", s)
}
}
}
func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user")
if len(scopes) != 0 {
t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes))
}
}
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
// from_meta projects must be present
for _, p := range registry.ListFromMetaProjects() {
if !nameSet[p] {
t.Errorf("from_meta project %q missing from getDomainMetadata", p)
}
}
}
func TestGetDomainMetadata_IncludesShortcutOnlyDomains(t *testing.T) {
domains := getDomainMetadata("zh")
nameSet := make(map[string]bool)
for _, dm := range domains {
nameSet[dm.Name] = true
}
for _, name := range getShortcutOnlyDomainNames() {
if !nameSet[name] {
t.Errorf("shortcut-only domain %q missing from getDomainMetadata", name)
}
}
}
func TestGetDomainMetadata_Sorted(t *testing.T) {
domains := getDomainMetadata("zh")
for i := 1; i < len(domains); i++ {
if domains[i].Name < domains[i-1].Name {
t.Errorf("not sorted: %q before %q", domains[i-1].Name, domains[i].Name)
}
}
}
func TestGetDomainMetadata_HasTitleAndDescription(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Title == "" {
t.Errorf("domain %q has empty Title", dm.Name)
}
}
}
func TestAuthLoginRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "cli_test", AppSecret: "secret", Brand: core.BrandFeishu,
})
// TestFactory has IsTerminal=false by default
opts := &LoginOptions{Factory: f, Ctx: context.Background()}
err := authLoginRun(opts)
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
// Should mention specifying scopes
msg := err.Error()
if !strings.Contains(msg, "scopes") {
t.Errorf("expected error to mention scopes, got: %s", msg)
}
// Stderr should contain background hint
stderrStr := stderr.String()
if !strings.Contains(stderrStr, "background") {
t.Errorf("expected stderr to mention background, got: %s", stderrStr)
}
}
func TestGetDomainMetadata_ExcludesEvent(t *testing.T) {
domains := getDomainMetadata("zh")
for _, dm := range domains {
if dm.Name == "event" {
t.Error("event should not appear in interactive domain list")
}
}
}

66
cmd/auth/logout.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// LogoutOptions holds all inputs for auth logout.
type LogoutOptions struct {
Factory *cmdutil.Factory
}
// NewCmdAuthLogout creates the auth logout subcommand.
func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Command {
opts := &LogoutOptions{Factory: f}
cmd := &cobra.Command{
Use: "logout",
Short: "Log out (clear token)",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return authLogoutRun(opts)
},
}
return cmd
}
func authLogoutRun(opts *LogoutOptions) error {
f := opts.Factory
multi, _ := core.LoadMultiAppConfig()
if multi == nil || len(multi.Apps) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.")
return nil
}
app := &multi.Apps[0]
if len(app.Users) == 0 {
fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.")
return nil
}
for _, user := range app.Users {
if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil {
fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err)
}
}
app.Users = []core.AppUser{}
if err := core.SaveMultiAppConfig(multi); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
return nil
}

74
cmd/auth/scopes.go Normal file
View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
)
// ScopesOptions holds all inputs for auth scopes.
type ScopesOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Format string
}
// NewCmdAuthScopes creates the auth scopes subcommand.
func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobra.Command {
opts := &ScopesOptions{Factory: f}
cmd := &cobra.Command{
Use: "scopes",
Short: "Query scopes enabled for the app",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
if runF != nil {
return runF(opts)
}
return authScopesRun(opts)
},
}
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
return cmd
}
func authScopesRun(opts *ScopesOptions) error {
f := opts.Factory
config, err := f.Config()
if err != nil {
return err
}
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
if err != nil {
return output.ErrWithHint(output.ExitAPI, "permission",
fmt.Sprintf("failed to get app scope info: %v", err),
"ensure the app has enabled the application:application:self_manage scope.")
}
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
})
}
return nil
}

131
cmd/auth/status.go Normal file
View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
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/core"
"github.com/larksuite/cli/internal/output"
)
// StatusOptions holds all inputs for auth status.
type StatusOptions struct {
Factory *cmdutil.Factory
Verify bool
}
// NewCmdAuthStatus creates the auth status subcommand.
func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
opts := &StatusOptions{Factory: f}
cmd := &cobra.Command{
Use: "status",
Short: "View current auth status",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return authStatusRun(opts)
},
}
cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)")
return cmd
}
func authStatusRun(opts *StatusOptions) error {
f := opts.Factory
config, err := f.Config()
if err != nil {
return err
}
defaultAs := config.DefaultAs
if defaultAs == "" {
defaultAs = "auto"
}
result := map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"defaultAs": defaultAs,
}
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
}
// 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()
}
token, err := larkauth.GetValidAccessToken(httpClient, larkauth.NewUATCallOptions(config, f.IOStreams.ErrOut))
if err != nil {
return false, "token unusable: " + err.Error()
}
sdk, err := f.LarkClient()
if err != nil {
return false, "failed to create SDK client: " + err.Error()
}
if err := larkauth.VerifyUserToken(context.Background(), sdk, token); err != nil {
return false, "server rejected token: " + err.Error()
}
return true, ""
}

View File

@@ -0,0 +1,41 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package completion
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// NewCmdCompletion creates the completion command that generates shell completion scripts.
func NewCmdCompletion(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "completion <shell>",
Short: "Generate shell completion scripts",
Long: "Generate shell completion scripts for bash, zsh, fish, or powershell.",
Hidden: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
root := cmd.Root()
out := f.IOStreams.Out
switch args[0] {
case "bash":
return root.GenBashCompletionV2(out, true)
case "zsh":
return root.GenZshCompletion(out)
case "fish":
return root.GenFishCompletion(out, true)
case "powershell":
return root.GenPowerShellCompletionWithDesc(out)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
cmdutil.DisableAuthCheck(cmd)
return cmd
}

32
cmd/config/config.go Normal file
View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/cobra"
)
// NewCmdConfig creates the config command with subcommands.
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Global CLI configuration management",
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigInit(f, nil))
cmd.AddCommand(NewCmdConfigRemove(f, nil))
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
return cmd
}
func parseBrand(value string) core.LarkBrand {
if value == "lark" {
return core.BrandLark
}
return core.BrandFeishu
}

159
cmd/config/config_test.go Normal file
View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestConfigInitCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.IOStreams.In = strings.NewReader("secret123\n")
var gotOpts *ConfigInitOptions
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"--app-id", "cli_test", "--app-secret-stdin", "--brand", "lark"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.AppID != "cli_test" {
t.Errorf("expected AppID cli_test, got %s", gotOpts.AppID)
}
if !gotOpts.AppSecretStdin {
t.Error("expected AppSecretStdin=true")
}
if gotOpts.Brand != "lark" {
t.Errorf("expected Brand lark, got %s", gotOpts.Brand)
}
}
func TestConfigShowCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
var gotOpts *ConfigShowOptions
cmd := NewCmdConfigShow(f, func(opts *ConfigShowOptions) error {
gotOpts = opts
return nil
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Error("expected opts to be set")
}
}
func TestConfigInitCmd_LangFlag(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
gotOpts = opts
return nil
})
f.IOStreams.In = strings.NewReader("y\n")
cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin", "--lang", "en"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "en" {
t.Errorf("expected Lang en, got %s", gotOpts.Lang)
}
if !gotOpts.langExplicit {
t.Error("expected langExplicit=true when --lang is passed")
}
}
func TestConfigInitCmd_LangDefault(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigInitOptions
cmd := NewCmdConfigInit(f, func(opts *ConfigInitOptions) error {
gotOpts = opts
return nil
})
f.IOStreams.In = strings.NewReader("y\n")
cmd.SetArgs([]string{"--app-id", "x", "--app-secret-stdin"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Lang != "zh" {
t.Errorf("expected default Lang zh, got %s", gotOpts.Lang)
}
if gotOpts.langExplicit {
t.Error("expected langExplicit=false when --lang is not passed")
}
}
func TestHasAnyNonInteractiveFlag(t *testing.T) {
tests := []struct {
name string
opts ConfigInitOptions
want bool
}{
{"empty", ConfigInitOptions{}, false},
{"new", ConfigInitOptions{New: true}, true},
{"app-id", ConfigInitOptions{AppID: "x"}, true},
{"app-secret-stdin", ConfigInitOptions{AppSecretStdin: true}, true},
{"app-id+secret-stdin", ConfigInitOptions{AppID: "x", AppSecretStdin: true}, true},
{"lang-only", ConfigInitOptions{Lang: "en"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.opts.hasAnyNonInteractiveFlag()
if got != tt.want {
t.Errorf("hasAnyNonInteractiveFlag() = %v, want %v", got, tt.want)
}
})
}
}
func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
// TestFactory has IsTerminal=false by default
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Lang: "zh"}
err := configInitRun(opts)
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
msg := err.Error()
if !strings.Contains(msg, "--new") {
t.Errorf("expected error to mention --new, got: %s", msg)
}
if !strings.Contains(msg, "terminal") {
t.Errorf("expected error to mention terminal, got: %s", msg)
}
}
func TestConfigRemoveCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *ConfigRemoveOptions
cmd := NewCmdConfigRemove(f, func(opts *ConfigRemoveOptions) error {
gotOpts = opts
return nil
})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts == nil {
t.Fatal("expected opts to be set")
}
if gotOpts.Factory != f {
t.Fatal("expected factory to be preserved in options")
}
}

51
cmd/config/default_as.go Normal file
View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// NewCmdConfigDefaultAs creates the "config default-as" subcommand.
func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "default-as [user|bot|auto]",
Short: "View or set default identity type",
Long: "Without arguments, shows the current default identity. Pass user, bot, or auto to set a new default.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
multi, err := core.LoadMultiAppConfig()
if err != nil {
return output.ErrWithHint(output.ExitValidation, "config", "not configured", "run: lark-cli config init")
}
if len(args) == 0 {
current := multi.Apps[0].DefaultAs
if current == "" {
current = "auto"
}
fmt.Fprintf(f.IOStreams.Out, "default-as: %s\n", current)
return nil
}
value := args[0]
if value != "user" && value != "bot" && value != "auto" {
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
}
multi.Apps[0].DefaultAs = value
if err := core.SaveMultiAppConfig(multi); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
return nil
},
}
return cmd
}

305
cmd/config/init.go Normal file
View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"bufio"
"context"
"fmt"
"io"
"strings"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// ConfigInitOptions holds all inputs for config init.
type ConfigInitOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
AppID string
appSecret string // internal only; populated from stdin, never from a CLI flag
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Lang string
langExplicit bool // true when --lang was explicitly passed
}
// NewCmdConfigInit creates the config init subcommand.
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
opts := &ConfigInitOptions{Factory: f}
cmd := &cobra.Command{
Use: "init",
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.`,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if runF != nil {
return runF(opts)
}
return configInitRun(opts)
},
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
return cmd
}
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
if existing == nil {
return
}
for _, app := range existing.Apps {
if app.AppId == skipAppID {
continue
}
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
}
// saveAsOnlyApp overwrites config.json with a single-app config.
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
config := &core.MultiAppConfig{
Apps: []core.AppConfig{{
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
}},
}
return core.SaveMultiAppConfig(config)
}
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return output.ErrValidation("failed to read secret from stdin: %v", err)
}
return output.ErrValidation("stdin is empty, expected app secret")
}
opts.appSecret = strings.TrimSpace(scanner.Text())
if opts.appSecret == "" {
return output.ErrValidation("app secret read from stdin is empty")
}
}
existing, err := core.LoadMultiAppConfig()
if err != nil {
existing = nil // treat as empty
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, opts.AppID)
if err := saveAsOnlyApp(opts.AppID, secret, brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
return nil
}
// For interactive modes, prompt language selection if --lang was not explicitly set
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
savedLang := ""
if existing != nil && len(existing.Apps) > 0 {
savedLang = existing.Apps[0].Lang
}
lang, err := promptLangSelection(savedLang)
if err != nil {
if err == huh.ErrUserAborted {
return output.ErrBare(1)
}
return err
}
opts.Lang = lang
}
msg := getInitMsg(opts.Lang)
// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
if err != nil {
return err
}
if result == nil {
return output.ErrValidation("app creation returned no result")
}
existing, _ := core.LoadMultiAppConfig()
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
return nil
}
// Mode 4: Interactive TUI (terminal)
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
if err != nil {
return err
}
if result == nil {
return output.ErrValidation("App ID and App Secret cannot be empty")
}
existing, _ := core.LoadMultiAppConfig()
if result.AppSecret != "" {
// New secret provided (either from "create" or "existing" with input)
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, result.AppID)
if err := saveAsOnlyApp(result.AppID, secret, result.Brand, opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else if result.Mode == "existing" && result.AppID != "" {
// Existing app with unchanged secret — update app ID and brand only
if existing != nil && len(existing.Apps) > 0 {
existing.Apps[0].AppId = result.AppID
existing.Apps[0].Brand = result.Brand
existing.Apps[0].Lang = opts.Lang
if err := core.SaveMultiAppConfig(existing); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
} else {
return output.ErrValidation("App Secret cannot be empty for new configuration")
}
} else {
return output.ErrValidation("App ID and App Secret cannot be empty")
}
if result.Mode == "existing" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
}
return nil
}
// Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal {
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
}
// Mode 5: Legacy interactive (readline fallback)
firstApp := (*core.AppConfig)(nil)
if existing != nil && len(existing.Apps) > 0 {
firstApp = &existing.Apps[0]
}
reader := bufio.NewReader(f.IOStreams.In)
readLine := func(prompt string) (string, error) {
fmt.Fprintf(f.IOStreams.ErrOut, "%s: ", prompt)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read input: %w", err)
}
if err == io.EOF && strings.TrimSpace(line) == "" {
return "", fmt.Errorf("input terminated unexpectedly (EOF)")
}
return strings.TrimSpace(line), nil
}
prompt := "App ID"
if firstApp != nil && firstApp.AppId != "" {
prompt += fmt.Sprintf(" [%s]", firstApp.AppId)
}
appIdInput, err := readLine(prompt)
if err != nil {
return output.ErrValidation("%s", err)
}
prompt = "App Secret"
if firstApp != nil && !firstApp.AppSecret.IsZero() {
prompt += " [****]"
}
appSecretInput, err := readLine(prompt)
if err != nil {
return output.ErrValidation("%s", err)
}
prompt = "Brand (lark/feishu)"
if firstApp != nil && firstApp.Brand != "" {
prompt += fmt.Sprintf(" [%s]", firstApp.Brand)
} else {
prompt += " [feishu]"
}
brandInput, err := readLine(prompt)
if err != nil {
return output.ErrValidation("%s", err)
}
resolvedAppId := appIdInput
if resolvedAppId == "" && firstApp != nil {
resolvedAppId = firstApp.AppId
}
var resolvedSecret core.SecretInput
if appSecretInput != "" {
resolvedSecret = core.PlainSecret(appSecretInput)
} else if firstApp != nil {
resolvedSecret = firstApp.AppSecret
}
resolvedBrand := brandInput
if resolvedBrand == "" && firstApp != nil {
resolvedBrand = string(firstApp.Brand)
}
if resolvedBrand == "" {
resolvedBrand = "feishu"
}
if resolvedAppId == "" || resolvedSecret.IsZero() {
return output.ErrValidation("App ID and App Secret cannot be empty")
}
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%v", err)
}
cleanupOldConfig(existing, f, resolvedAppId)
if err := saveAsOnlyApp(resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
return nil
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"fmt"
"net/http"
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/build"
qrcode "github.com/skip2/go-qrcode"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// configInitResult holds the result of the interactive config init flow.
type configInitResult struct {
Mode string // "create" or "existing"
Brand core.LarkBrand
AppID string
AppSecret string
}
// runInteractiveConfigInit shows an interactive TUI for config init.
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Phase 1: Choose mode
var mode string
form1 := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectAction).
Options(
huh.NewOption(msg.CreateNewApp, "create"),
huh.NewOption(msg.ConfigExistingApp, "existing"),
).
Value(&mode),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form1.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if mode == "existing" {
return runExistingAppForm(f, msg)
}
return runCreateAppFlow(ctx, f, "", msg)
}
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
// Load existing config for defaults
existing, _ := core.LoadMultiAppConfig()
var firstApp *core.AppConfig
if existing != nil && len(existing.Apps) > 0 {
firstApp = &existing.Apps[0]
}
var appID, appSecret, brand string
appIDInput := huh.NewInput().
Title("App ID").
Value(&appID)
if firstApp != nil && firstApp.AppId != "" {
appIDInput = appIDInput.Placeholder(firstApp.AppId)
} else {
appIDInput = appIDInput.Placeholder("cli_xxxx")
}
appSecretInput := huh.NewInput().
Title("App Secret").
EchoMode(huh.EchoModePassword).
Value(&appSecret)
if firstApp != nil && !firstApp.AppSecret.IsZero() {
appSecretInput = appSecretInput.Placeholder("****")
} else {
appSecretInput = appSecretInput.Placeholder("xxxx")
}
brand = "feishu"
if firstApp != nil && firstApp.Brand != "" {
brand = string(firstApp.Brand)
}
form := huh.NewForm(
huh.NewGroup(
appIDInput,
appSecretInput,
huh.NewSelect[string]().
Title(msg.Platform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
// Resolve defaults
if appID == "" && firstApp != nil {
appID = firstApp.AppId
}
if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() {
// Keep existing secret - caller will handle
return &configInitResult{
Mode: "existing",
Brand: parseBrand(brand),
AppID: appID,
}, nil
}
if appID == "" || appSecret == "" {
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
}
return &configInitResult{
Mode: "existing",
Brand: parseBrand(brand),
AppID: appID,
AppSecret: appSecret,
}, nil
}
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
} else {
// Phase 2: Brand selection
var brand string
form2 := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title(msg.SelectPlatform).
Options(
huh.NewOption(msg.Feishu, "feishu"),
huh.NewOption("Lark", "lark"),
).
Value(&brand),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
larkBrand = parseBrand(brand)
}
// Step 1: Request app registration (begin)
httpClient := &http.Client{}
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("app registration failed: %v", err)
}
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("%v", err)
}
// Step 4: Handle Lark brand special case
// If tenant_brand=lark and no client_secret, retry with lark brand endpoint
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
}
// Determine final brand from response
finalBrand := larkBrand
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
return &configInitResult{
Mode: "create",
Brand: finalBrand,
AppID: result.ClientID,
AppSecret: result.ClientSecret,
}, nil
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"github.com/charmbracelet/huh"
"github.com/larksuite/cli/internal/cmdutil"
)
type initMsg struct {
SelectAction string
CreateNewApp string
ConfigExistingApp string
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
SelectAction: "选择操作",
CreateNewApp: "一键配置应用 (推荐) ",
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
SelectAction: "Select action",
CreateNewApp: "Set up your app with one click (Recommended)",
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
func getInitMsg(lang string) *initMsg {
if lang == "en" {
return initMsgEn
}
return initMsgZh
}
// promptLangSelection shows an interactive language picker and returns the chosen lang code.
// savedLang is used as the pre-selected default (from existing config).
func promptLangSelection(savedLang string) (string, error) {
lang := savedLang
if lang != "en" {
lang = "zh"
}
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("Language / 语言").
Options(
huh.NewOption("中文", "zh"),
huh.NewOption("English", "en"),
).
Value(&lang),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form.Run(); err != nil {
return "", err
}
return lang, nil
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"testing"
)
func TestGetInitMsg_Zh(t *testing.T) {
msg := getInitMsg("zh")
if msg != initMsgZh {
t.Error("expected zh message set")
}
if msg.SelectAction != "选择操作" {
t.Errorf("unexpected SelectAction: %s", msg.SelectAction)
}
}
func TestGetInitMsg_En(t *testing.T) {
msg := getInitMsg("en")
if msg != initMsgEn {
t.Error("expected en message set")
}
if msg.SelectAction != "Select action" {
t.Errorf("unexpected SelectAction: %s", msg.SelectAction)
}
}
func TestGetInitMsg_DefaultsToZh(t *testing.T) {
for _, lang := range []string{"", "fr", "ja", "unknown"} {
msg := getInitMsg(lang)
if msg != initMsgZh {
t.Errorf("getInitMsg(%q) should default to zh", lang)
}
}
}
func TestInitMsgZh_AllFieldsNonEmpty(t *testing.T) {
assertAllFieldsNonEmpty(t, initMsgZh, "zh")
}
func TestInitMsgEn_AllFieldsNonEmpty(t *testing.T) {
assertAllFieldsNonEmpty(t, initMsgEn, "en")
}
func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
t.Helper()
fields := map[string]string{
"SelectAction": msg.SelectAction,
"CreateNewApp": msg.CreateNewApp,
"ConfigExistingApp": msg.ConfigExistingApp,
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {
t.Errorf("%s.%s is empty", label, name)
}
}
}
func TestInitMsg_FormatStrings(t *testing.T) {
for _, lang := range []string{"zh", "en"} {
msg := getInitMsg(lang)
// AppCreated and ConfigSaved should contain %s for App ID
got := fmt.Sprintf(msg.AppCreated, "cli_test123")
if got == msg.AppCreated {
t.Errorf("%s AppCreated has no format verb", lang)
}
got = fmt.Sprintf(msg.ConfigSaved, "cli_test123")
if got == msg.ConfigSaved {
t.Errorf("%s ConfigSaved has no format verb", lang)
}
}
}

69
cmd/config/remove.go Normal file
View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"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/spf13/cobra"
)
// ConfigRemoveOptions holds all inputs for config remove.
type ConfigRemoveOptions struct {
Factory *cmdutil.Factory
}
// NewCmdConfigRemove creates the config remove subcommand.
func NewCmdConfigRemove(f *cmdutil.Factory, runF func(*ConfigRemoveOptions) error) *cobra.Command {
opts := &ConfigRemoveOptions{Factory: f}
cmd := &cobra.Command{
Use: "remove",
Short: "Remove app configuration (clears all tokens and config)",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return configRemoveRun(opts)
},
}
return cmd
}
func configRemoveRun(opts *ConfigRemoveOptions) error {
f := opts.Factory
config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
return output.ErrValidation("not configured yet")
}
// Clean up keychain entries for all apps
for _, app := range config.Apps {
core.RemoveSecretStore(app.AppSecret, f.Keychain)
for _, user := range app.Users {
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
}
}
// Save empty config
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
if err := core.SaveMultiAppConfig(empty); err != nil {
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
}
output.PrintSuccess(f.IOStreams.ErrOut, "Configuration removed")
userCount := 0
for _, app := range config.Apps {
userCount += len(app.Users)
}
if userCount > 0 {
fmt.Fprintf(f.IOStreams.ErrOut, "Cleared tokens for %d users\n", userCount)
}
return nil
}

66
cmd/config/show.go Normal file
View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra"
)
// ConfigShowOptions holds all inputs for config show.
type ConfigShowOptions struct {
Factory *cmdutil.Factory
}
// NewCmdConfigShow creates the config show subcommand.
func NewCmdConfigShow(f *cmdutil.Factory, runF func(*ConfigShowOptions) error) *cobra.Command {
opts := &ConfigShowOptions{Factory: f}
cmd := &cobra.Command{
Use: "show",
Short: "Show current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
}
return configShowRun(opts)
},
}
return cmd
}
func configShowRun(opts *ConfigShowOptions) error {
f := opts.Factory
config, err := core.LoadMultiAppConfig()
if err != nil || config == nil || len(config.Apps) == 0 {
fmt.Fprintf(f.IOStreams.ErrOut, "Not configured yet. Config file path: %s\n", core.GetConfigPath())
fmt.Fprintln(f.IOStreams.ErrOut, "Run `lark-cli config init` to initialize.")
return nil
}
app := config.Apps[0]
users := "(no logged-in users)"
if len(app.Users) > 0 {
var userStrs []string
for _, u := range app.Users {
userStrs = append(userStrs, fmt.Sprintf("%s (%s)", u.UserName, u.UserOpenId))
}
users = strings.Join(userStrs, ", ")
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": app.AppId,
"appSecret": "****",
"brand": app.Brand,
"lang": app.Lang,
"users": users,
})
fmt.Fprintf(f.IOStreams.ErrOut, "\nConfig file path: %s\n", core.GetConfigPath())
return nil
}

235
cmd/doctor/doctor.go Normal file
View File

@@ -0,0 +1,235 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/spf13/cobra"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// DoctorOptions holds inputs for the doctor command.
type DoctorOptions struct {
Factory *cmdutil.Factory
Ctx context.Context
Offline bool
}
// NewCmdDoctor creates the doctor command.
func NewCmdDoctor(f *cmdutil.Factory) *cobra.Command {
opts := &DoctorOptions{Factory: f}
cmd := &cobra.Command{
Use: "doctor",
Short: "CLI health check: config, auth, and connectivity",
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
return doctorRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.Flags().BoolVar(&opts.Offline, "offline", false, "skip network checks (only verify local state)")
return cmd
}
// checkResult represents one diagnostic check.
type checkResult struct {
Name string `json:"name"`
Status string `json:"status"` // "pass", "fail", "skip"
Message string `json:"message"`
Hint string `json:"hint,omitempty"`
}
func pass(name, msg string) checkResult {
return checkResult{Name: name, Status: "pass", Message: msg}
}
func fail(name, msg, hint string) checkResult {
return checkResult{Name: name, Status: "fail", Message: msg, Hint: hint}
}
func skip(name, msg string) checkResult {
return checkResult{Name: name, Status: "skip", Message: msg}
}
func doctorRun(opts *DoctorOptions) error {
f := opts.Factory
var checks []checkResult
// ── 1. Config file ──
_, err := core.LoadMultiAppConfig()
if err != nil {
checks = append(checks, fail("config_file", err.Error(), "run: lark-cli config init"))
return finishDoctor(f, checks)
}
checks = append(checks, pass("config_file", "config.json found"))
// ── 2. App resolved ──
cfg, err := f.Config()
if err != nil {
hint := ""
var cfgErr *core.ConfigError
if errors.As(err, &cfgErr) {
hint = cfgErr.Hint
}
checks = append(checks, fail("app_resolved", err.Error(), hint))
return finishDoctor(f, checks)
}
checks = append(checks, pass("app_resolved", fmt.Sprintf("app: %s (%s)", cfg.AppID, cfg.Brand)))
ep := core.ResolveEndpoints(cfg.Brand)
// ── 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)
}
// ── 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)
}
// networkChecks probes Open API and MCP endpoints concurrently.
func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints) []checkResult {
if opts.Offline {
return []checkResult{
skip("endpoint_open", "skipped (--offline)"),
skip("endpoint_mcp", "skipped (--offline)"),
}
}
httpClient := &http.Client{}
mcpURL := ep.MCP + "/mcp"
type probeResult struct {
name string
url string
err error
}
var wg sync.WaitGroup
results := make([]probeResult, 2)
wg.Add(2)
go func() {
defer wg.Done()
defer func() { recover() }()
results[0] = probeResult{"endpoint_open", ep.Open, probeEndpoint(ctx, httpClient, ep.Open)}
}()
go func() {
defer wg.Done()
defer func() { recover() }()
results[1] = probeResult{"endpoint_mcp", mcpURL, probeEndpoint(ctx, httpClient, mcpURL)}
}()
wg.Wait()
var checks []checkResult
for _, r := range results {
if r.err != nil {
checks = append(checks, fail(r.name, fmt.Sprintf("%s unreachable: %s", r.url, r.err), "check network or proxy settings"))
} else {
checks = append(checks, pass(r.name, r.url+" reachable"))
}
}
return checks
}
// probeEndpoint sends a HEAD request to check reachability.
func probeEndpoint(ctx context.Context, client *http.Client, url string) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
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
}
func finishDoctor(f *cmdutil.Factory, checks []checkResult) error {
allOK := true
for _, c := range checks {
if c.Status == "fail" {
allOK = false
break
}
}
result := map[string]interface{}{
"ok": allOK,
"checks": checks,
}
output.PrintJson(f.IOStreams.Out, result)
if !allOK {
return output.ErrBare(1)
}
return nil
}

97
cmd/doctor/doctor_test.go Normal file
View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doctor
import (
"context"
"encoding/json"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestNewCmdDoctor_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdDoctor(f)
cmd.SetArgs([]string{"--offline"})
// We only test flag parsing; skip actual execution by intercepting RunE.
var gotOffline bool
origRunE := cmd.RunE
cmd.RunE = func(cmd2 *cobra.Command, args []string) error {
v, _ := cmd2.Flags().GetBool("offline")
gotOffline = v
return nil
}
_ = origRunE
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !gotOffline {
t.Error("expected --offline to be true")
}
}
func TestFinishDoctor(t *testing.T) {
t.Run("all pass returns nil", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
checks := []checkResult{
pass("check1", "ok"),
skip("check2", "skipped"),
}
err := finishDoctor(f, checks)
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
var result struct {
OK bool `json:"ok"`
}
json.Unmarshal(stdout.Bytes(), &result)
if !result.OK {
t.Error("expected ok=true")
}
})
t.Run("any fail returns error", func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
checks := []checkResult{
pass("check1", "ok"),
fail("check2", "bad", "fix it"),
}
err := finishDoctor(f, checks)
if err == nil {
t.Fatal("expected error, got nil")
}
var result struct {
OK bool `json:"ok"`
}
json.Unmarshal(stdout.Bytes(), &result)
if result.OK {
t.Error("expected ok=false")
}
})
}
func TestNetworkChecks_Offline(t *testing.T) {
ep := core.Endpoints{Open: "https://open.feishu.cn", MCP: "https://mcp.feishu.cn"}
opts := &DoctorOptions{Ctx: context.Background(), Offline: true}
checks := networkChecks(opts.Ctx, opts, ep)
if len(checks) != 2 {
t.Fatalf("expected 2 checks, got %d", len(checks))
}
for _, c := range checks {
if c.Status != "skip" {
t.Errorf("expected skip, got %s for %s", c.Status, c.Name)
}
}
}

307
cmd/root.go Normal file
View File

@@ -0,0 +1,307 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"strconv"
"github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/completion"
cmdconfig "github.com/larksuite/cli/cmd/config"
"github.com/larksuite/cli/cmd/doctor"
"github.com/larksuite/cli/cmd/schema"
"github.com/larksuite/cli/cmd/service"
internalauth "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/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts"
"github.com/spf13/cobra"
)
const rootLong = `lark-cli — Lark/Feishu CLI tool.
USAGE:
lark-cli <command> [subcommand] [method] [options]
lark-cli api <method> <path> [--params <json>] [--data <json>]
lark-cli schema <service.resource.method> [--format pretty]
EXAMPLES:
# View upcoming events
lark-cli calendar +agenda
# List calendar events
lark-cli calendar events list --params '{"calendar_id":"primary"}'
# Search users
lark-cli contact +search-user --query "John"
# Generic API call
lark-cli api GET /open-apis/calendar/v4/calendars
FLAGS:
--params <json> URL/query parameters JSON
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
--as <type> identity type: user | bot | auto (default: auto)
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
--page-all automatically paginate through all pages
--page-size <N> page size (0 = use API default)
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
-o, --output <path> output file path for binary responses
--dry-run print request without executing
AI AGENT SKILLS:
lark-cli pairs with AI agent skills (Claude Code, etc.) that
teach the agent Lark API patterns, best practices, and workflows.
Install all skills:
npx skills add larksuite/cli --all -y
Or pick specific domains:
npx skills add larksuite/cli -s lark-calendar -y
npx skills add larksuite/cli -s lark-im -y
Learn more: https://github.com/larksuite/cli#install-ai-agent-skills
COMMUNITY:
GitHub: https://github.com/larksuite/cli
Issues: https://github.com/larksuite/cli/issues
Docs: https://open.feishu.cn/document/
More help: lark-cli <command> --help`
// Execute runs the root command and returns the process exit code.
func Execute() int {
f := cmdutil.NewDefault()
rootCmd := &cobra.Command{
Use: "lark-cli",
Short: "Lark/Feishu CLI — OAuth authorization, UAT management, API calls",
Long: rootLong,
Version: build.Version,
}
installTipsHelpFunc(rootCmd)
rootCmd.SilenceErrors = true
rootCmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
cmd.SilenceUsage = true
}
rootCmd.AddCommand(cmdconfig.NewCmdConfig(f))
rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(doctor.NewCmdDoctor(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil))
rootCmd.AddCommand(schema.NewCmdSchema(f, nil))
rootCmd.AddCommand(completion.NewCmdCompletion(f))
service.RegisterServiceCommands(rootCmd, f)
shortcuts.RegisterShortcuts(rootCmd, f)
if err := rootCmd.Execute(); err != nil {
return handleRootError(f, err)
}
return 0
}
// handleRootError dispatches a command error to the appropriate handler
// and returns the process exit code.
func handleRootError(f *cmdutil.Factory, err error) int {
errOut := f.IOStreams.ErrOut
// 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
}
// All other structured errors normalize to ExitError.
if exitErr := asExitError(err); exitErr != nil {
if exitErr.Raw {
// Raw errors (e.g. from `api` command) already printed the full API
// response to stdout; skip enrichment and duplicate stderr envelope.
return exitErr.Code
}
enrichPermissionError(f, exitErr)
output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
return exitErr.Code
}
// Cobra errors (required flags, unknown commands, etc.)
fmt.Fprintln(errOut, "Error:", err)
return 1
}
// 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.Code {
case internalauth.LarkErrBlockByPolicyTryAuth:
codeStr = "challenge_required"
case internalauth.LarkErrBlockByPolicy:
codeStr = "access_denied"
default:
codeStr = strconv.Itoa(spErr.Code)
}
errData := map[string]interface{}{
"type": "auth_error",
"code": codeStr,
"message": spErr.Message,
"retryable": false,
}
if spErr.ChallengeURL != "" {
errData["challenge_url"] = spErr.ChallengeURL
}
if spErr.CLIHint != "" {
errData["hint"] = spErr.CLIHint
}
env := map[string]interface{}{"ok": false, "error": errData}
b, err := json.MarshalIndent(env, "", " ")
if err != nil {
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
return
}
fmt.Fprintln(w, string(b))
}
// installTipsHelpFunc wraps the default help function to append a TIPS section
// when a command has tips set via cmdutil.SetTips.
func installTipsHelpFunc(root *cobra.Command) {
defaultHelp := root.HelpFunc()
root.SetHelpFunc(func(cmd *cobra.Command, args []string) {
defaultHelp(cmd, args)
tips := cmdutil.GetTips(cmd)
if len(tips) == 0 {
return
}
out := cmd.OutOrStdout()
fmt.Fprintln(out)
fmt.Fprintln(out, "Tips:")
for _, tip := range tips {
fmt.Fprintf(out, " • %s\n", tip)
}
})
}
// 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
scopes := extractRequiredScopes(exitErr.Detail.Detail)
if len(scopes) == 0 {
return
}
cfg, err := f.Config()
if err != nil {
return
}
// Select the recommended (least-privilege) scope
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
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
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)"
} else {
exitErr.Detail.Hint = 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)
}
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)"
} else {
exitErr.Detail.Hint = fmt.Sprintf(
"enable scope in console (see console_url), or 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)
}
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
}

189
cmd/root_test.go Normal file
View File

@@ -0,0 +1,189 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmd
import (
"strings"
"testing"
"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/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// TestPersistentPreRunE_AuthCheckDisabledAnnotations verifies that
// auth, config, and schema commands have auth check disabled,
// while api does not.
func TestPersistentPreRunE_AuthCheckDisabledAnnotations(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
authCmd := auth.NewCmdAuth(f)
if !cmdutil.IsAuthCheckDisabled(authCmd) {
t.Error("expected auth command to have auth check disabled")
}
configCmd := cmdconfig.NewCmdConfig(f)
if !cmdutil.IsAuthCheckDisabled(configCmd) {
t.Error("expected config command to have auth check disabled")
}
schemaCmd := schema.NewCmdSchema(f, nil)
if !cmdutil.IsAuthCheckDisabled(schemaCmd) {
t.Error("expected schema command to have auth check disabled")
}
apiCmd := api.NewCmdApi(f, nil)
if cmdutil.IsAuthCheckDisabled(apiCmd) {
t.Error("expected api command to NOT have auth check disabled")
}
}
func TestPersistentPreRunE_AuthSubcommands(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
authCmd := auth.NewCmdAuth(f)
for _, sub := range authCmd.Commands() {
if !cmdutil.IsAuthCheckDisabled(sub) {
t.Errorf("expected auth subcommand %q to inherit disabled auth check", sub.Name())
}
}
}
func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
configCmd := cmdconfig.NewCmdConfig(f)
for _, sub := range configCmd.Commands() {
if !cmdutil.IsAuthCheckDisabled(sub) {
t.Errorf("expected config subcommand %q to inherit disabled auth check", sub.Name())
}
}
}
func TestHandleRootError_RawError_SkipsEnrichmentAndEnvelope(t *testing.T) {
f, _, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
// 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"},
},
})
err.Raw = true
code := handleRootError(f, err)
if code != output.ExitAPI {
t.Errorf("expected exit code %d, got %d", output.ExitAPI, code)
}
// stderr should be empty — no envelope written
if stderr.Len() != 0 {
t.Errorf("expected empty stderr for Raw error, got: %s", stderr.String())
}
// 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")
}
}
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)
}
})
}
}

500
cmd/schema/schema.go Normal file
View File

@@ -0,0 +1,500 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"fmt"
"io"
"sort"
"strings"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/util"
"github.com/spf13/cobra"
)
// SchemaOptions holds all inputs for the schema command.
type SchemaOptions struct {
Factory *cmdutil.Factory
// Positional args
Path string
// Flags
Format string
}
func printServices(w io.Writer) {
services := registry.ListFromMetaProjects()
fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset)
for _, s := range services {
spec := registry.LoadFromMeta(s)
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset)
}
fmt.Fprintf(w, "\n%sUsage: lark-cli schema <service>.<resource>.<method>%s\n", output.Dim, output.Reset)
}
func printResourceList(w io.Writer, spec map[string]interface{}) {
name := registry.GetStrFromMap(spec, "name")
version := registry.GetStrFromMap(spec, "version")
title := registry.GetStrFromMap(spec, "title")
if title == "" {
title = registry.GetStrFromMap(spec, "description")
}
servicePath := registry.GetStrFromMap(spec, "servicePath")
fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title)
fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset)
resources, _ := spec["resources"].(map[string]interface{})
for _, resName := range sortedKeys(resources) {
fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset)
resMap, _ := resources[resName].(map[string]interface{})
methods, _ := resMap["methods"].(map[string]interface{})
for _, methodName := range sortedKeys(methods) {
m, _ := methods[methodName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
danger := ""
if d, _ := m["danger"].(bool); d {
danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset)
}
fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger)
}
fmt.Fprintln(w)
}
fmt.Fprintf(w, "%sUsage: lark-cli schema %s.<resource>.<method>%s\n", output.Dim, name, output.Reset)
}
func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) {
servicePath := registry.GetStrFromMap(spec, "servicePath")
specName := registry.GetStrFromMap(spec, "name")
methodPath := registry.GetStrFromMap(method, "path")
fullPath := servicePath + "/" + methodPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
desc := registry.GetStrFromMap(method, "description")
fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset)
httpColor := output.Yellow
if httpMethod == "GET" {
httpColor = output.Green
} else if httpMethod == "DELETE" {
httpColor = output.Red
}
fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath)
if desc != "" {
fmt.Fprintf(w, " %s\n", desc)
}
fmt.Fprintln(w)
// Parameters
params, _ := method["parameters"].(map[string]interface{})
if len(params) > 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
fmt.Fprintf(w, " %s--params%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
for _, paramName := range sortedParamKeys(params) {
p, _ := params[paramName].(map[string]interface{})
pType := registry.GetStrFromMap(p, "type")
if pType == "" {
pType = "string"
}
location := registry.GetStrFromMap(p, "location")
required, _ := p["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
locColor := output.Dim
if location == "path" {
locColor = output.Yellow
}
// Options (enum values)
optStr := formatOptions(p)
fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr)
if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" {
pdesc = util.TruncateStrWithEllipsis(pdesc, 100)
fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset)
}
if ex := registry.GetStrFromMap(p, "example"); ex != "" {
fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(p); rangeStr != "" {
fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset)
}
}
fmt.Fprintln(w)
}
// --data for write methods
if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" {
if len(params) == 0 {
fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset)
}
fmt.Fprintf(w, " %s--data%s <json> %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset)
requestBody, _ := method["requestBody"].(map[string]interface{})
if len(requestBody) > 0 {
printNestedFields(w, requestBody, " ", "")
}
fmt.Fprintln(w)
}
// Response
responseBody, _ := method["responseBody"].(map[string]interface{})
if len(responseBody) > 0 {
fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset)
printNestedFields(w, responseBody, " ", "")
fmt.Fprintln(w)
}
// Identity
if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
var identities []string
for _, t := range tokens {
if s, ok := t.(string); ok {
switch s {
case "user":
identities = append(identities, "user")
case "tenant":
identities = append(identities, "bot")
}
}
}
if len(identities) > 0 {
fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", "))
}
}
// Scopes (all)
if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 {
var scopeStrs []string
for _, s := range scopes {
if str, ok := s.(string); ok {
scopeStrs = append(scopeStrs, str)
}
}
fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", "))
}
// CLI example
fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName)
// Docs
if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" {
fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl)
}
}
func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) {
for _, fieldName := range sortedFieldKeys(fields) {
f, _ := fields[fieldName].(map[string]interface{})
fullName := fieldName
if prefix != "" {
fullName = prefix + "." + fieldName
}
fType := registry.GetStrFromMap(f, "type")
required, _ := f["required"].(bool)
reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset)
if required {
reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset)
}
optStr := formatOptions(f)
fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr)
desc := registry.GetStrFromMap(f, "description")
if desc != "" {
desc = util.TruncateStrWithEllipsis(desc, 100)
fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset)
}
if ex := registry.GetStrFromMap(f, "example"); ex != "" {
fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset)
}
if rangeStr := formatRange(f); rangeStr != "" {
fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset)
}
if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 {
printNestedFields(w, props, indent+" ", fullName)
}
}
}
// formatOptions returns " — val1 | val2 | ..." if field has options, else "".
func formatOptions(f map[string]interface{}) string {
opts, ok := f["options"].([]interface{})
if !ok || len(opts) == 0 {
return ""
}
var vals []string
for _, o := range opts {
if om, ok := o.(map[string]interface{}); ok {
if v := registry.GetStrFromMap(om, "value"); v != "" {
vals = append(vals, v)
}
}
}
if len(vals) == 0 {
return ""
}
return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset)
}
// formatRange returns "min..max" if field has min/max, else "".
func formatRange(f map[string]interface{}) string {
minVal := registry.GetStrFromMap(f, "min")
maxVal := registry.GetStrFromMap(f, "max")
if minVal == "" && maxVal == "" {
return ""
}
if minVal != "" && maxVal != "" {
return minVal + ".." + maxVal
}
if minVal != "" {
return ">=" + minVal
}
return "<=" + maxVal
}
// sortedKeys returns map keys in alphabetical order.
func sortedKeys(m map[string]interface{}) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// sortedParamKeys returns parameter keys sorted: required first, then alphabetical.
func sortedParamKeys(params map[string]interface{}) []string {
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
pi, _ := params[keys[i]].(map[string]interface{})
pj, _ := params[keys[j]].(map[string]interface{})
ri, _ := pi["required"].(bool)
rj, _ := pj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
// sortedFieldKeys returns field keys sorted: required first, then alphabetical.
func sortedFieldKeys(fields map[string]interface{}) []string {
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
fi, _ := fields[keys[i]].(map[string]interface{})
fj, _ := fields[keys[j]].(map[string]interface{})
ri, _ := fi["required"].(bool)
rj, _ := fj["required"].(bool)
if ri != rj {
return ri
}
return keys[i] < keys[j]
})
return keys
}
func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) {
for i := len(parts); i >= 1; i-- {
candidateName := strings.Join(parts[:i], ".")
if res, ok := resources[candidateName]; ok {
if resMap, ok := res.(map[string]interface{}); ok {
return resMap, candidateName, parts[i:]
}
}
}
return nil, "", nil
}
// NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook).
func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Command {
opts := &SchemaOptions{Factory: f}
cmd := &cobra.Command{
Use: "schema [path]",
Short: "View API method parameters, types, and scopes",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
opts.Path = args[0]
}
if runF != nil {
return runF(opts)
}
return schemaRun(opts)
},
}
cmdutil.DisableAuthCheck(cmd)
cmd.ValidArgsFunction = completeSchemaPath
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty")
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp
})
return cmd
}
// completeSchemaPath provides tab-completion for the schema path argument.
// It handles dotted resource names (e.g. app.table.fields) by iterating all
// resources and classifying each as a prefix-match or fully-matched.
func completeSchemaPath(_ *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
parts := strings.Split(toComplete, ".")
// Level 1: complete service names
if len(parts) <= 1 {
var completions []string
for _, s := range registry.ListFromMetaProjects() {
if strings.HasPrefix(s, toComplete) {
completions = append(completions, s+".")
}
}
return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace
}
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// afterService = everything user typed after "serviceName."
afterService := strings.Join(parts[1:], ".")
var completions []string
for resName, resVal := range resources {
if strings.HasPrefix(resName, afterService) {
// afterService is a prefix of this resource name → resource candidate
completions = append(completions, serviceName+"."+resName+".")
} else if strings.HasPrefix(afterService, resName+".") {
// This resource is fully matched; remainder is method prefix
methodPrefix := afterService[len(resName)+1:]
resMap, _ := resVal.(map[string]interface{})
if resMap == nil {
continue
}
methods, _ := resMap["methods"].(map[string]interface{})
for methodName := range methods {
if strings.HasPrefix(methodName, methodPrefix) {
completions = append(completions, serviceName+"."+resName+"."+methodName)
}
}
}
}
sort.Strings(completions)
// If all completions end with ".", user is still navigating resources → NoSpace
allTrailingDot := len(completions) > 0
for _, c := range completions {
if !strings.HasSuffix(c, ".") {
allTrailingDot = false
break
}
}
directive := cobra.ShellCompDirectiveNoFileComp
if allTrailingDot {
directive |= cobra.ShellCompDirectiveNoSpace
}
return completions, directive
}
func schemaRun(opts *SchemaOptions) error {
out := opts.Factory.IOStreams.Out
if opts.Path == "" {
printServices(out)
return nil
}
parts := strings.Split(opts.Path, ".")
serviceName := parts[0]
spec := registry.LoadFromMeta(serviceName)
if spec == nil {
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown service: %s", serviceName),
fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", ")))
}
if len(parts) == 1 {
if opts.Format == "pretty" {
printResourceList(out, spec)
} else {
output.PrintJson(out, spec)
}
return nil
}
resources, _ := spec["resources"].(map[string]interface{})
resource, resName, remaining := findResourceByPath(resources, parts[1:])
if resource == nil {
var resNames []string
for k := range resources {
resNames = append(resNames, k)
}
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")),
fmt.Sprintf("Available: %s", strings.Join(resNames, ", ")))
}
if len(remaining) == 0 {
if opts.Format == "pretty" {
fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset)
methods, _ := resource["methods"].(map[string]interface{})
for _, mName := range sortedKeys(methods) {
m, _ := methods[mName].(map[string]interface{})
httpMethod := registry.GetStrFromMap(m, "httpMethod")
desc := registry.GetStrFromMap(m, "description")
fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset)
}
fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.<method>%s\n", output.Dim, serviceName, resName, output.Reset)
} else {
output.PrintJson(out, resource)
}
return nil
}
methodName := remaining[0]
methods, _ := resource["methods"].(map[string]interface{})
method, ok := methods[methodName].(map[string]interface{})
if !ok {
var mNames []string
for k := range methods {
mNames = append(mNames, k)
}
return output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName),
fmt.Sprintf("Available: %s", strings.Join(mNames, ", ")))
}
if opts.Format == "pretty" {
printMethodDetail(out, spec, resName, methodName, method)
} else {
output.PrintJson(out, method)
}
return nil
}

63
cmd/schema/schema_test.go Normal file
View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package schema
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestSchemaCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, nil)
var gotOpts *SchemaOptions
cmd := NewCmdSchema(f, func(opts *SchemaOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if gotOpts.Path != "calendar.events.list" {
t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path)
}
if gotOpts.Format != "pretty" {
t.Errorf("expected Format=pretty, got %s", gotOpts.Format)
}
}
func TestSchemaCmd_NoArgs(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "Available services") {
t.Error("expected service list output")
}
}
func TestSchemaCmd_UnknownService(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := NewCmdSchema(f, nil)
cmd.SetArgs([]string{"nonexistent_service"})
err := cmd.Execute()
if err == nil {
t.Error("expected error for unknown service")
}
if !strings.Contains(err.Error(), "Unknown service") {
t.Errorf("expected 'Unknown service' error, got: %v", err)
}
}

432
cmd/service/service.go Normal file
View File

@@ -0,0 +1,432 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"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/internal/util"
"github.com/larksuite/cli/internal/validate"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/spf13/cobra"
)
// RegisterServiceCommands registers all service commands from from_meta specs.
func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) {
for _, project := range registry.ListFromMetaProjects() {
spec := registry.LoadFromMeta(project)
if spec == nil {
continue
}
specName := registry.GetStrFromMap(spec, "name")
servicePath := registry.GetStrFromMap(spec, "servicePath")
if specName == "" || servicePath == "" {
continue
}
resources, _ := spec["resources"].(map[string]interface{})
if resources == nil {
continue
}
registerService(parent, spec, resources, f)
}
}
func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) {
specName := registry.GetStrFromMap(spec, "name")
specDesc := registry.GetServiceDescription(specName, "en")
if specDesc == "" {
specDesc = registry.GetStrFromMap(spec, "description")
}
// Find existing service command or create one
var svc *cobra.Command
for _, c := range parent.Commands() {
if c.Name() == specName {
svc = c
break
}
}
if svc == nil {
svc = &cobra.Command{
Use: specName,
Short: specDesc,
}
parent.AddCommand(svc)
}
for resName, resource := range resources {
resMap, _ := resource.(map[string]interface{})
if resMap == nil {
continue
}
registerResource(svc, spec, resName, resMap, f)
}
}
func registerResource(parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) {
res := &cobra.Command{
Use: name,
Short: name + " operations",
}
parent.AddCommand(res)
methods, _ := resource["methods"].(map[string]interface{})
for methodName, method := range methods {
methodMap, _ := method.(map[string]interface{})
if methodMap == nil {
continue
}
registerMethod(res, spec, methodMap, methodName, name, f)
}
}
// ServiceMethodOptions holds all inputs for a dynamically registered service method command.
type ServiceMethodOptions struct {
Factory *cmdutil.Factory
Cmd *cobra.Command
Ctx context.Context
Spec map[string]interface{}
Method map[string]interface{}
SchemaPath string
// Flags
Params string
Data string
As core.Identity
Output string
PageAll bool
PageLimit int
PageDelay int
Format string
DryRun bool
}
func registerMethod(parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) {
parent.AddCommand(NewCmdServiceMethod(f, spec, method, name, resName, nil))
}
// NewCmdServiceMethod creates a command for a dynamically registered service method.
func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command {
desc := registry.GetStrFromMap(method, "description")
httpMethod := registry.GetStrFromMap(method, "httpMethod")
specName := registry.GetStrFromMap(spec, "name")
schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name)
opts := &ServiceMethodOptions{
Factory: f,
Spec: spec,
Method: method,
SchemaPath: schemaPath,
}
var asStr string
cmd := &cobra.Command{
Use: name,
Short: desc,
Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Cmd = cmd
opts.Ctx = cmd.Context()
opts.As = core.Identity(asStr)
if runF != nil {
return runF(opts)
}
return serviceMethodRun(opts)
},
}
cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON")
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON")
}
cmd.Flags().StringVar(&asStr, "as", "auto", "identity type: user | bot | auto (default)")
cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses")
cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages")
cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)")
cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages")
cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv")
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing")
_ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"user", "bot"}, cobra.ShellCompDirectiveNoFileComp
})
_ = cmd.RegisterFlagCompletionFunc("format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips"))
return cmd
}
func serviceMethodRun(opts *ServiceMethodOptions) error {
f := opts.Factory
opts.As = f.ResolveAs(opts.Cmd, opts.As)
// Check if this API method supports the resolved identity.
if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 {
if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil {
return err
}
}
if opts.PageAll && opts.Output != "" {
return output.ErrValidation("--output and --page-all are mutually exclusive")
}
config, err := f.ResolveConfig(opts.As)
if err != nil {
return err
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
scopes, _ := opts.Method["scopes"].([]interface{})
if !opts.As.IsBot() {
if err := checkServiceScopes(config, opts.Method, scopes); err != nil {
return err
}
}
request, err := buildServiceRequest(opts)
if err != nil {
return err
}
if opts.DryRun {
return serviceDryRun(f, request, config, opts.Format)
}
ac, err := f.NewAPIClientWithConfig(config)
if err != nil {
return err
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut,
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
}
resp, err := ac.DoAPI(opts.Ctx, request)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
}
return client.HandleResponse(resp, client.ResponseOptions{
OutputPath: opts.Output,
Format: format,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CheckError: checkErr,
})
}
// checkServiceScopes pre-checks user scopes before making the API call.
func checkServiceScopes(config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
requiredScopes, hasRequired := method["requiredScopes"].([]interface{})
if hasRequired && len(requiredScopes) > 0 {
// Strict: ALL requiredScopes must be present
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
if stored != nil {
required := make([]string, 0, len(requiredScopes))
for _, s := range requiredScopes {
if str, ok := s.(string); ok {
required = append(required, str)
}
}
if missing := auth.MissingScopes(stored.Scope, required); len(missing) > 0 {
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
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.", strings.Join(missing, " ")))
}
}
return nil
}
if len(scopes) == 0 {
return nil
}
// Default: ANY one of the declared scopes is sufficient
stored := auth.GetStoredToken(config.AppID, config.UserOpenId)
if stored == nil {
return nil
}
grantedScopes := make(map[string]bool)
for _, s := range strings.Fields(stored.Scope) {
grantedScopes[s] = true
}
for _, s := range scopes {
if str, ok := s.(string); ok && grantedScopes[str] {
return nil
}
}
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))
}
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, error) {
spec := opts.Spec
method := opts.Method
schemaPath := opts.SchemaPath
httpMethod := registry.GetStrFromMap(method, "httpMethod")
var params map[string]interface{}
if opts.Params != "" {
if err := json.Unmarshal([]byte(opts.Params), &params); err != nil {
return client.RawApiRequest{}, output.ErrValidation("--params invalid JSON format")
}
} else {
params = map[string]interface{}{}
}
url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path")
parameters, _ := method["parameters"].(map[string]interface{})
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "path" {
continue
}
val, ok := params[name]
if !ok || util.IsEmptyValue(val) {
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required path parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
valStr := fmt.Sprintf("%v", val)
if err := validate.ResourceName(valStr, name); err != nil {
return client.RawApiRequest{}, output.ErrValidation("%s", err)
}
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
delete(params, name)
}
queryParams := map[string]interface{}{}
for name, param := range parameters {
p, _ := param.(map[string]interface{})
if registry.GetStrFromMap(p, "location") != "query" {
continue
}
value, exists := params[name]
required, _ := p["required"].(bool)
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
return client.RawApiRequest{}, output.ErrWithHint(output.ExitValidation, "validation",
fmt.Sprintf("missing required query parameter: %s", name),
fmt.Sprintf("lark-cli schema %s", schemaPath))
}
if exists && !util.IsEmptyValue(value) {
queryParams[name] = value
}
}
for name, value := range params {
if _, ok := queryParams[name]; !ok {
queryParams[name] = value
}
}
data, err := cmdutil.ParseOptionalBody(httpMethod, opts.Data)
if err != nil {
return client.RawApiRequest{}, err
}
request := client.RawApiRequest{
Method: httpMethod,
URL: url,
Params: queryParams,
Data: data,
As: opts.As,
}
if opts.Output != "" {
request.ExtraOpts = append(request.ExtraOpts, larkcore.WithFileDownload())
}
return request, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
}
// 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, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error {
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
pf := output.NewPaginatedFormatter(out, format)
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) {
pf.FormatPage(items)
}, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
}
if apiErr := checkErr(result); apiErr != nil {
return apiErr
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
output.FormatValue(out, result, output.FormatJSON)
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return output.ErrNetwork("API call failed: %s", err)
}
if apiErr := checkErr(result); apiErr != nil {
return apiErr
}
output.FormatValue(out, result, format)
return nil
}
}

552
cmd/service/service_test.go Normal file
View File

@@ -0,0 +1,552 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package service
import (
"strings"
"testing"
"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"
)
// ── helpers ──
var testConfig = &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}
func driveSpec() map[string]interface{} {
return map[string]interface{}{
"name": "drive",
"servicePath": "/open-apis/drive/v1",
}
}
func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} {
m := map[string]interface{}{
"path": "files/{file_token}/copy",
"httpMethod": httpMethod,
}
if params != nil {
m["parameters"] = params
} else {
m["parameters"] = map[string]interface{}{
"file_token": map[string]interface{}{
"type": "string", "location": "path", "required": true,
},
}
}
return m
}
func tokenStub() *httpmock.Stub {
return &httpmock.Stub{
URL: "tenant_access_token",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-test", "expire": 7200,
},
}
}
// ── registerService ──
func TestRegisterService(t *testing.T) {
parent := &cobra.Command{Use: "root"}
f := &cmdutil.Factory{}
spec := map[string]interface{}{
"name": "base",
"description": "Base API",
"servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{
"description": "List tables",
"httpMethod": "GET",
},
},
},
}
registerService(parent, spec, resources, f)
// service command exists
svc, _, err := parent.Find([]string{"base"})
if err != nil || svc.Name() != "base" {
t.Fatalf("expected 'base' command, got err=%v", err)
}
// resource sub-command
res, _, err := parent.Find([]string{"base", "tables"})
if err != nil || res.Name() != "tables" {
t.Fatalf("expected 'tables' command, got err=%v", err)
}
// method sub-command
meth, _, err := parent.Find([]string{"base", "tables", "list"})
if err != nil || meth.Name() != "list" {
t.Fatalf("expected 'list' command, got err=%v", err)
}
}
func TestRegisterService_MergesExistingCommand(t *testing.T) {
parent := &cobra.Command{Use: "root"}
existing := &cobra.Command{Use: "base", Short: "existing"}
parent.AddCommand(existing)
f := &cmdutil.Factory{}
spec := map[string]interface{}{
"name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3",
}
resources := map[string]interface{}{
"tables": map[string]interface{}{
"methods": map[string]interface{}{
"list": map[string]interface{}{"description": "List", "httpMethod": "GET"},
},
},
}
registerService(parent, spec, resources, f)
// Should reuse existing, not duplicate
count := 0
for _, c := range parent.Commands() {
if c.Name() == "base" {
count++
}
}
if count != 1 {
t.Errorf("expected 1 'base' command, got %d", count)
}
// Resource should be added under the existing command
_, _, err := parent.Find([]string{"base", "tables", "list"})
if err != nil {
t.Fatalf("expected 'list' under existing 'base' command, got err=%v", err)
}
}
// ── NewCmdServiceMethod flags ──
func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil)
if cmd.Flags().Lookup("data") != nil {
t.Error("GET method should not have --data flag")
}
if cmd.Use != "list" {
t.Errorf("expected Use=list, got %s", cmd.Use)
}
if !strings.Contains(cmd.Long, "schema drive.files.list") {
t.Errorf("expected schema path in Long, got %s", cmd.Long)
}
}
func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil)
if cmd.Flags().Lookup("data") == nil {
t.Error("POST method should have --data flag")
}
}
func TestNewCmdServiceMethod_RunFCallback(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
var captured *ServiceMethodOptions
cmd := NewCmdServiceMethod(f, driveSpec(),
map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files",
func(opts *ServiceMethodOptions) error {
captured = opts
return nil
})
cmd.SetArgs([]string{"--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if captured == nil {
t.Fatal("runF was not called")
}
if captured.As != core.AsBot {
t.Errorf("expected As=bot, got %s", captured.As)
}
if captured.SchemaPath != "drive.files.list" {
t.Errorf("expected SchemaPath=drive.files.list, got %s", captured.SchemaPath)
}
}
// ── dry-run / buildServiceRequest ──
func TestServiceMethod_DryRun_PathParam(t *testing.T) {
tests := []struct {
name string
fileToken string
wantInURL string
}{
{"normal token", "boxcn123abc", "/open-apis/drive/v1/files/boxcn123abc/copy"},
{"hyphen and underscore", "ou_abc-123_def", "/open-apis/drive/v1/files/ou_abc-123_def/copy"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"` + tt.fileToken + `"}`,
"--data", `{"name":"test.txt"}`,
"--dry-run",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), tt.wantInURL) {
t.Errorf("expected URL containing %q, got:\n%s", tt.wantInURL, stdout.String())
}
})
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
fileToken string
wantErr string
}{
{"path traversal with slashes", "../../auth/v3/token", "path traversal"},
{"single dot-dot", "../admin", "path traversal"},
{"question mark injection", "token?evil=true", "invalid characters"},
{"hash injection", "token#fragment", "invalid characters"},
{"percent-encoded bypass", "token%2F..%2Fadmin", "invalid characters"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
cmd.SetArgs([]string{
"--params", `{"file_token":"` + tt.fileToken + `"}`,
"--data", `{"name":"test.txt"}`,
"--dry-run",
})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for malicious path parameter")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Errorf("expected error containing %q, got: %v", tt.wantErr, err)
}
})
}
}
func TestServiceMethod_MissingPathParam(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, driveSpec(), driveMethod("POST", nil), "copy", "files", nil)
cmd.SetArgs([]string{"--params", `{}`, "--data", `{}`, "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for missing path param")
}
if !strings.Contains(err.Error(), "missing required path parameter") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) {
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"q": map[string]interface{}{"location": "query", "required": true},
},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for missing required query param")
}
if !strings.Contains(err.Error(), "missing required query parameter: q") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) {
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{
"path": "items", "httpMethod": "GET",
"parameters": map[string]interface{}{
"page_size": map[string]interface{}{"location": "query", "required": true},
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"})
err := cmd.Execute()
if err != nil {
t.Fatalf("expected no error with --page-all skipping page_size, got: %v", err)
}
if !strings.Contains(stdout.String(), "Dry Run") {
t.Error("expected dry-run output")
}
}
func TestServiceMethod_InvalidParamsJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--params", "{bad", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid JSON")
}
if !strings.Contains(err.Error(), "--params invalid JSON format") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServiceMethod_InvalidDataJSON(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}
cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil)
cmd.SetArgs([]string{"--data", "{bad", "--dry-run"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for invalid --data JSON")
}
if !strings.Contains(err.Error(), "--data invalid JSON format") {
t.Errorf("unexpected error: %v", err)
}
}
func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
spec := map[string]interface{}{
"name": "svc", "servicePath": "/open-apis/svc/v1",
}
method := map[string]interface{}{"path": "items", "httpMethod": "GET"}
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error for --output + --page-all conflict")
}
if !strings.Contains(err.Error(), "mutually exclusive") {
t.Errorf("unexpected error: %v", err)
}
}
// ── bot mode integration with httpmock ──
func TestServiceMethod_BotMode_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, testConfig)
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"result": "success"},
},
})
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"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "success") {
t.Errorf("expected 'success' in output, got:\n%s", stdout.String())
}
}
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(tokenStub())
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,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
},
})
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", "--page-all"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"id"`) {
t.Errorf("expected items in output, got:\n%s", stdout.String())
}
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(tokenStub())
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
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", "--format", "unknown"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
}
}
// ── 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)
}
}
// ── helpers ──
func isExitError(err error, target **output.ExitError) bool {
ee, ok := err.(*output.ExitError)
if ok && target != nil {
*target = ee
}
return ok
}

54
go.mod Normal file
View File

@@ -0,0 +1,54 @@
module github.com/larksuite/cli
go 1.23.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/net v0.33.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.27.0
golang.org/x/text v0.23.0
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
)

155
go.sum Normal file
View File

@@ -0,0 +1,155 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,225 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
)
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
UserCode string
VerificationUri string
VerificationUriComplete string
ExpiresIn int
Interval int
}
// AppRegistrationResult is the result of a successful app registration poll.
type AppRegistrationResult struct {
ClientID string
ClientSecret string
UserInfo *AppRegUserInfo
}
// AppRegUserInfo contains user info returned from app registration.
type AppRegUserInfo struct {
OpenID string
TenantBrand string // "feishu" or "lark"
}
// RequestAppRegistration initiates the app registration device flow.
func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
ep := core.ResolveEndpoints(brand)
regEp := core.ResolveEndpoints(core.BrandFeishu) // registration begin always uses feishu
endpoint := regEp.Accounts + "/oauth/v1/app/registration"
form := url.Values{}
form.Set("action", "begin")
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("app registration failed: read body: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("app registration failed: HTTP %d response not JSON", resp.StatusCode)
}
_, hasError := data["error"]
if resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", msg)
}
expiresIn := getInt(data, "expires_in", 300)
interval := getInt(data, "interval", 5)
userCode := getStr(data, "user_code")
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := fmt.Sprintf("%s/page/cli?user_code=%s", ep.Open, userCode)
return &AppRegistrationResponse{
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
}, nil
}
// BuildVerificationURL appends CLI tracking parameters to the verification URL.
func BuildVerificationURL(baseURL, cliVersion string) string {
sep := "&"
if !strings.Contains(baseURL, "?") {
sep = "?"
}
return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) +
"&ocv=" + url.QueryEscape(cliVersion) +
"&from=cli"
}
// PollAppRegistration polls the app registration endpoint until the app is created or the flow times out.
// If the result has ClientSecret == "" and UserInfo.TenantBrand == "lark", the caller should
// retry with BrandLark to get the secret from accounts.larksuite.com.
func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) (*AppRegistrationResult, error) {
if errOut == nil {
errOut = io.Discard
}
const maxPollInterval = 60
const maxPollAttempts = 200
ep := core.ResolveEndpoints(brand)
endpoint := ep.Accounts + "/oauth/v1/app/registration"
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, fmt.Errorf("polling was cancelled")
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return nil, fmt.Errorf("polling was cancelled")
}
form := url.Values{}
form.Set("action", "poll")
form.Set("device_code", deviceCode)
req, err := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll network error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll read error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: poll parse error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
errStr := getStr(data, "error")
// Success: client_id present
if errStr == "" && getStr(data, "client_id") != "" {
result := &AppRegistrationResult{
ClientID: getStr(data, "client_id"),
ClientSecret: getStr(data, "client_secret"),
}
if userInfoRaw, ok := data["user_info"].(map[string]interface{}); ok {
result.UserInfo = &AppRegUserInfo{
OpenID: getStr(userInfoRaw, "open_id"),
TenantBrand: getStr(userInfoRaw, "tenant_brand"),
}
}
return result, nil
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, fmt.Errorf("app registration denied by user")
case "expired_token", "invalid_grant":
return nil, fmt.Errorf("device code expired, please try again")
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
if desc == "" {
desc = "Unknown error"
}
return nil, fmt.Errorf("app registration failed: %s", desc)
}
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
}
return nil, fmt.Errorf("app registration timed out, please try again")
}

View File

@@ -0,0 +1,32 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/smartystreets/goconvey/convey"
)
func Test_BuildVerificationURL(t *testing.T) {
t.Run("URL不含问号则添加?分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify", "1.0.0")
convey.Convey("should add ? separator", t, func() {
convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(result, convey.ShouldStartWith, "https://example.com/verify?")
})
})
t.Run("URL已含问号则添加&分隔符", func(t *testing.T) {
result := BuildVerificationURL("https://example.com/verify?code=abc", "2.0.0")
convey.Convey("should add & separator", t, func() {
convey.So(result, convey.ShouldContainSubstring, "&lpv=2.0.0")
convey.So(result, convey.ShouldContainSubstring, "&ocv=2.0.0")
convey.So(result, convey.ShouldContainSubstring, "&from=cli")
convey.So(result, convey.ShouldNotContainSubstring, "?lpv=")
})
})
}

View File

@@ -0,0 +1,287 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
)
// DeviceAuthResponse is the response from the device authorization endpoint.
type DeviceAuthResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationUri string `json:"verification_uri"`
VerificationUriComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// DeviceFlowTokenData contains the token data from a successful device flow.
type DeviceFlowTokenData struct {
AccessToken string
RefreshToken string
ExpiresIn int
RefreshExpiresIn int
Scope string
}
// DeviceFlowResult is the result of polling the token endpoint.
type DeviceFlowResult struct {
OK bool
Token *DeviceFlowTokenData
Error string
Message string
}
// OAuthEndpoints contains the OAuth endpoint URLs.
type OAuthEndpoints struct {
DeviceAuthorization string
Token string
}
// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand.
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
ep := core.ResolveEndpoints(brand)
return OAuthEndpoints{
DeviceAuthorization: ep.Accounts + "/oauth/v1/device_authorization",
Token: ep.Open + "/open-apis/authen/v2/oauth/token",
}
}
// RequestDeviceAuthorization requests a device authorization code.
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
if errOut == nil {
errOut = io.Discard
}
endpoints := ResolveOAuthEndpoints(brand)
if !strings.Contains(scope, "offline_access") {
if scope != "" {
scope = scope + " offline_access"
} else {
scope = "offline_access"
}
}
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
form := url.Values{}
form.Set("client_id", appId)
form.Set("scope", scope)
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Authorization", "Basic "+basicAuth)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("Device authorization failed: read body: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("Device authorization failed: HTTP %d response not JSON", resp.StatusCode)
}
_, hasError := data["error"]
if resp.StatusCode >= 400 || hasError {
msg := getStr(data, "error_description")
if msg == "" {
msg = getStr(data, "error")
}
if msg == "" {
msg = "Unknown error"
}
return nil, fmt.Errorf("Device authorization failed: %s", msg)
}
expiresIn := getInt(data, "expires_in", 240)
interval := getInt(data, "interval", 5)
verificationUri := getStr(data, "verification_uri")
verificationUriComplete := getStr(data, "verification_uri_complete")
if verificationUriComplete == "" {
verificationUriComplete = verificationUri
}
return &DeviceAuthResponse{
DeviceCode: getStr(data, "device_code"),
UserCode: getStr(data, "user_code"),
VerificationUri: verificationUri,
VerificationUriComplete: verificationUriComplete,
ExpiresIn: expiresIn,
Interval: interval,
}, nil
}
// PollDeviceToken polls the token endpoint until authorization completes or times out.
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
if errOut == nil {
errOut = io.Discard
}
const maxPollInterval = 60
const maxPollAttempts = 200
endpoints := ResolveOAuthEndpoints(brand)
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
currentInterval := interval
attempts := 0
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
form.Set("device_code", deviceCode)
form.Set("client_id", appId)
form.Set("client_secret", appSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll network error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll read error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll parse error: %v\n", err)
currentInterval = minInt(currentInterval+1, maxPollInterval)
continue
}
errStr := getStr(data, "error")
if errStr == "" && getStr(data, "access_token") != "" {
fmt.Fprintf(errOut, "[lark-cli] device-flow: token obtained successfully\n")
refreshToken := getStr(data, "refresh_token")
tokenExpiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
if refreshToken == "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n")
refreshExpiresIn = tokenExpiresIn
}
return &DeviceFlowResult{
OK: true,
Token: &DeviceFlowTokenData{
AccessToken: getStr(data, "access_token"),
RefreshToken: refreshToken,
ExpiresIn: tokenExpiresIn,
RefreshExpiresIn: refreshExpiresIn,
Scope: getStr(data, "scope"),
},
}
}
switch errStr {
case "authorization_pending":
continue
case "slow_down":
currentInterval = minInt(currentInterval+5, maxPollInterval)
fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
msg := getStr(data, "error_description")
if msg == "" {
msg = "Authorization denied by user"
}
return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg}
case "expired_token", "invalid_grant":
msg := getStr(data, "error_description")
if msg == "" {
msg = "Device code expired, please try again"
}
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg}
}
desc := getStr(data, "error_description")
if desc == "" {
desc = errStr
}
if desc == "" {
desc = "Unknown error"
}
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc)
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc}
}
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts)
}
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"}
}
// helpers
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func getStr(m map[string]interface{}, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func getInt(m map[string]interface{}, key string, fallback int) int {
if v, ok := m[key]; ok {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
}
}
return fallback
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestResolveOAuthEndpoints_Feishu(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandFeishu)
if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}
func TestResolveOAuthEndpoints_Lark(t *testing.T) {
ep := ResolveOAuthEndpoints(core.BrandLark)
if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" {
t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization)
}
if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" {
t.Errorf("Token = %q", ep.Token)
}
}

56
internal/auth/errors.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"fmt"
"github.com/larksuite/cli/internal/output"
)
const (
LarkErrBlockByPolicy = 21001 // access denied by access control policy
LarkErrBlockByPolicyTryAuth = 21000 // access denied by access control policy; challenge is required to be completed by user in order to gain access
)
// RefreshTokenRetryable contains error codes that allow one immediate retry.
// All other refresh errors clear the token immediately.
var RefreshTokenRetryable = map[int]bool{
output.LarkErrRefreshServerError: true,
}
// TokenRetryCodes contains error codes that allow retry after token refresh.
var TokenRetryCodes = map[int]bool{
output.LarkErrTokenInvalid: true,
output.LarkErrTokenExpired: true,
}
// NeedAuthorizationError is thrown when no valid UAT exists.
type NeedAuthorizationError struct {
UserOpenId string
}
func (e *NeedAuthorizationError) Error() string {
return fmt.Sprintf("need_user_authorization (user: %s)", e.UserOpenId)
}
// SecurityPolicyError is returned when a request is blocked by access control policies.
type SecurityPolicyError struct {
Code int
Message string
ChallengeURL string
CLIHint string
Err error
}
func (e *SecurityPolicyError) Error() string {
if e.Err != nil {
return fmt.Sprintf("security policy error [%d]: %s: %v", e.Code, e.Message, e.Err)
}
return fmt.Sprintf("security policy error [%d]: %s", e.Code, e.Message)
}
func (e *SecurityPolicyError) Unwrap() error {
return e.Err
}

22
internal/auth/scope.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import "strings"
// MissingScopes returns the elements of required that are absent from storedScope.
// storedScope is a space-separated list of granted scope strings (as stored in the token).
func MissingScopes(storedScope string, required []string) []string {
granted := make(map[string]bool)
for _, s := range strings.Fields(storedScope) {
granted[s] = true
}
var missing []string
for _, s := range required {
if !granted[s] {
missing = append(missing, s)
}
}
return missing
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"testing"
)
func TestMissingScopes(t *testing.T) {
tests := []struct {
name string
storedScope string
required []string
expected []string
}{
{
name: "all matched",
storedScope: "a b c",
required: []string{"a", "b"},
expected: nil,
},
{
name: "partial missing",
storedScope: "a b",
required: []string{"a", "c"},
expected: []string{"c"},
},
{
name: "all missing",
storedScope: "a b",
required: []string{"x", "y"},
expected: []string{"x", "y"},
},
{
name: "empty storedScope",
storedScope: "",
required: []string{"a"},
expected: []string{"a"},
},
{
name: "empty required",
storedScope: "a b",
required: []string{},
expected: nil,
},
{
name: "extra whitespace in storedScope",
storedScope: " a b c ",
required: []string{"b"},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MissingScopes(tt.storedScope, tt.required)
if !sliceEqual(got, tt.expected) {
t.Errorf("MissingScopes(%q, %v) = %v, want %v", tt.storedScope, tt.required, got, tt.expected)
}
})
}
}
func sliceEqual(a, b []string) bool {
if len(a) == 0 && len(b) == 0 {
return true
}
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"encoding/json"
"fmt"
"time"
"github.com/larksuite/cli/internal/keychain"
)
// StoredUAToken represents a stored user access token.
type StoredUAToken struct {
UserOpenId string `json:"userOpenId"`
AppId string `json:"appId"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"` // Unix ms
RefreshExpiresAt int64 `json:"refreshExpiresAt"` // Unix ms
Scope string `json:"scope"`
GrantedAt int64 `json:"grantedAt"` // Unix ms
}
const refreshAheadMs = 5 * 60 * 1000 // 5 minutes
func accountKey(appId, userOpenId string) string {
return fmt.Sprintf("%s:%s", appId, userOpenId)
}
// MaskToken masks a token for safe logging.
func MaskToken(token string) string {
if len(token) <= 8 {
return "****"
}
return "****" + token[len(token)-4:]
}
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
jsonStr := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if jsonStr == "" {
return nil
}
var token StoredUAToken
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
return nil
}
return &token
}
// SetStoredToken persists a UAT.
func SetStoredToken(token *StoredUAToken) error {
key := accountKey(token.AppId, token.UserOpenId)
data, err := json.Marshal(token)
if err != nil {
return err
}
return keychain.Set(keychain.LarkCliService, key, string(data))
}
// RemoveStoredToken removes a stored UAT.
func RemoveStoredToken(appId, userOpenId string) error {
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
}
// TokenStatus determines the freshness of a stored token.
func TokenStatus(token *StoredUAToken) string {
now := time.Now().UnixMilli()
if now < token.ExpiresAt-refreshAheadMs {
return "valid"
}
if now < token.RefreshExpiresAt {
return "needs_refresh"
}
return "expired"
}

199
internal/auth/transport.go Normal file
View File

@@ -0,0 +1,199 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
)
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
// and checks for security policy errors.
type SecurityPolicyTransport struct {
Base http.RoundTripper
}
func (t *SecurityPolicyTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityPolicyTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)
if err != nil {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
return nil, err
}
if resp == nil || resp.Body == nil {
return resp, nil
}
// Only process JSON responses to avoid memory spikes on large files
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
if !strings.Contains(contentType, "application/json") {
return resp, nil
}
// Read up to 64KB of the body to check for security policy errors
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
resp.Body.Close()
return nil, fmt.Errorf("failed to read response body in security transport: %w", err)
}
// Restore the body so it can be read by the caller, preserving streaming capability
resp.Body = struct {
io.Reader
io.Closer
}{
io.MultiReader(bytes.NewReader(bodyBytes), resp.Body),
resp.Body,
}
// Try to parse it as JSON
var result map[string]interface{}
if err := json.Unmarshal(bodyBytes, &result); err != nil {
return resp, nil
}
// 1. Try to handle as MCP (JSON-RPC) format first
if err := t.tryHandleMCPResponse(result); err != nil {
resp.Body.Close()
return nil, err
}
// 2. Try to handle as OpenAPI error format
if err := t.tryHandleOAPIResponse(result); err != nil {
resp.Body.Close()
return nil, err
}
return resp, nil
}
func (t *SecurityPolicyTransport) tryHandleMCPResponse(result map[string]interface{}) error {
// MCP (JSON-RPC) response format:
// {
// "error": {
// "code": 21000,
// "message": "...",
// "data": { "challenge_url": "...", "cli_hint": "..." }
// }
// }
errMap, ok := result["error"].(map[string]interface{})
if !ok {
return nil
}
code := getInt(errMap, "code", 0)
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
return nil
}
dataMap, ok := errMap["data"].(map[string]interface{})
if !ok {
return nil
}
// Clean up backticks and spaces from challenge_url
challengeUrl := strings.Trim(getStr(dataMap, "challenge_url"), " `")
cliHint := getStr(dataMap, "cli_hint")
msg := getStr(errMap, "message")
if challengeUrl != "" || cliHint != "" {
// Security validation for challengeUrl
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
challengeUrl = ""
}
if challengeUrl != "" || cliHint != "" {
return &SecurityPolicyError{
Code: code,
Message: msg,
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
}
return nil
}
func (t *SecurityPolicyTransport) tryHandleOAPIResponse(result map[string]interface{}) error {
// 1. Extract code
code := getInt(result, "code", 0)
// If code is 0, check if it's already in our error format {"error": {"code": 21000, ...}, "ok": false}
if code == 0 {
if errMap, ok := result["error"].(map[string]interface{}); ok {
code = getInt(errMap, "code", 0)
}
}
// 2. Check if it's a security policy error
if code != LarkErrBlockByPolicyTryAuth && code != LarkErrBlockByPolicy {
return nil
}
// 3. Extract details
var challengeUrl, cliHint, msg string
if dataMap, ok := result["data"].(map[string]interface{}); ok {
// Standard OAPI format
challengeUrl = getStr(dataMap, "challenge_url")
cliHint = getStr(dataMap, "cli_hint")
msg = getStr(result, "msg")
} else if errMap, ok := result["error"].(map[string]interface{}); ok {
// Already formatted error format (e.g. from internal API or CLI output)
challengeUrl = getStr(errMap, "challenge_url")
cliHint = getStr(errMap, "hint")
msg = getStr(errMap, "message")
}
// 4. Print and exit if we have enough info
if msg != "" || challengeUrl != "" || cliHint != "" {
// Security validation for challengeUrl
if challengeUrl != "" && !isValidChallengeURL(challengeUrl) {
challengeUrl = ""
}
if msg != "" || challengeUrl != "" || cliHint != "" {
return &SecurityPolicyError{
Code: code,
Message: msg,
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
}
return nil
}
func isValidChallengeURL(rawURL string) bool {
if rawURL == "" {
return false
}
u, err := url.Parse(rawURL)
if err != nil {
return false
}
// 1. Must be https
if u.Scheme != "https" {
return false
}
return true
}

305
internal/auth/uat_client.go Normal file
View File

@@ -0,0 +1,305 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"github.com/gofrs/flock"
"github.com/larksuite/cli/internal/core"
)
var safeIDChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func sanitizeID(id string) string {
return safeIDChars.ReplaceAllString(id, "_")
}
// UATCallOptions contains options for UAT API calls.
type UATCallOptions struct {
UserOpenId string
AppId string
AppSecret string
Domain core.LarkBrand
ErrOut io.Writer // diagnostic/status output (caller injects f.IOStreams.ErrOut)
}
// UATStatus represents the status of a user access token.
type UATStatus struct {
Authorized bool `json:"authorized"`
UserOpenId string `json:"userOpenId"`
Scope string `json:"scope,omitempty"`
ExpiresAt int64 `json:"expiresAt,omitempty"`
RefreshExpiresAt int64 `json:"refreshExpiresAt,omitempty"`
GrantedAt int64 `json:"grantedAt,omitempty"`
TokenStatus string `json:"tokenStatus,omitempty"`
}
// NewUATCallOptions creates UATCallOptions from a CLI config.
func NewUATCallOptions(cfg *core.CliConfig, errOut io.Writer) UATCallOptions {
if errOut == nil {
errOut = os.Stderr
}
return UATCallOptions{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AppSecret: cfg.AppSecret,
Domain: cfg.Brand,
ErrOut: errOut,
}
}
var refreshLocks sync.Map
// GetValidAccessToken obtains a valid access token for the given user.
func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string, error) {
stored := GetStoredToken(opts.AppId, opts.UserOpenId)
if stored == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
status := TokenStatus(stored)
if status == "valid" {
return stored.AccessToken, nil
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts, stored)
if err != nil {
return "", err
}
if refreshed == nil {
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
return refreshed.AccessToken, nil
}
// expired
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
}
return "", &NeedAuthorizationError{UserOpenId: opts.UserOpenId}
}
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
// 1. Process-level lock (prevents multiple goroutines in the same process)
done := make(chan struct{})
if existing, loaded := refreshLocks.LoadOrStore(key, done); loaded {
// Another goroutine is already refreshing; wait for it
if ch, ok := existing.(chan struct{}); ok {
<-ch
} else {
// fallback in case of unexpected type
refreshLocks.Delete(key)
}
return GetStoredToken(opts.AppId, opts.UserOpenId), nil
}
// We own the process lock; done is the channel stored in the map
defer func() {
close(done)
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
if err := os.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
safeAppId := sanitizeID(opts.AppId)
safeUserOpenId := sanitizeID(opts.UserOpenId)
lockFile := filepath.Join(lockDir, fmt.Sprintf("refresh_%s_%s.lock", safeAppId, safeUserOpenId))
fileLock := flock.New(lockFile)
// Try to acquire the lock, wait if necessary
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
locked, err := fileLock.TryLockContext(ctx, 500*time.Millisecond)
if err != nil {
return nil, fmt.Errorf("failed to acquire cross-process lock: %w", err)
}
if !locked {
return nil, fmt.Errorf("timeout waiting for cross-process lock")
}
defer fileLock.Unlock()
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
}
}
// 4. Actually perform the refresh
return doRefreshToken(httpClient, opts, stored)
}
func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
errOut := opts.ErrOut
if errOut == nil {
errOut = os.Stderr
}
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
}
return nil, nil
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %v", err)
}
return data, nil
}
data, err := callEndpoint()
if err != nil {
return nil, err
}
code := getInt(data, "code", -1)
if code == LarkErrBlockByPolicy || code == LarkErrBlockByPolicyTryAuth {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &SecurityPolicyError{
Code: code,
Message: msg,
ChallengeURL: challengeUrl,
CLIHint: cliHint,
}
}
errStr := getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if RefreshTokenRetryable[code] {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
// Retry succeeded, fall through to parse token below.
} else {
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
}
}
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
GrantedAt: stored.GrantedAt,
}
if err := SetStoredToken(updated); err != nil {
return nil, err
}
return updated, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"bytes"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestNewUATCallOptions(t *testing.T) {
cfg := &core.CliConfig{
AppID: "app123",
AppSecret: "secret",
Brand: core.BrandLark,
UserOpenId: "ou_test",
}
errOut := &bytes.Buffer{}
opts := NewUATCallOptions(cfg, errOut)
if opts.AppId != "app123" {
t.Errorf("AppId = %q, want app123", opts.AppId)
}
if opts.AppSecret != "secret" {
t.Errorf("AppSecret = %q, want secret", opts.AppSecret)
}
if opts.Domain != core.BrandLark {
t.Errorf("Domain = %q, want lark", opts.Domain)
}
if opts.UserOpenId != "ou_test" {
t.Errorf("UserOpenId = %q, want ou_test", opts.UserOpenId)
}
if opts.ErrOut != errOut {
t.Error("ErrOut not set correctly")
}
}

39
internal/auth/verify.go Normal file
View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// VerifyUserToken calls /authen/v1/user_info to confirm the token is accepted server-side.
// Returns nil on success or an error describing why the server rejected the token.
func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string) error {
apiResp, err := sdk.Do(ctx, &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/authen/v1/user_info",
SupportedAccessTokenTypes: []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser},
}, larkcore.WithUserAccessToken(accessToken))
if err != nil {
return err
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
return fmt.Errorf("failed to parse response: %v", err)
}
if resp.Code != 0 {
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
}
return nil
}

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestVerifyUserToken_TransportError(t *testing.T) {
reg := &httpmock.Registry{}
// Register no stubs — any request will fail with "no stub" error
sdk := lark.NewClient("test-app", "test-secret",
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpmock.NewClient(reg)),
)
err := VerifyUserToken(context.Background(), sdk, "test-token")
if err == nil {
t.Fatal("expected error from transport failure, got nil")
}
}
func TestVerifyUserToken(t *testing.T) {
tests := []struct {
name string
body interface{}
wantErr bool
errSubstr string
}{
{
name: "success",
body: map[string]interface{}{"code": 0, "msg": "ok"},
wantErr: false,
},
{
name: "token invalid",
body: map[string]interface{}{"code": 99991668, "msg": "invalid token"},
wantErr: true,
errSubstr: "[99991668]",
},
{
name: "non-JSON response",
body: "not json",
wantErr: true,
errSubstr: "invalid character",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/authen/v1/user_info",
Body: tt.body,
})
sdk := lark.NewClient("test-app", "test-secret",
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpmock.NewClient(reg)),
)
err := VerifyUserToken(context.Background(), sdk, "test-token")
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), tt.errSubstr) {
t.Errorf("error %q does not contain %q", err.Error(), tt.errSubstr)
}
} else {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
})
}
}

23
internal/build/build.go Normal file
View File

@@ -0,0 +1,23 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package build
import "runtime/debug"
// Version is dynamically set by -ldflags or falls back to module info.
var Version = "DEV"
// Date is the build date in YYYY-MM-DD format, set by -ldflags.
var Date = ""
func init() {
if Version == "DEV" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
Version = info.Main.Version
}
}
if Version == "" {
Version = "DEV"
}
}

270
internal/client/client.go Normal file
View File

@@ -0,0 +1,270 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// RawApiRequest describes a raw API request.
type RawApiRequest struct {
Method string
URL string
Params map[string]interface{}
Data interface{}
As core.Identity
ExtraOpts []larkcore.RequestOptionFunc // additional SDK request options (e.g. security headers)
}
// APIClient wraps lark.Client for all Lark Open API calls.
type APIClient struct {
Config *core.CliConfig
SDK *lark.Client // All Lark API calls go through SDK
HTTP *http.Client // Only for non-Lark API (OAuth, MCP, etc.)
ErrOut io.Writer // debug/progress output
}
// buildApiReq converts a RawApiRequest into SDK types and collects
// request-specific options (ExtraOpts, URL-based headers).
// Auth is handled separately by DoSDKRequest.
func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []larkcore.RequestOptionFunc) {
queryParams := make(larkcore.QueryParams)
for k, v := range request.Params {
switch val := v.(type) {
case []string:
queryParams[k] = val
case []interface{}:
for _, item := range val {
queryParams.Add(k, fmt.Sprintf("%v", item))
}
default:
queryParams.Set(k, fmt.Sprintf("%v", v))
}
}
apiReq := &larkcore.ApiReq{
HttpMethod: strings.ToUpper(request.Method),
ApiPath: request.URL,
Body: request.Data,
QueryParams: queryParams,
}
var opts []larkcore.RequestOptionFunc
opts = append(opts, request.ExtraOpts...)
return apiReq, opts
}
// DoSDKRequest resolves auth for the given identity and executes a pre-built SDK request.
// This is the shared auth+execute path used by both DoAPI (generic API calls via RawApiRequest)
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
var opts []larkcore.RequestOptionFunc
if as.IsBot() {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeTenant}
} else {
req.SupportedAccessTokenTypes = []larkcore.AccessTokenType{larkcore.AccessTokenTypeUser}
if c.Config.UserOpenId == "" {
return nil, fmt.Errorf("login required: lark-cli auth login (or use --as bot)")
}
token, err := auth.GetValidAccessToken(c.HTTP, auth.NewUATCallOptions(c.Config, c.ErrOut))
if err != nil {
return nil, err
}
opts = append(opts, larkcore.WithUserAccessToken(token))
}
opts = append(opts, extraOpts...)
return c.SDK.Do(ctx, req, opts...)
}
// DoAPI executes a raw Lark SDK request and returns the raw *larkcore.ApiResp.
// Unlike CallAPI which always JSON-decodes, DoAPI returns the raw response — suitable
// for file downloads (pass larkcore.WithFileDownload() via request.ExtraOpts) and
// any endpoint whose Content-Type may not be JSON.
func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore.ApiResp, error) {
apiReq, extraOpts := c.buildApiReq(request)
return c.DoSDKRequest(ctx, apiReq, request.As, extraOpts...)
}
// CallAPI is a convenience wrapper: DoAPI + ParseJSONResponse.
// Use DoAPI directly when the response may not be JSON (e.g. file downloads).
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
resp, err := c.DoAPI(ctx, request)
if err != nil {
return nil, err
}
return ParseJSONResponse(resp)
}
// paginateLoop runs the core pagination loop. For each successful page (code == 0),
// it calls onResult if non-nil. It always accumulates and returns all raw page results.
func (c *APIClient) paginateLoop(ctx context.Context, request RawApiRequest, opts PaginationOptions, onResult func(interface{})) ([]interface{}, error) {
var allResults []interface{}
var pageToken string
page := 0
pageDelay := opts.PageDelay
if pageDelay == 0 {
pageDelay = 200
}
for {
page++
params := make(map[string]interface{})
for k, v := range request.Params {
params[k] = v
}
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(c.ErrOut, "[page %d] fetching...\n", page)
result, err := c.CallAPI(ctx, RawApiRequest{
Method: request.Method,
URL: request.URL,
Params: params,
Data: request.Data,
As: request.As,
ExtraOpts: request.ExtraOpts,
})
if err != nil {
if page == 1 {
return nil, err
}
fmt.Fprintf(c.ErrOut, "[page %d] error, stopping pagination\n", page)
break
}
if resultMap, ok := result.(map[string]interface{}); ok {
code, _ := util.ToFloat64(resultMap["code"])
if code != 0 {
allResults = append(allResults, result)
if page == 1 {
return allResults, nil
}
fmt.Fprintf(c.ErrOut, "[page %d] API error (code=%.0f), stopping pagination\n", page, code)
break
}
}
if onResult != nil {
onResult(result)
}
allResults = append(allResults, result)
pageToken = ""
if resultMap, ok := result.(map[string]interface{}); ok {
if data, ok := resultMap["data"].(map[string]interface{}); ok {
hasMore, _ := data["has_more"].(bool)
if hasMore {
if pt, ok := data["page_token"].(string); ok && pt != "" {
pageToken = pt
} else if pt, ok := data["next_page_token"].(string); ok && pt != "" {
pageToken = pt
}
}
}
}
if pageToken == "" {
break
}
if opts.PageLimit > 0 && page >= opts.PageLimit {
fmt.Fprintf(c.ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", opts.PageLimit)
break
}
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
return allResults, nil
}
// PaginateAll fetches all pages and returns a single merged result.
// Use this for formats that need the complete dataset (e.g. JSON).
func (c *APIClient) PaginateAll(ctx context.Context, request RawApiRequest, opts PaginationOptions) (interface{}, error) {
results, err := c.paginateLoop(ctx, request, opts, nil)
if err != nil {
return nil, err
}
if len(results) == 0 {
return map[string]interface{}{}, nil
}
if len(results) == 1 {
return results[0], nil
}
return mergePagedResults(c.ErrOut, results), nil
}
// StreamPages fetches all pages and streams each page's list items via onItems.
// Returns the last page result (for error checking), whether any list items were found,
// and any network error. Use this for streaming formats (ndjson, table, csv).
func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onItems func([]interface{}), opts PaginationOptions) (result interface{}, hasItems bool, err error) {
totalItems := 0
results, loopErr := c.paginateLoop(ctx, request, opts, func(r interface{}) {
resultMap, ok := r.(map[string]interface{})
if !ok {
return
}
data, ok := resultMap["data"].(map[string]interface{})
if !ok {
return
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return
}
items, ok := data[arrayField].([]interface{})
if !ok {
return
}
totalItems += len(items)
onItems(items)
hasItems = true
})
if loopErr != nil {
return nil, false, loopErr
}
if hasItems {
fmt.Fprintf(c.ErrOut, "[pagination] streamed %d pages, %d total items\n", len(results), totalItems)
}
if len(results) > 0 {
return results[len(results)-1], hasItems, nil
}
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
}
// CheckLarkResponse inspects a Lark API response for business-level errors (non-zero code).
// Uses type assertion instead of interface{} == nil to satisfy interface_nil_check lint.
// Returns nil if result is not a map, map is nil, or code is 0.
func CheckLarkResponse(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, _ := resultMap["msg"].(string)
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
}

View File

@@ -0,0 +1,356 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
// roundTripFunc is an adapter to use a function as http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
// jsonResponse creates an HTTP response with JSON body.
func jsonResponse(body interface{}) *http.Response {
b, _ := json.Marshal(body)
return &http.Response{
StatusCode: 200,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(b)),
}
}
// newTestAPIClient creates an APIClient with a mock HTTP transport.
func newTestAPIClient(t *testing.T, rt http.RoundTripper) (*APIClient, *bytes.Buffer) {
t.Helper()
errBuf := &bytes.Buffer{}
httpClient := &http.Client{Transport: rt}
sdk := lark.NewClient("test-app", "test-secret",
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(httpClient),
)
return &APIClient{
SDK: sdk,
ErrOut: errBuf,
}, errBuf
}
func TestIsJSONContentType(t *testing.T) {
tests := []struct {
ct string
want bool
}{
{"application/json", true},
{"application/json; charset=utf-8", true},
{"text/json", true},
{"application/octet-stream", false},
{"image/png", false},
{"text/html", false},
{"", false},
}
for _, tt := range tests {
if got := IsJSONContentType(tt.ct); got != tt.want {
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
}
}
func TestMimeToExt(t *testing.T) {
tests := []struct {
ct string
want string
}{
{"image/png", ".png"},
{"image/jpeg", ".jpg"},
{"application/pdf", ".pdf"},
{"text/plain", ".txt"},
{"application/octet-stream", ".bin"},
{"", ".bin"},
}
for _, tt := range tests {
if got := mimeToExt(tt.ct); got != tt.want {
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
}
}
}
func TestStreamPages_NonBatchAPI_NoArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"user_id": "u123",
"name": "Test User",
},
}), nil
}
})
ac, errBuf := newTestAPIClient(t, rt)
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users/u123",
As: "bot",
}, func(items []interface{}) {
t.Error("onItems should not be called for non-batch API")
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if hasItems {
t.Error("expected hasItems=false for non-batch API")
}
if strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected no pagination summary log for non-batch API")
}
if result == nil {
t.Fatal("expected non-nil result")
}
resultMap, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected result to be a map")
}
data, _ := resultMap["data"].(map[string]interface{})
if data["user_id"] != "u123" {
t.Errorf("expected user_id=u123, got %v", data["user_id"])
}
}
func TestStreamPages_BatchAPI_WithArrayField(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}, map[string]interface{}{"id": "2"}},
"has_more": false,
},
}), nil
}
})
ac, errBuf := newTestAPIClient(t, rt)
var streamedItems []interface{}
result, hasItems, err := ac.StreamPages(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, func(items []interface{}) {
streamedItems = append(streamedItems, items...)
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !hasItems {
t.Error("expected hasItems=true for batch API")
}
if len(streamedItems) != 2 {
t.Errorf("expected 2 streamed items, got %d", len(streamedItems))
}
if !strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected pagination summary log for batch API")
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestPaginateAll_PageLimitStopsPagination(t *testing.T) {
apiCalls := 0
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
apiCalls++
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": apiCalls}},
"has_more": true,
"page_token": "next",
},
}), nil
}
})
ac, errBuf := newTestAPIClient(t, rt)
_, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "bot",
}, PaginationOptions{PageLimit: 2, PageDelay: 0})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if apiCalls != 2 {
t.Errorf("expected 2 API calls with PageLimit=2, got %d", apiCalls)
}
if !strings.Contains(errBuf.String(), "reached page limit (2), stopping. Use --page-all --page-limit 0 to fetch all pages.") {
t.Errorf("expected page limit log, got: %s", errBuf.String())
}
}
func TestBuildApiReq_QueryParams(t *testing.T) {
ac := &APIClient{}
tests := []struct {
name string
params map[string]interface{}
want larkcore.QueryParams
}{
{
name: "scalar values",
params: map[string]interface{}{"page_size": 20, "user_id_type": "open_id"},
want: larkcore.QueryParams{
"page_size": []string{"20"},
"user_id_type": []string{"open_id"},
},
},
{
name: "[]interface{} array",
params: map[string]interface{}{"department_ids": []interface{}{"d1", "d2", "d3"}},
want: larkcore.QueryParams{
"department_ids": []string{"d1", "d2", "d3"},
},
},
{
name: "[]string array",
params: map[string]interface{}{"statuses": []string{"active", "inactive"}},
want: larkcore.QueryParams{
"statuses": []string{"active", "inactive"},
},
},
{
name: "mixed scalar and array",
params: map[string]interface{}{
"user_id_type": "open_id",
"ids": []interface{}{"id1", "id2"},
},
want: larkcore.QueryParams{
"user_id_type": []string{"open_id"},
"ids": []string{"id1", "id2"},
},
},
{
name: "empty array",
params: map[string]interface{}{"tags": []interface{}{}},
want: larkcore.QueryParams{},
},
{
name: "nil params",
params: nil,
want: larkcore.QueryParams{},
},
{
name: "bool value",
params: map[string]interface{}{"with_bot": true},
want: larkcore.QueryParams{"with_bot": []string{"true"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiReq, _ := ac.buildApiReq(RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
Params: tt.params,
})
got := apiReq.QueryParams
// Check all expected keys exist with correct values
for k, wantVals := range tt.want {
gotVals, ok := got[k]
if !ok {
t.Errorf("missing key %q", k)
continue
}
if len(gotVals) != len(wantVals) {
t.Errorf("key %q: got %d values %v, want %d values %v", k, len(gotVals), gotVals, len(wantVals), wantVals)
continue
}
for i := range wantVals {
if gotVals[i] != wantVals[i] {
t.Errorf("key %q[%d]: got %q, want %q", k, i, gotVals[i], wantVals[i])
}
}
}
// Check no unexpected keys
for k := range got {
if _, ok := tt.want[k]; !ok {
t.Errorf("unexpected key %q with values %v", k, got[k])
}
}
})
}
}
func TestPaginateAll_NoStreamSummaryLog(t *testing.T) {
rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"tenant_access_token": "t-token", "expire": 7200,
}), nil
default:
return jsonResponse(map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"id": "1"}},
"has_more": false,
},
}), nil
}
})
ac, errBuf := newTestAPIClient(t, rt)
result, err := ac.PaginateAll(context.Background(), RawApiRequest{
Method: "GET",
URL: "/open-apis/contact/v3/users",
As: "bot",
}, PaginationOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Contains(errBuf.String(), "[pagination] streamed") {
t.Error("expected no streaming summary log from PaginateAll")
}
if result == nil {
t.Fatal("expected non-nil result")
}
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"fmt"
"io"
"github.com/larksuite/cli/internal/output"
)
// PaginationOptions contains pagination control options.
type PaginationOptions struct {
PageLimit int // max pages to fetch; 0 = unlimited (default: 10)
PageDelay int // ms, default 200
}
func mergePagedResults(w io.Writer, results []interface{}) interface{} {
if len(results) == 0 {
return map[string]interface{}{}
}
firstMap, ok := results[0].(map[string]interface{})
if !ok {
return map[string]interface{}{"pages": results}
}
data, ok := firstMap["data"].(map[string]interface{})
if !ok {
return map[string]interface{}{"pages": results}
}
arrayField := output.FindArrayField(data)
if arrayField == "" {
return map[string]interface{}{"pages": results}
}
var merged []interface{}
for _, r := range results {
if rm, ok := r.(map[string]interface{}); ok {
if d, ok := rm["data"].(map[string]interface{}); ok {
if items, ok := d[arrayField].([]interface{}); ok {
merged = append(merged, items...)
}
}
}
}
fmt.Fprintf(w, "[pagination] merged %d pages, %d total items\n", len(results), len(merged))
mergedData := make(map[string]interface{})
for k, v := range data {
mergedData[k] = v
}
mergedData[arrayField] = merged
mergedData["has_more"] = false
delete(mergedData, "page_token")
delete(mergedData, "next_page_token")
result := make(map[string]interface{})
for k, v := range firstMap {
result[k] = v
}
result["data"] = mergedData
return result
}

188
internal/client/response.go Normal file
View File

@@ -0,0 +1,188 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
"github.com/larksuite/cli/internal/validate"
)
// ── Response routing ──
// ResponseOptions configures how HandleResponse routes a raw API response.
type ResponseOptions struct {
OutputPath string // --output flag; "" = auto-detect
Format output.Format // output format for JSON responses
Out io.Writer // stdout
ErrOut io.Writer // stderr
// CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse.
CheckError func(interface{}) error
}
// HandleResponse routes a raw *larkcore.ApiResp to the appropriate output:
// 1. If Content-Type is JSON, check for business errors first (even with --output).
// 2. If --output is set and response is not a JSON error, save to file.
// 3. If Content-Type is non-JSON and no --output, auto-save binary to file.
func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
ct := resp.Header.Get("Content-Type")
check := opts.CheckError
if check == nil {
check = CheckLarkResponse
}
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
// instead of falling through to the binary-save path.
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body)
}
// JSON responses: always check for business errors before saving.
if IsJSONContentType(ct) || ct == "" {
result, err := ParseJSONResponse(resp)
if err != nil {
return output.ErrNetwork("API call failed: %v", err)
}
if apiErr := check(result); apiErr != nil {
return apiErr
}
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
}
output.FormatValue(opts.Out, result, opts.Format)
return nil
}
// Non-JSON (binary) responses.
if opts.OutputPath != "" {
return saveAndPrint(resp, opts.OutputPath, opts.Out)
}
// No --output: auto-save with derived filename.
meta, err := SaveResponse(resp, ResolveFilename(resp))
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
output.PrintJson(opts.Out, meta)
return nil
}
func saveAndPrint(resp *larkcore.ApiResp, path string, w io.Writer) error {
meta, err := SaveResponse(resp, path)
if err != nil {
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
}
output.PrintJson(w, meta)
return nil
}
// ── JSON helpers ──
// IsJSONContentType reports whether the Content-Type header indicates a JSON response.
func IsJSONContentType(ct string) bool {
return strings.Contains(ct, "application/json") || strings.Contains(ct, "text/json")
}
// ParseJSONResponse decodes a raw SDK response body as JSON.
// CallAPI and HandleResponse both delegate to this function.
func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) {
var result interface{}
dec := json.NewDecoder(bytes.NewReader(resp.RawBody))
dec.UseNumber()
if err := dec.Decode(&result); err != nil {
return nil, fmt.Errorf("response parse error: %v (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500))
}
return result, nil
}
// ── File saving ──
// SaveResponse writes an API response body to the given outputPath and returns metadata.
func SaveResponse(resp *larkcore.ApiResp, outputPath string) (map[string]interface{}, error) {
safePath, err := validate.SafeOutputPath(outputPath)
if err != nil {
return nil, fmt.Errorf("unsafe output path: %s", err)
}
if err := os.MkdirAll(filepath.Dir(safePath), 0700); err != nil {
return nil, fmt.Errorf("create directory: %s", err)
}
if err := validate.AtomicWrite(safePath, resp.RawBody, 0644); err != nil {
return nil, fmt.Errorf("cannot write file: %s", err)
}
return map[string]interface{}{
"saved_path": safePath,
"size_bytes": len(resp.RawBody),
"content_type": resp.Header.Get("Content-Type"),
}, nil
}
// ResolveFilename picks a filename from the response headers.
// Priority: Content-Disposition filename > Content-Type extension > "download.bin".
func ResolveFilename(resp *larkcore.ApiResp) string {
if name := larkcore.FileNameByHeader(resp.Header); name != "" {
return name
}
return "download" + mimeToExt(resp.Header.Get("Content-Type"))
}
// mimeToExt maps a Content-Type to a file extension (with leading dot).
func mimeToExt(ct string) string {
if ct == "" {
return ".bin"
}
mediaType, _, _ := mime.ParseMediaType(ct)
switch mediaType {
case "application/pdf":
return ".pdf"
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "text/plain":
return ".txt"
case "text/csv":
return ".csv"
case "text/html":
return ".html"
case "application/zip":
return ".zip"
case "application/xml", "text/xml":
return ".xml"
case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet":
return ".xlsx"
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return ".docx"
case "application/vnd.openxmlformats-officedocument.presentationml.presentation":
return ".pptx"
default:
return ".bin"
}
}
// httpExitCode maps HTTP status ranges to CLI exit codes:
// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error).
func httpExitCode(status int) int {
if status >= 500 {
return output.ExitNetwork
}
return output.ExitAPI
}

View File

@@ -0,0 +1,334 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"bytes"
"errors"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
)
func newApiResp(body []byte, headers map[string]string) *larkcore.ApiResp {
return newApiRespWithStatus(200, body, headers)
}
func newApiRespWithStatus(status int, body []byte, headers map[string]string) *larkcore.ApiResp {
h := http.Header{}
for k, v := range headers {
h.Set(k, v)
}
return &larkcore.ApiResp{
StatusCode: status,
Header: h,
RawBody: body,
}
}
func TestIsJSONContentType_Extended(t *testing.T) {
tests := []struct {
ct string
want bool
}{
{"application/json", true},
{"application/json; charset=utf-8", true},
{"text/json", true},
{"application/octet-stream", false},
{"", false},
}
for _, tt := range tests {
if got := IsJSONContentType(tt.ct); got != tt.want {
t.Errorf("IsJSONContentType(%q) = %v, want %v", tt.ct, got, tt.want)
}
}
}
func TestParseJSONResponse(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"123"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
result, err := ParseJSONResponse(resp)
if err != nil {
t.Fatalf("ParseJSONResponse failed: %v", err)
}
m, ok := result.(map[string]interface{})
if !ok {
t.Fatal("expected map result")
}
if m["msg"] != "ok" {
t.Errorf("expected msg=ok, got %v", m["msg"])
}
}
func TestParseJSONResponse_Invalid(t *testing.T) {
resp := newApiResp([]byte(`not json`), map[string]string{"Content-Type": "application/json"})
_, err := ParseJSONResponse(resp)
if err == nil {
t.Error("expected error for invalid JSON")
}
}
func TestResolveFilename(t *testing.T) {
tests := []struct {
name string
headers map[string]string
want string
}{
{
"from content-type pdf",
map[string]string{"Content-Type": "application/pdf"},
"download.pdf",
},
{
"from content-type png",
map[string]string{"Content-Type": "image/png"},
"download.png",
},
{
"unknown type",
map[string]string{"Content-Type": "application/octet-stream"},
"download.bin",
},
{
"empty content-type",
map[string]string{},
"download.bin",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp := newApiResp([]byte("data"), tt.headers)
got := ResolveFilename(resp)
if got != tt.want {
t.Errorf("ResolveFilename() = %q, want %q", got, tt.want)
}
})
}
}
func TestMimeToExt_Extended(t *testing.T) {
tests := []struct {
ct string
want string
}{
{"application/pdf", ".pdf"},
{"image/png", ".png"},
{"image/jpeg", ".jpg"},
{"image/gif", ".gif"},
{"text/plain", ".txt"},
{"text/csv", ".csv"},
{"text/html", ".html"},
{"application/zip", ".zip"},
{"application/xml", ".xml"},
{"text/xml", ".xml"},
{"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ".xlsx"},
{"application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx"},
{"application/vnd.openxmlformats-officedocument.presentationml.presentation", ".pptx"},
{"application/octet-stream", ".bin"},
{"", ".bin"},
}
for _, tt := range tests {
if got := mimeToExt(tt.ct); got != tt.want {
t.Errorf("mimeToExt(%q) = %q, want %q", tt.ct, got, tt.want)
}
}
}
func TestSaveResponse(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
body := []byte("hello binary data")
resp := newApiResp(body, map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, "test_output.bin")
if err != nil {
t.Fatalf("SaveResponse failed: %v", err)
}
if meta["size_bytes"] != len(body) {
t.Errorf("expected size_bytes=%d, got %v", len(body), meta["size_bytes"])
}
savedPath, _ := meta["saved_path"].(string)
data, err := os.ReadFile(savedPath)
if err != nil {
t.Fatalf("read saved file: %v", err)
}
if !bytes.Equal(data, body) {
t.Errorf("saved content mismatch")
}
}
func TestSaveResponse_CreatesDir(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("data"), map[string]string{"Content-Type": "application/octet-stream"})
meta, err := SaveResponse(resp, filepath.Join("sub", "deep", "out.bin"))
if err != nil {
t.Fatalf("SaveResponse with nested dir failed: %v", err)
}
savedPath, _ := meta["saved_path"].(string)
if _, err := os.Stat(savedPath); err != nil {
t.Errorf("expected file to exist at %s", savedPath)
}
}
func TestHandleResponse_JSON(t *testing.T) {
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err != nil {
t.Fatalf("HandleResponse failed: %v", err)
}
if !bytes.Contains(out.Bytes(), []byte(`"code"`)) {
t.Errorf("expected JSON output, got: %s", out.String())
}
}
func TestHandleResponse_JSONWithError(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err == nil {
t.Error("expected error for non-zero code")
}
}
func TestHandleResponse_BinaryAutoSave(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
Out: &out,
ErrOut: &errOut,
})
if err != nil {
t.Fatalf("HandleResponse binary failed: %v", err)
}
if !bytes.Contains(errOut.Bytes(), []byte("binary response detected")) {
t.Errorf("expected binary detection message, got: %s", errOut.String())
}
}
func TestHandleResponse_BinaryWithOutput(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"})
var out bytes.Buffer
var errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{
OutputPath: "out.png",
Out: &out,
ErrOut: &errOut,
})
if err != nil {
t.Fatalf("HandleResponse with output path failed: %v", err)
}
data, _ := os.ReadFile("out.png")
if string(data) != "PNG DATA" {
t.Errorf("expected saved PNG DATA, got: %s", data)
}
}
func TestHandleResponse_NonJSONError_404(t *testing.T) {
resp := newApiRespWithStatus(404, []byte("404 page not found"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
if err == nil {
t.Fatal("expected error for 404 text/plain")
}
got := err.Error()
if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") {
t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code)
}
}
func TestHandleResponse_NonJSONError_502(t *testing.T) {
resp := newApiRespWithStatus(502, []byte("<html>Bad Gateway</html>"), map[string]string{"Content-Type": "text/html"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
if err == nil {
t.Fatal("expected error for 502 text/html")
}
got := err.Error()
if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") {
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork {
t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code)
}
}
func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) {
dir := t.TempDir()
origWd, _ := os.Getwd()
os.Chdir(dir)
defer os.Chdir(origWd)
resp := newApiRespWithStatus(200, []byte("plain text file content"), map[string]string{"Content-Type": "text/plain"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
if err != nil {
t.Fatalf("expected no error for 200 text/plain, got: %v", err)
}
if !strings.Contains(errOut.String(), "binary response detected") {
t.Errorf("expected binary detection message, got: %s", errOut.String())
}
}
func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) {
body := []byte(`{"code":99991400,"msg":"invalid token"}`)
resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"})
var out, errOut bytes.Buffer
err := HandleResponse(resp, ResponseOptions{Out: &out, ErrOut: &errOut})
if err == nil {
t.Fatal("expected error for 403 JSON with non-zero code")
}
if !strings.Contains(err.Error(), "99991400") {
t.Errorf("expected lark error code in message, got: %s", err.Error())
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "github.com/spf13/cobra"
const skipAuthCheckKey = "skipAuthCheck"
// DisableAuthCheck marks a command (and all its children) as not requiring auth.
func DisableAuthCheck(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[skipAuthCheckKey] = "true"
}
// IsAuthCheckDisabled returns true if the command or any ancestor has auth check disabled.
func IsAuthCheckDisabled(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Annotations != nil && c.Annotations[skipAuthCheckKey] == "true" {
return true
}
}
return false
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestDisableAuthCheck(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
if IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be enabled by default")
}
DisableAuthCheck(cmd)
if !IsAuthCheckDisabled(cmd) {
t.Error("expected auth check to be disabled after DisableAuthCheck")
}
}
func TestIsAuthCheckDisabled_Inheritance(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
if IsAuthCheckDisabled(child) {
t.Error("expected child auth check enabled before parent annotation")
}
DisableAuthCheck(parent)
if !IsAuthCheckDisabled(child) {
t.Error("expected child to inherit disabled auth check from parent")
}
}
func TestIsAuthCheckDisabled_NoInheritanceUpward(t *testing.T) {
parent := &cobra.Command{Use: "parent"}
child := &cobra.Command{Use: "child"}
parent.AddCommand(child)
DisableAuthCheck(child)
if IsAuthCheckDisabled(parent) {
t.Error("parent should not inherit disabled auth check from child")
}
if !IsAuthCheckDisabled(child) {
t.Error("child should have disabled auth check")
}
}

252
internal/cmdutil/dryrun.go Normal file
View File

@@ -0,0 +1,252 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"fmt"
"io"
"net/url"
"sort"
"strings"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
)
// DryRunAPICall describes a single API call in dry-run output.
type DryRunAPICall struct {
Desc string `json:"desc,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Params map[string]interface{} `json:"params,omitempty"`
Body interface{} `json:"body,omitempty"`
}
// DryRunAPI is the builder and result type for dry-run output.
// URL templates use :param placeholders; Set stores actual values; MarshalJSON and Format resolve them.
type DryRunAPI struct {
desc string
calls []DryRunAPICall
extra map[string]interface{}
}
func NewDryRunAPI() *DryRunAPI {
return &DryRunAPI{extra: map[string]interface{}{}}
}
// --- HTTP method builders (add a call, return self for chaining) ---
func (d *DryRunAPI) GET(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "GET", URL: url})
return d
}
func (d *DryRunAPI) POST(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "POST", URL: url})
return d
}
func (d *DryRunAPI) PUT(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PUT", URL: url})
return d
}
func (d *DryRunAPI) DELETE(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "DELETE", URL: url})
return d
}
func (d *DryRunAPI) PATCH(url string) *DryRunAPI {
d.calls = append(d.calls, DryRunAPICall{Method: "PATCH", URL: url})
return d
}
// Body sets the request body on the last added call.
func (d *DryRunAPI) Body(body interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Body = body
}
return d
}
// Params sets query parameters on the last added call.
func (d *DryRunAPI) Params(params map[string]interface{}) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Params = params
}
return d
}
// Desc sets a description on the last added call.
// If no calls exist yet, sets the top-level description.
func (d *DryRunAPI) Desc(desc string) *DryRunAPI {
if n := len(d.calls); n > 0 {
d.calls[n-1].Desc = desc
} else {
d.desc = desc
}
return d
}
// Set adds an extra context field. Values are also used to resolve :key placeholders in URLs.
func (d *DryRunAPI) Set(key string, value interface{}) *DryRunAPI {
d.extra[key] = value
return d
}
// resolveURL replaces :key placeholders in url with path-escaped values from extra.
func (d *DryRunAPI) resolveURL(rawURL string) string {
for k, v := range d.extra {
rawURL = strings.ReplaceAll(rawURL, ":"+k, url.PathEscape(fmt.Sprintf("%v", v)))
}
return rawURL
}
// MarshalJSON serializes as {"description": "...", "api": [...calls with resolved URLs], ...extra}.
func (d *DryRunAPI) MarshalJSON() ([]byte, error) {
resolved := make([]DryRunAPICall, len(d.calls))
for i, c := range d.calls {
resolved[i] = DryRunAPICall{
Desc: c.Desc,
Method: c.Method,
URL: d.resolveURL(c.URL),
Params: c.Params,
Body: c.Body,
}
}
m := make(map[string]interface{}, len(d.extra)+2)
if d.desc != "" {
m["description"] = d.desc
}
m["api"] = resolved
for k, v := range d.extra {
m[k] = v
}
return json.Marshal(m)
}
// Format renders the dry-run output as plain text for AI/human consumption.
func (d *DryRunAPI) Format() string {
var b strings.Builder
if d.desc != "" {
b.WriteString("# ")
b.WriteString(d.desc)
b.WriteByte('\n')
}
for i, c := range d.calls {
if i > 0 || d.desc != "" {
b.WriteByte('\n')
}
if c.Desc != "" {
b.WriteString("# ")
b.WriteString(c.Desc)
b.WriteByte('\n')
}
u := d.resolveURL(c.URL)
if len(c.Params) > 0 {
u += "?" + encodeParams(c.Params)
}
method := c.Method
if method == "" {
method = "GET"
}
b.WriteString(method)
b.WriteByte(' ')
b.WriteString(u)
b.WriteByte('\n')
if !util.IsNil(c.Body) {
j, _ := json.Marshal(c.Body)
b.WriteString(" ")
b.Write(j)
b.WriteByte('\n')
}
}
if len(d.calls) == 0 && len(d.extra) > 0 {
if d.desc != "" {
b.WriteByte('\n')
}
keys := make([]string, 0, len(d.extra))
for k := range d.extra {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
sv := dryRunFormatValue(d.extra[k])
if sv == "" {
continue
}
b.WriteString(k)
b.WriteString(": ")
b.WriteString(sv)
b.WriteByte('\n')
}
}
return b.String()
}
func dryRunFormatValue(v interface{}) string {
switch val := v.(type) {
case string:
return val
case nil:
return ""
default:
j, _ := json.Marshal(val)
return string(j)
}
}
func encodeParams(params map[string]interface{}) string {
vals := url.Values{}
for k, v := range params {
vals.Set(k, fmt.Sprintf("%v", v))
}
return vals.Encode()
}
// PrintDryRun outputs a standardised dry-run summary using DryRunAPI.
// When format is "pretty", outputs human-readable text; otherwise JSON.
func PrintDryRun(w io.Writer, request client.RawApiRequest, config *core.CliConfig, format string) error {
dr := NewDryRunAPI()
switch request.Method {
case "POST":
dr.POST(request.URL)
case "PUT":
dr.PUT(request.URL)
case "PATCH":
dr.PATCH(request.URL)
case "DELETE":
dr.DELETE(request.URL)
default:
dr.GET(request.URL)
}
if len(request.Params) > 0 {
dr.Params(request.Params)
}
if !util.IsNil(request.Data) {
dr.Body(request.Data)
}
dr.Set("as", string(request.As))
dr.Set("appId", config.AppID)
if config.UserOpenId != "" {
dr.Set("userOpenId", config.UserOpenId)
}
fmt.Fprintln(w, "=== Dry Run ===")
if format == "pretty" {
fmt.Fprint(w, dr.Format())
} else {
output.PrintJson(w, dr)
}
return nil
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
)
func TestDryRunAPI_SingleGET(t *testing.T) {
dr := NewDryRunAPI().
Desc("list calendars").
GET("/open-apis/calendar/v4/calendars")
text := dr.Format()
if !strings.Contains(text, "# list calendars") {
t.Errorf("expected description in text output, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/calendar/v4/calendars") {
t.Errorf("expected GET line in text output, got: %s", text)
}
}
func TestDryRunAPI_WithParams(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/test").
Params(map[string]interface{}{"page_size": 20})
text := dr.Format()
if !strings.Contains(text, "page_size=20") {
t.Errorf("expected query params in text output, got: %s", text)
}
}
func TestDryRunAPI_WithBody(t *testing.T) {
dr := NewDryRunAPI().
POST("/open-apis/test").
Body(map[string]interface{}{"title": "hello"})
text := dr.Format()
if !strings.Contains(text, "POST /open-apis/test") {
t.Errorf("expected POST line, got: %s", text)
}
if !strings.Contains(text, `"title"`) {
t.Errorf("expected body in output, got: %s", text)
}
}
func TestDryRunAPI_ResolveURL(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/calendar/v4/calendars/:calendar_id/events").
Set("calendar_id", "cal_abc123")
text := dr.Format()
if !strings.Contains(text, "cal_abc123") {
t.Errorf("expected resolved calendar_id in URL, got: %s", text)
}
if strings.Contains(text, ":calendar_id") {
t.Errorf("expected placeholder to be resolved, got: %s", text)
}
}
func TestDryRunAPI_MarshalJSON(t *testing.T) {
dr := NewDryRunAPI().
Desc("test api").
GET("/open-apis/test").
Set("as", "user")
data, err := json.Marshal(dr)
if err != nil {
t.Fatalf("MarshalJSON failed: %v", err)
}
var m map[string]interface{}
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if m["description"] != "test api" {
t.Errorf("expected description, got: %v", m["description"])
}
if m["as"] != "user" {
t.Errorf("expected as=user, got: %v", m["as"])
}
api, ok := m["api"].([]interface{})
if !ok || len(api) != 1 {
t.Errorf("expected 1 api call, got: %v", m["api"])
}
}
func TestDryRunAPI_MultipleCalls(t *testing.T) {
dr := NewDryRunAPI().
GET("/open-apis/first").Desc("step 1").
POST("/open-apis/second").Desc("step 2")
text := dr.Format()
if !strings.Contains(text, "# step 1") || !strings.Contains(text, "# step 2") {
t.Errorf("expected both step descriptions, got: %s", text)
}
if !strings.Contains(text, "GET /open-apis/first") || !strings.Contains(text, "POST /open-apis/second") {
t.Errorf("expected both calls, got: %s", text)
}
}
func TestDryRunAPI_ExtraFieldsOnly(t *testing.T) {
dr := NewDryRunAPI().
Desc("info only").
Set("calendar_id", "cal_123").
Set("summary", "My Calendar")
text := dr.Format()
if !strings.Contains(text, "calendar_id: cal_123") {
t.Errorf("expected extra field, got: %s", text)
}
if !strings.Contains(text, "summary: My Calendar") {
t.Errorf("expected extra field, got: %s", text)
}
}
func TestPrintDryRun_JSON(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "GET",
URL: "/open-apis/test",
As: "user",
}, &core.CliConfig{AppID: "app123"}, "json")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "=== Dry Run ===") {
t.Errorf("expected header, got: %s", out)
}
if !strings.Contains(out, "app123") {
t.Errorf("expected appId in output, got: %s", out)
}
}
func TestPrintDryRun_Pretty(t *testing.T) {
var buf bytes.Buffer
err := PrintDryRun(&buf, client.RawApiRequest{
Method: "POST",
URL: "/open-apis/test",
Data: map[string]interface{}{"key": "val"},
As: "bot",
}, &core.CliConfig{AppID: "app456"}, "pretty")
if err != nil {
t.Fatalf("PrintDryRun failed: %v", err)
}
out := buf.String()
if !strings.Contains(out, "POST /open-apis/test") {
t.Errorf("expected POST line in pretty output, got: %s", out)
}
}
func TestDryRunFormatValue(t *testing.T) {
tests := []struct {
name string
v interface{}
want string
}{
{"string", "hello", "hello"},
{"nil", nil, ""},
{"number", 42, "42"},
{"bool", true, "true"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := dryRunFormatValue(tt.v); got != tt.want {
t.Errorf("dryRunFormatValue(%v) = %q, want %q", tt.v, got, tt.want)
}
})
}
}

141
internal/cmdutil/factory.go Normal file
View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"net/http"
"os"
"strings"
lark "github.com/larksuite/oapi-sdk-go/v3"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/output"
)
// ResolveConfig returns Config() for bot identity, or AuthConfig() for user identity.
func (f *Factory) ResolveConfig(as core.Identity) (*core.CliConfig, error) {
if as.IsBot() {
return f.Config()
}
return f.AuthConfig()
}
// Factory holds shared dependencies injected into every command.
// All function fields are lazily initialized and cached after first call.
// In tests, replace any field to stub out external dependencies.
type Factory struct {
Config func() (*core.CliConfig, error) // lazily loads app config (credentials, brand, defaultAs)
AuthConfig func() (*core.CliConfig, error) // like Config but also requires a logged-in user
HttpClient func() (*http.Client, error) // HTTP client for non-Lark API calls (with retry and security headers)
LarkClient func() (*lark.Client, error) // Lark SDK client for all Open API calls
IOStreams *IOStreams // stdin/stdout/stderr streams
Keychain keychain.KeychainAccess // secret storage (real keychain in prod, mock in tests)
IdentityAutoDetected bool // set by ResolveAs when identity was auto-detected
ResolvedIdentity core.Identity // identity resolved by the last ResolveAs call
}
// ResolveAs returns the effective identity type.
// If the user explicitly passed --as, use that value; otherwise use the configured default.
// When the value is "auto" (or unset), auto-detect based on login state.
func (f *Factory) ResolveAs(cmd *cobra.Command, flagAs core.Identity) core.Identity {
f.IdentityAutoDetected = false
if cmd != nil && cmd.Flags().Changed("as") {
if flagAs != "auto" {
f.ResolvedIdentity = flagAs
return flagAs
}
// --as auto: fall through to auto-detect
} else if defaultAs := f.resolveDefaultAs(); defaultAs != "" && defaultAs != "auto" {
f.ResolvedIdentity = core.Identity(defaultAs)
return f.ResolvedIdentity
}
// Auto-detect based on login state
f.IdentityAutoDetected = true
result := f.autoDetectIdentity()
f.ResolvedIdentity = result
return result
}
// resolveDefaultAs returns the configured default identity: env var > config file.
func (f *Factory) resolveDefaultAs() string {
if v := os.Getenv("LARKSUITE_CLI_DEFAULT_AS"); v != "" {
return v
}
if cfg, err := f.Config(); err == nil {
return cfg.DefaultAs
}
return ""
}
// autoDetectIdentity checks the login state and returns user if logged in, bot otherwise.
func (f *Factory) autoDetectIdentity() core.Identity {
cfg, err := f.Config()
if err != nil || cfg.UserOpenId == "" {
return core.AsBot
}
stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId)
if stored == nil {
return core.AsBot
}
if auth.TokenStatus(stored) == "expired" {
return core.AsBot
}
return core.AsUser
}
// CheckIdentity verifies the resolved identity is in the supported list.
// On success, sets f.ResolvedIdentity. On failure, returns an error
// tailored to whether the identity was explicit (--as) or auto-detected.
func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
for _, t := range supported {
if string(as) == t {
f.ResolvedIdentity = as
return nil
}
}
list := strings.Join(supported, ", ")
if f.IdentityAutoDetected {
return output.ErrValidation(
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
as, list, supported[0])
}
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
}
// NewAPIClient creates an APIClient using the Factory's base Config (app credentials only).
// For user-mode calls where the correct user profile matters, use NewAPIClientWithConfig instead.
func (f *Factory) NewAPIClient() (*client.APIClient, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
return f.NewAPIClientWithConfig(cfg)
}
// NewAPIClientWithConfig creates an APIClient with an explicit config.
// Use this when the caller has already resolved the correct user profile
// (e.g. via AuthConfig for user-mode commands).
func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient, error) {
sdk, err := f.LarkClient()
if err != nil {
return nil, err
}
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
errOut := io.Discard
if f.IOStreams != nil {
errOut = f.IOStreams.ErrOut
}
return &client.APIClient{Config: cfg, SDK: sdk, HTTP: httpClient, ErrOut: errOut}, nil
}

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"net/http"
"os"
"sync"
"time"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"golang.org/x/term"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
)
// NewDefault creates a production Factory with cached closures.
func NewDefault() *Factory {
f := &Factory{
Keychain: keychain.Default(),
}
f.IOStreams = &IOStreams{
In: os.Stdin,
Out: os.Stdout,
ErrOut: os.Stderr,
IsTerminal: term.IsTerminal(int(os.Stdin.Fd())),
}
f.Config = cachedConfigFunc(f)
f.AuthConfig = cachedAuthConfigFunc(f)
f.HttpClient = cachedHttpClientFunc()
f.LarkClient = cachedLarkClientFunc(f)
return f
}
func cachedConfigFunc(f *Factory) func() (*core.CliConfig, error) {
return sync.OnceValues(func() (*core.CliConfig, error) {
cfg, err := core.RequireConfig(f.Keychain)
if err != nil {
return cfg, err
}
registry.InitWithBrand(cfg.Brand)
return cfg, nil
})
}
func cachedAuthConfigFunc(f *Factory) func() (*core.CliConfig, error) {
return sync.OnceValues(func() (*core.CliConfig, error) {
return core.RequireAuth(f.Keychain)
})
}
// safeRedirectPolicy prevents credential headers from being forwarded
// when a response redirects to a different host (e.g. Lark API 302 → CDN).
// Strips Authorization, X-Lark-MCP-UAT, and X-Lark-MCP-TAT on cross-host
// redirects; other headers like X-Cli-* pass through.
func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
req.Header.Del("Authorization")
req.Header.Del("X-Lark-MCP-UAT")
req.Header.Del("X-Lark-MCP-TAT")
}
return nil
}
func cachedHttpClientFunc() func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
var transport = http.DefaultTransport
transport = &RetryTransport{Base: transport}
transport = &SecurityHeaderTransport{Base: transport}
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
CheckRedirect: safeRedirectPolicy,
}
return client, nil
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
cfg, err := f.Config()
if err != nil {
return nil, err
}
opts := []lark.ClientOptionFunc{
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHeaders(BaseSecurityHeaders()),
}
// Build SDK transport chain
var sdkTransport = http.DefaultTransport
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(cfg.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
client := lark.NewClient(cfg.AppID, cfg.AppSecret, opts...)
return client, nil
})
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc()
c1, err := fn()
if err != nil {
t.Fatalf("first call: %v", err)
}
if c1 == nil {
t.Fatal("first call returned nil")
}
c2, err := fn()
if err != nil {
t.Fatalf("second call: %v", err)
}
if c1 != c2 {
t.Error("expected same *http.Client instance on second call (cache hit)")
}
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc()
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
}
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc()
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")
}
}

View File

@@ -0,0 +1,282 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/core"
)
// newCmdWithAsFlag creates a cobra.Command with a --as string flag for testing.
func newCmdWithAsFlag(asValue string, changed bool) *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("as", "auto", "identity")
if changed {
_ = cmd.Flags().Set("as", asValue)
}
return cmd
}
// --- ResolveAs tests ---
func TestResolveAs_ExplicitAs(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("bot", true)
got := f.ResolveAs(cmd, core.AsBot)
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for explicit --as")
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAsUser(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("user", true)
got := f.ResolveAs(cmd, core.AsUser)
if got != core.AsUser {
t.Errorf("want user, got %s", got)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestResolveAs_ExplicitAuto_FallsToAutoDetect(t *testing.T) {
// --as auto explicitly: should fall through to auto-detect
// Config has no UserOpenId → auto-detect returns bot
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", true)
got := f.ResolveAs(cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (auto-detect, no login), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true for auto-detect path")
}
}
func TestResolveAs_DefaultAs_FromConfig(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "bot",
})
cmd := newCmdWithAsFlag("auto", false) // --as not changed
got := f.ResolveAs(cmd, "auto")
if got != core.AsBot {
t.Errorf("want bot (from default-as config), got %s", got)
}
if f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be false for default-as path")
}
}
func TestResolveAs_DefaultAs_FromEnv(t *testing.T) {
os.Setenv("LARKSUITE_CLI_DEFAULT_AS", "user")
defer os.Unsetenv("LARKSUITE_CLI_DEFAULT_AS")
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(cmd, "auto")
if got != core.AsUser {
t.Errorf("want user (from env), got %s", got)
}
}
func TestResolveAs_DefaultAs_AutoValue_FallsToAutoDetect(t *testing.T) {
// default-as = "auto" should fall through to auto-detect
f, _, _, _ := TestFactory(t, &core.CliConfig{
AppID: "a", AppSecret: "s",
DefaultAs: "auto",
})
cmd := newCmdWithAsFlag("auto", false)
got := f.ResolveAs(cmd, "auto")
// No UserOpenId → auto-detect returns bot
if got != core.AsBot {
t.Errorf("want bot (auto-detect), got %s", got)
}
if !f.IdentityAutoDetected {
t.Error("IdentityAutoDetected should be true")
}
}
func TestResolveAs_NilCmd_AutoDetect(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.ResolveAs(nil, "auto")
if got != core.AsBot {
t.Errorf("want bot, got %s", got)
}
}
// --- CheckIdentity tests ---
func TestCheckIdentity_Supported(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsBot, []string{"bot", "user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsBot {
t.Errorf("ResolvedIdentity want bot, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Supported_UserOnly(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
err := f.CheckIdentity(core.AsUser, []string{"user"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if f.ResolvedIdentity != core.AsUser {
t.Errorf("ResolvedIdentity want user, got %s", f.ResolvedIdentity)
}
}
func TestCheckIdentity_Unsupported_Explicit(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = false // explicit --as
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--as user is not supported") {
t.Errorf("unexpected error message: %v", err)
}
if !strings.Contains(err.Error(), "bot") {
t.Errorf("error should mention supported identity: %v", err)
}
}
func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
f.IdentityAutoDetected = true
err := f.CheckIdentity(core.AsUser, []string{"bot"})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "resolved identity") {
t.Errorf("expected 'resolved identity' in error, got: %v", err)
}
if !strings.Contains(err.Error(), "hint: use --as bot") {
t.Errorf("expected hint in error, got: %v", err)
}
}
// --- ResolveConfig tests ---
func TestResolveConfig_Bot(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
f, _, _, _ := TestFactory(t, cfg)
got, err := f.ResolveConfig(core.AsBot)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.AppID != "a" {
t.Errorf("want AppID a, got %s", got.AppID)
}
}
func TestResolveConfig_User(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s"}
f, _, _, _ := TestFactory(t, cfg)
got, err := f.ResolveConfig(core.AsUser)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.AppID != "a" {
t.Errorf("want AppID a, got %s", got.AppID)
}
}
// --- autoDetectIdentity tests ---
func TestAutoDetectIdentity_NoUserOpenId(t *testing.T) {
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "a", AppSecret: "s"})
got := f.autoDetectIdentity()
if got != core.AsBot {
t.Errorf("want bot (no UserOpenId), got %s", got)
}
}
func TestAutoDetectIdentity_ConfigError(t *testing.T) {
f := &Factory{
Config: func() (*core.CliConfig, error) {
return nil, os.ErrNotExist
},
}
got := f.autoDetectIdentity()
if got != core.AsBot {
t.Errorf("want bot (config error), got %s", got)
}
}
// --- NewAPIClient / NewAPIClientWithConfig tests ---
func TestNewAPIClient(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClient()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
}
func TestNewAPIClientWithConfig(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac.Config.AppID != "a" {
t.Errorf("want AppID a, got %s", ac.Config.AppID)
}
if ac.SDK == nil {
t.Error("SDK should not be nil")
}
if ac.HTTP == nil {
t.Error("HTTP should not be nil")
}
}
func TestNewAPIClientWithConfig_NilIOStreams(t *testing.T) {
cfg := &core.CliConfig{AppID: "a", AppSecret: "s", Brand: core.BrandLark}
f, _, _, _ := TestFactory(t, cfg)
f.IOStreams = nil
ac, err := f.NewAPIClientWithConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ac == nil {
t.Fatal("expected non-nil APIClient")
}
}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"fmt"
"io"
"github.com/larksuite/cli/internal/core"
)
// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"])
// to CLI identity names (e.g. ["bot", "user"]).
func AccessTokensToIdentities(tokens []interface{}) []string {
var identities []string
for _, t := range tokens {
if ts, ok := t.(string); ok {
if ts == "tenant" {
identities = append(identities, "bot")
} else {
identities = append(identities, ts)
}
}
}
return identities
}
// PrintIdentity outputs the current identity to stderr so callers (including AI agents)
// can see which identity is being used for the API call.
func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) {
if as.IsBot() {
if autoDetected {
fmt.Fprintln(w, "[identity: bot (auto — not logged in; `auth login` for user identity)]")
} else {
fmt.Fprintln(w, "[identity: bot]")
}
} else if config != nil && config.UserOpenId != "" {
fmt.Fprintf(w, "[identity: user (%s)]\n", config.UserOpenId)
} else {
fmt.Fprintln(w, "[identity: user]")
}
}

View File

@@ -0,0 +1,101 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestAccessTokensToIdentities(t *testing.T) {
tests := []struct {
name string
tokens []interface{}
want []string
}{
{
name: "tenant becomes bot",
tokens: []interface{}{"tenant"},
want: []string{"bot"},
},
{
name: "user stays user",
tokens: []interface{}{"user"},
want: []string{"user"},
},
{
name: "tenant and user",
tokens: []interface{}{"tenant", "user"},
want: []string{"bot", "user"},
},
{
name: "empty list",
tokens: []interface{}{},
want: nil,
},
{
name: "non-string values skipped",
tokens: []interface{}{"tenant", 42, "user"},
want: []string{"bot", "user"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := AccessTokensToIdentities(tt.tokens)
if len(got) != len(tt.want) {
t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got)
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i])
}
}
})
}
}
func TestPrintIdentity_BotExplicit(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsBot, nil, false)
if !strings.Contains(buf.String(), "[identity: bot]") {
t.Errorf("unexpected output: %s", buf.String())
}
}
func TestPrintIdentity_BotAutoDetected(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsBot, nil, true)
if !strings.Contains(buf.String(), "auto") {
t.Errorf("expected auto hint, got: %s", buf.String())
}
}
func TestPrintIdentity_UserWithOpenId(t *testing.T) {
var buf bytes.Buffer
cfg := &core.CliConfig{UserOpenId: "ou_abc123"}
PrintIdentity(&buf, core.AsUser, cfg, false)
if !strings.Contains(buf.String(), "ou_abc123") {
t.Errorf("expected UserOpenId in output, got: %s", buf.String())
}
}
func TestPrintIdentity_UserWithoutOpenId(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsUser, &core.CliConfig{}, false)
if !strings.Contains(buf.String(), "[identity: user]") {
t.Errorf("unexpected output: %s", buf.String())
}
}
func TestPrintIdentity_UserNilConfig(t *testing.T) {
var buf bytes.Buffer
PrintIdentity(&buf, core.AsUser, nil, false)
if !strings.Contains(buf.String(), "[identity: user]") {
t.Errorf("unexpected output: %s", buf.String())
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "io"
// IOStreams provides the standard input/output/error streams.
// Commands should use these instead of os.Stdin/Stdout/Stderr
// to enable testing and output capture.
type IOStreams struct {
In io.Reader
Out io.Writer
ErrOut io.Writer
IsTerminal bool
}

40
internal/cmdutil/json.go Normal file
View File

@@ -0,0 +1,40 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"github.com/larksuite/cli/internal/output"
)
// ParseOptionalBody parses --data JSON for methods that accept a request body.
// Returns (nil, nil) if the method has no body or data is empty.
func ParseOptionalBody(httpMethod, data string) (interface{}, error) {
switch httpMethod {
case "POST", "PUT", "PATCH", "DELETE":
default:
return nil, nil
}
if data == "" {
return nil, nil
}
var body interface{}
if err := json.Unmarshal([]byte(data), &body); err != nil {
return nil, output.ErrValidation("--data invalid JSON format")
}
return body, nil
}
// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty.
func ParseJSONMap(input, label string) (map[string]any, error) {
if input == "" {
return map[string]any{}, nil
}
var result map[string]any
if err := json.Unmarshal([]byte(input), &result); err != nil {
return nil, output.ErrValidation("%s invalid format, expected JSON object", label)
}
return result, nil
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import "testing"
func TestParseOptionalBody(t *testing.T) {
tests := []struct {
name string
method string
data string
wantNil bool
wantErr bool
}{
{"GET ignored", "GET", `{"a":1}`, true, false},
{"POST empty data", "POST", "", true, false},
{"POST valid", "POST", `{"key":"val"}`, false, false},
{"PUT valid", "PUT", `[1,2,3]`, false, false},
{"PATCH valid", "PATCH", `"hello"`, false, false},
{"DELETE valid", "DELETE", `{"id":"1"}`, false, false},
{"POST invalid json", "POST", `{bad}`, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOptionalBody(tt.method, tt.data)
if (err != nil) != tt.wantErr {
t.Errorf("ParseOptionalBody() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantNil && got != nil {
t.Errorf("ParseOptionalBody() = %v, want nil", got)
}
if !tt.wantNil && !tt.wantErr && got == nil {
t.Error("ParseOptionalBody() = nil, want non-nil")
}
})
}
}
func TestParseJSONMap(t *testing.T) {
tests := []struct {
name string
input string
label string
wantLen int
wantErr bool
}{
{"empty input", "", "--params", 0, false},
{"valid json", `{"a":"1","b":"2"}`, "--params", 2, false},
{"invalid json", `{bad}`, "--params", 0, true},
{"json array", `[1,2]`, "--data", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseJSONMap(tt.input, tt.label)
if (err != nil) != tt.wantErr {
t.Errorf("ParseJSONMap() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && len(got) != tt.wantLen {
t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen)
}
})
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"io"
"net/http"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func TestRetryTransport_NoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 0}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call, got %d", calls)
}
}
func TestRetryTransport_RetryOn500(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if calls < 3 {
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("ok"))}, nil
})
rt := &RetryTransport{Base: base, MaxRetries: 3, Delay: 1 * time.Millisecond}
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 200 {
t.Errorf("expected 200 after retries, got %d", resp.StatusCode)
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
}
func TestRetryTransport_DefaultNoRetry(t *testing.T) {
calls := 0
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("error"))}, nil
})
rt := &RetryTransport{Base: base} // default MaxRetries=0
req, _ := http.NewRequest("GET", "http://example.com/test", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if resp.StatusCode != 500 {
t.Errorf("expected 500 with no retries, got %d", resp.StatusCode)
}
if calls != 1 {
t.Errorf("expected 1 call with default config, got %d", calls)
}
}

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"context"
"net/http"
"github.com/larksuite/cli/internal/build"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
const (
HeaderSource = "X-Cli-Source"
HeaderVersion = "X-Cli-Version"
HeaderShortcut = "X-Cli-Shortcut"
HeaderExecutionId = "X-Cli-Execution-Id"
SourceValue = "lark-cli"
HeaderUserAgent = "User-Agent"
)
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
func UserAgentValue() string {
return SourceValue + "/" + build.Version
}
// BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header {
h := make(http.Header)
h.Set(HeaderSource, SourceValue)
h.Set(HeaderVersion, build.Version)
h.Set(HeaderUserAgent, UserAgentValue())
return h
}
// ── Context utilities ──
type ctxKey string
const (
ctxShortcutName ctxKey = "lark:shortcut-name"
ctxExecutionId ctxKey = "lark:execution-id"
)
// ContextWithShortcut injects shortcut name and execution ID into the context.
func ContextWithShortcut(ctx context.Context, name, executionId string) context.Context {
ctx = context.WithValue(ctx, ctxShortcutName, name)
ctx = context.WithValue(ctx, ctxExecutionId, executionId)
return ctx
}
// ShortcutNameFromContext extracts the shortcut name from the context.
func ShortcutNameFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxShortcutName).(string)
return v, ok && v != ""
}
// ExecutionIdFromContext extracts the execution ID from the context.
func ExecutionIdFromContext(ctx context.Context) (string, bool) {
v, ok := ctx.Value(ctxExecutionId).(string)
return v, ok && v != ""
}
// ShortcutHeaderOpts extracts Shortcut info from the context and returns a
// RequestOptionFunc that injects the corresponding headers into SDK requests.
// Returns nil if the context has no Shortcut info.
func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
}
h := make(http.Header)
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
return larkcore.WithHeaders(h)
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"bytes"
"net/http"
"testing"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
// noopKeychain is a no-op KeychainAccess for tests that don't need keychain.
type noopKeychain struct{}
func (n *noopKeychain) Get(service, account string) (string, error) { return "", nil }
func (n *noopKeychain) Set(service, account, value string) error { return nil }
func (n *noopKeychain) Remove(service, account string) error { return nil }
// TestFactory creates a Factory for testing.
// Returns (factory, stdout buffer, stderr buffer, http mock registry).
func TestFactory(t *testing.T, config *core.CliConfig) (*Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
reg := &httpmock.Registry{}
t.Cleanup(func() { reg.Verify(t) })
stdoutBuf := &bytes.Buffer{}
stderrBuf := &bytes.Buffer{}
mockClient := httpmock.NewClient(reg)
// SDK mock client wraps the mock transport with UserAgentTransport
// so that User-Agent overrides the SDK default (oapi-sdk-go/v3.x.x).
sdkMockClient := &http.Client{
Transport: &UserAgentTransport{Base: reg},
}
// Build a test LarkClient using the config
var testLarkClient *lark.Client
if config != nil && config.AppID != "" {
opts := []lark.ClientOptionFunc{
lark.WithLogLevel(larkcore.LogLevelError),
lark.WithHttpClient(sdkMockClient),
lark.WithHeaders(BaseSecurityHeaders()),
}
if config.Brand != "" {
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(config.Brand)))
}
testLarkClient = lark.NewClient(config.AppID, config.AppSecret, opts...)
}
f := &Factory{
Config: func() (*core.CliConfig, error) { return config, nil },
AuthConfig: func() (*core.CliConfig, error) { return config, nil },
HttpClient: func() (*http.Client, error) { return mockClient, nil },
LarkClient: func() (*lark.Client, error) { return testLarkClient, nil },
IOStreams: &IOStreams{In: nil, Out: stdoutBuf, ErrOut: stderrBuf},
Keychain: &noopKeychain{},
}
return f, stdoutBuf, stderrBuf, reg
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
func TestTestFactory_ReplacesGlobals(t *testing.T) {
config := &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
f, stdout, stderr, reg := TestFactory(t, config)
// Factory should return our config
got, err := f.Config()
if err != nil {
t.Fatalf("Config() error: %v", err)
}
if got.AppID != "test-app" {
t.Errorf("want AppID test-app, got %s", got.AppID)
}
// IOStreams.Out/ErrOut should be our buffers
output.PrintJson(f.IOStreams.Out, map[string]string{"key": "value"})
if !strings.Contains(stdout.String(), `"key"`) {
t.Error("output.PrintJson did not write to test stdout")
}
output.PrintError(f.IOStreams.ErrOut, "test error")
if !strings.Contains(stderr.String(), "test error") {
t.Error("output.PrintError did not write to test stderr")
}
// Register a stub so Verify passes
reg.Register(&httpmock.Stub{
URL: "/test",
Body: "ok",
})
// Use the stub via Factory HttpClient
httpClient, err := f.HttpClient()
if err != nil {
t.Fatalf("HttpClient() error: %v", err)
}
baseURL := core.ResolveOpenBaseURL(core.BrandFeishu)
req, _ := http.NewRequest("GET", baseURL+"/test", nil)
resp, err := httpClient.Do(req)
if err != nil {
t.Fatalf("HttpClient request error: %v", err)
}
resp.Body.Close()
}

58
internal/cmdutil/theme.go Normal file
View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// ThemeFeishu returns a huh theme with Feishu brand colors.
func ThemeFeishu() *huh.Theme {
t := huh.ThemeBase()
var (
blue = lipgloss.Color("#1456F0") // 标题、边框
teal = lipgloss.Color("#33D6C0") // 选择器、光标、输入提示
cyan = lipgloss.Color("#3EC3C0") // 选中项
orange = lipgloss.Color("#FF811A") // 按钮高亮
magenta = lipgloss.Color("#CC398C") // 错误
text = lipgloss.AdaptiveColor{Light: "#1F2329", Dark: "#E8E8E8"}
subtext = lipgloss.AdaptiveColor{Light: "#8F959E", Dark: "#8F959E"}
btnBg = lipgloss.AdaptiveColor{Light: "#EEF3FF", Dark: "#2B3A5C"}
)
t.Focused.Base = t.Focused.Base.BorderForeground(blue)
t.Focused.Card = t.Focused.Base
t.Focused.Title = t.Focused.Title.Foreground(blue).Bold(true)
t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(blue).Bold(true)
t.Focused.Description = t.Focused.Description.Foreground(subtext)
t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(magenta)
t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(magenta)
t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(teal)
t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(teal)
t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(teal)
t.Focused.Option = t.Focused.Option.Foreground(text)
t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(teal)
t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(cyan)
t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(cyan).SetString("✓ ")
t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(text)
t.Focused.UnselectedPrefix = t.Focused.UnselectedPrefix.Foreground(subtext).SetString("• ")
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(orange).Bold(true)
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(btnBg)
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(teal)
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(subtext)
t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(teal)
t.Blurred = t.Focused
t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
t.Blurred.Card = t.Blurred.Base
t.Blurred.NextIndicator = lipgloss.NewStyle()
t.Blurred.PrevIndicator = lipgloss.NewStyle()
t.Group.Title = t.Focused.Title
t.Group.Description = t.Focused.Description
return t
}

47
internal/cmdutil/tips.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"encoding/json"
"github.com/spf13/cobra"
)
const tipsAnnotationKey = "tips"
// SetTips sets the tips for a command (stored as JSON in Annotations).
func SetTips(cmd *cobra.Command, tips []string) {
if len(tips) == 0 {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
data, _ := json.Marshal(tips)
cmd.Annotations[tipsAnnotationKey] = string(data)
}
// AddTips appends tips to a command (merges with existing).
func AddTips(cmd *cobra.Command, tips ...string) {
existing := GetTips(cmd)
SetTips(cmd, append(existing, tips...))
}
// GetTips retrieves the tips from a command's annotations.
func GetTips(cmd *cobra.Command) []string {
if cmd.Annotations == nil {
return nil
}
raw, ok := cmd.Annotations[tipsAnnotationKey]
if !ok {
return nil
}
var tips []string
err := json.Unmarshal([]byte(raw), &tips)
if err != nil {
return nil
}
return tips
}

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"testing"
"github.com/spf13/cobra"
)
func TestSetTipsAndGetTips(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
tips := []string{"tip one", "tip two"}
SetTips(cmd, tips)
got := GetTips(cmd)
if len(got) != 2 || got[0] != "tip one" || got[1] != "tip two" {
t.Fatalf("expected %v, got %v", tips, got)
}
}
func TestSetTipsEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetTips(cmd, nil)
if cmd.Annotations != nil {
t.Fatal("expected nil annotations for empty tips")
}
}
func TestGetTipsNoAnnotations(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
got := GetTips(cmd)
if got != nil {
t.Fatalf("expected nil, got %v", got)
}
}
func TestAddTips(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
SetTips(cmd, []string{"first"})
AddTips(cmd, "second", "third")
got := GetTips(cmd)
if len(got) != 3 || got[0] != "first" || got[1] != "second" || got[2] != "third" {
t.Fatalf("expected [first second third], got %v", got)
}
}
func TestAddTipsToEmpty(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
AddTips(cmd, "only")
got := GetTips(cmd)
if len(got) != 1 || got[0] != "only" {
t.Fatalf("expected [only], got %v", got)
}
}

View File

@@ -0,0 +1,100 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"net/http"
"time"
)
// RetryTransport is an http.RoundTripper that retries on 5xx responses
// and network errors. MaxRetries defaults to 0 (no retries).
type RetryTransport struct {
Base http.RoundTripper
MaxRetries int
Delay time.Duration // base delay for exponential backoff; defaults to 500ms
}
func (t *RetryTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
func (t *RetryTransport) delay() time.Duration {
if t.Delay > 0 {
return t.Delay
}
return 500 * time.Millisecond
}
// RoundTrip implements http.RoundTripper.
func (t *RetryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := t.base().RoundTrip(req)
if t.MaxRetries <= 0 {
return resp, err
}
for attempt := 0; attempt < t.MaxRetries; attempt++ {
if err == nil && resp.StatusCode < 500 {
return resp, nil
}
// Clone request for retry
cloned := req.Clone(req.Context())
if req.Body != nil && req.GetBody != nil {
cloned.Body, _ = req.GetBody()
}
delay := t.delay() * (1 << uint(attempt))
time.Sleep(delay)
resp, err = t.base().RoundTrip(cloned)
}
return resp, err
}
// UserAgentTransport is an http.RoundTripper that sets the User-Agent header.
// Used in the SDK transport chain to override the SDK's default User-Agent.
type UserAgentTransport struct {
Base http.RoundTripper
}
func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set(HeaderUserAgent, UserAgentValue())
if t.Base != nil {
return t.Base.RoundTrip(req)
}
return http.DefaultTransport.RoundTrip(req)
}
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
// headers into every request. Shortcut headers are read from the request context.
type SecurityHeaderTransport struct {
Base http.RoundTripper
}
func (t *SecurityHeaderTransport) base() http.RoundTripper {
if t.Base != nil {
return t.Base
}
return http.DefaultTransport
}
// RoundTrip implements http.RoundTripper.
func (t *SecurityHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
for k, vs := range BaseSecurityHeaders() {
for _, v := range vs {
req.Header.Set(k, v)
}
}
// Shortcut headers are propagated via context (see section 5.6 of the design doc).
if name, ok := ShortcutNameFromContext(req.Context()); ok {
req.Header.Set(HeaderShortcut, name)
}
if eid, ok := ExecutionIdFromContext(req.Context()); ok {
req.Header.Set(HeaderExecutionId, eid)
}
return t.base().RoundTrip(req)
}

141
internal/core/config.go Normal file
View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/validate"
)
// Identity represents the caller identity for API requests.
type Identity string
const (
AsUser Identity = "user"
AsBot Identity = "bot"
)
// IsBot returns true if the identity is bot.
func (id Identity) IsBot() bool { return id == AsBot }
// AppUser is a logged-in user record stored in config.
type AppUser struct {
UserOpenId string `json:"userOpenId"`
UserName string `json:"userName"`
}
// AppConfig is a per-app configuration entry (stored format — secrets may be unresolved).
type AppConfig struct {
AppId string `json:"appId"`
AppSecret SecretInput `json:"appSecret"`
Brand LarkBrand `json:"brand"`
Lang string `json:"lang,omitempty"`
DefaultAs string `json:"defaultAs,omitempty"` // "user" | "bot" | "auto"
Users []AppUser `json:"users"`
}
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
Apps []AppConfig `json:"apps"`
}
// CliConfig is the resolved single-app config used by downstream code.
type CliConfig struct {
AppID string
AppSecret string
Brand LarkBrand
DefaultAs string // "user" | "bot" | "auto" | "" (from config file)
UserOpenId string
UserName string
}
// GetConfigDir returns the config directory path.
// If the home directory cannot be determined, it falls back to a relative path
// and prints a warning to stderr.
func GetConfigDir() string {
if dir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
}
return filepath.Join(home, ".lark-cli")
}
// GetConfigPath returns the config file path.
func GetConfigPath() string {
return filepath.Join(GetConfigDir(), "config.json")
}
// LoadMultiAppConfig loads multi-app config from disk.
func LoadMultiAppConfig() (*MultiAppConfig, error) {
data, err := os.ReadFile(GetConfigPath())
if err != nil {
return nil, err
}
var multi MultiAppConfig
if err := json.Unmarshal(data, &multi); err != nil {
return nil, fmt.Errorf("invalid config format: %w", err)
}
if len(multi.Apps) == 0 {
return nil, fmt.Errorf("invalid config format: no apps")
}
return &multi, nil
}
// SaveMultiAppConfig saves config to disk.
func SaveMultiAppConfig(config *MultiAppConfig) error {
dir := GetConfigDir()
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return validate.AtomicWrite(GetConfigPath(), append(data, '\n'), 0600)
}
// RequireConfig loads the single-app config. Takes Apps[0] directly.
func RequireConfig(kc keychain.KeychainAccess) (*CliConfig, error) {
raw, err := LoadMultiAppConfig()
if err != nil || raw == nil || len(raw.Apps) == 0 {
return nil, &ConfigError{Code: 2, Type: "config", Message: "not configured", Hint: "run `lark-cli config init --new` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete setup."}
}
app := raw.Apps[0]
secret, err := ResolveSecretInput(app.AppSecret, kc)
if err != nil {
return nil, &ConfigError{Code: 2, Type: "config", Message: err.Error()}
}
cfg := &CliConfig{
AppID: app.AppId,
AppSecret: secret,
Brand: app.Brand,
DefaultAs: app.DefaultAs,
}
if len(app.Users) > 0 {
cfg.UserOpenId = app.Users[0].UserOpenId
cfg.UserName = app.Users[0].UserName
}
return cfg, nil
}
// RequireAuth loads config and ensures a user is logged in.
func RequireAuth(kc keychain.KeychainAccess) (*CliConfig, error) {
cfg, err := RequireConfig(kc)
if err != nil {
return nil, err
}
if cfg.UserOpenId == "" {
return nil, &ConfigError{Code: 3, Type: "auth", Message: "not logged in", Hint: "run `lark-cli auth login` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login."}
}
return cfg, nil
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"encoding/json"
"testing"
)
func TestAppConfig_LangSerialization(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
Brand: BrandFeishu, Lang: "en", Users: []AppUser{},
}
data, err := json.Marshal(app)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got AppConfig
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Lang != "en" {
t.Errorf("Lang = %q, want %q", got.Lang, "en")
}
}
func TestAppConfig_LangOmitEmpty(t *testing.T) {
app := AppConfig{
AppId: "cli_test", AppSecret: PlainSecret("secret"),
Brand: BrandFeishu, Users: []AppUser{},
}
data, err := json.Marshal(app)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Lang should be omitted when empty
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("unmarshal raw: %v", err)
}
if _, exists := raw["lang"]; exists {
t.Error("expected lang to be omitted when empty")
}
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
config := &MultiAppConfig{
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
}},
}
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got MultiAppConfig
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(got.Apps) != 1 {
t.Fatalf("expected 1 app, got %d", len(got.Apps))
}
if got.Apps[0].Lang != "zh" {
t.Errorf("Lang = %q, want %q", got.Apps[0].Lang, "zh")
}
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
}

22
internal/core/errors.go Normal file
View File

@@ -0,0 +1,22 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import "fmt"
// ConfigError is a structured error from config resolution.
// It carries enough information for main.go to convert it into an output.ExitError.
type ConfigError struct {
Code int // exit code: 2=validation, 3=auth
Type string // "config" or "auth"
Message string
Hint string
}
func (e *ConfigError) Error() string {
if e.Hint != "" {
return fmt.Sprintf("%s\n %s", e.Message, e.Hint)
}
return e.Message
}

86
internal/core/secret.go Normal file
View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"encoding/json"
"fmt"
)
// ---------------------------------------------------------------------------
// SecretRef — external secret reference
// ---------------------------------------------------------------------------
// SecretRef references a secret stored externally.
type SecretRef struct {
Source string `json:"source"` // "file" | "keychain"
Provider string `json:"provider,omitempty"` // optional, reserved
ID string `json:"id"` // env var name / file path / command / keychain key
}
// ---------------------------------------------------------------------------
// SecretInput — union type: plain string or SecretRef
// ---------------------------------------------------------------------------
// SecretInput represents a secret value: either a plain string or a SecretRef object.
type SecretInput struct {
Plain string // non-empty for plain string values
Ref *SecretRef // non-nil for SecretRef values
}
// PlainSecret creates a SecretInput from a plain string.
func PlainSecret(s string) SecretInput {
return SecretInput{Plain: s}
}
// IsZero returns true if the SecretInput has no value.
func (s SecretInput) IsZero() bool {
return s.Plain == "" && s.Ref == nil
}
// IsSecretRef returns true if this is a SecretRef object (env/file/keychain).
func (s SecretInput) IsSecretRef() bool {
return s.Ref != nil
}
// IsPlain returns true if this is a plain text string (not a SecretRef).
func (s SecretInput) IsPlain() bool {
return s.Ref == nil
}
// MarshalJSON serializes SecretInput: plain string → JSON string, SecretRef → JSON object.
func (s SecretInput) MarshalJSON() ([]byte, error) {
if s.Ref != nil {
return json.Marshal(s.Ref)
}
return json.Marshal(s.Plain)
}
// UnmarshalJSON deserializes SecretInput from either a JSON string or a SecretRef object.
func (s *SecretInput) UnmarshalJSON(data []byte) error {
// Try string first
var plain string
if err := json.Unmarshal(data, &plain); err == nil {
s.Plain = plain
s.Ref = nil
return nil
}
// Try SecretRef object
var ref SecretRef
if err := json.Unmarshal(data, &ref); err == nil && isValidSource(ref.Source) && ref.ID != "" {
s.Ref = &ref
s.Plain = ""
return nil
}
return fmt.Errorf("appSecret must be a string or {source, id} object")
}
// ValidSecretSources is the set of recognized SecretRef sources.
var ValidSecretSources = map[string]bool{
"file": true, "keychain": true,
}
func isValidSource(source string) bool {
return ValidSecretSources[source]
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"fmt"
"os"
"strings"
"github.com/larksuite/cli/internal/keychain"
)
const secretKeyPrefix = "appsecret:"
func secretAccountKey(appId string) string {
return secretKeyPrefix + appId
}
// ResolveSecretInput resolves a SecretInput to a plain string.
// SecretRef objects are resolved by source (file / keychain).
func ResolveSecretInput(s SecretInput, kc keychain.KeychainAccess) (string, error) {
if s.Ref == nil {
return s.Plain, nil
}
switch s.Ref.Source {
case "file":
data, err := os.ReadFile(s.Ref.ID)
if err != nil {
return "", fmt.Errorf("failed to read secret file %s: %w", s.Ref.ID, err)
}
return strings.TrimSpace(string(data)), nil
case "keychain":
return kc.Get(keychain.LarkCliService, s.Ref.ID)
default:
return "", fmt.Errorf("unknown secret source: %s", s.Ref.Source)
}
}
// ForStorage determines how to store a secret in config.json.
// - SecretRef → preserved as-is
// - Plain text → stored in keychain, returns keychain SecretRef
// Returns error if keychain is unavailable (no silent plaintext fallback).
func ForStorage(appId string, input SecretInput, kc keychain.KeychainAccess) (SecretInput, error) {
if !input.IsPlain() {
return input, nil // SecretRef → keep as-is
}
key := secretAccountKey(appId)
if err := kc.Set(keychain.LarkCliService, key, input.Plain); err != nil {
return SecretInput{}, fmt.Errorf("keychain unavailable: %w\nhint: use file: reference in config to bypass keychain", err)
}
return SecretInput{Ref: &SecretRef{Source: "keychain", ID: key}}, nil
}
// RemoveSecretStore cleans up keychain entries when an app is removed.
// Errors are intentionally ignored — cleanup is best-effort.
func RemoveSecretStore(input SecretInput, kc keychain.KeychainAccess) {
if input.IsSecretRef() && input.Ref.Source == "keychain" {
_ = kc.Remove(keychain.LarkCliService, input.Ref.ID)
}
}

44
internal/core/types.go Normal file
View File

@@ -0,0 +1,44 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
// Any other string is treated as a custom base URL.
type LarkBrand string
const (
BrandFeishu LarkBrand = "feishu"
BrandLark LarkBrand = "lark"
)
// Endpoints holds resolved endpoint URLs for different Lark services.
type Endpoints struct {
Open string // e.g. "https://open.feishu.cn"
Accounts string // e.g. "https://accounts.feishu.cn"
MCP string // e.g. "https://mcp.feishu.cn"
}
// ResolveEndpoints resolves endpoint URLs based on brand.
func ResolveEndpoints(brand LarkBrand) Endpoints {
switch brand {
case BrandLark:
return Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
}
default:
return Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
}
}
}
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
func ResolveOpenBaseURL(brand LarkBrand) string {
return ResolveEndpoints(brand).Open
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import "testing"
func TestResolveEndpoints_Feishu(t *testing.T) {
ep := ResolveEndpoints(BrandFeishu)
if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn", ep.Open)
}
if ep.Accounts != "https://accounts.feishu.cn" {
t.Errorf("Accounts = %q, want feishu.cn", ep.Accounts)
}
if ep.MCP != "https://mcp.feishu.cn" {
t.Errorf("MCP = %q, want feishu.cn", ep.MCP)
}
}
func TestResolveEndpoints_Lark(t *testing.T) {
ep := ResolveEndpoints(BrandLark)
if ep.Open != "https://open.larksuite.com" {
t.Errorf("Open = %q, want larksuite.com", ep.Open)
}
if ep.Accounts != "https://accounts.larksuite.com" {
t.Errorf("Accounts = %q, want larksuite.com", ep.Accounts)
}
if ep.MCP != "https://mcp.larksuite.com" {
t.Errorf("MCP = %q, want larksuite.com", ep.MCP)
}
}
func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) {
ep := ResolveEndpoints("")
if ep.Open != "https://open.feishu.cn" {
t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open)
}
}
func TestResolveOpenBaseURL(t *testing.T) {
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu.cn" {
t.Errorf("ResolveOpenBaseURL(feishu) = %q", got)
}
if got := ResolveOpenBaseURL(BrandLark); got != "https://open.larksuite.com" {
t.Errorf("ResolveOpenBaseURL(lark) = %q", got)
}
}

View File

@@ -0,0 +1,137 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package httpmock
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
)
// Stub defines a preset HTTP response.
type Stub struct {
Method string // empty = match any method
URL string // substring match on URL
Status int // default 200
Body interface{} // auto JSON-serialized
RawBody []byte // raw bytes (takes precedence over Body when non-nil)
ContentType string // override Content-Type header (default: application/json)
Headers http.Header // optional full response headers (takes precedence over ContentType)
matched bool
// CapturedHeaders records the request headers of the matched request.
// Populated after RoundTrip matches this stub.
CapturedHeaders http.Header
CapturedBody []byte
}
// Registry records stubs and implements http.RoundTripper.
type Registry struct {
mu sync.Mutex
stubs []*Stub
}
// Register adds a stub to the registry.
func (r *Registry) Register(s *Stub) {
r.mu.Lock()
defer r.mu.Unlock()
if s.Status == 0 {
s.Status = 200
}
r.stubs = append(r.stubs, s)
}
// RoundTrip implements http.RoundTripper.
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
urlStr := req.URL.String()
r.mu.Lock()
var matched *Stub
for _, s := range r.stubs {
if s.matched {
continue
}
if s.Method != "" && s.Method != req.Method {
continue
}
if s.URL != "" && !strings.Contains(urlStr, s.URL) {
continue
}
s.matched = true
s.CapturedHeaders = req.Header.Clone()
if req.Body != nil {
s.CapturedBody, _ = io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(s.CapturedBody))
}
matched = s
break
}
r.mu.Unlock()
if matched != nil {
resp, err := stubResponse(matched)
if err != nil {
return nil, fmt.Errorf("httpmock: stub %s %s: %w", matched.Method, matched.URL, err)
}
return resp, nil
}
return nil, fmt.Errorf("httpmock: no stub for %s %s", req.Method, req.URL)
}
// Verify asserts all stubs were matched.
func (r *Registry) Verify(t testing.TB) {
t.Helper()
r.mu.Lock()
defer r.mu.Unlock()
for _, s := range r.stubs {
if !s.matched {
t.Errorf("httpmock: unmatched stub: %s %s", s.Method, s.URL)
}
}
}
func stubResponse(s *Stub) (*http.Response, error) {
ct := s.ContentType
if ct == "" {
ct = "application/json"
}
var body io.ReadCloser
if s.RawBody != nil {
body = io.NopCloser(bytes.NewReader(s.RawBody))
} else {
switch v := s.Body.(type) {
case string:
body = io.NopCloser(strings.NewReader(v))
case []byte:
body = io.NopCloser(bytes.NewReader(v))
default:
b, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal body: %w", err)
}
body = io.NopCloser(bytes.NewReader(b))
}
}
return &http.Response{
StatusCode: s.Status,
Header: func() http.Header {
if s.Headers != nil {
return s.Headers.Clone()
}
return http.Header{"Content-Type": []string{ct}}
}(),
Body: body,
}, nil
}
// NewClient returns an http.Client that uses the Registry as its transport.
func NewClient(reg *Registry) *http.Client {
return &http.Client{Transport: reg}
}

View File

@@ -0,0 +1,114 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package httpmock
import (
"io"
"net/http"
"testing"
)
func TestRegistry_RoundTrip(t *testing.T) {
reg := &Registry{}
reg.Register(&Stub{
Method: "GET",
URL: "/open-apis/test",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
client := NewClient(reg)
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("want status 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if got := string(body); got == "" {
t.Error("expected non-empty body")
}
}
func TestRegistry_NoStub(t *testing.T) {
reg := &Registry{}
client := NewClient(reg)
req, _ := http.NewRequest("GET", "https://example.com/missing", nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("expected error for unmatched request")
}
}
func TestRegistry_MethodMismatch(t *testing.T) {
reg := &Registry{}
reg.Register(&Stub{
Method: "POST",
URL: "/open-apis/test",
Body: "ok",
})
client := NewClient(reg)
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/test", nil)
_, err := client.Do(req)
if err == nil {
t.Fatal("expected error for method mismatch")
}
}
func TestRegistry_Verify_AllMatched(t *testing.T) {
reg := &Registry{}
reg.Register(&Stub{
Method: "GET",
URL: "/used",
Body: "ok",
})
client := NewClient(reg)
req, _ := http.NewRequest("GET", "https://example.com/used", nil)
resp, _ := client.Do(req)
resp.Body.Close()
reg.Verify(t)
}
func TestRegistry_Verify_Unmatched(t *testing.T) {
reg := &Registry{}
reg.Register(&Stub{
Method: "DELETE",
URL: "/unused",
Body: "ok",
})
fakeT := &testing.T{}
reg.Verify(fakeT)
if !fakeT.Failed() {
t.Error("Verify should report failure for unmatched stub")
}
}
func TestRegistry_CustomStatus(t *testing.T) {
reg := &Registry{}
reg.Register(&Stub{
URL: "/error",
Status: 500,
Body: `{"error":"internal"}`,
})
client := NewClient(reg)
req, _ := http.NewRequest("GET", "https://example.com/error", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 500 {
t.Errorf("want status 500, got %d", resp.StatusCode)
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package keychain
import "fmt"
// defaultKeychain implements KeychainAccess using the real platform keychain.
type defaultKeychain struct{}
func (d *defaultKeychain) Get(service, account string) (string, error) {
val := Get(service, account)
if val == "" {
return "", fmt.Errorf("keychain entry not found: %s/%s", service, account)
}
return val, nil
}
func (d *defaultKeychain) Set(service, account, value string) error {
return Set(service, account, value)
}
func (d *defaultKeychain) Remove(service, account string) error {
return Remove(service, account)
}
// Default returns a KeychainAccess backed by the real platform keychain.
func Default() KeychainAccess {
return &defaultKeychain{}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package keychain provides cross-platform secure storage for secrets.
// macOS uses the system Keychain; Linux uses AES-256-GCM encrypted files; Windows uses DPAPI + registry.
package keychain
const (
// LarkCliService is the unified keychain service name for all secrets
// (both AppSecret and UAT). Entries are distinguished by account key format:
// - AppSecret: "appsecret:<appId>"
// - UAT: "<appId>:<userOpenId>"
LarkCliService = "lark-cli"
)
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
// Used by AppSecret operations (ForStorage, ResolveSecretInput, RemoveSecretStore).
// UAT operations in token_store.go use the package-level Get/Set/Remove directly.
type KeychainAccess interface {
Get(service, account string) (string, error)
Set(service, account, value string) error
Remove(service, account string) error
}
// Get retrieves a value from the keychain.
// Returns empty string if the entry does not exist.
func Get(service, account string) string {
return platformGet(service, account)
}
// Set stores a value in the keychain, overwriting any existing entry.
func Set(service, account, data string) error {
return platformSet(service, account, data)
}
// Remove deletes an entry from the keychain. No error if not found.
func Remove(service, account string) error {
return platformRemove(service, account)
}

View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build darwin
package keychain
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"regexp"
"time"
"github.com/google/uuid"
"github.com/zalando/go-keyring"
)
const keychainTimeout = 5 * time.Second
const masterKeyBytes = 32
const ivBytes = 12
const tagBytes = 16
// StorageDir returns the storage directory for a given service name on macOS.
func StorageDir(service string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".lark-cli", "keychain", service)
}
return filepath.Join(home, "Library", "Application Support", service)
}
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
func getMasterKey(service string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
type result struct {
key []byte
err error
}
resCh := make(chan result, 1)
go func() {
defer func() { recover() }()
encodedKey, err := keyring.Get(service, "master.key")
if err == nil {
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr == nil && len(key) == masterKeyBytes {
resCh <- result{key: key, err: nil}
return
}
}
// Generate new master key if not found or invalid
key := make([]byte, masterKeyBytes)
if _, randErr := rand.Read(key); randErr != nil {
resCh <- result{key: nil, err: randErr}
return
}
encodedKey = base64.StdEncoding.EncodeToString(key)
setErr := keyring.Set(service, "master.key", encodedKey)
resCh <- result{key: key, err: setErr}
}()
select {
case res := <-resCh:
return res.key, res.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
iv := make([]byte, ivBytes)
if _, err := rand.Read(iv); err != nil {
return nil, err
}
ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil)
result := make([]byte, 0, ivBytes+len(ciphertext))
result = append(result, iv...)
result = append(result, ciphertext...)
return result, nil
}
func decryptData(data []byte, key []byte) (string, error) {
if len(data) < ivBytes+tagBytes {
return "", os.ErrInvalid
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := data[:ivBytes]
ciphertext := data[ivBytes:]
plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func platformGet(service, account string) string {
key, err := getMasterKey(service)
if err != nil {
return ""
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
return ""
}
plaintext, err := decryptData(data, key)
if err != nil {
return ""
}
return plaintext
}
func platformSet(service, account, data string) error {
key, err := getMasterKey(service)
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
encrypted, err := encryptData(data, key)
if err != nil {
return err
}
targetPath := filepath.Join(dir, safeFileName(account))
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
defer os.Remove(tmpPath)
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return err
}
// Atomic rename to prevent file corruption during multi-process writes
if err := os.Rename(tmpPath, targetPath); err != nil {
return err
}
return nil
}
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

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