diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0ca1e097..933004ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,36 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
-## [1.1.0] - 2026-03-28
+## [1.0.2] - 2026-03-29
+
+Deep workspace tooling upgrade: pipeline-based input correction, output filtering, enhanced stdin handling, and multi-endpoint routing.
+
+### Added
+
+- Pipeline engine (`internal/pipeline`) for pre-parse and post-parse input correction
+ - `AliasHandler`: normalises model-generated flag casing (e.g. `--userId` → `--user-id`)
+ - `StickyHandler`: splits glued flag values (e.g. `--limit100` → `--limit 100`)
+ - `ParamNameHandler`: fixes near-miss flag typos (e.g. `--limt` → `--limit`)
+ - `ParamValueHandler`: normalises structured parameter values after parsing
+- Output filtering via `--fields` and `--jq` global flags (`internal/output/filter.go`)
+ - `--fields`: comma-separated field selection for top-level keys (case-insensitive)
+ - `--jq`: jq expression filtering powered by `gojq` library
+- `StdinGuard` for safe single-read stdin across multiple flags in one invocation
+- `ResolveInputSource` unified resolver supporting `@file`, `@-` (explicit stdin), and implicit pipe fallback
+- `@file` / `@-` syntax support for all string-typed override flags in tool commands
+- Chat helper support for `@file` input to read message content from files
+- Tool-level endpoint routing (`dynamicToolEndpoints`) for multi-endpoint products
+- Comprehensive test suites for pipeline handlers, stdin guard, canonical commands, and chat input
+
+### Changed
+
+- `directRuntimeEndpoint` now accepts tool name for finer-grained endpoint resolution
+- `collectOverrides` resolves `@file` / `@-` for all string-typed flags
+- `NewRootCommand` refactored to `NewRootCommandWithEngine` with optional pipeline engine
+- `schema` command no longer hidden (visible in help output)
+- Default output format changed from `table` to `json`
+
+## [1.0.1] - 2026-03-28
Backward-compatible feature and security update after the initial 1.0.0 release.
diff --git a/README.md b/README.md
index bf3b86a5..fa29bbbc 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
DingTalk Workspace CLI (dws)
-dws — 钉钉工作台命令行工具,为人类和 AI Agent 而生。
+dws — DingTalk Workspace on the command line, built for humans and AI agents.
@@ -15,121 +15,124 @@
- 中文版 · English · 参考手册 · 更新日志
+ 中文版 · English · Reference · Changelog
> [!IMPORTANT]
-> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
+> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
>
>
-目录
+Table of Contents
-- [为什么选择 dws?](#why-dws)
-- [安装](#安装)
-- [开始使用](#开始使用)
-- [快速开始](#快速开始)
-- [核心服务](#核心服务)
-- [安全设计](#安全设计)
+- [Why dws?](#why-dws)
+- [Installation](#installation)
+- [Getting Started](#getting-started)
+- [Quick Start](#quick-start)
+- [Using with Agents](#using-with-agents)
+- [New Features](#new-features)
+- [Key Services](#key-services)
+- [Security by Design](#security-by-design)
- [AI Agent Skills](#ai-agent-skills)
-- [参考与文档](#参考与文档)
-- [贡献指南](#贡献指南)
+- [Reference & Docs](#reference--docs)
+- [Contributing](#contributing)
---
-为什么选择 dws?
+Why dws?
-- **为人类而设计** — `--help` 查看用法,`--dry-run` 预览请求,`-f table/json/raw` 切换格式。
-- **为 AI Agent 而设计** — 结构化 JSON 响应 + 内置 Agent Skills,开箱即用。
-- **为企业管理员而设计** — 零信任架构:OAuth 设备流认证 + 域名白名单 + 权限最小化。**没有一个字节能绕过安全鉴权和审计。**
+- **For humans** — `--help` for usage, `--dry-run` to preview requests, `-f table/json/raw` for output formats.
+- **For AI agents** — structured JSON responses + built-in Agent Skills, ready out of the box.
+- **For enterprise admins** — zero-trust architecture: OAuth device-flow auth + domain allowlisting + least-privilege scoping. **Not a single byte can bypass authentication and audit.**
-## 安装
+## Installation
-**macOS / Linux:**
+**macOS / Linux:**
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
-**Windows(PowerShell):**
+**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
-其他安装方式
+Other install methods
-**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
+**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
-**从源码构建**:
+**Build from source**:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
cd dingtalk-workspace-cli
-make build
+go build -o dws ./cmd # build to current directory
+cp dws ~/.local/bin/ # install to PATH
```
-> 二进制文件默认安装到 `~/.local/bin`。如找不到 `dws`,请将其添加到 PATH:`export PATH="$HOME/.local/bin:$PATH"`
+> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
-## 开始使用
+## Getting Started
-### 步骤 1:创建钉钉应用
+### Step 1: Create a DingTalk Application
-进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
+Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
-查看截图
+View screenshot
-
+
-### 步骤 2:配置重定向 URL
+### Step 2: Configure Redirect URL
-进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
+Go to app settings → **Security Settings**. Add the following redirect URLs and save:
```
http://127.0.0.1
https://login.dingtalk.com
```
-> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
+> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
-查看截图
+View screenshot
-
+
-### 步骤 3:发布应用
+### Step 3: Publish the Application
-点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
+Click "App Release - Version Management & Release" to publish and go live.
-查看截图
+View screenshot
-
+
-### 步骤 4:申请白名单
+### Step 4: Request Whitelist Access
-加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
+Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
-### 步骤 5:登录认证
+### Step 5: Authenticate
```bash
dws auth login --client-id --client-secret
```
-或通过环境变量:
+Or via environment variables:
```bash
export DWS_CLIENT_ID=
@@ -137,114 +140,226 @@ export DWS_CLIENT_SECRET=
dws auth login
```
-> CLI 参数优先于环境变量。凭证用于钉钉 OAuth 设备流认证。
+> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow.
-## 快速开始
+## Quick Start
```bash
-dws contact user search --keyword "悟空" # 搜索联系人
-dws calendar event list # 查看日历日程
-dws todo task create --title "季度汇报" --executors "" # 创建待办
-dws todo task list --dry-run # 预览操作但不执行
+dws contact user search --keyword "Alice" # search contacts
+dws calendar event list # list calendar events
+dws todo task create --title "Quarterly report" --executors "" # create a todo
+dws todo task list --dry-run # preview without executing
```
-## 核心服务
+## Using with Agents
-| 服务 | 命令 | 描述 |
-|---------|---------|-------------|
-| 通讯录 | `contact` | 用户 / 部门 |
-| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
-| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
-| 待办 | `todo` | 任务管理 |
-| 审批 | `approval` | 流程 / 表单 / 实例 |
-| 考勤 | `attendance` | 打卡 / 排班 / 统计 |
-| DING | `ding` | DING 消息 / 发送 / 撤回 |
-| 日志 | `report` | 日志 / 模版 / 统计 |
-| 智能表格 | `aitable` | AI 表格操作 |
-| 工作台 | `workbench` | 应用查询 |
-| 开发者文档 | `devdoc` | 开放平台文档搜索 |
+dws is designed as an AI-native CLI. Here's how to integrate it with your agent:
-运行 `dws --help` 查看完整列表,或 `dws --help` 查看子命令。
-
-
-即将推出
-
-`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
-
-
-
-## 安全设计
-
-`dws` 从架构层面将安全作为一等公民,而非事后补丁。**凭证不落盘、Token 不出域、权限不越界、操作不脱审** — 每一次 API 调用都必须经过钉钉开放平台的鉴权和审计链路,无例外。
-
-
-开发者安全机制
-
-| 机制 | 说明 |
-|------|------|
-| **Token 加密存储** | **PBKDF2(600,000 次迭代 + SHA-256)+ AES-256-GCM** 加密,密钥绑定设备物理 MAC 地址;macOS 集成系统 Keychain、Windows 集成 DPAPI 提供额外保护,跨设备无法解密 |
-| **输入安全防护** | 路径遍历防护(符号链接解析 + 工作目录约束)、CRLF 注入拦截、Unicode 视觉欺骗字符过滤,防止 AI Agent 被恶意指令诱导 |
-| **域名白名单** | `DWS_TRUSTED_DOMAINS` 默认仅信任 `*.dingtalk.com`,Bearer Token 不会发送到非白名单域 |
-| **并发安全** | 双层锁机制(进程内 + 跨进程文件锁)保障 Token 刷新原子性,适配高并发 MCP Server 场景 |
-| **数据完整性** | 所有配置写入采用原子操作(temp + fsync + rename),确保进程中断时数据不损坏 |
-| **HTTPS 强制** | 除 loopback 开发调试外,所有请求强制 TLS |
-| **Dry-run 预览** | `--dry-run` 展示调用参数但不执行,防止误操作生产数据 |
-| **凭证零落盘** | Client ID / Secret 仅在内存中使用,不写入配置文件或日志 |
-
-
-
-
-企业管理员安全机制
-
-| 机制 | 说明 |
-|------|------|
-| **OAuth 设备流认证** | 用户必须通过管理员授权的钉钉应用认证,未授权应用无法获取 Token |
-| **权限最小化** | CLI 仅能调用管理员授予该应用的 API 权限范围,无法越权 |
-| **白名单准入** | 共创阶段需管理员主动确认开通,后续支持自助审批 |
-| **操作全链路审计** | 每一次数据读写都经过钉钉开放平台 API,企业管理员可在管理后台实时追溯完整调用日志,任何异常操作无处隐藏 |
-
-
-
-
-ISV / 企业服务商安全机制
-
-| 机制 | 说明 |
-|------|------|
-| **租户数据隔离** | 以已授权应用身份调用 API,不同租户数据严格隔离 |
-| **Skill 沙箱** | Agent Skills 是 Markdown 文档(`SKILL.md`),仅提供 prompt 描述,不执行任意代码 |
-| **集成链路零盲区** | ISV Skill 与 dws Skill 联调时,每一次 API 调用都强制经过钉钉开放平台鉴权,完整调用链路可追溯,不存在绕过审计的旁路 |
-
-
-
-> 发现安全漏洞?请通过 [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new) 报告,详见 [SECURITY.md](./SECURITY.md)。
-
-## AI Agent Skills
-
-仓库为每个钉钉产品提供 Agent Skill(`SKILL.md`),安装脚本会自动部署到 `~/.agents/skills/dws`。
+### 1. Setup
```bash
-# 仅安装 skills 到当前项目
+# Install dws
+curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
+
+# Configure auth via environment variables (recommended for agents, no interactive login)
+export DWS_CLIENT_ID=
+export DWS_CLIENT_SECRET=
+dws auth login
+```
+
+### 2. Agent Invocation Patterns
+
+```bash
+# Use --yes to skip confirmation prompts (required for agents)
+dws todo task create --title "Review PR" --yes
+
+# Use --dry-run to preview operations (safe execution)
+dws contact user search --keyword "test" --dry-run
+
+# Use --jq to extract precisely (save tokens)
+dws contact user search --keyword "test" --jq '.response.content'
+
+# Use --fields to return only needed fields
+dws calendar event list --fields response
+```
+
+### 3. Schema Discovery (Agent Self-Exploration)
+
+Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
+
+```bash
+# Step 1: Discover all available products
+dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
+
+# Step 2: Inspect target tool's parameter schema
+dws schema aitable.query_records --jq '.tool.input_schema'
+
+# Step 3: Construct the correct call
+dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
+```
+
+### 4. Error Tolerance
+
+The smart correction engine is highly tolerant of common agent-generated mistakes:
+
+| Agent Output | dws Auto-Corrects To |
+|-----------|--------------|
+| `--userId` | `--user-id` |
+| `--limit100` | `--limit 100` |
+| `--tabel-id` | `--table-id` |
+| `--USER-ID` | `--user-id` |
+| `--user_name` | `--user-name` |
+
+### 5. Agent Skill Integration
+
+After installing Agent Skills, tools like Claude Code / Cursor can use DingTalk capabilities directly:
+
+```bash
+# Install skills into current project
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
-> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
+> See the [AI Agent Skills](#ai-agent-skills) section for details.
-### ISV Skill 联调
+## New Features
-编写您自己的 Agent Skill,与 dws 内置 skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
+### Smart Input Correction
-**示例**:CRM Skill 调用日历 Skill 为客户创建会议,再通过待办 Skill 分配跟进任务 — AI Agent 在一次对话中完成跨系统协作。
+dws includes a built-in pipeline engine that auto-corrects common AI model parameter mistakes:
-## 参考与文档
+```bash
+# Naming convention auto-conversion (camelCase / snake_case / UPPER -> kebab-case)
+dws aitable record query --baseId x --tableId y # auto-corrected to --base-id --table-id
-- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
-- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
-- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
+# Sticky argument splitting
+dws contact user search --keyword "test" --timeout30 # auto-split to --timeout 30
-## 贡献指南
+# Fuzzy flag name matching
+dws aitable record query --base-id x --tabel-id y # --tabel-id -> --table-id
-参见 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解构建、测试和开发工作流。
+# Value normalization (boolean / number / date / enum)
+# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
+```
-## 许可证
+### JSON Field Selection & jq Filtering
+
+Fine-grained output control to reduce token consumption:
+
+```bash
+# Return only specific fields
+dws aitable record query --base-id x --table-id y --fields invocation,response
+
+# Built-in jq expressions
+dws aitable record query --base-id x --table-id y --jq '.invocation.params'
+dws schema --jq '.products[] | {id, tools: (.tools | length)}'
+```
+
+### Schema Introspection
+
+Agents can query parameter schemas before making calls:
+
+```bash
+dws schema # list all products and tools
+dws schema aitable.query_records # view parameter schema
+dws schema aitable.query_records --jq '.tool.input_schema.required' # view required fields
+dws schema --jq '.products[].id' # extract all product IDs
+```
+
+## Key Services
+
+| Service | Command | Description |
+|---------|---------|-------------|
+| Contact | `contact` | Users / departments |
+| Chat | `chat` | Group management / members / bot messaging / webhook |
+| Calendar | `calendar` | Events / meeting rooms / free-busy |
+| Todo | `todo` | Task management |
+| Approval | `approval` | Processes / forms / instances |
+| Attendance | `attendance` | Clock-in / shifts / statistics |
+| Ding | `ding` | DING messages / send / recall |
+| Report | `report` | Reports / templates / statistics |
+| AITable | `aitable` | AI table operations |
+| Workbench | `workbench` | App query |
+| DevDoc | `devdoc` | Open platform docs search |
+
+Run `dws --help` for the full list, or `dws --help` for subcommands.
+
+
+Coming soon
+
+`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
+
+
+
+Security by Design
+
+`dws` treats security as a first-class architectural concern, not an afterthought. **Credentials never touch disk, tokens never leave trusted domains, permissions never exceed grants, operations never escape audit** — every API call must pass through DingTalk Open Platform's authentication and audit chain, no exceptions.
+
+
+For Developers
+
+| Mechanism | Details |
+|-----------|----------|
+| **Encrypted token storage** | **PBKDF2 + AES-256-GCM** encryption, keyed by device physical MAC address; cross-platform Keychain/DPAPI integration provides additional protection — tokens cannot be decrypted on another machine |
+| **Input security** | Path traversal protection (symlink resolution + working directory containment), CRLF injection blocking, Unicode visual spoofing filtering — prevents AI Agents from being tricked by malicious instructions |
+| **Domain allowlist** | `DWS_TRUSTED_DOMAINS` defaults to `*.dingtalk.com`; bearer tokens are never sent to non-allowlisted domains |
+| **HTTPS enforced** | All requests require TLS; HTTP only permitted for loopback during development |
+| **Dry-run preview** | `--dry-run` shows call parameters without executing, preventing accidental mutations |
+| **Zero credential persistence** | Client ID / Secret used in memory only — never written to config files or logs |
+
+
+
+
+For Enterprise Admins
+
+| Mechanism | Details |
+|-----------|---------|
+| **OAuth device-flow auth** | Users must authenticate through an admin-authorized DingTalk application |
+| **Least-privilege scoping** | CLI can only invoke APIs granted to the application — no privilege escalation |
+| **Allowlist gating** | Admin confirmation required during co-creation phase; self-service approval planned |
+| **Full-chain audit** | Every data read/write passes through the DingTalk Open Platform API — enterprise admins can trace complete call logs in real time; no anomalous operation can hide |
+
+
+
+
+For ISVs
+
+| Mechanism | Details |
+|-----------|---------|
+| **Tenant data isolation** | Operates under authorized app identity; cross-tenant access is impossible |
+| **Skill sandbox** | Agent Skills are Markdown documents (`SKILL.md`) — prompt descriptions only, no arbitrary code execution |
+| **Zero blind spots** | Every API call during ISV–dws skill orchestration is forced through DingTalk Open Platform authentication — full call chain is traceable with no bypass path |
+
+
+
+> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md).
+
+## AI Agent Skills
+
+The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically.
+
+```bash
+# Install skills into the current project only
+curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
+```
+
+> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
+
+### ISV Skill Integration
+
+Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
+
+**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation.
+
+## Reference & Docs
+
+- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
+- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
+- [Changelog](./CHANGELOG.md) — release history and migration notes
+
+## Contributing
+
+See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, testing, and development workflow.
+
+## License
Apache-2.0
diff --git a/README_en.md b/README_en.md
deleted file mode 100644
index 2c32bad0..00000000
--- a/README_en.md
+++ /dev/null
@@ -1,248 +0,0 @@
-DingTalk Workspace CLI (dws)
-
-dws — DingTalk Workspace on the command line, built for humans and AI agents.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 中文版 · English · Reference · Changelog
-
-
-> [!IMPORTANT]
-> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Please join the DingTalk DWS co-creation group to complete whitelist configuration. See [Getting Started](#getting-started) below.
->
->
-
-
-Table of Contents
-
-- [Why dws?](#why-dws)
-- [Installation](#installation)
-- [Getting Started](#getting-started)
-- [Quick Start](#quick-start)
-- [Key Services](#key-services)
-- [Security by Design](#security-by-design)
-- [AI Agent Skills](#ai-agent-skills)
-- [Reference & Docs](#reference--docs)
-- [Contributing](#contributing)
-
-
-
----
-
-Why dws?
-
-- **For humans** — `--help` for usage, `--dry-run` to preview requests, `-f table/json/raw` for output formats.
-- **For AI agents** — structured JSON responses + built-in Agent Skills, ready out of the box.
-- **For enterprise admins** — zero-trust architecture: OAuth device-flow auth + domain allowlisting + least-privilege scoping. **Not a single byte can bypass authentication and audit.**
-
-## Installation
-
-**macOS / Linux:**
-
-```bash
-curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
-```
-
-**Windows (PowerShell):**
-
-```powershell
-irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
-```
-
-
-Other install methods
-
-**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
-
-**Build from source**:
-
-```bash
-git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
-cd dingtalk-workspace-cli
-make build
-```
-
-> The binary installs to `~/.local/bin` by default. If `dws` is not found, add it to PATH: `export PATH="$HOME/.local/bin:$PATH"`
-
-
-
-## Getting Started
-
-### Step 1: Create a DingTalk Application
-
-Go to the [Open Platform Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App**.
-
-
-View screenshot
-
-
-
-
-
-### Step 2: Configure Redirect URL
-
-Go to app settings → **Security Settings**. Add the following redirect URLs and save:
-
-```
-http://127.0.0.1
-https://login.dingtalk.com
-```
-
-> `http://127.0.0.1` is for local browser login; `https://login.dingtalk.com` is for `--device` device-flow login (Docker containers, remote servers, and other headless environments). We recommend configuring both.
-
-
-View screenshot
-
-
-
-
-
-### Step 3: Publish the Application
-
-Click "App Release - Version Management & Release" to publish and go live.
-
-
-View screenshot
-
-
-
-
-
-### Step 4: Request Whitelist Access
-
-Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
-
-### Step 5: Authenticate
-
-```bash
-dws auth login --client-id --client-secret
-```
-
-Or via environment variables:
-
-```bash
-export DWS_CLIENT_ID=
-export DWS_CLIENT_SECRET=
-dws auth login
-```
-
-> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow.
-
-## Quick Start
-
-```bash
-dws contact user search --keyword "Alice" # search contacts
-dws calendar event list # list calendar events
-dws todo task create --title "Quarterly report" --executors "" # create a todo
-dws todo task list --dry-run # preview without executing
-```
-
-## Key Services
-
-| Service | Command | Description |
-|---------|---------|-------------|
-| Contact | `contact` | Users / departments |
-| Chat | `chat` | Group management / members / bot messaging / webhook |
-| Calendar | `calendar` | Events / meeting rooms / free-busy |
-| Todo | `todo` | Task management |
-| Approval | `approval` | Processes / forms / instances |
-| Attendance | `attendance` | Clock-in / shifts / statistics |
-| Ding | `ding` | DING messages / send / recall |
-| Report | `report` | Reports / templates / statistics |
-| AITable | `aitable` | AI table operations |
-| Workbench | `workbench` | App query |
-| DevDoc | `devdoc` | Open platform docs search |
-
-Run `dws --help` for the full list, or `dws --help` for subcommands.
-
-
-Coming soon
-
-`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
-
-
-
-Security by Design
-
-`dws` treats security as a first-class architectural concern, not an afterthought. **Credentials never touch disk, tokens never leave trusted domains, permissions never exceed grants, operations never escape audit** — every API call must pass through DingTalk Open Platform's authentication and audit chain, no exceptions.
-
-
-For Developers
-
-| Mechanism | Details |
-|-----------|----------|
-| **Encrypted token storage** | **PBKDF2 + AES-256-GCM** encryption, keyed by device physical MAC address; cross-platform Keychain/DPAPI integration provides additional protection — tokens cannot be decrypted on another machine |
-| **Input security** | Path traversal protection (symlink resolution + working directory containment), CRLF injection blocking, Unicode visual spoofing filtering — prevents AI Agents from being tricked by malicious instructions |
-| **Domain allowlist** | `DWS_TRUSTED_DOMAINS` defaults to `*.dingtalk.com`; bearer tokens are never sent to non-allowlisted domains |
-| **HTTPS enforced** | All requests require TLS; HTTP only permitted for loopback during development |
-| **Dry-run preview** | `--dry-run` shows call parameters without executing, preventing accidental mutations |
-| **Zero credential persistence** | Client ID / Secret used in memory only — never written to config files or logs |
-
-
-
-
-For Enterprise Admins
-
-| Mechanism | Details |
-|-----------|---------|
-| **OAuth device-flow auth** | Users must authenticate through an admin-authorized DingTalk application |
-| **Least-privilege scoping** | CLI can only invoke APIs granted to the application — no privilege escalation |
-| **Allowlist gating** | Admin confirmation required during co-creation phase; self-service approval planned |
-| **Full-chain audit** | Every data read/write passes through the DingTalk Open Platform API — enterprise admins can trace complete call logs in real time; no anomalous operation can hide |
-
-
-
-
-For ISVs
-
-| Mechanism | Details |
-|-----------|---------|
-| **Tenant data isolation** | Operates under authorized app identity; cross-tenant access is impossible |
-| **Skill sandbox** | Agent Skills are Markdown documents (`SKILL.md`) — prompt descriptions only, no arbitrary code execution |
-| **Zero blind spots** | Every API call during ISV–dws skill orchestration is forced through DingTalk Open Platform authentication — full call chain is traceable with no bypass path |
-
-
-
-> Found a vulnerability? Report via [GitHub Security Advisories](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/security/advisories/new). See [SECURITY.md](./SECURITY.md).
-
-## AI Agent Skills
-
-The repo ships Agent Skills (`SKILL.md` files) for every DingTalk product. The install script deploys them to `~/.agents/skills/dws` automatically.
-
-```bash
-# Install skills into the current project only
-curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
-```
-
-> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
-
-### ISV Skill Integration
-
-Author your own Agent Skills and orchestrate them with dws skills for cross-product workflows: **ISV Skill → dws Skill → DingTalk Open Platform API (enforced auth + full audit)**.
-
-**Example**: A CRM Skill invokes the Calendar Skill to create a client meeting, then triggers the Todo Skill to assign follow-ups — the AI agent completes cross-system collaboration in a single conversation.
-
-## Reference & Docs
-
-- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
-- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
-- [Changelog](./CHANGELOG.md) — release history and migration notes
-
-## Contributing
-
-See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, testing, and development workflow.
-
-## License
-
-Apache-2.0
diff --git a/go.mod b/go.mod
index 891b6f89..2a734851 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,8 @@ require (
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
+ github.com/itchyny/gojq v0.12.18 // indirect
+ github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
)
diff --git a/go.sum b/go.sum
index cafaf057..4e538ff1 100644
--- a/go.sum
+++ b/go.sum
@@ -11,6 +11,10 @@ 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
+github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
+github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
+github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go
index c49d52f4..4c34c3e4 100644
--- a/internal/app/direct_runtime.go
+++ b/internal/app/direct_runtime.go
@@ -24,10 +24,11 @@ import (
)
var (
- dynamicMu sync.RWMutex
- dynamicEndpoints map[string]string
- dynamicProducts map[string]bool
- dynamicAliases map[string]string
+ dynamicMu sync.RWMutex
+ dynamicEndpoints map[string]string
+ dynamicProducts map[string]bool
+ dynamicAliases map[string]string
+ dynamicToolEndpoints map[string]string // tool name → endpoint
)
var legacyDirectRuntimeAliases = map[string]string{
@@ -46,6 +47,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
endpoints := make(map[string]string)
products := make(map[string]bool)
aliases := make(map[string]string)
+ toolEndpoints := make(map[string]string)
for _, server := range servers {
if server.CLI.Skip {
continue
@@ -70,10 +72,26 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
aliases[alias] = id
}
}
+ // Build tool → endpoint mapping from CLI tools and overrides.
+ if endpoint != "" {
+ for _, tool := range server.CLI.Tools {
+ toolName := strings.TrimSpace(tool.Name)
+ if toolName != "" {
+ toolEndpoints[toolName] = endpoint
+ }
+ }
+ for toolName := range server.CLI.ToolOverrides {
+ toolName = strings.TrimSpace(toolName)
+ if toolName != "" {
+ toolEndpoints[toolName] = endpoint
+ }
+ }
+ }
}
dynamicEndpoints = endpoints
dynamicProducts = products
dynamicAliases = aliases
+ dynamicToolEndpoints = toolEndpoints
}
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
@@ -88,11 +106,9 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
}
}
-func directRuntimeEndpoint(productID string) (string, bool) {
+func directRuntimeEndpoint(productID, toolName string) (string, bool) {
+ // Priority 0: env-var override always wins (DINGTALK__MCP_URL).
normalized := normalizeDirectRuntimeProductID(productID)
- dynamicMu.RLock()
- de := dynamicEndpoints
- dynamicMu.RUnlock()
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
@@ -100,6 +116,25 @@ func directRuntimeEndpoint(productID string) (string, bool) {
if override, ok := productEndpointOverride(candidate); ok {
return override, true
}
+ }
+
+ dynamicMu.RLock()
+ de := dynamicEndpoints
+ te := dynamicToolEndpoints
+ dynamicMu.RUnlock()
+
+ // Priority 1: tool-level endpoint (resolves multi-endpoint products).
+ if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
+ if endpoint, ok := te[tool]; ok {
+ return endpoint, true
+ }
+ }
+
+ // Priority 2: product-level endpoint.
+ for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
+ if candidate == "" {
+ continue
+ }
if de != nil {
if endpoint, ok := de[candidate]; ok {
return endpoint, true
diff --git a/internal/app/flags.go b/internal/app/flags.go
index 33ea9112..bcdc3f05 100644
--- a/internal/app/flags.go
+++ b/internal/app/flags.go
@@ -23,7 +23,9 @@ type GlobalFlags struct {
ClientSecret string
Debug bool
DryRun bool
+ Fields string
Format string
+ JQ string
Mock bool
Output string
Timeout int
@@ -37,7 +39,9 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().StringVar(&flags.ClientSecret, "client-secret", "", "Override OAuth client secret (DingTalk AppSecret)")
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
+ cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw")
+ cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
_ = cmd.PersistentFlags().MarkHidden("output")
diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go
index 30ed2e42..514c6c47 100644
--- a/internal/app/recovery_command.go
+++ b/internal/app/recovery_command.go
@@ -324,7 +324,7 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
- endpoint, err := r.resolveEndpoint(ctx, serverID)
+ endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
@@ -344,8 +344,8 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName
return &result, nil
}
-func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID string) (string, error) {
- if endpoint, ok := directRuntimeEndpoint(productID); ok {
+func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID, toolName string) (string, error) {
+ if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
return endpoint, nil
}
if r == nil || r.loader == nil {
diff --git a/internal/app/root.go b/internal/app/root.go
index cfc9364e..509f6ea3 100644
--- a/internal/app/root.go
+++ b/internal/app/root.go
@@ -37,6 +37,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
@@ -53,7 +55,14 @@ func Execute() int {
defer cancel()
recovery.ResetRuntimeState()
- root := NewRootCommand(ctx)
+ engine := newPipelineEngine()
+ root := NewRootCommandWithEngine(ctx, engine)
+
+ // Run PreParse handlers on raw argv before Cobra parses flags.
+ // This corrects model-generated errors like --userId → --user-id
+ // and --limit100 → --limit 100.
+ pipeline.RunPreParse(root, engine)
+
executed, err := root.ExecuteC()
if err != nil {
if executed == nil {
@@ -165,7 +174,15 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
- } else {
+ }
+ return NewRootCommandWithEngine(rootCtx, nil)
+}
+
+// NewRootCommandWithEngine constructs the root CLI command with an
+// optional pipeline engine for input correction. When engine is nil,
+// no pipeline processing is applied.
+func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
+ if rootCtx == nil {
rootCtx = context.Background()
}
flags := &GlobalFlags{}
@@ -209,10 +226,9 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
bindPersistentFlags(root, flags)
schemaCmd := newSchemaCommand(loader)
- schemaCmd.Hidden = true
genSkillsCmd := newGenerateSkillsCommand()
genSkillsCmd.Hidden = true
- mcpCmd := newMCPCommand(rootCtx, loader, runner)
+ mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
utilityCommands := []*cobra.Command{
@@ -512,8 +528,8 @@ func newGenerateSkillsCommand() *cobra.Command {
return cmd
}
-func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner) *cobra.Command {
- return cli.NewMCPCommand(ctx, loader, runner)
+func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
+ return cli.NewMCPCommand(ctx, loader, runner, engine)
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
@@ -804,3 +820,22 @@ func CloseFileLogger() {
fileLogger.Close()
}
}
+
+// newPipelineEngine creates and configures the pipeline engine with
+// the standard set of handlers for model input correction.
+func newPipelineEngine() *pipeline.Engine {
+ engine := pipeline.NewEngine()
+ engine.RegisterAll(
+ // PreParse handlers run in order: alias → sticky → paramname.
+ // Alias normalises case first (--userId → --user-id), then
+ // sticky splits glued values (--limit100 → --limit 100), then
+ // paramname fixes near-miss typos (--limt → --limit).
+ handlers.AliasHandler{},
+ handlers.StickyHandler{},
+ handlers.ParamNameHandler{},
+
+ // PostParse handlers normalise structured values.
+ handlers.ParamValueHandler{},
+ )
+ return engine
+}
diff --git a/internal/app/runner.go b/internal/app/runner.go
index 4f32ba95..6c294ecc 100644
--- a/internal/app/runner.go
+++ b/internal/app/runner.go
@@ -88,7 +88,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
}
if shouldUseDirectRuntime(invocation) {
- if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct); ok {
+ if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
return r.executeInvocation(ctx, endpoint, invocation)
}
}
diff --git a/internal/app/version.go b/internal/app/version.go
index 43317dce..2942c201 100644
--- a/internal/app/version.go
+++ b/internal/app/version.go
@@ -13,7 +13,7 @@
package app
-var version = "v1.1.0"
+var version = "v1.0.2"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go
index cf3c341a..8b39b221 100644
--- a/internal/cli/canonical.go
+++ b/internal/cli/canonical.go
@@ -30,6 +30,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/spf13/cobra"
)
@@ -56,7 +57,7 @@ type FlagSpec struct {
Description string
}
-func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner) *cobra.Command {
+func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
catalog, loadErr := loader.Load(ctx)
longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp."
@@ -94,17 +95,27 @@ func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Ru
if product.CLI != nil && product.CLI.Skip {
continue
}
- productCommand := newProductCommand(product, runner)
+ productCommand := newProductCommand(product, runner, engine)
cmd.AddCommand(productCommand)
- addGroupedProductAlias(cmd, product, runner)
+ addGroupedProductAlias(cmd, product, runner, engine)
}
return cmd
}
func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
- cmd := &cobra.Command{
- Use: "schema [canonical-product.tool]",
- Short: "Inspect canonical schema metadata",
+ return &cobra.Command{
+ Use: "schema [product.tool]",
+ Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)",
+ Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。
+
+不带参数时列出所有产品及其工具数量;带 product.tool 路径时
+输出该工具的完整输入 Schema(JSON Schema 格式)。
+
+示例:
+ dws schema # 列出所有产品
+ dws schema aitable.query_records # 查看 aitable query_records 的参数 Schema
+ dws schema --fields id,tools # 只显示 id 和 tools 字段
+ dws schema --jq '.products[].id' # 用 jq 提取所有产品 ID`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -113,24 +124,20 @@ func NewSchemaCommand(loader CatalogLoader) *cobra.Command {
return err
}
- jsonOut, err := cmd.Flags().GetBool("json")
- if err != nil {
- return apperrors.NewInternal("failed to read schema flags")
- }
-
payload, err := schemaPayload(catalog, args)
if err != nil {
return err
}
- if jsonOut {
- return output.WriteJSON(cmd.OutOrStdout(), payload)
- }
- return writeSchemaText(cmd.OutOrStdout(), payload)
+ return output.WriteFiltered(
+ cmd.OutOrStdout(),
+ output.ResolveFormat(cmd, output.FormatJSON),
+ payload,
+ output.ResolveFields(cmd),
+ output.ResolveJQ(cmd),
+ )
},
}
- cmd.Flags().Bool("json", false, "Emit schema metadata as JSON")
- return cmd
}
func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec {
@@ -169,7 +176,7 @@ func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []Fl
return specs
}
-func newProductCommand(product ir.CanonicalProduct, runner executor.Runner) *cobra.Command {
+func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
shortDescription := product.DisplayName
if strings.TrimSpace(product.Description) != "" {
shortDescription = product.Description
@@ -204,12 +211,12 @@ func newProductCommand(product ir.CanonicalProduct, runner executor.Runner) *cob
}
for _, tool := range product.Tools {
- cmd.AddCommand(newToolCommand(product, tool, runner))
+ cmd.AddCommand(newToolCommand(product, tool, runner, engine))
}
return cmd
}
-func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner) {
+func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) {
if root == nil || product.CLI == nil {
return
}
@@ -260,7 +267,7 @@ func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, ru
cliCopy.Group = ""
aliasProduct.CLI = &cliCopy
}
- productCommand := newProductCommand(aliasProduct, runner)
+ productCommand := newProductCommand(aliasProduct, runner, engine)
productCommand.Use = leaf
productCommand.Aliases = nil
if leaf != aliasProduct.ID {
@@ -269,7 +276,7 @@ func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, ru
parent.AddCommand(productCommand)
}
-func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner) *cobra.Command {
+func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
shortDescription := tool.Title
if strings.TrimSpace(tool.Description) != "" {
shortDescription = tool.Description
@@ -303,60 +310,125 @@ func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner
}
dryRun = value
}
+
+ // One guard per invocation ensures stdin is read at most once.
+ guard := NewStdinGuard()
+
jsonPayload, err := cmd.Flags().GetString("json")
if err != nil {
return apperrors.NewInternal("failed to read --json")
}
- // Support @file syntax for --json: read file contents as JSON payload.
- if content, isFile, fileErr := ReadFileArg(jsonPayload); fileErr != nil {
- return fileErr
- } else if isFile {
- jsonPayload = content
- }
-
- // Support stdin pipe: if no --json given, read from pipe.
- if jsonPayload == "" {
- if stdinData, stdinErr := ReadStdinIfPiped(); stdinErr != nil {
- return stdinErr
- } else if stdinData != "" {
- jsonPayload = stdinData
- }
+ // Resolve @file / @- for --json flag.
+ jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard)
+ if err != nil {
+ return err
}
paramsPayload, err := cmd.Flags().GetString("params")
if err != nil {
return apperrors.NewInternal("failed to read --params")
}
- overrides, err := collectOverrides(cmd, specs)
+
+ // Resolve @file / @- for all string-typed override flags BEFORE
+ // the implicit stdin fallback, so explicit @- in any flag takes
+ // priority over the implicit pipe read.
+ overrides, err := collectOverrides(cmd, specs, guard)
if err != nil {
return err
}
+
+ // Implicit stdin fallback (lowest priority): if no --json was
+ // given and no flag claimed stdin via @-, read from pipe.
+ if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() {
+ if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil {
+ return claimErr
+ }
+ stdinData, stdinErr := ReadStdin()
+ if stdinErr != nil {
+ return stdinErr
+ }
+ jsonPayload = stdinData
+ }
+
params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides)
if err != nil {
return err
}
+
+ // PostParse: normalise parameter values (date formats,
+ // booleans, enums) using the tool's input schema.
+ if engine != nil && engine.HasHandlers(pipeline.PostParse) {
+ pctx := &pipeline.Context{
+ Command: tool.CanonicalPath,
+ Params: params,
+ Schema: tool.InputSchema,
+ }
+ if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil {
+ return pipeErr
+ }
+ params = pctx.Params
+ }
+
if err := ValidateInputSchema(params, tool.InputSchema); err != nil {
return err
}
if !dryRun {
- if err := confirmSensitiveTool(cmd, tool); err != nil {
+ if err := confirmSensitiveTool(cmd, tool, guard); err != nil {
return err
}
}
+
+ // PreRequest: last chance to inspect/mutate payload before
+ // the JSON-RPC call is dispatched.
+ if engine != nil && engine.HasHandlers(pipeline.PreRequest) {
+ pctx := &pipeline.Context{
+ Command: tool.CanonicalPath,
+ Params: params,
+ Schema: tool.InputSchema,
+ Payload: params,
+ }
+ if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil {
+ return pipeErr
+ }
+ params = pctx.Params
+ }
+
invocation := executor.NewInvocation(product, tool, params)
invocation.DryRun = dryRun
result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
+
+ // PostResponse: transform or enrich the response before
+ // writing it to stdout.
+ if engine != nil && engine.HasHandlers(pipeline.PostResponse) {
+ pctx := &pipeline.Context{
+ Command: tool.CanonicalPath,
+ Params: params,
+ Schema: tool.InputSchema,
+ Response: result.Response,
+ }
+ if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil {
+ return pipeErr
+ }
+ result.Response = pctx.Response
+ }
+
if warning := lifecycleWarning(product); warning != "" {
if result.Response == nil {
result.Response = map[string]any{}
}
result.Response["warning"] = warning
}
- return output.WriteJSON(cmd.OutOrStdout(), result)
+ return output.WriteFiltered(
+ cmd.OutOrStdout(),
+ output.ResolveFormat(cmd, output.FormatJSON),
+ result,
+ output.ResolveFields(cmd),
+ output.ResolveJQ(cmd),
+ )
},
}
@@ -416,7 +488,7 @@ func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) {
}
}
-func collectOverrides(cmd *cobra.Command, specs []FlagSpec) (map[string]any, error) {
+func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (map[string]any, error) {
overrides := make(map[string]any)
for _, spec := range specs {
flagName := strings.TrimSpace(spec.FlagName)
@@ -434,7 +506,12 @@ func collectOverrides(cmd *cobra.Command, specs []FlagSpec) (map[string]any, err
if err != nil {
return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName))
}
- overrides[spec.PropertyName] = value
+ // Resolve @file / @- for all string-typed flags.
+ resolved, resolveErr := ResolveInputSource(value, flagName, guard)
+ if resolveErr != nil {
+ return nil, resolveErr
+ }
+ overrides[spec.PropertyName] = resolved
case flagJSON:
value, err := cmd.Flags().GetString(flagName)
if err != nil {
@@ -528,16 +605,7 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) {
}, nil
}
-func writeSchemaText(w io.Writer, payload map[string]any) error {
- if path, ok := payload["path"].(string); ok && path != "" {
- _, err := fmt.Fprintf(w, "schema for %s\n", path)
- return err
- }
- _, err := fmt.Fprintln(w, "canonical schema catalog")
- return err
-}
-
-func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor) error {
+func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error {
if !tool.Sensitive {
return nil
}
@@ -554,6 +622,13 @@ func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor) error {
return nil
}
+ // Stdin was consumed for data input — interactive confirmation is impossible.
+ if guard != nil && guard.Claimed() {
+ return apperrors.NewValidation(
+ "stdin used for data input; pass --yes to confirm sensitive operation",
+ )
+ }
+
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath)
confirmed, err := readYesNo(cmd.InOrStdin())
if err != nil {
diff --git a/internal/cli/canonical_test.go b/internal/cli/canonical_test.go
index f14c5720..5e010f2e 100644
--- a/internal/cli/canonical_test.go
+++ b/internal/cli/canonical_test.go
@@ -25,6 +25,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
+ "github.com/spf13/cobra"
)
func TestBuildFlagSpecsGeneratesOnlySupportedTopLevelFlags(t *testing.T) {
@@ -124,7 +125,7 @@ func TestNewMCPCommandReturnsLoaderErrorForInvocations(t *testing.T) {
t.Parallel()
wantErr := errors.New("fixture missing")
- cmd := NewMCPCommand(context.Background(), errorLoader{err: wantErr}, executor.EchoRunner{})
+ cmd := NewMCPCommand(context.Background(), errorLoader{err: wantErr}, executor.EchoRunner{}, nil)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
@@ -153,7 +154,7 @@ func TestNewMCPCommandSkipsProductsMarkedSkip(t *testing.T) {
},
},
},
- }, executor.EchoRunner{})
+ }, executor.EchoRunner{}, nil)
if got := cmd.Commands(); len(got) != 1 || got[0].Name() != "drive" {
t.Fatalf("mcp commands = %#v, want only drive", got)
@@ -178,7 +179,7 @@ func TestProductCommandUsesCLICommandAlias(t *testing.T) {
},
},
},
- }, runner)
+ }, runner, nil)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -212,7 +213,7 @@ func TestNewMCPCommandAddsGroupedRoutesFromCLIMetadata(t *testing.T) {
},
},
},
- }, runner)
+ }, runner, nil)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -257,7 +258,7 @@ func TestToolCommandUsesCLINameAndFlagHints(t *testing.T) {
},
},
},
- }, runner)
+ }, runner, nil)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
@@ -300,7 +301,7 @@ func TestToolCommandValidatesInputSchemaBeforeRun(t *testing.T) {
},
},
},
- }, runner)
+ }, runner, nil)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -340,7 +341,7 @@ func TestToolCommandSupportsDryRunWithoutSensitiveConfirmation(t *testing.T) {
},
},
},
- }, runner)
+ }, runner, nil)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -380,7 +381,7 @@ func TestDeprecatedLifecycleAddsWarningToResult(t *testing.T) {
},
},
},
- }, executor.EchoRunner{})
+ }, executor.EchoRunner{}, nil)
var out bytes.Buffer
var errOut bytes.Buffer
@@ -424,7 +425,7 @@ func TestDeprecatedLifecyclePrintsWarningToStderr(t *testing.T) {
},
},
},
- }, executor.EchoRunner{})
+ }, executor.EchoRunner{}, nil)
var out bytes.Buffer
var errOut bytes.Buffer
@@ -462,7 +463,7 @@ func TestSensitiveToolConfirmationWorksWithoutYesFlag(t *testing.T) {
},
},
},
- }, executor.EchoRunner{})
+ }, executor.EchoRunner{}, nil)
var out bytes.Buffer
cmd.SetOut(&out)
@@ -491,7 +492,7 @@ func TestLegacyCandidateLifecycleAddsWarningToResult(t *testing.T) {
},
},
},
- }, executor.EchoRunner{})
+ }, executor.EchoRunner{}, nil)
var out bytes.Buffer
var errOut bytes.Buffer
@@ -514,6 +515,524 @@ func TestLegacyCandidateLifecycleAddsWarningToResult(t *testing.T) {
}
}
+// ---------------------------------------------------------------------------
+// Input source resolution: @file for string flags
+// ---------------------------------------------------------------------------
+
+func TestToolCommandResolvesAtFileForStringFlag(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "msg.md")
+ if err := os.WriteFile(filePath, []byte("Hello from file"), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ "user_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"chat", "send_message", "--text", "@" + filePath, "--user-id", "u001"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["text"] != "Hello from file" {
+ t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "Hello from file")
+ }
+ if runner.last.Params["user_id"] != "u001" {
+ t.Errorf("params[user_id] = %q, want %q", runner.last.Params["user_id"], "u001")
+ }
+}
+
+func TestToolCommandResolvesAtFileForJsonFlag(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "payload.json")
+ payload := `{"text":"from json file","user_id":"u002"}`
+ if err := os.WriteFile(filePath, []byte(payload), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ "user_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"chat", "send_message", "--json", "@" + filePath})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["text"] != "from json file" {
+ t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "from json file")
+ }
+ if runner.last.Params["user_id"] != "u002" {
+ t.Errorf("params[user_id] = %q, want %q", runner.last.Params["user_id"], "u002")
+ }
+}
+
+func TestToolCommandMultipleAtFileFlags(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ titlePath := filepath.Join(dir, "title.txt")
+ bodyPath := filepath.Join(dir, "body.md")
+ if err := os.WriteFile(titlePath, []byte("My Title"), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+ if err := os.WriteFile(bodyPath, []byte("# Body\n\nContent here"), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "doc",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "create_document",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "title": map[string]any{"type": "string"},
+ "body": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"doc", "create_document", "--title", "@" + titlePath, "--body", "@" + bodyPath})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["title"] != "My Title" {
+ t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "My Title")
+ }
+ if runner.last.Params["body"] != "# Body\n\nContent here" {
+ t.Errorf("params[body] = %q, want %q", runner.last.Params["body"], "# Body\n\nContent here")
+ }
+}
+
+func TestToolCommandAtFileMissingReturnsError(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"chat", "send_message", "--text", "@/nonexistent/file.txt"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail for missing @file")
+ }
+ if !strings.Contains(err.Error(), "--text") {
+ t.Errorf("error should mention flag name, got: %v", err)
+ }
+ if runner.called != 0 {
+ t.Error("runner should not be called on @file error")
+ }
+}
+
+func TestToolCommandAtFileForJsonMissingReturnsError(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"chat", "send_message", "--json", "@/nonexistent/payload.json"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail for missing @file on --json")
+ }
+ if !strings.Contains(err.Error(), "--json") {
+ t.Errorf("error should mention --json, got: %v", err)
+ }
+}
+
+func TestToolCommandAtFileUTF8ContentPreserved(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "chinese.txt")
+ content := "你好世界 🌍\n第二行"
+ if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"chat", "send_message", "--text", "@" + filePath})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["text"] != content {
+ t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], content)
+ }
+}
+
+func TestToolCommandPlainAtValueNotResolvedForNonStringFlags(t *testing.T) {
+ t.Parallel()
+
+ // Integer and boolean flags should NOT resolve @file syntax.
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "todo",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "create_task",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "title": map[string]any{"type": "string"},
+ "priority": map[string]any{"type": "integer"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"todo", "create_task", "--title", "test", "--priority", "3"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["priority"] != 3 {
+ t.Errorf("params[priority] = %v, want 3", runner.last.Params["priority"])
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Input source resolution: --json @file override priority
+// ---------------------------------------------------------------------------
+
+func TestToolCommandJsonFlagOverridesOverrideFlags(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "base.json")
+ if err := os.WriteFile(filePath, []byte(`{"text":"from-json","user_id":"json-user"}`), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "chat",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "send_message",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string"},
+ "user_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ // --text override should win over --json base payload.
+ cmd.SetArgs([]string{"chat", "send_message", "--json", "@" + filePath, "--text", "override"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["text"] != "override" {
+ t.Errorf("params[text] = %q, want %q (override should win)", runner.last.Params["text"], "override")
+ }
+ if runner.last.Params["user_id"] != "json-user" {
+ t.Errorf("params[user_id] = %q, want %q (from json base)", runner.last.Params["user_id"], "json-user")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Sensitive tool + stdin guard interaction
+// ---------------------------------------------------------------------------
+
+func TestSensitiveToolWithStdinClaimedRequiresYes(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "msg.txt")
+ if err := os.WriteFile(filePath, []byte("content"), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ // Sensitive tool + @file (does NOT claim stdin) → should still prompt.
+ // We provide "yes" on stdin to pass confirmation.
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "doc",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "delete_document",
+ Sensitive: true,
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "doc_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetIn(strings.NewReader("yes\n"))
+ cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.called != 1 {
+ t.Errorf("runner called = %d, want 1", runner.called)
+ }
+}
+
+func TestSensitiveToolDeniedOnStdinWithNoYesFlag(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "doc",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "delete_document",
+ Sensitive: true,
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "doc_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetIn(strings.NewReader("no\n"))
+ cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001"})
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail when user denies confirmation")
+ }
+ if !strings.Contains(err.Error(), "cancelled") {
+ t.Errorf("error should mention cancellation, got: %v", err)
+ }
+ if runner.called != 0 {
+ t.Error("runner should not be called when confirmation denied")
+ }
+}
+
+func TestSensitiveToolWithYesFlagSkipsConfirmation(t *testing.T) {
+ t.Parallel()
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "doc",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "delete_document",
+ Sensitive: true,
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "doc_id": map[string]any{"type": "string"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.PersistentFlags().Bool("yes", false, "Skip confirmation")
+ cmd.SetArgs([]string{"doc", "delete_document", "--doc-id", "DOC001", "--yes"})
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.called != 1 {
+ t.Errorf("runner called = %d, want 1", runner.called)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// collectOverrides: @file does not affect non-string flag types
+// ---------------------------------------------------------------------------
+
+func TestCollectOverridesResolvesAtFileOnlyForStringKind(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "name.txt")
+ if err := os.WriteFile(filePath, []byte("resolved name"), 0o644); err != nil {
+ t.Fatalf("WriteFile() error = %v", err)
+ }
+
+ runner := &captureRunner{}
+ cmd := newTestMCPCommand(t, ir.Catalog{
+ Products: []ir.CanonicalProduct{
+ {
+ ID: "contact",
+ Tools: []ir.ToolDescriptor{
+ {
+ RPCName: "search_user",
+ InputSchema: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "keyword": map[string]any{"type": "string"},
+ "active": map[string]any{"type": "boolean"},
+ "limit": map[string]any{"type": "integer"},
+ },
+ },
+ },
+ },
+ },
+ },
+ }, runner)
+
+ cmd.SetArgs([]string{"contact", "search_user",
+ "--keyword", "@" + filePath,
+ "--active=true",
+ "--limit", "10",
+ })
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+
+ if runner.last.Params["keyword"] != "resolved name" {
+ t.Errorf("params[keyword] = %q, want %q", runner.last.Params["keyword"], "resolved name")
+ }
+ if runner.last.Params["active"] != true {
+ t.Errorf("params[active] = %v, want true", runner.last.Params["active"])
+ }
+ if runner.last.Params["limit"] != 10 {
+ t.Errorf("params[limit] = %v, want 10", runner.last.Params["limit"])
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Test helper
+// ---------------------------------------------------------------------------
+
+func newTestMCPCommand(t *testing.T, catalog ir.Catalog, runner executor.Runner) *cobra.Command {
+ t.Helper()
+ cmd := NewMCPCommand(context.Background(), StaticLoader{Catalog: catalog}, runner, nil)
+ var out, errOut bytes.Buffer
+ cmd.SetOut(&out)
+ cmd.SetErr(&errOut)
+ return cmd
+}
+
type errorLoader struct {
err error
}
diff --git a/internal/cli/loader.go b/internal/cli/loader.go
index ffb89406..8f6f094e 100644
--- a/internal/cli/loader.go
+++ b/internal/cli/loader.go
@@ -50,6 +50,22 @@ func (l StaticLoader) Load(_ context.Context) (ir.Catalog, error) {
return l.Catalog, nil
}
+// CatalogLoaderFrom creates a CatalogLoader that returns a
+// pre-loaded catalog and error. This allows multiple consumers
+// (schema command, MCP command tree) to share one discovery result.
+func CatalogLoaderFrom(catalog ir.Catalog, err error) CatalogLoader {
+ return &preloadedLoader{catalog: catalog, err: err}
+}
+
+type preloadedLoader struct {
+ catalog ir.Catalog
+ err error
+}
+
+func (l *preloadedLoader) Load(_ context.Context) (ir.Catalog, error) {
+ return l.catalog, l.err
+}
+
type FixtureLoader struct {
Path string
}
diff --git a/internal/cli/stdin.go b/internal/cli/stdin.go
index 35d9a922..c99c46b7 100644
--- a/internal/cli/stdin.go
+++ b/internal/cli/stdin.go
@@ -14,9 +14,11 @@
package cli
import (
+ "fmt"
"io"
"os"
"strings"
+ "sync"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -27,17 +29,89 @@ const (
maxStdinSize = 10 * 1024 * 1024 // 10 MB
)
+// StdinGuard ensures stdin is consumed at most once per command invocation.
+// Multiple flags using @- or implicit stdin fallback would race on the same
+// reader; StdinGuard detects and rejects the second claim with a clear error.
+type StdinGuard struct {
+ mu sync.Mutex
+ claimed bool
+ claimBy string
+}
+
+// NewStdinGuard creates a fresh guard for one command invocation.
+func NewStdinGuard() *StdinGuard {
+ return &StdinGuard{}
+}
+
+// Claim marks stdin as consumed by the named source (e.g. "--text @-").
+// Returns an error if stdin was already claimed.
+func (g *StdinGuard) Claim(source string) error {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ if g.claimed {
+ return apperrors.NewValidation(fmt.Sprintf(
+ "stdin already consumed by %s; cannot also read stdin for %s",
+ g.claimBy, source,
+ ))
+ }
+ g.claimed = true
+ g.claimBy = source
+ return nil
+}
+
+// Claimed reports whether stdin has been consumed.
+func (g *StdinGuard) Claimed() bool {
+ g.mu.Lock()
+ defer g.mu.Unlock()
+ return g.claimed
+}
+
+// StdinIsPipe reports whether stdin is a pipe (not a terminal).
+// This is a non-consuming check — it only inspects file mode via stat.
+func StdinIsPipe() bool {
+ info, err := os.Stdin.Stat()
+ if err != nil {
+ return false
+ }
+ return info.Mode()&os.ModeCharDevice == 0
+}
+
// ReadStdinIfPiped reads all data from stdin if it is a pipe (not a terminal).
// Returns empty string if stdin is a terminal or has no data.
func ReadStdinIfPiped() (string, error) {
- info, err := os.Stdin.Stat()
+ if !StdinIsPipe() {
+ return "", nil
+ }
+ return readStdinBounded()
+}
+
+// ReadStdin reads all data from stdin unconditionally (up to maxStdinSize).
+// Use this when the caller has explicitly requested stdin via @-.
+func ReadStdin() (string, error) {
+ return readStdinBounded()
+}
+
+// readFileBounded opens a file and reads up to maxStdinSize bytes.
+// Uses io.LimitReader to avoid TOCTOU between stat and read.
+func readFileBounded(path string) ([]byte, error) {
+ f, err := os.Open(path)
if err != nil {
- return "", nil
+ return nil, apperrors.NewValidation("@file: " + err.Error())
}
- // Check if stdin is a pipe (not a character device / terminal).
- if info.Mode()&os.ModeCharDevice != 0 {
- return "", nil
+ defer f.Close()
+
+ data, err := io.ReadAll(io.LimitReader(f, maxStdinSize+1))
+ if err != nil {
+ return nil, apperrors.NewValidation("@file: " + err.Error())
}
+ if int64(len(data)) > maxStdinSize {
+ return nil, apperrors.NewValidation("@file: file exceeds 10 MB limit")
+ }
+ return data, nil
+}
+
+// readStdinBounded reads from os.Stdin up to maxStdinSize bytes.
+func readStdinBounded() (string, error) {
data, err := io.ReadAll(io.LimitReader(os.Stdin, maxStdinSize+1))
if err != nil {
return "", apperrors.NewValidation("failed to read stdin: " + err.Error())
@@ -51,6 +125,8 @@ func ReadStdinIfPiped() (string, error) {
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
// Returns the original value unchanged if it does not start with "@".
// Returns an error if the file cannot be read or exceeds the size limit.
+//
+// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
func ReadFileArg(value string) (string, bool, error) {
if !strings.HasPrefix(value, "@") {
return value, false, nil
@@ -59,18 +135,51 @@ func ReadFileArg(value string) (string, bool, error) {
if path == "" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
-
- info, err := os.Stat(path)
- if err != nil {
- return "", false, apperrors.NewValidation("@file: " + err.Error())
- }
- if info.Size() > maxStdinSize {
- return "", false, apperrors.NewValidation("@file: file exceeds 10 MB limit")
+ // @- is stdin, not a file — callers should use ResolveInputSource.
+ if path == "-" {
+ return value, false, nil
}
- data, err := os.ReadFile(path)
+ data, err := readFileBounded(path)
if err != nil {
- return "", false, apperrors.NewValidation("@file: " + err.Error())
+ return "", false, err
}
return string(data), true, nil
}
+
+// ResolveInputSource resolves a flag value that may reference an external
+// input source. It supports three forms:
+//
+// - "@-" reads from stdin (requires StdinGuard claim)
+// - "@" reads from the named file
+// - anything else returned unchanged
+//
+// The flagName parameter is used only for error messages and StdinGuard tracking.
+func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
+ if !strings.HasPrefix(value, "@") {
+ return value, nil
+ }
+
+ path := value[1:]
+ if path == "" {
+ return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
+ }
+
+ // @- reads from stdin.
+ if path == "-" {
+ if guard == nil {
+ return "", apperrors.NewValidation(fmt.Sprintf("--%s: stdin (@-) not available in this context", flagName))
+ }
+ if err := guard.Claim(fmt.Sprintf("--%s @-", flagName)); err != nil {
+ return "", err
+ }
+ return ReadStdin()
+ }
+
+ // @ reads from file.
+ data, err := readFileBounded(path)
+ if err != nil {
+ return "", fmt.Errorf("--%s: %w", flagName, err)
+ }
+ return string(data), nil
+}
diff --git a/internal/cli/stdin_test.go b/internal/cli/stdin_test.go
index 0d0cfb30..e5e3e823 100644
--- a/internal/cli/stdin_test.go
+++ b/internal/cli/stdin_test.go
@@ -20,6 +20,19 @@ import (
"testing"
)
+// writeTestFile is a test helper that writes data to a file and fails the
+// test immediately if the write fails, preventing confusing downstream errors.
+func writeTestFile(t *testing.T, path string, data []byte) {
+ t.Helper()
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatalf("failed to write test file %s: %v", path, err)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ReadFileArg
+// ---------------------------------------------------------------------------
+
func TestReadFileArgPlainValue(t *testing.T) {
t.Parallel()
val, isFile, err := ReadFileArg("hello world")
@@ -39,7 +52,7 @@ func TestReadFileArgReadsFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "input.txt")
- os.WriteFile(path, []byte(`{"title":"test"}`), 0o644)
+ writeTestFile(t, path, []byte(`{"title":"test"}`))
val, isFile, err := ReadFileArg("@" + path)
if err != nil {
@@ -78,12 +91,18 @@ func TestReadFileArgSizeLimit(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "huge.txt")
// Create a file slightly over maxStdinSize (write 10MB + 1 byte)
- f, _ := os.Create(path)
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("failed to create test file: %v", err)
+ }
data := strings.Repeat("x", maxStdinSize+1)
- f.WriteString(data)
+ if _, err := f.WriteString(data); err != nil {
+ f.Close()
+ t.Fatalf("failed to write test data: %v", err)
+ }
f.Close()
- _, _, err := ReadFileArg("@" + path)
+ _, _, err = ReadFileArg("@" + path)
if err == nil {
t.Fatal("expected error for oversized file")
}
@@ -92,6 +111,21 @@ func TestReadFileArgSizeLimit(t *testing.T) {
}
}
+func TestReadFileArgAtDashPassThrough(t *testing.T) {
+ t.Parallel()
+ // @- means stdin — ReadFileArg should NOT handle it, just pass through.
+ val, isFile, err := ReadFileArg("@-")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if isFile {
+ t.Error("@- should not be treated as a file by ReadFileArg")
+ }
+ if val != "@-" {
+ t.Errorf("got %q, want %q", val, "@-")
+ }
+}
+
func TestReadStdinIfPipedReturnsEmptyForTerminal(t *testing.T) {
// This test runs in a terminal context (go test), so stdin is a terminal.
// ReadStdinIfPiped should return empty string.
@@ -103,3 +137,548 @@ func TestReadStdinIfPipedReturnsEmptyForTerminal(t *testing.T) {
t.Errorf("expected empty string for terminal stdin, got %q", val)
}
}
+
+func TestStdinIsPipeReturnsFalseForTerminal(t *testing.T) {
+ // go test runs with stdin as a terminal, so StdinIsPipe should return false.
+ if StdinIsPipe() {
+ t.Error("expected StdinIsPipe() == false in terminal context")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// readFileBounded (via ReadFileArg)
+// ---------------------------------------------------------------------------
+
+func TestReadFileBoundedEmptyFile(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "empty.txt")
+ writeTestFile(t, path, []byte(""))
+
+ val, isFile, err := ReadFileArg("@" + path)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !isFile {
+ t.Error("should be detected as file")
+ }
+ if val != "" {
+ t.Errorf("expected empty content, got %q", val)
+ }
+}
+
+func TestReadFileBoundedPermissionDenied(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ path := filepath.Join(dir, "noperm.txt")
+ writeTestFile(t, path, []byte("secret"))
+ os.Chmod(path, 0o000)
+ t.Cleanup(func() { os.Chmod(path, 0o644) })
+
+ _, _, err := ReadFileArg("@" + path)
+ if err == nil {
+ t.Fatal("expected error for permission denied")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// StdinGuard
+// ---------------------------------------------------------------------------
+
+func TestStdinGuardFirstClaimSucceeds(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ if err := guard.Claim("--text @-"); err != nil {
+ t.Fatalf("first claim should succeed: %v", err)
+ }
+ if !guard.Claimed() {
+ t.Error("guard should report claimed after successful claim")
+ }
+}
+
+func TestStdinGuardSecondClaimFails(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ _ = guard.Claim("--text @-")
+
+ err := guard.Claim("--body @-")
+ if err == nil {
+ t.Fatal("second claim should fail")
+ }
+ if !strings.Contains(err.Error(), "--text @-") {
+ t.Errorf("error should mention first claimer, got: %v", err)
+ }
+ if !strings.Contains(err.Error(), "--body @-") {
+ t.Errorf("error should mention second claimer, got: %v", err)
+ }
+}
+
+func TestStdinGuardNotClaimedInitially(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ if guard.Claimed() {
+ t.Error("fresh guard should not be claimed")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ResolveInputSource
+// ---------------------------------------------------------------------------
+
+func TestResolveInputSourcePlainValue(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("hello", "text", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "hello" {
+ t.Errorf("got %q, want %q", val, "hello")
+ }
+ if guard.Claimed() {
+ t.Error("plain value should not claim stdin")
+ }
+}
+
+func TestResolveInputSourceEmptyValue(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("", "text", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "" {
+ t.Errorf("got %q, want empty", val)
+ }
+}
+
+func TestResolveInputSourceAtFileReadsFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "msg.txt")
+ writeTestFile(t, path, []byte("file content here"))
+
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("@"+path, "text", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "file content here" {
+ t.Errorf("got %q, want %q", val, "file content here")
+ }
+ // @file should NOT claim stdin.
+ if guard.Claimed() {
+ t.Error("@file should not claim stdin")
+ }
+}
+
+func TestResolveInputSourceAtFileEmptyName(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ _, err := ResolveInputSource("@", "json", guard)
+ if err == nil {
+ t.Fatal("expected error for empty @file name")
+ }
+ if !strings.Contains(err.Error(), "--json") {
+ t.Errorf("error should mention flag name, got: %v", err)
+ }
+}
+
+func TestResolveInputSourceAtFileMissing(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ _, err := ResolveInputSource("@/no/such/file.txt", "data", guard)
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+ if !strings.Contains(err.Error(), "--data") {
+ t.Errorf("error should mention flag name, got: %v", err)
+ }
+}
+
+func TestResolveInputSourceAtFileSizeLimit(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "huge.bin")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("failed to create test file: %v", err)
+ }
+ if _, err := f.WriteString(strings.Repeat("x", maxStdinSize+1)); err != nil {
+ f.Close()
+ t.Fatalf("failed to write test data: %v", err)
+ }
+ f.Close()
+
+ guard := NewStdinGuard()
+ _, resolveErr := ResolveInputSource("@"+path, "body", guard)
+ if resolveErr == nil {
+ t.Fatal("expected error for oversized file")
+ }
+ if !strings.Contains(resolveErr.Error(), "10 MB") {
+ t.Errorf("error should mention size limit, got: %v", resolveErr)
+ }
+}
+
+func TestResolveInputSourceAtDashNilGuardFails(t *testing.T) {
+ t.Parallel()
+ _, err := ResolveInputSource("@-", "text", nil)
+ if err == nil {
+ t.Fatal("expected error when guard is nil")
+ }
+ if !strings.Contains(err.Error(), "not available") {
+ t.Errorf("error should mention stdin unavailability, got: %v", err)
+ }
+}
+
+func TestResolveInputSourceAtDashDoubleClaimFails(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ // Simulate first claim from another source.
+ _ = guard.Claim("--json @-")
+
+ _, err := ResolveInputSource("@-", "text", guard)
+ if err == nil {
+ t.Fatal("expected error for double stdin claim")
+ }
+ if !strings.Contains(err.Error(), "already consumed") {
+ t.Errorf("error should mention stdin conflict, got: %v", err)
+ }
+}
+
+func TestResolveInputSourceAtFileMultiline(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "multiline.md")
+ content := "# Title\n\nLine 1\nLine 2\nLine 3\n"
+ writeTestFile(t, path, []byte(content))
+
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("@"+path, "text", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != content {
+ t.Errorf("multiline content mismatch:\ngot: %q\nwant: %q", val, content)
+ }
+}
+
+func TestResolveInputSourceAtFileUTF8(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "chinese.txt")
+ content := "你好世界 🌍"
+ writeTestFile(t, path, []byte(content))
+
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("@"+path, "text", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != content {
+ t.Errorf("UTF-8 content mismatch:\ngot: %q\nwant: %q", val, content)
+ }
+}
+
+func TestResolveInputSourceAtFileJSON(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ path := filepath.Join(dir, "payload.json")
+ content := `{"title":"meeting","startTime":"2026-03-29T10:00:00Z"}`
+ writeTestFile(t, path, []byte(content))
+
+ guard := NewStdinGuard()
+ val, err := ResolveInputSource("@"+path, "json", guard)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != content {
+ t.Errorf("JSON content mismatch:\ngot: %q\nwant: %q", val, content)
+ }
+}
+
+func TestResolveInputSourceValueStartingWithAtSign(t *testing.T) {
+ t.Parallel()
+ // A value like "@mention" that looks like @file but the file doesn't exist
+ // should return an error (user likely intended file input).
+ guard := NewStdinGuard()
+ _, err := ResolveInputSource("@mention_someone", "text", guard)
+ if err == nil {
+ t.Fatal("expected error for non-existent @file path")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ResolveInputSource: table-driven edge cases
+// ---------------------------------------------------------------------------
+
+func TestResolveInputSourceTable(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ existingFile := filepath.Join(dir, "exists.txt")
+ writeTestFile(t, existingFile, []byte("file-data"))
+
+ tests := []struct {
+ name string
+ value string
+ flagName string
+ guard *StdinGuard
+ wantVal string
+ wantErr string // substring to match in error, "" means no error
+ wantClaim bool // expect guard to be claimed after call
+ }{
+ {
+ name: "plain string unchanged",
+ value: "hello",
+ flagName: "text",
+ guard: NewStdinGuard(),
+ wantVal: "hello",
+ },
+ {
+ name: "empty string unchanged",
+ value: "",
+ flagName: "text",
+ guard: NewStdinGuard(),
+ wantVal: "",
+ },
+ {
+ name: "plain string with special chars",
+ value: "hello@world.com",
+ flagName: "email",
+ guard: NewStdinGuard(),
+ wantVal: "hello@world.com",
+ },
+ {
+ name: "@file reads content",
+ value: "@" + existingFile,
+ flagName: "data",
+ guard: NewStdinGuard(),
+ wantVal: "file-data",
+ },
+ {
+ name: "@file does not claim stdin",
+ value: "@" + existingFile,
+ flagName: "data",
+ guard: NewStdinGuard(),
+ wantVal: "file-data",
+ wantClaim: false,
+ },
+ {
+ name: "bare @ is error",
+ value: "@",
+ flagName: "body",
+ guard: NewStdinGuard(),
+ wantErr: "must not be empty",
+ },
+ {
+ name: "missing file is error",
+ value: "@/tmp/does-not-exist-" + t.Name(),
+ flagName: "file",
+ guard: NewStdinGuard(),
+ wantErr: "--file",
+ },
+ {
+ name: "@- with nil guard is error",
+ value: "@-",
+ flagName: "text",
+ guard: nil,
+ wantErr: "not available",
+ },
+ {
+ name: "@- with pre-claimed guard is error",
+ value: "@-",
+ flagName: "body",
+ guard: func() *StdinGuard {
+ g := NewStdinGuard()
+ _ = g.Claim("--json @-")
+ return g
+ }(),
+ wantErr: "already consumed",
+ },
+ {
+ name: "error message includes flag name",
+ value: "@/nonexistent",
+ flagName: "my-flag",
+ guard: NewStdinGuard(),
+ wantErr: "--my-flag",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ val, err := ResolveInputSource(tt.value, tt.flagName, tt.guard)
+ if tt.wantErr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tt.wantErr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != tt.wantVal {
+ t.Errorf("value = %q, want %q", val, tt.wantVal)
+ }
+ if tt.guard != nil && tt.wantClaim != tt.guard.Claimed() {
+ t.Errorf("guard.Claimed() = %v, want %v", tt.guard.Claimed(), tt.wantClaim)
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// ReadFileArg: table-driven edge cases
+// ---------------------------------------------------------------------------
+
+func TestReadFileArgTable(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ existingFile := filepath.Join(dir, "data.txt")
+ writeTestFile(t, existingFile, []byte("content"))
+ emptyFile := filepath.Join(dir, "empty.txt")
+ writeTestFile(t, emptyFile, []byte(""))
+
+ tests := []struct {
+ name string
+ value string
+ wantVal string
+ wantIsFile bool
+ wantErr string
+ }{
+ {"plain string", "hello", "hello", false, ""},
+ {"empty string", "", "", false, ""},
+ {"email-like value", "user@domain.com", "user@domain.com", false, ""},
+ {"@file reads content", "@" + existingFile, "content", true, ""},
+ {"@file empty content", "@" + emptyFile, "", true, ""},
+ {"@- passes through", "@-", "@-", false, ""},
+ {"bare @ is error", "@", "", false, "must not be empty"},
+ {"missing file is error", "@/nonexistent", "", false, "@file"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ val, isFile, err := ReadFileArg(tt.value)
+ if tt.wantErr != "" {
+ if err == nil {
+ t.Fatalf("expected error containing %q, got nil", tt.wantErr)
+ }
+ if !strings.Contains(err.Error(), tt.wantErr) {
+ t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != tt.wantVal {
+ t.Errorf("value = %q, want %q", val, tt.wantVal)
+ }
+ if isFile != tt.wantIsFile {
+ t.Errorf("isFile = %v, want %v", isFile, tt.wantIsFile)
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// StdinGuard: table-driven claim sequences
+// ---------------------------------------------------------------------------
+
+func TestStdinGuardClaimSequences(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ claims []string // sources to claim in order
+ wantFails int // how many claims should fail
+ wantErrSub string // substring expected in first failure
+ }{
+ {
+ name: "single claim succeeds",
+ claims: []string{"--json @-"},
+ wantFails: 0,
+ },
+ {
+ name: "second claim fails",
+ claims: []string{"--json @-", "--text @-"},
+ wantFails: 1,
+ wantErrSub: "already consumed",
+ },
+ {
+ name: "third claim also fails",
+ claims: []string{"--json @-", "--text @-", "--body @-"},
+ wantFails: 2,
+ wantErrSub: "already consumed",
+ },
+ {
+ name: "error names both sources",
+ claims: []string{"implicit stdin (pipe)", "--text @-"},
+ wantFails: 1,
+ wantErrSub: "implicit stdin (pipe)",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+ fails := 0
+ var firstErr error
+ for _, source := range tt.claims {
+ if err := guard.Claim(source); err != nil {
+ fails++
+ if firstErr == nil {
+ firstErr = err
+ }
+ }
+ }
+ if fails != tt.wantFails {
+ t.Errorf("failures = %d, want %d", fails, tt.wantFails)
+ }
+ if tt.wantErrSub != "" && firstErr != nil {
+ if !strings.Contains(firstErr.Error(), tt.wantErrSub) {
+ t.Errorf("first error = %q, want substring %q", firstErr.Error(), tt.wantErrSub)
+ }
+ }
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// StdinGuard concurrency safety
+// ---------------------------------------------------------------------------
+
+func TestStdinGuardConcurrentClaims(t *testing.T) {
+ t.Parallel()
+ guard := NewStdinGuard()
+
+ const goroutines = 50
+ results := make(chan error, goroutines)
+ for i := 0; i < goroutines; i++ {
+ go func(id int) {
+ results <- guard.Claim("goroutine")
+ }(i)
+ }
+
+ successCount := 0
+ for i := 0; i < goroutines; i++ {
+ if err := <-results; err == nil {
+ successCount++
+ }
+ }
+ if successCount != 1 {
+ t.Errorf("exactly one goroutine should succeed, got %d", successCount)
+ }
+}
diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go
index ae9c8b82..af48d0e5 100644
--- a/internal/compat/dynamic_commands.go
+++ b/internal/compat/dynamic_commands.go
@@ -35,7 +35,12 @@ import (
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
- var commands []*cobra.Command
+ type builtCmd struct {
+ cmd *cobra.Command
+ parent string // cli.Parent: attach as sub-command of this top-level command
+ }
+
+ var built []builtCmd
for _, server := range servers {
cli := server.CLI
// §1.5: cli.skip → skip entire service
@@ -136,7 +141,41 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
}
}
- commands = append(commands, rootCmd)
+ built = append(built, builtCmd{cmd: rootCmd, parent: strings.TrimSpace(cli.Parent)})
+ }
+
+ // Collect top-level commands first, then attach child commands via cli.Parent.
+ topLevel := make(map[string]*cobra.Command)
+ var topOrder []string
+ var children []builtCmd
+
+ for _, b := range built {
+ if b.parent == "" {
+ name := b.cmd.Name()
+ if _, exists := topLevel[name]; !exists {
+ topOrder = append(topOrder, name)
+ }
+ topLevel[name] = b.cmd
+ } else {
+ children = append(children, b)
+ }
+ }
+ for _, child := range children {
+ if parent, ok := topLevel[child.parent]; ok {
+ parent.AddCommand(child.cmd)
+ } else {
+ // Parent not found among dynamic commands; emit as top-level.
+ name := child.cmd.Name()
+ if _, exists := topLevel[name]; !exists {
+ topOrder = append(topOrder, name)
+ }
+ topLevel[name] = child.cmd
+ }
+ }
+
+ commands := make([]*cobra.Command, 0, len(topLevel))
+ for _, name := range topOrder {
+ commands = append(commands, topLevel[name])
}
return commands
}
diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go
new file mode 100644
index 00000000..68f06cc2
--- /dev/null
+++ b/internal/compat/dynamic_commands_test.go
@@ -0,0 +1,145 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compat
+
+import (
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
+)
+
+func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-chat",
+ CLI: market.CLIOverlay{
+ ID: "group-chat",
+ Command: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "list_conversations": {CLIName: "list"},
+ },
+ },
+ },
+ {
+ Endpoint: "https://endpoint-bot",
+ CLI: market.CLIOverlay{
+ ID: "bot",
+ Command: "bot",
+ Parent: "chat",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "send_robot_message": {CLIName: "send"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+
+ // Should produce only one top-level command: "chat"
+ if len(cmds) != 1 {
+ names := make([]string, len(cmds))
+ for i, c := range cmds {
+ names[i] = c.Name()
+ }
+ t.Fatalf("expected 1 top-level command, got %d: %v", len(cmds), names)
+ }
+ if cmds[0].Name() != "chat" {
+ t.Fatalf("expected top-level command 'chat', got %q", cmds[0].Name())
+ }
+
+ // "bot" should be a sub-command of "chat"
+ found := false
+ for _, sub := range cmds[0].Commands() {
+ if sub.Name() == "bot" {
+ found = true
+ // "bot" should have its own sub-command "send"
+ hasSend := false
+ for _, leaf := range sub.Commands() {
+ if leaf.Name() == "send" {
+ hasSend = true
+ }
+ }
+ if !hasSend {
+ t.Fatal("expected 'bot' to have sub-command 'send'")
+ }
+ }
+ }
+ if !found {
+ t.Fatal("expected 'bot' as sub-command of 'chat'")
+ }
+}
+
+func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-orphan",
+ CLI: market.CLIOverlay{
+ ID: "orphan",
+ Command: "orphan",
+ Parent: "nonexistent",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "do_something": {CLIName: "do"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+
+ // Parent not found, should fall back to top-level
+ if len(cmds) != 1 {
+ t.Fatalf("expected 1 top-level command, got %d", len(cmds))
+ }
+ if cmds[0].Name() != "orphan" {
+ t.Fatalf("expected top-level command 'orphan', got %q", cmds[0].Name())
+ }
+}
+
+func TestBuildDynamicCommands_NoParent(t *testing.T) {
+ t.Parallel()
+
+ servers := []market.ServerDescriptor{
+ {
+ Endpoint: "https://endpoint-a",
+ CLI: market.CLIOverlay{
+ ID: "svc-a",
+ Command: "alpha",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "tool_a": {CLIName: "run"},
+ },
+ },
+ },
+ {
+ Endpoint: "https://endpoint-b",
+ CLI: market.CLIOverlay{
+ ID: "svc-b",
+ Command: "beta",
+ ToolOverrides: map[string]market.CLIToolOverride{
+ "tool_b": {CLIName: "exec"},
+ },
+ },
+ },
+ }
+
+ cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
+
+ if len(cmds) != 2 {
+ t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
+ }
+}
diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go
index 31f56f40..d89360aa 100644
--- a/internal/helpers/chat.go
+++ b/internal/helpers/chat.go
@@ -17,6 +17,7 @@ import (
"context"
"strings"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -92,12 +93,14 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
return err
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
tool,
params,
- ))
+ )
+ invocation.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
@@ -229,7 +232,7 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
}
allMembers := prependOwner(currentUserID, memberUserIDs)
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
"create_internal_group",
@@ -237,7 +240,9 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
"groupMembers": stringSliceToAny(allMembers),
"groupName": name,
},
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -253,6 +258,8 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
}
func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, string, error) {
+ guard := cli.NewStdinGuard()
+
group, err := cmd.Flags().GetString("group")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --group")
@@ -265,13 +272,14 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --robot-code")
}
- title, err := cmd.Flags().GetString("title")
+ title, err := resolveStringFlag(cmd, "title", guard, false)
if err != nil {
- return nil, "", apperrors.NewInternal("failed to read --title")
+ return nil, "", err
}
- text, err := cmd.Flags().GetString("text")
+ // --text is the primary content flag: receives stdin pipe when empty.
+ text, err := resolveStringFlag(cmd, "text", guard, true)
if err != nil {
- return nil, "", apperrors.NewInternal("failed to read --text")
+ return nil, "", err
}
switch {
@@ -448,9 +456,11 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
"openConversationId": groupID,
"processQueryKeys": processQueryKeys,
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "recall_robot_group_message", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -460,9 +470,11 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
"robotCode": robotCode,
"processQueryKeys": processQueryKeys,
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "batch_recall_robot_users_msg", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -488,9 +500,17 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
+ guard := cli.NewStdinGuard()
token, _ := cmd.Flags().GetString("token")
- title, _ := cmd.Flags().GetString("title")
- text, _ := cmd.Flags().GetString("text")
+ title, err := resolveStringFlag(cmd, "title", guard, false)
+ if err != nil {
+ return err
+ }
+ // --text is the primary content flag: receives stdin pipe when empty.
+ text, err := resolveStringFlag(cmd, "text", guard, true)
+ if err != nil {
+ return err
+ }
if strings.TrimSpace(token) == "" {
return apperrors.NewValidation("--token is required")
}
@@ -514,9 +534,11 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
if v, _ := cmd.Flags().GetString("at-users"); v != "" {
params["atUserIds"] = splitCSV(v)
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ invocation := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "send_message_by_custom_robot", params,
- ))
+ )
+ invocation.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), invocation)
if err != nil {
return err
}
@@ -579,9 +601,11 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
"openconversation_id": groupID,
"group_name": name,
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -616,9 +640,11 @@ func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
"openconversation_id": groupID,
"userId": splitCSV(usersStr),
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -653,9 +679,11 @@ func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
"openconversationId": groupID,
"userIdList": splitCSV(usersStr),
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
@@ -690,9 +718,11 @@ func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
"robotCode": robotCode,
"openConversationId": groupID,
}
- result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
+ inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
- ))
+ )
+ inv.DryRun = commandDryRun(cmd)
+ result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
diff --git a/internal/helpers/chat_input_test.go b/internal/helpers/chat_input_test.go
new file mode 100644
index 00000000..4c2a92ab
--- /dev/null
+++ b/internal/helpers/chat_input_test.go
@@ -0,0 +1,456 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package helpers
+
+import (
+ "bytes"
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
+ "github.com/spf13/cobra"
+)
+
+// inputCaptureRunner records invocations with a call counter.
+type inputCaptureRunner struct {
+ last executor.Invocation
+ called int
+}
+
+func (r *inputCaptureRunner) Run(_ context.Context, inv executor.Invocation) (executor.Result, error) {
+ r.last = inv
+ r.called++
+ return executor.Result{Invocation: inv}, nil
+}
+
+func writeInputTestFile(t *testing.T, path string, data []byte) {
+ t.Helper()
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatalf("failed to write test file %s: %v", path, err)
+ }
+}
+
+// newInputTestChatRoot builds the full chat command tree for testing.
+func newInputTestChatRoot(t *testing.T, runner executor.Runner) *cobra.Command {
+ t.Helper()
+ h := chatHandler{}
+ root := &cobra.Command{Use: "dws"}
+ root.AddCommand(h.Command(runner))
+ var out, errOut bytes.Buffer
+ root.SetOut(&out)
+ root.SetErr(&errOut)
+ return root
+}
+
+// ---------------------------------------------------------------------------
+// send-by-bot: --text @file
+// ---------------------------------------------------------------------------
+
+func TestSendByBotTextFromFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "msg.md")
+ writeInputTestFile(t, filePath, []byte("# Weekly Report\n\nAll green."))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "周报",
+ "--text", "@" + filePath,
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.called != 1 {
+ t.Fatalf("runner called = %d, want 1", runner.called)
+ }
+ if runner.last.Params["markdown"] != "# Weekly Report\n\nAll green." {
+ t.Errorf("params[markdown] = %q, want file content", runner.last.Params["markdown"])
+ }
+}
+
+func TestSendByBotTitleFromFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "title.txt")
+ writeInputTestFile(t, filePath, []byte("Dynamic Title"))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "@" + filePath,
+ "--text", "content here",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["title"] != "Dynamic Title" {
+ t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "Dynamic Title")
+ }
+}
+
+func TestSendByBotTextAndTitleBothFromFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ titlePath := filepath.Join(dir, "title.txt")
+ textPath := filepath.Join(dir, "body.md")
+ writeInputTestFile(t, titlePath, []byte("File Title"))
+ writeInputTestFile(t, textPath, []byte("File Body"))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "@" + titlePath,
+ "--text", "@" + textPath,
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["title"] != "File Title" {
+ t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "File Title")
+ }
+ if runner.last.Params["markdown"] != "File Body" {
+ t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], "File Body")
+ }
+}
+
+func TestSendByBotTextFromFileMissingReturnsError(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "test",
+ "--text", "@/nonexistent/file.md",
+ })
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail for missing @file")
+ }
+ if !strings.Contains(err.Error(), "--text") {
+ t.Errorf("error should mention --text, got: %v", err)
+ }
+ if runner.called != 0 {
+ t.Error("runner should not be called on @file error")
+ }
+}
+
+func TestSendByBotTextUTF8Preserved(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "chinese.md")
+ content := "你好世界 🌍\n第二行"
+ writeInputTestFile(t, filePath, []byte(content))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "测试",
+ "--text", "@" + filePath,
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["markdown"] != content {
+ t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], content)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// send-by-bot: backward compatibility (plain --text)
+// ---------------------------------------------------------------------------
+
+func TestSendByBotPlainTextStillWorks(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "test",
+ "--text", "plain message",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["markdown"] != "plain message" {
+ t.Errorf("params[markdown] = %q, want %q", runner.last.Params["markdown"], "plain message")
+ }
+}
+
+func TestSendByBotSingleChatStillWorks(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--users", "u001,u002",
+ "--title", "test",
+ "--text", "hello",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Tool != "batch_send_robot_msg_to_users" {
+ t.Errorf("tool = %q, want batch_send_robot_msg_to_users", runner.last.Tool)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// send-by-bot: validation still works
+// ---------------------------------------------------------------------------
+
+func TestSendByBotEmptyTextStillErrors(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--group", "G001",
+ "--title", "test",
+ // --text not provided
+ })
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail when --text is empty")
+ }
+ if !strings.Contains(err.Error(), "--text") {
+ t.Errorf("error should mention --text, got: %v", err)
+ }
+}
+
+func TestSendByBotMissingGroupAndUsersStillErrors(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-bot",
+ "--robot-code", "BOT001",
+ "--title", "test",
+ "--text", "hello",
+ })
+
+ err := cmd.Execute()
+ if err == nil {
+ t.Fatal("Execute() should fail when --group and --users both missing")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// send-by-webhook: --text @file and --title @file
+// ---------------------------------------------------------------------------
+
+func TestWebhookTextFromFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "alert.md")
+ writeInputTestFile(t, filePath, []byte("CPU > 90%"))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-webhook",
+ "--token", "TOKEN001",
+ "--title", "告警",
+ "--text", "@" + filePath,
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["text"] != "CPU > 90%" {
+ t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "CPU > 90%")
+ }
+}
+
+func TestWebhookTitleFromFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "title.txt")
+ writeInputTestFile(t, filePath, []byte("Alert Title"))
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-webhook",
+ "--token", "TOKEN001",
+ "--title", "@" + filePath,
+ "--text", "body content",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["title"] != "Alert Title" {
+ t.Errorf("params[title] = %q, want %q", runner.last.Params["title"], "Alert Title")
+ }
+}
+
+func TestWebhookPlainTextStillWorks(t *testing.T) {
+ t.Parallel()
+
+ runner := &inputCaptureRunner{}
+ cmd := newInputTestChatRoot(t, runner)
+ cmd.SetArgs([]string{"chat", "message", "send-by-webhook",
+ "--token", "TOKEN001",
+ "--title", "test",
+ "--text", "plain webhook message",
+ })
+
+ if err := cmd.Execute(); err != nil {
+ t.Fatalf("Execute() error = %v", err)
+ }
+ if runner.last.Params["text"] != "plain webhook message" {
+ t.Errorf("params[text] = %q, want %q", runner.last.Params["text"], "plain webhook message")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// resolveStringFlag unit tests
+// ---------------------------------------------------------------------------
+
+func TestResolveStringFlagPlainValue(t *testing.T) {
+ t.Parallel()
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("text", "", "")
+ cmd.SetArgs([]string{"--text", "hello"})
+ _ = cmd.Execute()
+
+ guard := cli.NewStdinGuard()
+ val, err := resolveStringFlag(cmd, "text", guard, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "hello" {
+ t.Errorf("got %q, want %q", val, "hello")
+ }
+}
+
+func TestResolveStringFlagAtFile(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ filePath := filepath.Join(dir, "data.txt")
+ writeInputTestFile(t, filePath, []byte("file content"))
+
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("body", "", "")
+ cmd.SetArgs([]string{"--body", "@" + filePath})
+ _ = cmd.Execute()
+
+ guard := cli.NewStdinGuard()
+ val, err := resolveStringFlag(cmd, "body", guard, false)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "file content" {
+ t.Errorf("got %q, want %q", val, "file content")
+ }
+ if guard.Claimed() {
+ t.Error("@file should not claim stdin")
+ }
+}
+
+func TestResolveStringFlagAtFileMissing(t *testing.T) {
+ t.Parallel()
+
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("text", "", "")
+ cmd.SetArgs([]string{"--text", "@/no/such/file"})
+ _ = cmd.Execute()
+
+ guard := cli.NewStdinGuard()
+ _, err := resolveStringFlag(cmd, "text", guard, false)
+ if err == nil {
+ t.Fatal("expected error for missing @file")
+ }
+ if !strings.Contains(err.Error(), "--text") {
+ t.Errorf("error should mention flag name, got: %v", err)
+ }
+}
+
+func TestResolveStringFlagPrimaryContentNoStdinInTerminal(t *testing.T) {
+ t.Parallel()
+
+ // In go test context, stdin is a terminal — primary content fallback
+ // should NOT read stdin (StdinIsPipe returns false).
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("text", "", "")
+ cmd.SetArgs([]string{})
+ _ = cmd.Execute()
+
+ guard := cli.NewStdinGuard()
+ val, err := resolveStringFlag(cmd, "text", guard, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "" {
+ t.Errorf("got %q, want empty (no stdin pipe in terminal)", val)
+ }
+ if guard.Claimed() {
+ t.Error("guard should not be claimed in terminal context")
+ }
+}
+
+func TestResolveStringFlagExplicitValueBlocksStdinFallback(t *testing.T) {
+ t.Parallel()
+
+ // When --text has an explicit value, primaryContent stdin fallback
+ // should NOT activate even if it's the primary flag.
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("text", "", "")
+ cmd.SetArgs([]string{"--text", "explicit"})
+ _ = cmd.Execute()
+
+ guard := cli.NewStdinGuard()
+ val, err := resolveStringFlag(cmd, "text", guard, true)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if val != "explicit" {
+ t.Errorf("got %q, want %q", val, "explicit")
+ }
+ if guard.Claimed() {
+ t.Error("explicit value should not claim stdin")
+ }
+}
diff --git a/internal/helpers/common.go b/internal/helpers/common.go
index 9ab48e52..52cab375 100644
--- a/internal/helpers/common.go
+++ b/internal/helpers/common.go
@@ -15,6 +15,7 @@ package helpers
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -28,6 +29,37 @@ func preferLegacyLeaf(cmd *cobra.Command) {
cli.SetOverridePriority(cmd, 100)
}
+// resolveStringFlag reads a string flag, resolves @file/@- input sources,
+// and falls back to stdin pipe when the flag is the designated primary
+// content flag and the user did not provide an explicit value.
+//
+// primaryContent indicates this flag is the default stdin receiver for the
+// command (e.g. --text for chat send). When true and the flag value is empty,
+// stdin pipe data is used automatically.
+func resolveStringFlag(cmd *cobra.Command, flagName string, guard *cli.StdinGuard, primaryContent bool) (string, error) {
+ raw, err := cmd.Flags().GetString(flagName)
+ if err != nil {
+ return "", apperrors.NewInternal("failed to read --" + flagName)
+ }
+
+ // Resolve @file / @- syntax.
+ resolved, err := cli.ResolveInputSource(raw, flagName, guard)
+ if err != nil {
+ return "", err
+ }
+
+ // Implicit stdin fallback: only for the primary content flag, only when
+ // the user did not provide an explicit value and stdin is unclaimed.
+ if resolved == "" && primaryContent && !guard.Claimed() && cli.StdinIsPipe() {
+ if claimErr := guard.Claim("implicit stdin → --" + flagName); claimErr != nil {
+ return "", claimErr
+ }
+ return cli.ReadStdin()
+ }
+
+ return resolved, nil
+}
+
func commandDryRun(cmd *cobra.Command) bool {
if cmd == nil {
return false
diff --git a/internal/market/registry.go b/internal/market/registry.go
index ff7b6d8e..070b622e 100644
--- a/internal/market/registry.go
+++ b/internal/market/registry.go
@@ -87,6 +87,7 @@ type LifecycleInfo struct {
type CLIOverlay struct {
ID string `json:"id"`
Command string `json:"command"`
+ Parent string `json:"parent,omitempty"`
Description string `json:"description"`
Prefixes []string `json:"prefixes"`
Aliases []string `json:"aliases"`
diff --git a/internal/output/filter.go b/internal/output/filter.go
new file mode 100644
index 00000000..c2b6191c
--- /dev/null
+++ b/internal/output/filter.go
@@ -0,0 +1,146 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package output
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "strings"
+
+ apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
+ "github.com/itchyny/gojq"
+)
+
+// SelectFields filters a JSON-serialisable payload to include only
+// the specified field names. It works on both objects and arrays:
+// - Object: returns a new object with only the matching keys.
+// - Array of objects: returns a new array where each element
+// contains only the matching keys.
+// - Other types: returned unchanged.
+//
+// Field names are matched case-insensitively against top-level keys.
+// Nested field selection (e.g. "response.data") is not supported;
+// use --jq for complex queries.
+func SelectFields(payload any, fields []string) any {
+ if len(fields) == 0 {
+ return payload
+ }
+
+ // Normalise to a generic JSON structure.
+ normalised := toGeneric(payload)
+
+ wanted := make(map[string]bool, len(fields))
+ for _, f := range fields {
+ wanted[strings.TrimSpace(strings.ToLower(f))] = true
+ }
+
+ switch typed := normalised.(type) {
+ case map[string]any:
+ return filterMap(typed, wanted)
+ case []any:
+ result := make([]any, 0, len(typed))
+ for _, item := range typed {
+ if m, ok := item.(map[string]any); ok {
+ result = append(result, filterMap(m, wanted))
+ } else {
+ result = append(result, item)
+ }
+ }
+ return result
+ default:
+ return normalised
+ }
+}
+
+// ApplyJQ applies a jq expression to a JSON-serialisable payload and
+// writes the results to w. Each result value is written as a separate
+// line of JSON.
+//
+// The expression is compiled once and evaluated against the
+// normalised payload. Multiple result values (e.g. from `.[]`) are
+// each written as indented JSON followed by a newline.
+func ApplyJQ(w io.Writer, payload any, expr string) error {
+ query, err := gojq.Parse(expr)
+ if err != nil {
+ return apperrors.NewValidation(fmt.Sprintf("invalid --jq expression: %v", err))
+ }
+
+ normalised := toGeneric(payload)
+ iter := query.Run(normalised)
+
+ first := true
+ for {
+ value, ok := iter.Next()
+ if !ok {
+ break
+ }
+ if err, isErr := value.(error); isErr {
+ return apperrors.NewValidation(fmt.Sprintf("--jq evaluation error: %v", err))
+ }
+ if !first {
+ if _, err := fmt.Fprintln(w); err != nil {
+ return err
+ }
+ }
+ first = false
+
+ data, marshalErr := json.MarshalIndent(value, "", " ")
+ if marshalErr != nil {
+ return apperrors.NewInternal("failed to encode --jq result")
+ }
+ if _, err := fmt.Fprint(w, string(data)); err != nil {
+ return err
+ }
+ }
+ if !first {
+ if _, err := fmt.Fprintln(w); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// filterMap returns a new map containing only keys that match the
+// wanted set (case-insensitive).
+func filterMap(m map[string]any, wanted map[string]bool) map[string]any {
+ result := make(map[string]any, len(wanted))
+ for key, value := range m {
+ if wanted[strings.ToLower(key)] {
+ result[key] = value
+ }
+ }
+ return result
+}
+
+// toGeneric converts an arbitrary Go value into a generic JSON
+// structure (map[string]any / []any / primitives) by round-tripping
+// through JSON marshal/unmarshal. This ensures consistent types
+// regardless of the original Go struct. The round-trip is always
+// performed because even a map[string]any may contain typed values
+// (e.g. []ir.CanonicalProduct) that gojq cannot handle.
+func toGeneric(payload any) any {
+ if payload == nil {
+ return nil
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return payload
+ }
+ var generic any
+ if err := json.Unmarshal(data, &generic); err != nil {
+ return payload
+ }
+ return generic
+}
diff --git a/internal/output/filter_test.go b/internal/output/filter_test.go
new file mode 100644
index 00000000..f28477fb
--- /dev/null
+++ b/internal/output/filter_test.go
@@ -0,0 +1,326 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package output
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// --- SelectFields tests ---
+
+func TestSelectFieldsObject(t *testing.T) {
+ payload := map[string]any{
+ "id": "123",
+ "name": "Alice",
+ "email": "alice@example.com",
+ "status": "active",
+ }
+
+ tests := []struct {
+ name string
+ fields []string
+ want map[string]any
+ }{
+ {
+ name: "select single field",
+ fields: []string{"name"},
+ want: map[string]any{"name": "Alice"},
+ },
+ {
+ name: "select multiple fields",
+ fields: []string{"id", "name"},
+ want: map[string]any{"id": "123", "name": "Alice"},
+ },
+ {
+ name: "case insensitive",
+ fields: []string{"ID", "Name"},
+ want: map[string]any{"id": "123", "name": "Alice"},
+ },
+ {
+ name: "non-existent field ignored",
+ fields: []string{"name", "nonexistent"},
+ want: map[string]any{"name": "Alice"},
+ },
+ {
+ name: "empty fields returns all",
+ fields: []string{},
+ want: payload,
+ },
+ {
+ name: "whitespace in field names trimmed",
+ fields: []string{" name ", " id "},
+ want: map[string]any{"id": "123", "name": "Alice"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := SelectFields(payload, tt.fields)
+ gotMap, ok := got.(map[string]any)
+ if !ok {
+ t.Fatalf("expected map, got %T", got)
+ }
+ if len(gotMap) != len(tt.want) {
+ t.Errorf("got %d fields, want %d", len(gotMap), len(tt.want))
+ }
+ for key, wantVal := range tt.want {
+ if gotVal, exists := gotMap[key]; !exists || gotVal != wantVal {
+ t.Errorf("field %q = %v, want %v", key, gotVal, wantVal)
+ }
+ }
+ })
+ }
+}
+
+func TestSelectFieldsArray(t *testing.T) {
+ payload := []any{
+ map[string]any{"id": "1", "name": "Alice", "age": 30},
+ map[string]any{"id": "2", "name": "Bob", "age": 25},
+ }
+
+ got := SelectFields(payload, []string{"id", "name"})
+ arr, ok := got.([]any)
+ if !ok {
+ t.Fatalf("expected []any, got %T", got)
+ }
+ if len(arr) != 2 {
+ t.Fatalf("got %d items, want 2", len(arr))
+ }
+
+ for i, item := range arr {
+ m, ok := item.(map[string]any)
+ if !ok {
+ t.Fatalf("item %d: expected map, got %T", i, item)
+ }
+ if len(m) != 2 {
+ t.Errorf("item %d: got %d fields, want 2", i, len(m))
+ }
+ if _, hasAge := m["age"]; hasAge {
+ t.Errorf("item %d: should not have 'age' field", i)
+ }
+ }
+}
+
+func TestSelectFieldsStruct(t *testing.T) {
+ type testStruct struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Age int `json:"age"`
+ }
+ payload := testStruct{ID: "1", Name: "Alice", Age: 30}
+
+ got := SelectFields(payload, []string{"id", "name"})
+ gotMap, ok := got.(map[string]any)
+ if !ok {
+ t.Fatalf("expected map after struct normalisation, got %T", got)
+ }
+ if _, hasAge := gotMap["age"]; hasAge {
+ t.Error("should not have 'age' field")
+ }
+ if gotMap["id"] != "1" || gotMap["name"] != "Alice" {
+ t.Errorf("got %v", gotMap)
+ }
+}
+
+func TestSelectFieldsPrimitive(t *testing.T) {
+ got := SelectFields("hello", []string{"name"})
+ if got != "hello" {
+ t.Errorf("primitive should pass through, got %v", got)
+ }
+}
+
+// --- ApplyJQ tests ---
+
+func TestApplyJQIdentity(t *testing.T) {
+ payload := map[string]any{"name": "Alice", "age": float64(30)}
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, "."); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+
+ var got map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal error: %v", err)
+ }
+ if got["name"] != "Alice" {
+ t.Errorf("name = %v, want Alice", got["name"])
+ }
+}
+
+func TestApplyJQFieldAccess(t *testing.T) {
+ payload := map[string]any{"name": "Alice", "age": float64(30)}
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, ".name"); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != `"Alice"` {
+ t.Errorf("got %q, want %q", got, `"Alice"`)
+ }
+}
+
+func TestApplyJQArrayIteration(t *testing.T) {
+ payload := map[string]any{
+ "items": []any{
+ map[string]any{"name": "Alice"},
+ map[string]any{"name": "Bob"},
+ },
+ }
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, ".items[].name"); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if !strings.Contains(got, `"Alice"`) || !strings.Contains(got, `"Bob"`) {
+ t.Errorf("got %q, want Alice and Bob", got)
+ }
+}
+
+func TestApplyJQSelect(t *testing.T) {
+ payload := map[string]any{
+ "items": []any{
+ map[string]any{"name": "Alice", "active": true},
+ map[string]any{"name": "Bob", "active": false},
+ },
+ }
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, `.items[] | select(.active) | .name`); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != `"Alice"` {
+ t.Errorf("got %q, want %q", got, `"Alice"`)
+ }
+}
+
+func TestApplyJQInvalidExpression(t *testing.T) {
+ var buf bytes.Buffer
+ err := ApplyJQ(&buf, map[string]any{}, ".[invalid")
+ if err == nil {
+ t.Fatal("expected error for invalid expression")
+ }
+ if !strings.Contains(err.Error(), "--jq") {
+ t.Errorf("error should mention --jq, got %q", err.Error())
+ }
+}
+
+func TestApplyJQLength(t *testing.T) {
+ payload := []any{1, 2, 3, 4, 5}
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, "length"); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != "5" {
+ t.Errorf("got %q, want %q", got, "5")
+ }
+}
+
+func TestApplyJQObjectConstruction(t *testing.T) {
+ payload := map[string]any{
+ "first_name": "Alice",
+ "last_name": "Smith",
+ "age": float64(30),
+ }
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, `{name: (.first_name + " " + .last_name), age}`); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal error: %v", err)
+ }
+ if got["name"] != "Alice Smith" {
+ t.Errorf("name = %v, want 'Alice Smith'", got["name"])
+ }
+}
+
+func TestApplyJQEmptyResult(t *testing.T) {
+ payload := map[string]any{"name": "Alice"}
+ var buf bytes.Buffer
+ if err := ApplyJQ(&buf, payload, ".nonexistent"); err != nil {
+ t.Fatalf("ApplyJQ error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != "null" {
+ t.Errorf("got %q, want %q", got, "null")
+ }
+}
+
+// --- WriteFiltered integration tests ---
+
+func TestWriteFilteredWithFields(t *testing.T) {
+ payload := map[string]any{
+ "id": "123",
+ "name": "test",
+ "data": "secret",
+ }
+ var buf bytes.Buffer
+ if err := WriteFiltered(&buf, FormatJSON, payload, "id,name", ""); err != nil {
+ t.Fatalf("WriteFiltered error: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal error: %v", err)
+ }
+ if _, hasData := got["data"]; hasData {
+ t.Error("should not have 'data' field")
+ }
+ if got["id"] != "123" || got["name"] != "test" {
+ t.Errorf("got %v", got)
+ }
+}
+
+func TestWriteFilteredWithJQ(t *testing.T) {
+ payload := map[string]any{"items": []any{"a", "b", "c"}}
+ var buf bytes.Buffer
+ if err := WriteFiltered(&buf, FormatJSON, payload, "", ".items | length"); err != nil {
+ t.Fatalf("WriteFiltered error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != "3" {
+ t.Errorf("got %q, want %q", got, "3")
+ }
+}
+
+func TestWriteFilteredJQTakesPrecedence(t *testing.T) {
+ payload := map[string]any{"id": "1", "name": "test"}
+ var buf bytes.Buffer
+ // Both --fields and --jq provided: jq wins.
+ if err := WriteFiltered(&buf, FormatJSON, payload, "id", ".name"); err != nil {
+ t.Fatalf("WriteFiltered error: %v", err)
+ }
+ got := strings.TrimSpace(buf.String())
+ if got != `"test"` {
+ t.Errorf("got %q, want %q", got, `"test"`)
+ }
+}
+
+func TestWriteFilteredNoFilters(t *testing.T) {
+ payload := map[string]any{"id": "1"}
+ var buf bytes.Buffer
+ if err := WriteFiltered(&buf, FormatJSON, payload, "", ""); err != nil {
+ t.Fatalf("WriteFiltered error: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal error: %v", err)
+ }
+ if got["id"] != "1" {
+ t.Errorf("id = %v, want 1", got["id"])
+ }
+}
diff --git a/internal/output/formatter.go b/internal/output/formatter.go
index d35881f7..b919b3cc 100644
--- a/internal/output/formatter.go
+++ b/internal/output/formatter.go
@@ -122,6 +122,61 @@ func normalizeFormat(raw string, fallback Format) Format {
}
}
+// WriteFiltered applies field selection and/or jq filtering before
+// writing the payload. If jq is non-empty, the jq result is written
+// directly (bypassing format). If fields is non-empty, the payload
+// is filtered to those fields before normal output.
+func WriteFiltered(w io.Writer, format Format, payload any, fields, jq string) error {
+ payload = unwrapCompatRuntimePayload(payload)
+
+ if strings.TrimSpace(jq) != "" {
+ return ApplyJQ(w, payload, strings.TrimSpace(jq))
+ }
+
+ if strings.TrimSpace(fields) != "" {
+ fieldList := strings.Split(fields, ",")
+ payload = SelectFields(payload, fieldList)
+ }
+
+ return Write(w, format, payload)
+}
+
+// ResolveFields extracts the --fields flag value from the command.
+func ResolveFields(cmd *cobra.Command) string {
+ if cmd == nil {
+ return ""
+ }
+ for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
+ if flags == nil {
+ continue
+ }
+ if f := flags.Lookup("fields"); f != nil && f.Changed {
+ if v, err := flags.GetString("fields"); err == nil {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
+// ResolveJQ extracts the --jq flag value from the command.
+func ResolveJQ(cmd *cobra.Command) string {
+ if cmd == nil {
+ return ""
+ }
+ for _, flags := range []*pflag.FlagSet{cmd.Flags(), cmd.InheritedFlags()} {
+ if flags == nil {
+ continue
+ }
+ if f := flags.Lookup("jq"); f != nil && f.Changed {
+ if v, err := flags.GetString("jq"); err == nil {
+ return v
+ }
+ }
+ }
+ return ""
+}
+
// WriteJSON marshals payload as indented JSON and writes it to w.
func WriteJSON(w io.Writer, payload any) error {
data, err := json.MarshalIndent(payload, "", " ")
diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go
new file mode 100644
index 00000000..7c401735
--- /dev/null
+++ b/internal/pipeline/cobra.go
@@ -0,0 +1,115 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pipeline
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+)
+
+// RunPreParse resolves the target command from the raw args, extracts
+// flag names from the Cobra command tree, and runs all PreParse
+// handlers. The corrected args are set back on the root command via
+// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
+// values.
+//
+// If the target command cannot be resolved (e.g. the user typed a
+// non-existent command), PreParse is skipped silently and Cobra will
+// handle the error.
+func RunPreParse(root *cobra.Command, engine *Engine) {
+ if engine == nil || !engine.HasHandlers(PreParse) {
+ return
+ }
+
+ rawArgs := os.Args[1:]
+ if len(rawArgs) == 0 {
+ return
+ }
+
+ // Traverse the command tree to find the target command.
+ target, _, err := root.Traverse(rawArgs)
+ if err != nil || target == nil {
+ return
+ }
+
+ // Build FlagInfo from the target command's registered flags.
+ flagInfos := FlagInfoFromCommand(target)
+ if len(flagInfos) == 0 {
+ return
+ }
+
+ ctx := &Context{
+ Args: append([]string{}, rawArgs...),
+ FlagSpecs: flagInfos,
+ }
+
+ if err := engine.RunPhase(PreParse, ctx); err != nil {
+ slog.Debug("pipeline pre-parse", "error", err)
+ return
+ }
+
+ // Only set corrected args if PreParse actually changed something.
+ if len(ctx.Corrections) > 0 {
+ root.SetArgs(ctx.Args)
+ for _, c := range ctx.Corrections {
+ slog.Debug("pipeline correction",
+ "handler", c.Handler,
+ "kind", c.Kind,
+ "field", c.Field,
+ "original", c.Original,
+ "corrected", c.Corrected,
+ )
+ }
+ }
+}
+
+// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
+// command's registered flags (both local and inherited).
+func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
+ if cmd == nil {
+ return nil
+ }
+
+ seen := make(map[string]bool)
+ var infos []FlagInfo
+
+ cmd.Flags().VisitAll(func(f *pflag.Flag) {
+ if seen[f.Name] {
+ return
+ }
+ seen[f.Name] = true
+ infos = append(infos, FlagInfo{
+ Name: f.Name,
+ PropertyName: f.Name,
+ Type: f.Value.Type(),
+ })
+ })
+
+ cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
+ if seen[f.Name] {
+ return
+ }
+ seen[f.Name] = true
+ infos = append(infos, FlagInfo{
+ Name: f.Name,
+ PropertyName: f.Name,
+ Type: f.Value.Type(),
+ })
+ })
+
+ return infos
+}
diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go
new file mode 100644
index 00000000..87496191
--- /dev/null
+++ b/internal/pipeline/engine.go
@@ -0,0 +1,98 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pipeline
+
+import "fmt"
+
+// Engine manages handler registration and executes the pipeline
+// chain. Handlers are grouped by phase and executed in registration
+// order within each phase. The engine is safe to use concurrently
+// for reads after all handlers have been registered; registration
+// itself is not concurrent-safe and should be done at startup.
+type Engine struct {
+ handlers map[Phase][]Handler
+}
+
+// NewEngine creates a pipeline engine with no registered handlers.
+func NewEngine() *Engine {
+ return &Engine{
+ handlers: make(map[Phase][]Handler),
+ }
+}
+
+// Register adds a handler to its declared phase. Handlers within
+// the same phase execute in registration order.
+func (e *Engine) Register(h Handler) {
+ phase := h.Phase()
+ e.handlers[phase] = append(e.handlers[phase], h)
+}
+
+// RegisterAll registers multiple handlers at once.
+func (e *Engine) RegisterAll(handlers ...Handler) {
+ for _, h := range handlers {
+ e.Register(h)
+ }
+}
+
+// Handlers returns the registered handlers for a given phase, in
+// registration order. The returned slice must not be modified.
+func (e *Engine) Handlers(phase Phase) []Handler {
+ return e.handlers[phase]
+}
+
+// HasHandlers reports whether at least one handler is registered
+// for the given phase.
+func (e *Engine) HasHandlers(phase Phase) bool {
+ return len(e.handlers[phase]) > 0
+}
+
+// RunPhase executes all handlers registered for the given phase in
+// chain order. If any handler returns an error, execution stops
+// immediately and the error is returned with the handler name as
+// context.
+func (e *Engine) RunPhase(phase Phase, ctx *Context) error {
+ for _, h := range e.handlers[phase] {
+ if err := h.Handle(ctx); err != nil {
+ return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err)
+ }
+ }
+ return nil
+}
+
+// Run executes all phases in order: Register → PreParse → PostParse
+// → PreRequest → PostResponse. Each phase runs its handler chain
+// completely before the next phase begins.
+//
+// Callers typically do not use Run directly — the CLI integration
+// calls RunPhase at each stage of the execution flow. Run is
+// provided for testing and for cases where the full pipeline must
+// be exercised in one shot.
+func (e *Engine) Run(ctx *Context) error {
+ for _, phase := range phases() {
+ if err := e.RunPhase(phase, ctx); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// HandlerCount returns the total number of registered handlers
+// across all phases.
+func (e *Engine) HandlerCount() int {
+ total := 0
+ for _, handlers := range e.handlers {
+ total += len(handlers)
+ }
+ return total
+}
diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go
new file mode 100644
index 00000000..ce798769
--- /dev/null
+++ b/internal/pipeline/engine_test.go
@@ -0,0 +1,300 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pipeline
+
+import (
+ "errors"
+ "strings"
+ "testing"
+)
+
+// --- test helpers ---
+
+type stubHandler struct {
+ name string
+ phase Phase
+ fn func(*Context) error
+ called bool
+ callSeq *[]string
+}
+
+func (h *stubHandler) Name() string { return h.name }
+func (h *stubHandler) Phase() Phase { return h.phase }
+func (h *stubHandler) Handle(ctx *Context) error {
+ h.called = true
+ if h.callSeq != nil {
+ *h.callSeq = append(*h.callSeq, h.name)
+ }
+ if h.fn != nil {
+ return h.fn(ctx)
+ }
+ return nil
+}
+
+func newStub(name string, phase Phase, fn func(*Context) error) *stubHandler {
+ return &stubHandler{name: name, phase: phase, fn: fn}
+}
+
+func newStubWithSeq(name string, phase Phase, seq *[]string) *stubHandler {
+ return &stubHandler{name: name, phase: phase, callSeq: seq}
+}
+
+// --- tests ---
+
+func TestNewEngine(t *testing.T) {
+ e := NewEngine()
+ if e == nil {
+ t.Fatal("NewEngine returned nil")
+ }
+ if got := e.HandlerCount(); got != 0 {
+ t.Errorf("HandlerCount = %d, want 0", got)
+ }
+}
+
+func TestRegisterAndHandlerCount(t *testing.T) {
+ e := NewEngine()
+ e.Register(newStub("a", PreParse, nil))
+ e.Register(newStub("b", PreParse, nil))
+ e.Register(newStub("c", PostParse, nil))
+
+ if got := e.HandlerCount(); got != 3 {
+ t.Errorf("HandlerCount = %d, want 3", got)
+ }
+ if got := len(e.Handlers(PreParse)); got != 2 {
+ t.Errorf("PreParse handlers = %d, want 2", got)
+ }
+ if got := len(e.Handlers(PostParse)); got != 1 {
+ t.Errorf("PostParse handlers = %d, want 1", got)
+ }
+ if got := len(e.Handlers(PreRequest)); got != 0 {
+ t.Errorf("PreRequest handlers = %d, want 0", got)
+ }
+}
+
+func TestRegisterAll(t *testing.T) {
+ e := NewEngine()
+ e.RegisterAll(
+ newStub("a", PreParse, nil),
+ newStub("b", PostParse, nil),
+ newStub("c", PreRequest, nil),
+ )
+ if got := e.HandlerCount(); got != 3 {
+ t.Errorf("HandlerCount = %d, want 3", got)
+ }
+}
+
+func TestHasHandlers(t *testing.T) {
+ e := NewEngine()
+ if e.HasHandlers(PreParse) {
+ t.Error("HasHandlers(PreParse) = true on empty engine")
+ }
+ e.Register(newStub("a", PreParse, nil))
+ if !e.HasHandlers(PreParse) {
+ t.Error("HasHandlers(PreParse) = false after registration")
+ }
+ if e.HasHandlers(PostResponse) {
+ t.Error("HasHandlers(PostResponse) = true, want false")
+ }
+}
+
+func TestRunPhaseChainOrder(t *testing.T) {
+ var seq []string
+ e := NewEngine()
+ e.Register(newStubWithSeq("first", PreParse, &seq))
+ e.Register(newStubWithSeq("second", PreParse, &seq))
+ e.Register(newStubWithSeq("third", PreParse, &seq))
+
+ ctx := &Context{Args: []string{"test"}}
+ if err := e.RunPhase(PreParse, ctx); err != nil {
+ t.Fatalf("RunPhase returned error: %v", err)
+ }
+ want := "first,second,third"
+ got := strings.Join(seq, ",")
+ if got != want {
+ t.Errorf("execution order = %q, want %q", got, want)
+ }
+}
+
+func TestRunPhaseContextMutationPropagates(t *testing.T) {
+ e := NewEngine()
+ e.Register(newStub("append-foo", PreParse, func(ctx *Context) error {
+ ctx.Args = append(ctx.Args, "--foo")
+ return nil
+ }))
+ e.Register(newStub("append-bar", PreParse, func(ctx *Context) error {
+ ctx.Args = append(ctx.Args, "--bar")
+ return nil
+ }))
+
+ ctx := &Context{Args: []string{"cmd"}}
+ if err := e.RunPhase(PreParse, ctx); err != nil {
+ t.Fatalf("RunPhase error: %v", err)
+ }
+ want := "cmd,--foo,--bar"
+ got := strings.Join(ctx.Args, ",")
+ if got != want {
+ t.Errorf("Args = %q, want %q", got, want)
+ }
+}
+
+func TestRunPhaseErrorAbortsChain(t *testing.T) {
+ boom := errors.New("boom")
+ h1 := newStub("ok", PreParse, nil)
+ h2 := newStub("fail", PreParse, func(*Context) error { return boom })
+ h3 := newStub("never", PreParse, nil)
+
+ e := NewEngine()
+ e.RegisterAll(h1, h2, h3)
+
+ ctx := &Context{}
+ err := e.RunPhase(PreParse, ctx)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ if !errors.Is(err, boom) {
+ t.Errorf("error = %v, want wrapped boom", err)
+ }
+ if !strings.Contains(err.Error(), "fail") {
+ t.Errorf("error should contain handler name, got %q", err.Error())
+ }
+ if !h1.called {
+ t.Error("h1 should have been called")
+ }
+ if !h2.called {
+ t.Error("h2 should have been called")
+ }
+ if h3.called {
+ t.Error("h3 should NOT have been called after error")
+ }
+}
+
+func TestRunPhaseEmptyPhaseNoError(t *testing.T) {
+ e := NewEngine()
+ ctx := &Context{}
+ if err := e.RunPhase(PreParse, ctx); err != nil {
+ t.Errorf("RunPhase on empty phase returned error: %v", err)
+ }
+}
+
+func TestRunAllPhasesOrder(t *testing.T) {
+ var seq []string
+ e := NewEngine()
+ e.Register(newStubWithSeq("post-resp", PostResponse, &seq))
+ e.Register(newStubWithSeq("pre-parse", PreParse, &seq))
+ e.Register(newStubWithSeq("register", Register, &seq))
+ e.Register(newStubWithSeq("post-parse", PostParse, &seq))
+ e.Register(newStubWithSeq("pre-req", PreRequest, &seq))
+
+ ctx := &Context{}
+ if err := e.Run(ctx); err != nil {
+ t.Fatalf("Run returned error: %v", err)
+ }
+ want := "register,pre-parse,post-parse,pre-req,post-resp"
+ got := strings.Join(seq, ",")
+ if got != want {
+ t.Errorf("phase execution order = %q, want %q", got, want)
+ }
+}
+
+func TestRunAbortsOnPhaseError(t *testing.T) {
+ var seq []string
+ e := NewEngine()
+ e.Register(newStubWithSeq("pre-parse", PreParse, &seq))
+ e.Register(newStub("post-parse-fail", PostParse, func(*Context) error {
+ return errors.New("fail")
+ }))
+ e.Register(newStubWithSeq("pre-req", PreRequest, &seq))
+
+ ctx := &Context{}
+ err := e.Run(ctx)
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ if len(seq) != 1 || seq[0] != "pre-parse" {
+ t.Errorf("seq = %v, want [pre-parse]", seq)
+ }
+}
+
+func TestContextAddCorrection(t *testing.T) {
+ ctx := &Context{}
+ ctx.AddCorrection("alias", PreParse, "--userId", "--userId", "--user-id", "alias")
+ ctx.AddCorrection("sticky", PreParse, "--limit", "--limit100", "--limit 100", "sticky")
+
+ if got := len(ctx.Corrections); got != 2 {
+ t.Fatalf("Corrections count = %d, want 2", got)
+ }
+ c := ctx.Corrections[0]
+ if c.Handler != "alias" || c.Original != "--userId" || c.Corrected != "--user-id" {
+ t.Errorf("first correction = %+v", c)
+ }
+}
+
+func TestPhaseString(t *testing.T) {
+ tests := []struct {
+ phase Phase
+ want string
+ }{
+ {Register, "register"},
+ {PreParse, "pre-parse"},
+ {PostParse, "post-parse"},
+ {PreRequest, "pre-request"},
+ {PostResponse, "post-response"},
+ {Phase(99), "unknown"},
+ }
+ for _, tt := range tests {
+ if got := tt.phase.String(); got != tt.want {
+ t.Errorf("Phase(%d).String() = %q, want %q", tt.phase, got, tt.want)
+ }
+ }
+}
+
+func TestRunPhaseErrorContainsPhaseAndHandler(t *testing.T) {
+ e := NewEngine()
+ e.Register(newStub("my-handler", PostParse, func(*Context) error {
+ return errors.New("bad value")
+ }))
+
+ err := e.RunPhase(PostParse, &Context{})
+ if err == nil {
+ t.Fatal("expected error")
+ }
+ msg := err.Error()
+ if !strings.Contains(msg, "post-parse") {
+ t.Errorf("error should contain phase name, got %q", msg)
+ }
+ if !strings.Contains(msg, "my-handler") {
+ t.Errorf("error should contain handler name, got %q", msg)
+ }
+}
+
+func TestMultipleHandlersSameAndDifferentPhases(t *testing.T) {
+ var seq []string
+ e := NewEngine()
+ e.Register(newStubWithSeq("pp1", PreParse, &seq))
+ e.Register(newStubWithSeq("pp2", PreParse, &seq))
+ e.Register(newStubWithSeq("op1", PostParse, &seq))
+ e.Register(newStubWithSeq("rq1", PreRequest, &seq))
+ e.Register(newStubWithSeq("rq2", PreRequest, &seq))
+ e.Register(newStubWithSeq("rs1", PostResponse, &seq))
+
+ ctx := &Context{}
+ if err := e.Run(ctx); err != nil {
+ t.Fatalf("Run error: %v", err)
+ }
+ want := "pp1,pp2,op1,rq1,rq2,rs1"
+ got := strings.Join(seq, ",")
+ if got != want {
+ t.Errorf("order = %q, want %q", got, want)
+ }
+}
diff --git a/internal/pipeline/handler.go b/internal/pipeline/handler.go
new file mode 100644
index 00000000..7bd2a963
--- /dev/null
+++ b/internal/pipeline/handler.go
@@ -0,0 +1,37 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pipeline
+
+// Handler is the core abstraction for pipeline extensions. Each
+// handler declares the phase it belongs to and provides a Handle
+// method that receives a mutable context. Handlers are executed in
+// registration order within a phase; each handler's output becomes
+// the next handler's input.
+//
+// A handler that returns a non-nil error aborts the chain — no
+// further handlers in the same phase (or subsequent phases) will
+// run.
+type Handler interface {
+ // Name returns a short, unique identifier for the handler
+ // (e.g. "sticky", "alias", "date-normalise"). Used in
+ // correction records and log output.
+ Name() string
+
+ // Phase returns the pipeline phase this handler belongs to.
+ Phase() Phase
+
+ // Handle processes the context and returns an error to abort
+ // the chain, or nil to continue.
+ Handle(ctx *Context) error
+}
diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go
new file mode 100644
index 00000000..f88cf595
--- /dev/null
+++ b/internal/pipeline/handlers/alias.go
@@ -0,0 +1,146 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+ "unicode"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+// AliasHandler normalises flag names in raw argv so that common
+// model-generated variants resolve to the canonical kebab-case name.
+//
+// Supported normalisations:
+// - camelCase → kebab-case (--userId → --user-id)
+// - snake_case → kebab-case (--user_name → --user-name)
+// - UPPER-CASE → lower-case (--USER-ID → --user-id)
+// - PascalCase → kebab-case (--UserName → --user-name)
+//
+// The handler only rewrites tokens that start with "--" and whose
+// normalised form matches a known flag name. Unknown flags are left
+// untouched so that Cobra can report them as errors.
+type AliasHandler struct{}
+
+func (AliasHandler) Name() string { return "alias" }
+func (AliasHandler) Phase() pipeline.Phase { return pipeline.PreParse }
+
+func (AliasHandler) Handle(ctx *pipeline.Context) error {
+ if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
+ return nil
+ }
+
+ known := buildFlagSet(ctx.FlagSpecs)
+ result := make([]string, 0, len(ctx.Args))
+
+ for i, arg := range ctx.Args {
+ rewritten, ok := tryNormaliseFlag(arg, known)
+ if ok {
+ ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias")
+ result = append(result, rewritten)
+ } else {
+ result = append(result, ctx.Args[i])
+ }
+ }
+
+ ctx.Args = result
+ return nil
+}
+
+// tryNormaliseFlag checks whether arg is a "--flag" token that can be
+// normalised to a known flag name. It handles both bare flags and
+// "--flag=value" syntax.
+func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
+ if !strings.HasPrefix(arg, "--") {
+ return "", false
+ }
+
+ bare := arg[2:]
+ if bare == "" {
+ return "", false
+ }
+
+ // Handle --flag=value syntax: split, normalise the key, reassemble.
+ var suffix string
+ if idx := strings.IndexByte(bare, '='); idx >= 0 {
+ suffix = bare[idx:] // includes "="
+ bare = bare[:idx]
+ }
+
+ // Already a known flag in its current form — no change needed.
+ if known[bare] {
+ return "", false
+ }
+
+ normalised := toKebabCase(bare)
+ if normalised == bare {
+ return "", false
+ }
+ if !known[normalised] {
+ return "", false
+ }
+
+ return "--" + normalised + suffix, true
+}
+
+// toKebabCase converts a string from camelCase, PascalCase, or
+// snake_case to kebab-case. Examples:
+//
+// "userId" → "user-id"
+// "UserName" → "user-name"
+// "user_name" → "user-name"
+// "USER_ID" → "user-id"
+// "pageSize" → "page-size"
+func toKebabCase(s string) string {
+ if s == "" {
+ return ""
+ }
+
+ var b strings.Builder
+ b.Grow(len(s) + 4) // small extra for hyphens
+
+ runes := []rune(s)
+ for i, r := range runes {
+ if r == '_' || r == ' ' {
+ if b.Len() > 0 {
+ b.WriteByte('-')
+ }
+ continue
+ }
+
+ if unicode.IsUpper(r) {
+ // Insert hyphen before an uppercase letter when:
+ // 1. Not at start, AND
+ // 2. Previous char was lowercase, OR
+ // 3. Next char is lowercase (handles "userID" → "user-id"
+ // at the boundary between "I" and "D" in "ID" we don't
+ // split, but "IDs" → we split before "s" which is
+ // handled by the lowercase check at the next iteration).
+ if i > 0 {
+ prev := runes[i-1]
+ if unicode.IsLower(prev) {
+ b.WriteByte('-')
+ } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
+ b.WriteByte('-')
+ }
+ }
+ b.WriteRune(unicode.ToLower(r))
+ } else {
+ b.WriteRune(unicode.ToLower(r))
+ }
+ }
+
+ return strings.Trim(b.String(), "-")
+}
diff --git a/internal/pipeline/handlers/alias_test.go b/internal/pipeline/handlers/alias_test.go
new file mode 100644
index 00000000..dd76a2fd
--- /dev/null
+++ b/internal/pipeline/handlers/alias_test.go
@@ -0,0 +1,186 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func TestToKebabCase(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {"userId", "user-id"},
+ {"UserName", "user-name"},
+ {"user_name", "user-name"},
+ {"USER_ID", "user-id"},
+ {"pageSize", "page-size"},
+ {"user-id", "user-id"},
+ {"limit", "limit"},
+ {"ID", "id"},
+ {"userID", "user-id"},
+ {"HTMLParser", "html-parser"},
+ {"getHTTPResponse", "get-http-response"},
+ {"a", "a"},
+ {"", ""},
+ {"ABC", "abc"},
+ {"already-kebab", "already-kebab"},
+ {"with spaces", "with-spaces"},
+ {"__leading_underscores", "leading-underscores"},
+ {"mixedCamel_and_snake", "mixed-camel-and-snake"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := toKebabCase(tt.input)
+ if got != tt.want {
+ t.Errorf("toKebabCase(%q) = %q, want %q", tt.input, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAliasHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ flags []string
+ want string
+ corrections int
+ }{
+ {
+ name: "camelCase to kebab-case",
+ args: []string{"--userId", "123"},
+ flags: []string{"user-id"},
+ want: "--user-id 123",
+ corrections: 1,
+ },
+ {
+ name: "snake_case to kebab-case",
+ args: []string{"--user_name", "alice"},
+ flags: []string{"user-name"},
+ want: "--user-name alice",
+ corrections: 1,
+ },
+ {
+ name: "UPPER to lower",
+ args: []string{"--USER-ID", "456"},
+ flags: []string{"user-id"},
+ want: "--user-id 456",
+ corrections: 1,
+ },
+ {
+ name: "PascalCase to kebab-case",
+ args: []string{"--PageSize", "20"},
+ flags: []string{"page-size"},
+ want: "--page-size 20",
+ corrections: 1,
+ },
+ {
+ name: "already correct — no change",
+ args: []string{"--user-id", "789"},
+ flags: []string{"user-id"},
+ want: "--user-id 789",
+ corrections: 0,
+ },
+ {
+ name: "unknown flag — left untouched",
+ args: []string{"--fooBar", "val"},
+ flags: []string{"user-id"},
+ want: "--fooBar val",
+ corrections: 0,
+ },
+ {
+ name: "with = syntax",
+ args: []string{"--userId=123"},
+ flags: []string{"user-id"},
+ want: "--user-id=123",
+ corrections: 1,
+ },
+ {
+ name: "multiple flags mixed",
+ args: []string{"--userId", "1", "--pageSize", "10", "--name", "test"},
+ flags: []string{"user-id", "page-size", "name"},
+ want: "--user-id 1 --page-size 10 --name test",
+ corrections: 2,
+ },
+ {
+ name: "single dash is ignored",
+ args: []string{"-v"},
+ flags: []string{"verbose"},
+ want: "-v",
+ corrections: 0,
+ },
+ {
+ name: "empty args",
+ args: []string{},
+ flags: []string{"limit"},
+ want: "",
+ corrections: 0,
+ },
+ {
+ name: "bare double dash",
+ args: []string{"--"},
+ flags: []string{"limit"},
+ want: "--",
+ corrections: 0,
+ },
+ {
+ name: "normalised form not in known set",
+ args: []string{"--unknownFlag"},
+ flags: []string{"limit"},
+ want: "--unknownFlag",
+ corrections: 0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Args: append([]string{}, tt.args...),
+ FlagSpecs: flagSpecs(tt.flags...),
+ }
+ h := AliasHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got := strings.Join(ctx.Args, " ")
+ if got != tt.want {
+ t.Errorf("Args = %q, want %q", got, tt.want)
+ }
+ if len(ctx.Corrections) != tt.corrections {
+ t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
+ }
+ for _, c := range ctx.Corrections {
+ if c.Kind != "alias" {
+ t.Errorf("correction kind = %q, want %q", c.Kind, "alias")
+ }
+ }
+ })
+ }
+}
+
+func TestAliasHandlerNameAndPhase(t *testing.T) {
+ h := AliasHandler{}
+ if h.Name() != "alias" {
+ t.Errorf("Name() = %q, want %q", h.Name(), "alias")
+ }
+ if h.Phase() != pipeline.PreParse {
+ t.Errorf("Phase() = %v, want PreParse", h.Phase())
+ }
+}
diff --git a/internal/pipeline/handlers/integration_test.go b/internal/pipeline/handlers/integration_test.go
new file mode 100644
index 00000000..eee94cd3
--- /dev/null
+++ b/internal/pipeline/handlers/integration_test.go
@@ -0,0 +1,284 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+// TestFullPreParsePipeline exercises the complete PreParse handler
+// chain: AliasHandler → StickyHandler → ParamNameHandler. It
+// simulates a model-generated CLI invocation with multiple errors
+// and verifies the pipeline corrects all of them in one pass.
+func TestFullPreParsePipeline(t *testing.T) {
+ engine := pipeline.NewEngine()
+ engine.RegisterAll(
+ AliasHandler{},
+ StickyHandler{},
+ ParamNameHandler{},
+ )
+
+ tests := []struct {
+ name string
+ args []string
+ flags []string
+ want string
+ corrections int
+ }{
+ {
+ name: "camelCase + sticky combined",
+ args: []string{"--userId", "123", "--pageSize50"},
+ flags: []string{"user-id", "page-size"},
+ want: "--user-id 123 --page-size 50",
+ corrections: 2, // alias(userId) + sticky(pageSize50)
+ },
+ {
+ name: "camelCase + typo combined",
+ args: []string{"--userId", "123", "--limt", "10"},
+ flags: []string{"user-id", "limit"},
+ want: "--user-id 123 --limit 10",
+ corrections: 2, // alias(userId) + fuzzy(limt)
+ },
+ {
+ name: "triple error: case + sticky + typo",
+ args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
+ flags: []string{"user-name", "limit", "offset"},
+ want: "--user-name alice --limit 100 --offset 0",
+ corrections: 3,
+ },
+ {
+ name: "snake_case + sticky",
+ args: []string{"--user_id", "42", "--pageSize20"},
+ flags: []string{"user-id", "page-size"},
+ want: "--user-id 42 --page-size 20",
+ corrections: 2,
+ },
+ {
+ name: "all correct — zero corrections",
+ args: []string{"--user-id", "123", "--limit", "10"},
+ flags: []string{"user-id", "limit"},
+ want: "--user-id 123 --limit 10",
+ corrections: 0,
+ },
+ {
+ name: "UPPER case flags",
+ args: []string{"--USER-ID", "999"},
+ flags: []string{"user-id"},
+ want: "--user-id 999",
+ corrections: 1,
+ },
+ {
+ name: "= syntax with camelCase",
+ args: []string{"--userId=123", "--pageSize=50"},
+ flags: []string{"user-id", "page-size"},
+ want: "--user-id=123 --page-size=50",
+ corrections: 2,
+ },
+ {
+ name: "camelCase sticky split with normalisation",
+ args: []string{"--limitValue100"},
+ flags: []string{"limit-value"},
+ want: "--limit-value 100",
+ corrections: 1, // sticky handles both kebab-normalisation and split
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Args: append([]string{}, tt.args...),
+ FlagSpecs: flagSpecs(tt.flags...),
+ }
+ if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
+ t.Fatalf("RunPhase error: %v", err)
+ }
+ got := strings.Join(ctx.Args, " ")
+ if got != tt.want {
+ t.Errorf("Args = %q, want %q", got, tt.want)
+ }
+ if len(ctx.Corrections) != tt.corrections {
+ t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
+ for _, c := range ctx.Corrections {
+ t.Logf(" %s/%s: %q → %q", c.Handler, c.Kind, c.Original, c.Corrected)
+ }
+ }
+ })
+ }
+}
+
+// TestFullPostParsePipeline exercises the PostParse handler chain
+// with the ParamValueHandler normalising multiple value types in a
+// single invocation.
+func TestFullPostParsePipeline(t *testing.T) {
+ engine := pipeline.NewEngine()
+ engine.RegisterAll(ParamValueHandler{})
+
+ ctx := &pipeline.Context{
+ Command: "aitable.list_records",
+ Params: map[string]any{
+ "verbose": "yes",
+ "limit": "1,000",
+ "status": "ACTIVE",
+ "created_at": "2024/03/29",
+ "name": "test",
+ },
+ Schema: map[string]any{
+ "properties": map[string]any{
+ "verbose": map[string]any{"type": "boolean"},
+ "limit": map[string]any{"type": "integer"},
+ "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}},
+ "created_at": map[string]any{"type": "string", "format": "date"},
+ "name": map[string]any{"type": "string"},
+ },
+ },
+ }
+
+ if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
+ t.Fatalf("RunPhase error: %v", err)
+ }
+
+ if got := ctx.Params["verbose"]; got != true {
+ t.Errorf("verbose = %v, want true", got)
+ }
+ if got := ctx.Params["limit"]; got != int64(1000) {
+ t.Errorf("limit = %v, want 1000", got)
+ }
+ if got := ctx.Params["status"]; got != "active" {
+ t.Errorf("status = %q, want %q", got, "active")
+ }
+ if got := ctx.Params["created_at"]; got != "2024-03-29" {
+ t.Errorf("created_at = %q, want %q", got, "2024-03-29")
+ }
+ // "name" is a plain string — should be untouched.
+ if got := ctx.Params["name"]; got != "test" {
+ t.Errorf("name = %q, want %q", got, "test")
+ }
+ if len(ctx.Corrections) != 4 {
+ t.Errorf("Corrections = %d, want 4", len(ctx.Corrections))
+ }
+}
+
+// TestFullPipelineEndToEnd exercises all phases in sequence:
+// PreParse → PostParse, simulating a model invocation that has
+// errors at both the argv level and the value level.
+func TestFullPipelineEndToEnd(t *testing.T) {
+ engine := pipeline.NewEngine()
+ engine.RegisterAll(
+ AliasHandler{},
+ StickyHandler{},
+ ParamNameHandler{},
+ ParamValueHandler{},
+ )
+
+ // Phase 1: PreParse — fix raw argv.
+ ctx := &pipeline.Context{
+ Args: []string{
+ "--userId", "u001",
+ "--pageSize50",
+ "--verbosetrue",
+ },
+ FlagSpecs: flagSpecs("user-id", "page-size", "verbose"),
+ }
+
+ if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
+ t.Fatalf("PreParse error: %v", err)
+ }
+
+ want := "--user-id u001 --page-size 50 --verbose true"
+ got := strings.Join(ctx.Args, " ")
+ if got != want {
+ t.Errorf("after PreParse: Args = %q, want %q", got, want)
+ }
+
+ preParseCorrections := len(ctx.Corrections)
+ if preParseCorrections != 3 {
+ t.Errorf("PreParse corrections = %d, want 3", preParseCorrections)
+ }
+
+ // Phase 2: PostParse — simulate Cobra having parsed the corrected
+ // args into structured params, then normalise values.
+ ctx.Command = "chat.send_message"
+ ctx.Params = map[string]any{
+ "user_id": "u001",
+ "page_size": "50",
+ "verbose": "true",
+ }
+ ctx.Schema = map[string]any{
+ "properties": map[string]any{
+ "user_id": map[string]any{"type": "string"},
+ "page_size": map[string]any{"type": "integer"},
+ "verbose": map[string]any{"type": "boolean"},
+ },
+ }
+
+ if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil {
+ t.Fatalf("PostParse error: %v", err)
+ }
+
+ if got := ctx.Params["verbose"]; got != true {
+ t.Errorf("verbose = %v (%T), want true (bool)", got, got)
+ }
+ // page_size "50" has no grouping separators — unchanged.
+ if got := ctx.Params["page_size"]; got != "50" {
+ t.Errorf("page_size = %v, want %q", got, "50")
+ }
+
+ totalCorrections := len(ctx.Corrections)
+ postParseCorrections := totalCorrections - preParseCorrections
+ if postParseCorrections != 1 {
+ t.Errorf("PostParse corrections = %d, want 1", postParseCorrections)
+ }
+}
+
+// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly
+// formatted args pass through the pipeline without modification.
+func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
+ engine := pipeline.NewEngine()
+ engine.RegisterAll(
+ AliasHandler{},
+ StickyHandler{},
+ ParamNameHandler{},
+ )
+
+ original := []string{
+ "--user-id", "123",
+ "--limit", "10",
+ "--offset", "0",
+ "--format", "json",
+ "--verbose",
+ }
+ ctx := &pipeline.Context{
+ Args: append([]string{}, original...),
+ FlagSpecs: flagSpecs("user-id", "limit", "offset", "format", "verbose"),
+ }
+
+ if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
+ t.Fatalf("RunPhase error: %v", err)
+ }
+
+ got := strings.Join(ctx.Args, " ")
+ want := strings.Join(original, " ")
+ if got != want {
+ t.Errorf("Args changed!\n got: %q\n want: %q", got, want)
+ }
+ if len(ctx.Corrections) != 0 {
+ t.Errorf("unexpected corrections: %d", len(ctx.Corrections))
+ for _, c := range ctx.Corrections {
+ t.Logf(" %s: %q → %q", c.Handler, c.Original, c.Corrected)
+ }
+ }
+}
diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go
new file mode 100644
index 00000000..1cd4207a
--- /dev/null
+++ b/internal/pipeline/handlers/paramname.go
@@ -0,0 +1,164 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+// ParamNameHandler performs fuzzy correction on flag names that are
+// not recognised after alias normalisation. It uses Levenshtein edit
+// distance to find the closest known flag name, with a conservative
+// threshold to avoid false positives.
+//
+// Correction rules:
+// - Edit distance must be ≤ maxEditDistance (default 2).
+// - The match must be unambiguous (exactly one candidate within threshold).
+// - Very short flag names (≤ 3 chars) use a tighter threshold of 1.
+//
+// This handler should run after AliasHandler in the PreParse phase
+// so that obvious normalisation (camelCase → kebab-case) is already
+// done and fuzzy matching only handles genuine near-misses.
+type ParamNameHandler struct{}
+
+const maxEditDistance = 2
+
+func (ParamNameHandler) Name() string { return "paramname" }
+func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse }
+
+func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
+ if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
+ return nil
+ }
+
+ known := buildFlagSet(ctx.FlagSpecs)
+ names := make([]string, 0, len(ctx.FlagSpecs))
+ for _, spec := range ctx.FlagSpecs {
+ if spec.Name != "" {
+ names = append(names, spec.Name)
+ }
+ }
+
+ result := make([]string, 0, len(ctx.Args))
+ for _, arg := range ctx.Args {
+ rewritten, ok := tryFuzzyMatch(arg, known, names)
+ if ok {
+ ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy")
+ result = append(result, rewritten)
+ } else {
+ result = append(result, arg)
+ }
+ }
+
+ ctx.Args = result
+ return nil
+}
+
+// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by
+// finding the closest known flag name within the edit distance threshold.
+func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) {
+ if !strings.HasPrefix(arg, "--") {
+ return "", false
+ }
+
+ bare := arg[2:]
+ if bare == "" {
+ return "", false
+ }
+
+ // Handle --flag=value syntax.
+ var suffix string
+ if idx := strings.IndexByte(bare, '='); idx >= 0 {
+ suffix = bare[idx:]
+ bare = bare[:idx]
+ }
+
+ // Already known — nothing to fix.
+ if known[bare] {
+ return "", false
+ }
+
+ threshold := maxEditDistance
+ if len(bare) <= 3 {
+ threshold = 1
+ }
+
+ bestDist := threshold + 1
+ bestMatch := ""
+ ambiguous := false
+
+ for _, candidate := range candidates {
+ dist := levenshtein(bare, candidate)
+ if dist < bestDist {
+ bestDist = dist
+ bestMatch = candidate
+ ambiguous = false
+ } else if dist == bestDist && candidate != bestMatch {
+ ambiguous = true
+ }
+ }
+
+ if bestDist > threshold || ambiguous || bestMatch == "" {
+ return "", false
+ }
+
+ return "--" + bestMatch + suffix, true
+}
+
+// levenshtein computes the edit distance between two strings using
+// the standard dynamic programming approach with O(min(m,n)) space.
+func levenshtein(a, b string) int {
+ if a == b {
+ return 0
+ }
+
+ la, lb := len(a), len(b)
+ if la == 0 {
+ return lb
+ }
+ if lb == 0 {
+ return la
+ }
+
+ // Ensure a is the shorter string for O(min) space.
+ if la > lb {
+ a, b = b, a
+ la, lb = lb, la
+ }
+
+ prev := make([]int, la+1)
+ curr := make([]int, la+1)
+ for i := range prev {
+ prev[i] = i
+ }
+
+ for j := 1; j <= lb; j++ {
+ curr[0] = j
+ for i := 1; i <= la; i++ {
+ cost := 1
+ if a[i-1] == b[j-1] {
+ cost = 0
+ }
+ curr[i] = min(
+ prev[i]+1, // deletion
+ curr[i-1]+1, // insertion
+ prev[i-1]+cost, // substitution
+ )
+ }
+ prev, curr = curr, prev
+ }
+ return prev[la]
+}
diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go
new file mode 100644
index 00000000..ef3f6c07
--- /dev/null
+++ b/internal/pipeline/handlers/paramname_test.go
@@ -0,0 +1,191 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func TestLevenshtein(t *testing.T) {
+ tests := []struct {
+ a, b string
+ want int
+ }{
+ {"", "", 0},
+ {"a", "", 1},
+ {"", "a", 1},
+ {"abc", "abc", 0},
+ {"abc", "abd", 1},
+ {"abc", "abcd", 1},
+ {"kitten", "sitting", 3},
+ {"limit", "limt", 1},
+ {"limit", "limti", 2},
+ {"user-id", "user-di", 2},
+ {"page", "pge", 1},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
+ got := levenshtein(tt.a, tt.b)
+ if got != tt.want {
+ t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
+ }
+ // Verify symmetry.
+ gotRev := levenshtein(tt.b, tt.a)
+ if gotRev != got {
+ t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
+ }
+ })
+ }
+}
+
+func TestParamNameHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ flags []string
+ want string
+ corrections int
+ }{
+ {
+ name: "typo correction — limt → limit",
+ args: []string{"--limt", "10"},
+ flags: []string{"limit", "offset", "name"},
+ want: "--limit 10",
+ corrections: 1,
+ },
+ {
+ name: "transposition — limti → limit",
+ args: []string{"--limti", "10"},
+ flags: []string{"limit", "offset"},
+ want: "--limit 10",
+ corrections: 1,
+ },
+ {
+ name: "known flag unchanged",
+ args: []string{"--limit", "10"},
+ flags: []string{"limit"},
+ want: "--limit 10",
+ corrections: 0,
+ },
+ {
+ name: "too far — no correction",
+ args: []string{"--xyz", "10"},
+ flags: []string{"limit"},
+ want: "--xyz 10",
+ corrections: 0,
+ },
+ {
+ name: "ambiguous — two equally close",
+ args: []string{"--nae", "val"},
+ flags: []string{"name", "nave"},
+ want: "--nae val",
+ corrections: 0,
+ },
+ {
+ name: "short flag tighter threshold",
+ args: []string{"--id", "1"},
+ flags: []string{"id"},
+ want: "--id 1",
+ corrections: 0,
+ },
+ {
+ name: "short flag one edit away",
+ args: []string{"--ix", "1"},
+ flags: []string{"id"},
+ want: "--id 1",
+ corrections: 1,
+ },
+ {
+ name: "short flag two edits away rejected",
+ args: []string{"--xy", "1"},
+ flags: []string{"id"},
+ want: "--xy 1",
+ corrections: 0,
+ },
+ {
+ name: "with = syntax",
+ args: []string{"--limt=10"},
+ flags: []string{"limit"},
+ want: "--limit=10",
+ corrections: 1,
+ },
+ {
+ name: "empty args",
+ args: []string{},
+ flags: []string{"limit"},
+ want: "",
+ corrections: 0,
+ },
+ {
+ name: "single dash ignored",
+ args: []string{"-l"},
+ flags: []string{"limit"},
+ want: "-l",
+ corrections: 0,
+ },
+ {
+ name: "missing char — offse → offset",
+ args: []string{"--offse", "0"},
+ flags: []string{"offset", "limit"},
+ want: "--offset 0",
+ corrections: 1,
+ },
+ {
+ name: "extra char — offsett → offset",
+ args: []string{"--offsett", "0"},
+ flags: []string{"offset", "limit"},
+ want: "--offset 0",
+ corrections: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Args: append([]string{}, tt.args...),
+ FlagSpecs: flagSpecs(tt.flags...),
+ }
+ h := ParamNameHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got := strings.Join(ctx.Args, " ")
+ if got != tt.want {
+ t.Errorf("Args = %q, want %q", got, tt.want)
+ }
+ if len(ctx.Corrections) != tt.corrections {
+ t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections)
+ }
+ for _, c := range ctx.Corrections {
+ if c.Kind != "fuzzy" {
+ t.Errorf("correction kind = %q, want %q", c.Kind, "fuzzy")
+ }
+ }
+ })
+ }
+}
+
+func TestParamNameHandlerNameAndPhase(t *testing.T) {
+ h := ParamNameHandler{}
+ if h.Name() != "paramname" {
+ t.Errorf("Name() = %q, want %q", h.Name(), "paramname")
+ }
+ if h.Phase() != pipeline.PreParse {
+ t.Errorf("Phase() = %v, want PreParse", h.Phase())
+ }
+}
diff --git a/internal/pipeline/handlers/paramvalue.go b/internal/pipeline/handlers/paramvalue.go
new file mode 100644
index 00000000..90444f93
--- /dev/null
+++ b/internal/pipeline/handlers/paramvalue.go
@@ -0,0 +1,312 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+// ParamValueHandler normalises parameter values after Cobra parsing.
+// It operates on the structured Params map using the tool's input
+// schema to determine value types and apply format-specific
+// corrections.
+//
+// Supported normalisations:
+// - Boolean: "yes"/"no"/"1"/"0"/"on"/"off"/"True"/"FALSE" → true/false
+// - Number: "1,000" / "1_000" → 1000 (strip grouping separators)
+// - Date: multiple input formats → RFC 3339 / ISO 8601
+// - Enum: case-insensitive matching against allowed values
+type ParamValueHandler struct{}
+
+func (ParamValueHandler) Name() string { return "paramvalue" }
+func (ParamValueHandler) Phase() pipeline.Phase { return pipeline.PostParse }
+
+func (ParamValueHandler) Handle(ctx *pipeline.Context) error {
+ if len(ctx.Params) == 0 || len(ctx.Schema) == 0 {
+ return nil
+ }
+
+ properties, ok := schemaProperties(ctx.Schema)
+ if !ok {
+ return nil
+ }
+
+ for key, value := range ctx.Params {
+ propSchema, ok := properties[key].(map[string]any)
+ if !ok {
+ continue
+ }
+
+ corrected, changed := normaliseValue(value, propSchema)
+ if changed {
+ original := fmt.Sprintf("%v", value)
+ ctx.Params[key] = corrected
+ ctx.AddCorrection("paramvalue", pipeline.PostParse, key, original, fmt.Sprintf("%v", corrected), classifyCorrection(propSchema))
+ }
+ }
+ return nil
+}
+
+// normaliseValue dispatches to the appropriate normaliser based on
+// the schema type and format.
+func normaliseValue(value any, schema map[string]any) (any, bool) {
+ // Enum normalisation takes priority — it is type-independent.
+ if enumValues, ok := schemaEnum(schema); ok {
+ return normaliseEnum(value, enumValues)
+ }
+
+ schemaType, _ := schema["type"].(string)
+ schemaFormat, _ := schema["format"].(string)
+
+ switch schemaType {
+ case "boolean":
+ return normaliseBoolean(value)
+ case "integer":
+ return normaliseInteger(value)
+ case "number":
+ return normaliseNumber(value)
+ case "string":
+ if isDateFormat(schemaFormat) {
+ return normaliseDate(value, schemaFormat)
+ }
+ }
+ return value, false
+}
+
+// --- boolean normalisation ---
+
+var booleanTrueValues = map[string]bool{
+ "true": true, "yes": true, "on": true, "1": true,
+}
+var booleanFalseValues = map[string]bool{
+ "false": true, "no": true, "off": true, "0": true,
+}
+
+func normaliseBoolean(value any) (any, bool) {
+ // Already a bool — no change needed.
+ if _, ok := value.(bool); ok {
+ return value, false
+ }
+
+ str, ok := value.(string)
+ if !ok {
+ return value, false
+ }
+ lower := strings.ToLower(strings.TrimSpace(str))
+ if booleanTrueValues[lower] {
+ return true, true
+ }
+ if booleanFalseValues[lower] {
+ return false, true
+ }
+ return value, false
+}
+
+// --- integer normalisation ---
+
+func normaliseInteger(value any) (any, bool) {
+ str, ok := value.(string)
+ if !ok {
+ return value, false
+ }
+ cleaned := stripNumberGrouping(str)
+ if cleaned == str {
+ return value, false
+ }
+ parsed, err := strconv.ParseInt(cleaned, 10, 64)
+ if err != nil {
+ return value, false
+ }
+ return parsed, true
+}
+
+// --- number normalisation ---
+
+func normaliseNumber(value any) (any, bool) {
+ str, ok := value.(string)
+ if !ok {
+ return value, false
+ }
+ cleaned := stripNumberGrouping(str)
+ if cleaned == str {
+ return value, false
+ }
+ parsed, err := strconv.ParseFloat(cleaned, 64)
+ if err != nil {
+ return value, false
+ }
+ return parsed, true
+}
+
+// stripNumberGrouping removes comma and underscore grouping
+// separators from a numeric string.
+func stripNumberGrouping(s string) string {
+ s = strings.TrimSpace(s)
+ s = strings.ReplaceAll(s, ",", "")
+ s = strings.ReplaceAll(s, "_", "")
+ return s
+}
+
+// --- date normalisation ---
+
+// Supported input date formats, tried in order.
+var dateFormats = []string{
+ time.RFC3339, // 2006-01-02T15:04:05Z07:00
+ "2006-01-02T15:04:05", // ISO without timezone
+ "2006-01-02 15:04:05", // space-separated datetime
+ "2006-01-02", // date only
+ "2006/01/02", // slash-separated
+ "02-01-2006", // DD-MM-YYYY
+ "01/02/2006", // US format MM/DD/YYYY
+ "Jan 2, 2006", // English short month
+ "January 2, 2006", // English full month
+ "2 Jan 2006", // Day-first English
+ "20060102", // Compact YYYYMMDD
+ "20060102T150405", // Compact with time
+ time.RFC1123, // Mon, 02 Jan 2006 15:04:05 MST
+ time.RFC1123Z, // Mon, 02 Jan 2006 15:04:05 -0700
+}
+
+func isDateFormat(format string) bool {
+ switch format {
+ case "date", "date-time", "datetime":
+ return true
+ }
+ return false
+}
+
+func normaliseDate(value any, schemaFormat string) (any, bool) {
+ str, ok := value.(string)
+ if !ok {
+ return value, false
+ }
+ str = strings.TrimSpace(str)
+ if str == "" {
+ return value, false
+ }
+
+ // Try parsing as millisecond timestamp.
+ if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
+ t := time.UnixMilli(ms).UTC()
+ formatted := formatDate(t, schemaFormat)
+ if formatted != str {
+ return formatted, true
+ }
+ return value, false
+ }
+
+ // Try parsing as second timestamp.
+ if sec, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 {
+ t := time.Unix(sec, 0).UTC()
+ formatted := formatDate(t, schemaFormat)
+ if formatted != str {
+ return formatted, true
+ }
+ return value, false
+ }
+
+ // Try known date formats.
+ for _, layout := range dateFormats {
+ t, err := time.Parse(layout, str)
+ if err == nil {
+ formatted := formatDate(t, schemaFormat)
+ if formatted != str {
+ return formatted, true
+ }
+ return value, false
+ }
+ }
+
+ return value, false
+}
+
+// formatDate produces the output format based on the schema format
+// field. "date" produces YYYY-MM-DD, everything else produces
+// RFC 3339.
+func formatDate(t time.Time, schemaFormat string) string {
+ if schemaFormat == "date" {
+ return t.Format("2006-01-02")
+ }
+ return t.Format(time.RFC3339)
+}
+
+// --- enum normalisation ---
+
+func schemaEnum(schema map[string]any) ([]any, bool) {
+ raw, ok := schema["enum"].([]any)
+ if !ok || len(raw) == 0 {
+ return nil, false
+ }
+ return raw, true
+}
+
+// normaliseEnum performs case-insensitive matching of a value against
+// the allowed enum values. If exactly one enum entry matches
+// (case-insensitive), the value is rewritten to match the canonical
+// case from the schema.
+func normaliseEnum(value any, allowed []any) (any, bool) {
+ str, ok := value.(string)
+ if !ok {
+ return value, false
+ }
+ lower := strings.ToLower(strings.TrimSpace(str))
+
+ var match any
+ matches := 0
+ for _, candidate := range allowed {
+ candidateStr, ok := candidate.(string)
+ if !ok {
+ continue
+ }
+ if strings.ToLower(candidateStr) == lower {
+ match = candidate
+ matches++
+ }
+ }
+
+ if matches == 1 && match != value {
+ return match, true
+ }
+ return value, false
+}
+
+// --- helpers ---
+
+func schemaProperties(schema map[string]any) (map[string]any, bool) {
+ properties, ok := schema["properties"].(map[string]any)
+ return properties, ok
+}
+
+func classifyCorrection(schema map[string]any) string {
+ if _, ok := schemaEnum(schema); ok {
+ return "enum"
+ }
+ schemaType, _ := schema["type"].(string)
+ schemaFormat, _ := schema["format"].(string)
+ if schemaType == "boolean" {
+ return "boolean"
+ }
+ if schemaType == "integer" || schemaType == "number" {
+ return "number"
+ }
+ if isDateFormat(schemaFormat) {
+ return "date"
+ }
+ return "value"
+}
diff --git a/internal/pipeline/handlers/paramvalue_test.go b/internal/pipeline/handlers/paramvalue_test.go
new file mode 100644
index 00000000..4894fbe4
--- /dev/null
+++ b/internal/pipeline/handlers/paramvalue_test.go
@@ -0,0 +1,352 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func makeSchema(properties map[string]any) map[string]any {
+ return map[string]any{"properties": properties}
+}
+
+func TestParamValueHandlerBoolean(t *testing.T) {
+ tests := []struct {
+ input any
+ want any
+ }{
+ {"yes", true},
+ {"Yes", true},
+ {"YES", true},
+ {"no", false},
+ {"No", false},
+ {"on", true},
+ {"off", false},
+ {"1", true},
+ {"0", false},
+ {"True", true},
+ {"FALSE", false},
+ {"true", true}, // already correct string form
+ {"false", false}, // already correct string form
+ {true, true}, // already bool — no change
+ {false, false}, // already bool — no change
+ {"maybe", "maybe"}, // not a boolean — unchanged
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"flag": tt.input},
+ Schema: makeSchema(map[string]any{
+ "flag": map[string]any{"type": "boolean"},
+ }),
+ }
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got := ctx.Params["flag"]
+ if got != tt.want {
+ t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
+ }
+ })
+ }
+}
+
+func TestParamValueHandlerInteger(t *testing.T) {
+ tests := []struct {
+ input any
+ want any
+ }{
+ {"1,000", int64(1000)},
+ {"1_000", int64(1000)},
+ {"1,000,000", int64(1000000)},
+ {"42", "42"}, // no grouping — unchanged
+ {42, 42}, // already int — unchanged
+ {"abc", "abc"}, // not a number — unchanged
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"count": tt.input},
+ Schema: makeSchema(map[string]any{
+ "count": map[string]any{"type": "integer"},
+ }),
+ }
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got := ctx.Params["count"]
+ if got != tt.want {
+ t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
+ }
+ })
+ }
+}
+
+func TestParamValueHandlerNumber(t *testing.T) {
+ tests := []struct {
+ input any
+ want any
+ }{
+ {"1,234.56", 1234.56},
+ {"1_000.5", 1000.5},
+ {"3.14", "3.14"}, // no grouping — unchanged
+ {3.14, 3.14}, // already float — unchanged
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"amount": tt.input},
+ Schema: makeSchema(map[string]any{
+ "amount": map[string]any{"type": "number"},
+ }),
+ }
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got := ctx.Params["amount"]
+ if got != tt.want {
+ t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
+ }
+ })
+ }
+}
+
+func TestParamValueHandlerDate(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ format string
+ want string
+ }{
+ {
+ name: "slash date → ISO",
+ input: "2024/03/29",
+ format: "date",
+ want: "2024-03-29",
+ },
+ {
+ name: "already ISO date — no change",
+ input: "2024-03-29",
+ format: "date",
+ want: "2024-03-29",
+ },
+ {
+ name: "space datetime → RFC3339",
+ input: "2024-03-29 14:30:00",
+ format: "date-time",
+ want: "2024-03-29T14:30:00Z",
+ },
+ {
+ name: "compact YYYYMMDD → ISO date",
+ input: "20240329",
+ format: "date",
+ want: "2024-03-29",
+ },
+ {
+ name: "English short month → RFC3339",
+ input: "Mar 29, 2024",
+ format: "date-time",
+ want: "2024-03-29T00:00:00Z",
+ },
+ {
+ name: "English full month → date",
+ input: "January 15, 2024",
+ format: "date",
+ want: "2024-01-15",
+ },
+ {
+ name: "millisecond timestamp → RFC3339",
+ input: "1711699200000",
+ format: "date-time",
+ want: "2024-03-29T08:00:00Z",
+ },
+ {
+ name: "already RFC3339 — no change",
+ input: "2024-03-29T14:30:00Z",
+ format: "date-time",
+ want: "2024-03-29T14:30:00Z",
+ },
+ {
+ name: "ISO without timezone → RFC3339",
+ input: "2024-03-29T14:30:00",
+ format: "date-time",
+ want: "2024-03-29T14:30:00Z",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"date": tt.input},
+ Schema: makeSchema(map[string]any{
+ "date": map[string]any{"type": "string", "format": tt.format},
+ }),
+ }
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ got, ok := ctx.Params["date"].(string)
+ if !ok {
+ t.Fatalf("result not a string: %v (%T)", ctx.Params["date"], ctx.Params["date"])
+ }
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestParamValueHandlerEnum(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ enum []any
+ want any
+ }{
+ {
+ name: "uppercase → canonical",
+ input: "ACTIVE",
+ enum: []any{"active", "inactive"},
+ want: "active",
+ },
+ {
+ name: "mixed case → canonical",
+ input: "Active",
+ enum: []any{"active", "inactive"},
+ want: "active",
+ },
+ {
+ name: "already correct — no change",
+ input: "active",
+ enum: []any{"active", "inactive"},
+ want: "active",
+ },
+ {
+ name: "no match — unchanged",
+ input: "deleted",
+ enum: []any{"active", "inactive"},
+ want: "deleted",
+ },
+ {
+ name: "non-string value — unchanged",
+ input: 42,
+ enum: []any{"active"},
+ want: 42,
+ },
+ {
+ name: "canonical preserves original case",
+ input: "json",
+ enum: []any{"JSON", "XML", "CSV"},
+ want: "JSON",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{"status": tt.input},
+ Schema: makeSchema(map[string]any{
+ "status": map[string]any{"type": "string", "enum": tt.enum},
+ }),
+ }
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+ if got := ctx.Params["status"]; got != tt.want {
+ t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want)
+ }
+ })
+ }
+}
+
+func TestParamValueHandlerMultipleParams(t *testing.T) {
+ ctx := &pipeline.Context{
+ Params: map[string]any{
+ "verbose": "yes",
+ "count": "1,000",
+ "status": "ACTIVE",
+ "created_at": "2024/03/29",
+ },
+ Schema: makeSchema(map[string]any{
+ "verbose": map[string]any{"type": "boolean"},
+ "count": map[string]any{"type": "integer"},
+ "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}},
+ "created_at": map[string]any{"type": "string", "format": "date"},
+ }),
+ }
+
+ h := ParamValueHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle error: %v", err)
+ }
+
+ if got := ctx.Params["verbose"]; got != true {
+ t.Errorf("verbose = %v, want true", got)
+ }
+ if got := ctx.Params["count"]; got != int64(1000) {
+ t.Errorf("count = %v, want 1000", got)
+ }
+ if got := ctx.Params["status"]; got != "active" {
+ t.Errorf("status = %q, want %q", got, "active")
+ }
+ if got := ctx.Params["created_at"]; got != "2024-03-29" {
+ t.Errorf("created_at = %q, want %q", got, "2024-03-29")
+ }
+ if len(ctx.Corrections) != 4 {
+ t.Errorf("corrections = %d, want 4", len(ctx.Corrections))
+ }
+}
+
+func TestParamValueHandlerEmptyInputs(t *testing.T) {
+ h := ParamValueHandler{}
+
+ // nil params
+ ctx := &pipeline.Context{Params: nil, Schema: makeSchema(map[string]any{})}
+ if err := h.Handle(ctx); err != nil {
+ t.Errorf("nil params: %v", err)
+ }
+
+ // nil schema
+ ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: nil}
+ if err := h.Handle(ctx); err != nil {
+ t.Errorf("nil schema: %v", err)
+ }
+
+ // schema without properties
+ ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: map[string]any{}}
+ if err := h.Handle(ctx); err != nil {
+ t.Errorf("no properties: %v", err)
+ }
+}
+
+func TestParamValueHandlerNameAndPhase(t *testing.T) {
+ h := ParamValueHandler{}
+ if h.Name() != "paramvalue" {
+ t.Errorf("Name() = %q, want %q", h.Name(), "paramvalue")
+ }
+ if h.Phase() != pipeline.PostParse {
+ t.Errorf("Phase() = %v, want PostParse", h.Phase())
+ }
+}
diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go
new file mode 100644
index 00000000..af99aed9
--- /dev/null
+++ b/internal/pipeline/handlers/sticky.go
@@ -0,0 +1,139 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+// StickyHandler detects glued flag-value pairs in raw argv and splits
+// them into separate tokens. For example, "--limit100" becomes
+// "--limit", "100" when "limit" is a known flag name.
+//
+// The handler only operates on tokens that start with "--" and do not
+// contain "=". It tries to match the longest known flag name prefix
+// and, if the remaining suffix is non-empty, splits the token.
+type StickyHandler struct{}
+
+func (StickyHandler) Name() string { return "sticky" }
+func (StickyHandler) Phase() pipeline.Phase { return pipeline.PreParse }
+
+func (StickyHandler) Handle(ctx *pipeline.Context) error {
+ if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
+ return nil
+ }
+
+ known := buildFlagSet(ctx.FlagSpecs)
+ result := make([]string, 0, len(ctx.Args))
+
+ for _, arg := range ctx.Args {
+ split, ok := trySplitSticky(arg, known)
+ if ok {
+ ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
+ result = append(result, split.flag, split.value)
+ } else {
+ result = append(result, arg)
+ }
+ }
+
+ ctx.Args = result
+ return nil
+}
+
+type stickyPair struct {
+ flag string
+ value string
+}
+
+// trySplitSticky checks if arg looks like a glued flag-value (e.g.
+// "--limit100") and returns the split result. It requires:
+// - arg starts with "--"
+// - arg does not contain "=" (that is a valid Cobra syntax)
+// - the prefix matches a known flag name (directly or after
+// kebab-case normalisation)
+// - the remaining suffix is non-empty
+//
+// When multiple flag names match as prefixes, the longest one wins.
+// The handler also tries kebab-case normalisation of each prefix so
+// that camelCase+glued values like "--pageSize50" are correctly
+// split to "--page-size", "50".
+func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
+ if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
+ return stickyPair{}, false
+ }
+
+ // Strip "--" prefix to work with the bare token.
+ bare := arg[2:]
+ if bare == "" {
+ return stickyPair{}, false
+ }
+
+ // If the whole token is a known flag, it is not sticky — it is
+ // a normal flag expecting a separate value token.
+ if known[bare] || known[toKebabCase(bare)] {
+ return stickyPair{}, false
+ }
+
+ // Try longest-prefix match: walk from len-1 down to 1, looking
+ // for the longest known flag that is a prefix of bare. For each
+ // candidate prefix, try both the raw form and kebab-case form.
+ bestLen := 0
+ bestFlag := ""
+ for i := len(bare) - 1; i >= 1; i-- {
+ prefix := bare[:i]
+
+ matchedFlag := ""
+ if known[prefix] {
+ matchedFlag = prefix
+ } else {
+ kebab := toKebabCase(prefix)
+ if kebab != "" && known[kebab] {
+ matchedFlag = kebab
+ }
+ }
+
+ if matchedFlag != "" && i > bestLen {
+ bestLen = i
+ bestFlag = matchedFlag
+ break // longest first since we walk from the end
+ }
+ }
+ if bestFlag == "" {
+ return stickyPair{}, false
+ }
+
+ suffix := bare[bestLen:]
+ if suffix == "" {
+ return stickyPair{}, false
+ }
+
+ return stickyPair{
+ flag: "--" + bestFlag,
+ value: suffix,
+ }, true
+}
+
+// buildFlagSet creates a set of known flag names (without "--" prefix)
+// from the context's FlagSpecs.
+func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool {
+ m := make(map[string]bool, len(specs))
+ for _, spec := range specs {
+ if spec.Name != "" {
+ m[spec.Name] = true
+ }
+ }
+ return m
+}
diff --git a/internal/pipeline/handlers/sticky_test.go b/internal/pipeline/handlers/sticky_test.go
new file mode 100644
index 00000000..434c356f
--- /dev/null
+++ b/internal/pipeline/handlers/sticky_test.go
@@ -0,0 +1,183 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
+)
+
+func flagSpecs(names ...string) []pipeline.FlagInfo {
+ specs := make([]pipeline.FlagInfo, len(names))
+ for i, name := range names {
+ specs[i] = pipeline.FlagInfo{Name: name, Type: "string"}
+ }
+ return specs
+}
+
+func TestStickyHandler(t *testing.T) {
+ tests := []struct {
+ name string
+ args []string
+ flags []string
+ want string
+ corrections int
+ }{
+ {
+ name: "basic split --limit100",
+ args: []string{"--limit100"},
+ flags: []string{"limit"},
+ want: "--limit 100",
+ corrections: 1,
+ },
+ {
+ name: "no split when flag takes value separately",
+ args: []string{"--limit", "100"},
+ flags: []string{"limit"},
+ want: "--limit 100",
+ corrections: 0,
+ },
+ {
+ name: "no split when = syntax used",
+ args: []string{"--limit=100"},
+ flags: []string{"limit"},
+ want: "--limit=100",
+ corrections: 0,
+ },
+ {
+ name: "no split when flag name is not known",
+ args: []string{"--unknown100"},
+ flags: []string{"limit"},
+ want: "--unknown100",
+ corrections: 0,
+ },
+ {
+ name: "split with string value",
+ args: []string{"--nameJohn"},
+ flags: []string{"name"},
+ want: "--name John",
+ corrections: 1,
+ },
+ {
+ name: "longest prefix wins",
+ args: []string{"--user-id123"},
+ flags: []string{"user", "user-id"},
+ want: "--user-id 123",
+ corrections: 1,
+ },
+ {
+ name: "multiple sticky args in one invocation",
+ args: []string{"--limit100", "--offset50"},
+ flags: []string{"limit", "offset"},
+ want: "--limit 100 --offset 50",
+ corrections: 2,
+ },
+ {
+ name: "mixed sticky and normal args",
+ args: []string{"--limit100", "--name", "test", "--offset50"},
+ flags: []string{"limit", "name", "offset"},
+ want: "--limit 100 --name test --offset 50",
+ corrections: 2,
+ },
+ {
+ name: "single dash prefix is ignored",
+ args: []string{"-l100"},
+ flags: []string{"l"},
+ want: "-l100",
+ corrections: 0,
+ },
+ {
+ name: "empty args",
+ args: []string{},
+ flags: []string{"limit"},
+ want: "",
+ corrections: 0,
+ },
+ {
+ name: "bare double dash",
+ args: []string{"--"},
+ flags: []string{"limit"},
+ want: "--",
+ corrections: 0,
+ },
+ {
+ name: "no flag specs available",
+ args: []string{"--limit100"},
+ flags: []string{},
+ want: "--limit100",
+ corrections: 0,
+ },
+ {
+ name: "exact flag name is not split",
+ args: []string{"--limit"},
+ flags: []string{"limit"},
+ want: "--limit",
+ corrections: 0,
+ },
+ {
+ name: "boolean-like value still splits",
+ args: []string{"--verbosetrue"},
+ flags: []string{"verbose"},
+ want: "--verbose true",
+ corrections: 1,
+ },
+ {
+ name: "hyphenated flag name with numeric suffix",
+ args: []string{"--page-size50"},
+ flags: []string{"page-size", "page"},
+ want: "--page-size 50",
+ corrections: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ctx := &pipeline.Context{
+ Args: append([]string{}, tt.args...),
+ FlagSpecs: flagSpecs(tt.flags...),
+ }
+ h := StickyHandler{}
+ if err := h.Handle(ctx); err != nil {
+ t.Fatalf("Handle returned error: %v", err)
+ }
+ got := strings.Join(ctx.Args, " ")
+ if got != tt.want {
+ t.Errorf("Args = %q, want %q", got, tt.want)
+ }
+ if len(ctx.Corrections) != tt.corrections {
+ t.Errorf("Corrections count = %d, want %d", len(ctx.Corrections), tt.corrections)
+ }
+ for _, c := range ctx.Corrections {
+ if c.Kind != "sticky" {
+ t.Errorf("correction kind = %q, want %q", c.Kind, "sticky")
+ }
+ if c.Handler != "sticky" {
+ t.Errorf("correction handler = %q, want %q", c.Handler, "sticky")
+ }
+ }
+ })
+ }
+}
+
+func TestStickyHandlerNameAndPhase(t *testing.T) {
+ h := StickyHandler{}
+ if h.Name() != "sticky" {
+ t.Errorf("Name() = %q, want %q", h.Name(), "sticky")
+ }
+ if h.Phase() != pipeline.PreParse {
+ t.Errorf("Phase() = %v, want PreParse", h.Phase())
+ }
+}
diff --git a/internal/pipeline/phase.go b/internal/pipeline/phase.go
new file mode 100644
index 00000000..92acb766
--- /dev/null
+++ b/internal/pipeline/phase.go
@@ -0,0 +1,158 @@
+// Copyright 2026 Alibaba Group
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pipeline
+
+// Phase represents a named stage in the CLI execution pipeline.
+// Handlers are grouped by phase and executed in chain order within
+// each phase. Phases themselves execute in a fixed order defined
+// by the engine.
+type Phase int
+
+const (
+ // Register runs at CLI startup when the Cobra command tree is
+ // being built. Handlers can add, remove, or modify commands.
+ Register Phase = iota
+
+ // PreParse runs before Cobra parses the raw argv. Handlers
+ // receive the raw argument slice and can rewrite it — for
+ // example to fix flag-name typos or split glued values.
+ PreParse
+
+ // PostParse runs after Cobra has successfully parsed flags and
+ // args. Handlers receive structured parameters plus the tool
+ // schema, enabling value-level corrections such as date format
+ // normalisation.
+ PostParse
+
+ // PreRequest runs after validation, just before the JSON-RPC
+ // call is dispatched. Handlers can inspect or mutate the final
+ // payload.
+ PreRequest
+
+ // PostResponse runs after the transport returns a result and
+ // before the output is written to stdout.
+ PostResponse
+)
+
+// String returns the human-readable name of the phase.
+func (p Phase) String() string {
+ switch p {
+ case Register:
+ return "register"
+ case PreParse:
+ return "pre-parse"
+ case PostParse:
+ return "post-parse"
+ case PreRequest:
+ return "pre-request"
+ case PostResponse:
+ return "post-response"
+ default:
+ return "unknown"
+ }
+}
+
+// phases returns all phases in execution order.
+func phases() []Phase {
+ return []Phase{Register, PreParse, PostParse, PreRequest, PostResponse}
+}
+
+// Context carries mutable state through the handler chain. Each phase
+// populates additional fields; earlier-phase fields remain available in
+// later phases so that handlers can correlate raw input with structured
+// parameters.
+type Context struct {
+ // Args is the raw argv slice (available from PreParse onward).
+ // PreParse handlers may rewrite this in place.
+ Args []string
+
+ // Command is the resolved product.tool canonical path
+ // (available from PostParse onward).
+ Command string
+
+ // Params holds structured key→value parameters after Cobra
+ // parsing (available from PostParse onward). Handlers may
+ // mutate values or add/remove keys.
+ Params map[string]any
+
+ // Schema is the JSON Schema for the resolved tool's input
+ // (available from PostParse onward). Handlers must treat this
+ // as read-only.
+ Schema map[string]any
+
+ // Payload is the merged, validated payload ready to be sent
+ // over the wire (available from PreRequest onward).
+ Payload map[string]any
+
+ // Response is the JSON-RPC result returned by the server
+ // (available from PostResponse onward). Handlers may mutate
+ // the response before it is written to stdout.
+ Response map[string]any
+
+ // FlagSpecs provides the list of known flag names for the
+ // current tool, derived from the input schema. PreParse
+ // handlers use this to match against raw argv tokens.
+ FlagSpecs []FlagInfo
+
+ // Corrections records every correction applied by handlers,
+ // enabling downstream logging and debugging.
+ Corrections []Correction
+}
+
+// FlagInfo describes a single CLI flag derived from a tool's input
+// schema. PreParse handlers use this to recognise valid flag names
+// when performing fuzzy matching or alias resolution.
+type FlagInfo struct {
+ // Name is the canonical kebab-case flag name (e.g. "user-id").
+ Name string
+
+ // PropertyName is the original schema property key (e.g. "userId").
+ PropertyName string
+
+ // Type is the JSON Schema type ("string", "integer", etc.).
+ Type string
+}
+
+// Correction records a single input correction applied by a handler.
+type Correction struct {
+ // Handler is the name of the handler that applied the correction.
+ Handler string
+
+ // Phase is the pipeline phase in which the correction occurred.
+ Phase Phase
+
+ // Field identifies the affected flag or parameter name.
+ Field string
+
+ // Original is the value before correction.
+ Original string
+
+ // Corrected is the value after correction.
+ Corrected string
+
+ // Kind classifies the correction (e.g. "alias", "sticky", "case").
+ Kind string
+}
+
+// AddCorrection appends a correction record to the context.
+func (c *Context) AddCorrection(handler string, phase Phase, field, original, corrected, kind string) {
+ c.Corrections = append(c.Corrections, Correction{
+ Handler: handler,
+ Phase: phase,
+ Field: field,
+ Original: original,
+ Corrected: corrected,
+ Kind: kind,
+ })
+}
diff --git a/test/contract/mcp_docs_coverage_test.go b/test/contract/mcp_docs_coverage_test.go
index 922f5e14..ceaeb7c9 100644
--- a/test/contract/mcp_docs_coverage_test.go
+++ b/test/contract/mcp_docs_coverage_test.go
@@ -173,7 +173,7 @@ func TestMCPProtocolDocsCoverageViaEndpointGeneratedCLI(t *testing.T) {
}
func inspectToolCommand(catalog ir.Catalog, productID, toolCommand string) (map[string]struct{}, string, error) {
- root := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{})
+ root := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}, nil)
toolCmd, _, err := root.Find([]string{productID, toolCommand})
if err != nil {
diff --git a/test/integration/extensions/cli_overlay_test.go b/test/integration/extensions/cli_overlay_test.go
index 25d4864f..b921811d 100644
--- a/test/integration/extensions/cli_overlay_test.go
+++ b/test/integration/extensions/cli_overlay_test.go
@@ -88,7 +88,7 @@ func TestCLIMetadataDrivesCanonicalCommandShape(t *testing.T) {
t.Fatalf("tool metadata = %#v, want make-doc hidden", tool)
}
- cmd := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{})
+ cmd := cli.NewMCPCommand(context.Background(), cli.StaticLoader{Catalog: catalog}, executor.EchoRunner{}, nil)
module := cmd.Commands()[0]
if !module.Hidden || module.Name() != "knowledge" {
t.Fatalf("module hidden/name = (%t,%s), want (true,knowledge)", module.Hidden, module.Name())