Compare commits

..
57 Commits
Author SHA1 Message Date
coffeeBigSir 937fb80ee9 Merge pull request #22 from DingTalk-Real-AI/robot-xf
Robot xf
2026-03-29 17:59:15 +08:00
tianlei.qjb e14d7f9846 npm install support 2026-03-29 17:55:12 +08:00
tianlei.qjb ad3c3a22bf Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-29 17:54:30 +08:00
tianlei.qjb e57b9ac6f3 npm install support 2026-03-29 17:54:20 +08:00
coffeeBigSir 36ea9b6b3c Merge pull request #21 from audanye-sudo/docs/add-chinese-readme
docs: add Chinese README (README_zh.md)
2026-03-29 16:56:01 +08:00
audanye-sudo dbfd785af3 docs: add Chinese README (README_zh.md) 2026-03-29 16:48:21 +08:00
tianlei.qjb 2cb55d7cb6 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-29 16:44:31 +08:00
coffeeBigSir 758da4d292 Merge pull request #20 from audanye-sudo/feat/deep-workspace-tooling
feat: deep workspace tooling — pipeline, filtering, stdin, routing
2026-03-29 16:33:40 +08:00
audanye-sudo 9c69dcc3e5 chore: wire pipeline engine into root command and bump version to v1.0.2
- Integrate pipeline engine with pre-parse input correction
- Unhide schema command
- Update CHANGELOG for v1.0.2
- Update README documentation
2026-03-29 16:30:20 +08:00
audanye-sudo c4bc460dc7 feat: add @file input support for chat helper commands 2026-03-29 16:29:56 +08:00
audanye-sudo c1893f40fc feat: add tool-level endpoint routing for multi-endpoint products
- Build tool → endpoint mapping from CLI tools and overrides
- directRuntimeEndpoint resolves by tool name before product fallback
- Support dynamic command routing with compat layer updates
2026-03-29 16:29:35 +08:00
audanye-sudo 13f248b285 feat: enhance stdin handling with StdinGuard and @file/@- support
- Add StdinGuard for safe single-read stdin across multiple flags
- Add ResolveInputSource unified resolver for @file, @-, and pipe
- Support @file / @- syntax for all string-typed override flags
- Remove unused writeSchemaText function
2026-03-29 16:29:13 +08:00
audanye-sudo 9bf51322cc feat: add --fields and --jq output filtering flags
- Add --fields for comma-separated field selection on JSON output
- Add --jq for jq expression filtering powered by gojq library
- Integrate filters into output formatter pipeline
2026-03-29 16:28:49 +08:00
audanye-sudo f75227aad1 feat: add pipeline engine for pre-parse and post-parse input correction
- AliasHandler: normalise model-generated flag casing (--userId → --user-id)
- StickyHandler: split glued flag values (--limit100 → --limit 100)
- ParamNameHandler: fix near-miss flag typos (--limt → --limit)
- ParamValueHandler: normalise structured parameter values after parsing
2026-03-29 16:28:19 +08:00
fantiu 4a4633d188 Merge pull request #18 from fantiu/feat-security
docs: update README security design section
2026-03-29 11:56:43 +08:00
fantiu 9eb85a22bb docs: update README security design section 2026-03-29 11:32:17 +08:00
tianlei.qjb d19a145472 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-29 11:06:18 +08:00
coffeeBigSir 4fab1610a2 Merge pull request #16 from audanye-sudo/feat/foundational-feature-upgrade
feat: foundational feature upgrades for validation, logging, and input
2026-03-29 00:47:54 +08:00
tianlei.qjb 82a2f7f77e Merge branch 'audanye-sudo-feat/foundational-feature-upgrade' 2026-03-29 00:35:40 +08:00
tianlei.qjb 50d2e75fd6 Merge branch 'feat/foundational-feature-upgrade' of https://github.com/audanye-sudo/dingtalk-workspace-cli into audanye-sudo-feat/foundational-feature-upgrade 2026-03-29 00:35:12 +08:00
audanye-sudo c8f1a9a912 chore: fix staticcheck warnings and restore missing .env.example
- Replace nil context with context.TODO() in logger calls
- Fix import ordering and struct field alignment
- Restore .env.example required by open-source audit tests
2026-03-29 00:30:27 +08:00
audanye-sudo 60086a8aaf docs: update redirect URL configuration for device flow login 2026-03-28 23:39:13 +08:00
audanye-sudo 9d90474fd3 feat: integrate file logger and transport-level input validation
- Wire file logger into root command lifecycle (init + close)
- Pass file logger to transport client for request/response logging
- Add control char and dangerous Unicode validation on tool calls
- Strip query/fragment from endpoints to prevent parameter injection
2026-03-28 23:39:05 +08:00
audanye-sudo 7fdabb6230 feat: improve MAC address selection with virtual NIC fallback
- Extract selectMAC for testable interface selection logic
- Prefer physical NICs, fall back to virtual MACs (e.g. Docker)
- Add comprehensive unit tests for all MAC selection scenarios
2026-03-28 23:38:55 +08:00
audanye-sudo d0ab1ff641 feat: add stdin pipe and @file input support for tool commands
- Add stdin.go with ReadStdinIfPiped and ReadFileArg helpers
- Support @file syntax for --json flag to read payload from file
- Fall back to stdin pipe when no --json argument is provided
2026-03-28 23:38:45 +08:00
audanye-sudo 53bd013899 feat: add structured file logger with rotation and redaction
- Add internal/logging package with JSON file logger
- Support log rotation at 10MB with 3 backup files
- Add MultiHandler for dual output (stderr + file)
- Include automatic secret redaction in log entries
- Add transport-level request/response logging helpers
2026-03-28 23:38:36 +08:00
audanye-sudo 464d571eb3 feat: add centralized input validation and sanitization package
- Add internal/validate with control char rejection, ANSI stripping,
  URL query/fragment stripping, and terminal sanitization
- Refactor output/sanitize.go to delegate to validate package
- Update sanitize test to reflect ANSI escape stripping behavior
2026-03-28 23:38:24 +08:00
tianlei.qjb 0faf6c1b50 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-28 22:30:26 +08:00
tianlei.qjb e1e70e2137 GitHub Actions CI fix 2026-03-28 22:30:19 +08:00
github-actions[bot] 372869d5e6 chore: update coverage badge [skip ci] 2026-03-28 14:22:20 +00:00
tianlei.qjb f3eb2bcb83 GitHub Actions CI 2026-03-28 22:21:20 +08:00
tianlei.qjb e7d20a1246 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-28 22:06:51 +08:00
tianlei.qjb 6d0917ff73 GitHub Actions CI 2026-03-28 22:06:25 +08:00
fantiu 3c104fcd03 Merge pull request #9 from wxianfeng/main
output default json for ai agent
2026-03-28 21:48:47 +08:00
xianfeng wang ee9e5671c4 Merge branch 'DingTalk-Real-AI:main' into main 2026-03-28 21:46:03 +08:00
wxianfeng 4446a3ad7a default output json 2026-03-28 21:44:08 +08:00
fantiu f7df083ac0 Merge pull request #8 from fantiu/fix-security
fix: correct structure about security.
2026-03-28 21:43:13 +08:00
fantiu b994aea18a fix: correct structure about security. 2026-03-28 21:42:06 +08:00
tianlei.qjb 944e136fe8 GitHub Actions CI 2026-03-28 21:41:23 +08:00
tianlei.qjb ad30854b59 GitHub Actions CI 2026-03-28 21:40:06 +08:00
coffeeBigSir a610cdb80d Merge pull request #5 from linuxliker/integrate-open-recovery-v1-1-0
feat: integrate open recovery workflow and bump v1.1.0
2026-03-28 20:45:40 +08:00
xiaojin.hxj aa3f8f3990 feat: integrate open recovery workflow and bump v1.1.0 2026-03-28 20:41:56 +08:00
fantiu cb67d98e4f Merge pull request #3 from audanye-sudo/feat/enhance-common-display-logic
docs: streamline README layout and add reference manual
2026-03-28 20:34:38 +08:00
qinzeandClaude Opus 4.6 0075cba438 docs: simplify ISV skill integration diagram to inline text
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:32:38 +08:00
qinzeandClaude Opus 4.6 077039bbf3 docs: streamline README layout and add reference manual
- Simplify README.md and README_en.md with cleaner structure
- Add centralized navigation links (reference, changelog)
- Condense co-creation notice and quick-start sections
- Add docs/reference.md as a unified command reference manual

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:26:20 +08:00
柏荣 3fc9f9608b Merge branch 'main' of github.com:DingTalk-Real-AI/dingtalk-workspace-cli
merge
2026-03-28 17:51:39 +08:00
柏荣 a1dffe454d 逻辑优化 2026-03-28 17:51:22 +08:00
tianlei.qjb 56412b12e4 readme.md add 中文/english link url 2026-03-28 14:30:27 +08:00
tianlei.qjb 76df9bfcf1 Merge branch 'main' of https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli 2026-03-28 01:10:52 +08:00
tianlei.qjb f3a4bcfc1f init 2026-03-28 01:10:38 +08:00
柏荣 045a3dcf31 优化auth命令 2026-03-27 22:13:02 +08:00
柏荣 75be1e3eca Merge branch 'main' of github.com:DingTalk-Real-AI/dingtalk-workspace-cli
init
2026-03-27 21:13:18 +08:00
柏荣 cc04a082b1 init 2026-03-27 21:12:49 +08:00
tianlei.qjb 78fdec17e5 init 2026-03-27 20:27:45 +08:00
柏荣 f3418a04de init 2026-03-27 20:17:18 +08:00
coffeeBigSir 2836a0ba1c Delete docs/.DS_Store 2026-03-27 19:55:05 +08:00
tianlei.qjb 58e807b81e init 2026-03-27 19:47:19 +08:00
134 changed files with 14811 additions and 2690 deletions
+21
View File
@@ -0,0 +1,21 @@
# DingTalk Workspace CLI Environment Variables
# Copy this file to .env and fill in your values
# DingTalk App Credentials (required for OAuth authentication)
# DWS_CLIENT_ID=<your-dingtalk-app-client-id>
# DWS_CLIENT_SECRET=<your-dingtalk-app-client-secret>
# Configuration directory (optional, defaults to ~/.dws)
# DWS_CONFIG_DIR=
# Language setting (optional, defaults to system locale)
# DWS_LANG=
# Trusted domains for API requests (optional)
# DWS_TRUSTED_DOMAINS=*.dingtalk.com
# Allow HTTP endpoints (0=no, 1=yes; default: 0)
# DWS_ALLOW_HTTP_ENDPOINTS=0
# Cache directory (optional, defaults to ~/.dws/cache)
# DWS_CACHE_DIR=
+4
View File
@@ -0,0 +1,4 @@
# Default code owners for all files
# These users will be automatically requested for review on PRs.
* @DingTalk-Real-AI/cli-maintainers
+36
View File
@@ -0,0 +1,36 @@
---
name: Bug Report
about: Report a bug to help us improve
title: "[Bug] "
labels: bug
assignees: ''
---
## Describe the Bug
A clear and concise description of what the bug is.
## Steps to Reproduce
1. Run `dws ...`
2. ...
3. See error
## Expected Behavior
A clear and concise description of what you expected to happen.
## Actual Behavior
What actually happened, including any error messages or unexpected output.
## Environment
- **OS**: [e.g., macOS 15.2, Ubuntu 24.04, Windows 11]
- **Architecture**: [e.g., arm64, amd64]
- **CLI Version**: [output of `dws version`]
- **Go Version** (if building from source): [output of `go version`]
## Additional Context
Add any other context about the problem here (logs, screenshots, etc.).
+27
View File
@@ -0,0 +1,27 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[Feature] "
labels: enhancement
assignees: ''
---
## Problem Statement
A clear and concise description of the problem or limitation you are experiencing.
## Proposed Solution
Describe the solution you'd like. Include any specific CLI commands, flags, or behaviors you envision.
## Alternatives Considered
A clear and concise description of any alternative solutions or features you've considered.
## Use Case
Describe the use case(s) that would benefit from this feature.
## Additional Context
Add any other context, mockups, or examples about the feature request here.
+17
View File
@@ -0,0 +1,17 @@
## Summary
- What changed?
- Why is this change needed?
## Verification
- [ ] `make build`
- [ ] `make lint`
- [ ] `make test`
- [ ] `make policy`
- [ ] `./scripts/policy/check-generated-drift.sh`
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
## Notes
- Any risks, follow-up work, or intentional scope cuts
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 50.0%"><title>coverage: 50.0%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">50.0%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">50.0%</text></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+143
View File
@@ -0,0 +1,143 @@
name: CI
on:
push:
branches:
- main
pull_request:
permissions:
contents: write
pull-requests: write
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Format Check
run: |
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
- name: Go Vet
run: go vet ./...
# golangci-lint temporarily disabled: v1.64.8 built with Go 1.24 is incompatible with Go 1.25
# - name: golangci-lint
# uses: golangci/golangci-lint-action@v6
# with:
# version: v1.64.8
# args: ./...
test:
name: Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Test with Race Detection
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Build
run: make build
- name: Run tests with coverage
run: |
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
go tool cover -func=coverage.txt
- name: Generate coverage report
run: go tool cover -html=coverage.txt -o coverage.html
- name: Upload coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage.txt
coverage.html
- name: Update coverage badge
if: github.ref == 'refs/heads/main'
run: |
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
COLOR="brightgreen"
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
COLOR="yellow"
else
COLOR="red"
fi
mkdir -p .github/badges
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
- name: Commit badge
if: github.ref == 'refs/heads/main'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/badges/coverage.svg || true
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
git push || true
policy:
name: Policy Check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build
- name: Policy
run: make policy
- name: Generated Drift
run: ./scripts/policy/check-generated-drift.sh
+51
View File
@@ -0,0 +1,51 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post-release packaging
run: ./scripts/release/post-goreleaser.sh
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+18 -42
View File
@@ -1,51 +1,27 @@
# Python
__pycache__/
.pytest_cache/
.venv*/
*.pyc
*.pyo
# Build outputs
dist/
.tmp-bin/
dws
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.worktrees/
.pytest_cache/
.venv*/
var/
.DS_Store
Thumbs.db
.agents/
/.idea/
/CLAUDE.md
/.claude/
/.idea/vcs.xml
/.idea/.gitignore
dws
test/cli/testdata/
tmp/
test/cli_compat/testdata/
.gitignore
.worktrees/
# Environment & Secrets
# Secrets & credentials
.env
.env.*
*.pem
*.key
credentials*
# Test artifacts
*.log
coverage/
test/cli/testdata/
test/cli_compat/testdata/
# Local working directories
.worktrees/
.agents/
var/
# Node (if applicable)
node_modules/
npm-debug.log*
# Plans (local design docs)
docs/plans/
# Claude
/CLAUDE.md
/.claude/
credentials*
+71
View File
@@ -0,0 +1,71 @@
# GoReleaser configuration for dws
# Docs: https://goreleaser.com
#
# To release:
# git tag -a v0.1.0 -m "Release v0.1.0"
# git push origin v0.1.0
#
# To test locally (no publish):
# goreleaser release --snapshot --clean
version: 2
before:
hooks:
- go mod tidy
builds:
- main: ./cmd
binary: dws
env:
- CGO_ENABLED=0
flags:
- -buildmode=pie
- -trimpath
ldflags:
- -s -w
- -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.version=v{{.Version}}
- -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.gitCommit={{.ShortCommit}}
- -X github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app.buildTime={{.Date}}
goos:
- darwin
- linux
- windows
goarch:
- amd64
- arm64
archives:
- formats:
- tar.gz
name_template: "dws-{{ .Os }}-{{ .Arch }}"
format_overrides:
- goos: windows
formats:
- zip
files:
- LICENSE
- NOTICE
- README.md
- CHANGELOG.md
checksum:
name_template: "checksums.txt"
algorithm: sha256
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
- "^chore:"
release:
github:
owner: DingTalk-Real-AI
name: dingtalk-workspace-cli
draft: false
prerelease: auto
name_template: "v{{.Version}}"
+48
View File
@@ -4,6 +4,54 @@ 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.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.
### Added
- JSON output support for `dws auth login` and `dws auth status`
- Cross-platform keychain-backed secure storage and migration helpers
- Atomic file write helpers to avoid partial config and download writes
- Stronger path and input validation helpers for local file operations
- Install-script coverage for local-source installs
### Changed
- Improved `auth login` help text, hidden compatibility flags, and interactive UX
- Added root-level flag suggestions for common compatibility mistakes such as `--json` and legacy auth flags
- Updated AITable upload parsing to accept nested `content` payloads
- Refreshed bundled skills metadata for the new CLI version
## [1.0.0] - 2026-03-27
First public release of DingTalk Workspace CLI.
+258 -253
View File
@@ -1,175 +1,138 @@
<h1 align="center">DingTalk Workspace CLI (dws)</h1>
**一个 CLI 搞定钉钉工作台所有功能 — 为人类和 AI Agent 而生。**<br>
覆盖通讯录、日历、待办、考勤、智能表格等核心能力,无需样板代码即可调用,所有响应均为结构化 JSON 输出,并内置 Agent Skills 让 AI 开箱即用。
<p align="center"><code>dws</code> — DingTalk Workspace on the command line, built for humans and AI agents.</p>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01oKAc2r28jOyyspcQt_!!6000000007968-2-tps-4096-1701.png" alt="DWS Product Overview" width="100%">
</p>
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
<p align="center">
<a href="./README_zh.md">中文版</a> · <a href="./README.md">English</a> · <a href="./docs/reference.md">Reference</a> · <a href="./CHANGELOG.md">Changelog</a>
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群,提供以下材料给官方人员完成白名单配置:① 钉钉应用的 Client ID;② 企业主管理员确认开通的凭证。后续将支持企业管理员自助审批开通。
> **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.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.0-red" alt="v1.0.0"></a>
</p>
<details>
<summary><strong>Table of Contents</strong></summary>
## 目录
- [为什么选择 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)
<h2 id="why-dws">为什么选择 dws?</h2>
</details>
**为人类而设计** — 告别手写 API 调用。`dws` 为每个资源提供 `--help`,用 `--dry-run` 预览请求,支持表格/JSON/原始格式的结构化输出。
---
**为 AI Agent 而设计** — 每个响应都是结构化 JSON。配合内置的 agent skills,您的 LLM 无需自定义工具即可管理钉钉工作台。
<h2 id="why-dws">Why dws?</h2>
```bash
# 搜索联系人
dws contact user search --keyword "悟空"
- **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.**
# 创建待办事项
dws todo task create --title "准备季度汇报材料" --executors "<userId>"
## Installation
# 预览操作但不执行
dws todo task list --dry-run
# JSON 输出供 agent 使用
dws contact user search --keyword "悟空" -f json
```
## 核心服务
`dws` 通过统一的命令界面覆盖钉钉产品:
| 服务 | 命令 | 描述 |
|---------|---------|-------------|
| 通讯录 | `contact` | 通讯录 / 用户 / 部门 |
| 群聊 | `chat` | 机器人消息 / Webhook / 机器人管理 |
| 智能表格 | `aitable` | AI 表格操作 |
| 日历 | `calendar` | 日历日程 / 会议室 / 闲忙 |
| 待办 | `todo` | 待办任务管理 |
| 审批 | `approval` | 审批流程 / 表单 / 实例 |
| 考勤 | `attendance` | 考勤打卡 / 排班 / 统计 |
| DING | `ding` | DING 消息 / 发送 / 撤回 |
| 日志 | `report` | 日志 / 模版 / 统计 |
| 工作台 | `workbench` | 工作台应用查询 |
| 开发者文档 | `devdoc` | 开放平台文档搜索 |
| 文档 | `doc` | 文档操作(即将推出) |
| 邮箱 | `mail` | 邮件管理(即将推出) |
| AI 听记 | `minutes` | AI 听记 / 会议纪要(即将推出) |
| 钉盘 | `drive` | 云盘 / 文件存储(即将推出) |
| 视频会议 | `conference` | 视频会议(即将推出) |
| Teambition | `tb` | 项目管理(即将推出) |
| AI 应用 | `aiapp` | AI 应用管理(即将推出) |
| 直播 | `live` | 直播管理(即将推出) |
| 技能市场 | `skill` | 技能搜索与下载(即将推出) |
运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看特定服务的命令。
## 安装
### 一键安装(推荐)
**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
```
> 自动检测操作系统和架构,从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载预编译二进制文件,并安装 Agent Skills 到 `~/.agents/skills/dws` — 无需 Go、Node.js 或其他依赖。大多数 AI Agent(Claude Code、Cursor、Windsurf 等)可自动发现 `.agents/skills/` 目录下的技能。
<details>
<summary>Other install methods</summary>
> [!TIP]
> 二进制文件默认安装到 `~/.local/bin`。如果安装后找不到 `dws` 命令,请将其添加到 PATH:
> ```bash
> export PATH="$HOME/.local/bin:$PATH"
> ```
> 将此行添加到 `~/.bashrc` 或 `~/.zshrc` 以永久生效。
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
### 预编译二进制文件(手动)
从 [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
./dws version
go build -o dws ./cmd # build to current directory
cp dws ~/.local/bin/ # install to PATH
```
这只会构建二进制文件。如需同时将 agent skills 安装到主目录:
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
```bash
sh scripts/install.sh
</details>
## 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**.
<details>
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
</p>
</details>
### 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
```
这会检测本地源码目录,无需从 GitHub 下载即可安装二进制文件和 skills。
## 开始使用
### 步骤 1:创建钉钉应用
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击右上角的**创建应用**,新建一个应用。
> `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.
<details>
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
</p>
</details>
### 步骤 2:配置重定向 URL
### Step 3: Publish the Application
创建应用后,进入应用内,点击**安全设置**。在「重定向 URL(回调设置)」里,输入 `http://127.0.0.1` 并保存。
Click "App Release - Version Management & Release" to publish and go live.
<details>
<summary>View screenshot</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
</p>
</details>
### 步骤 3:发布应用
### Step 4: Request Whitelist Access
点击「应用发布 - 版本管理与发布」,发布版本,使应用变成上线状态。
Join the DingTalk DWS co-creation group and provide your **Client ID** and **admin confirmation** to complete whitelist setup.
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
</p>
### 步骤 4:申请白名单
参照页面顶部的 [共创阶段说明](#important),加入钉钉 DWS 共创群完成白名单配置。
### 步骤 5:使用凭证登录
获取 Client ID(AppKey)和 Client Secret(AppSecret)后,可通过 CLI 参数指定:
### Step 5: Authenticate
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
或者通过环境变量设置:
Or via environment variables:
```bash
export DWS_CLIENT_ID=<your-app-key>
@@ -177,184 +140,226 @@ export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
> [!NOTE]
> CLI 参数优先级高于环境变量。这些凭证用于钉钉的 OAuth 设备流认证。
> CLI flags take precedence over environment variables. Credentials are used for DingTalk's OAuth device flow.
### Token 加密
Token 使用 **PBKDF2(600,000 次迭代)+ AES-256-GCM** 加密存储,密钥由您的设备 MAC 地址生成。
## 快速开始
## Quick Start
```bash
dws auth login # 钉钉身份认证
dws contact user search --keyword "悟空" # 搜索联系人
dws calendar event list # 列出日历事件
dws todo task create --title "准备季度汇报材料" --executors "<userId>" # 创建待办
dws contact user search --keyword "Alice" # search contacts
dws calendar event list # list calendar events
dws todo task create --title "Quarterly report" --executors "<userId>" # create a todo
dws todo task list --dry-run # preview without executing
```
## AI Agent Skills
## Using with Agents
仓库为每个支持的钉钉产品提供 agent skills(`SKILL.md` 文件)。
dws is designed as an AI-native CLI. Here's how to integrate it with your agent:
Skills 由[安装](#安装)脚本自动安装。如需单独将 skills 安装到现有项目:
### 1. Setup
```bash
# macOS / Linux — 仅将 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=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-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`)将 skills 安装到 `~/.agents/skills/dws`(主目录)。
当您想要为特定项目仓库添加 skills 时,请使用 `install-skills.sh`,它会安装到 `./.agents/skills/dws`(当前工作目录)。
> See the [AI Agent Skills](#ai-agent-skills) section for details.
> [!NOTE]
> **主目录 vs. 项目 skills**:`install.sh` 将 skills 放在 `$HOME/.agents/skills/dws`。`install-skills.sh` 安装到**当前工作目录**(`./.agents/skills/dws`),适用于为特定项目仓库添加 skills。
## New Features
## 高级用法
### Smart Input Correction
### 输出格式
所有命令支持多种输出格式:
dws includes a built-in pipeline engine that auto-corrects common AI model parameter mistakes:
```bash
# 表格(默认,适合人类阅读)
dws contact user search --keyword "悟空" -f table
# 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
# JSON(适合 agent 和管道处理)
dws contact user search --keyword "悟空" -f json
# Sticky argument splitting
dws contact user search --keyword "test" --timeout30 # auto-split to --timeout 30
# 原始 API 响应
dws contact user search --keyword "悟空" -f raw
# Fuzzy flag name matching
dws aitable record query --base-id x --tabel-id y # --tabel-id -> --table-id
# Value normalization (boolean / number / date / enum)
# "yes" -> true, "1,000" -> 1000, "2024/03/29" -> "2024-03-29", "ACTIVE" -> "active"
```
### 试运行
### JSON Field Selection & jq Filtering
预览 MCP 工具调用但不执行:
Fine-grained output control to reduce token consumption:
```bash
dws todo task list --dry-run
# 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 contact user search --keyword "李明" -o result.json
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
```
### Shell 自动补全
## 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 <service> --help` for subcommands.
<details>
<summary>Coming soon</summary>
`doc` (documents) · `mail` (email) · `minutes` (AI transcription) · `drive` (cloud drive) · `conference` (video) · `tb` (Teambition) · `aiapp` (AI apps) · `live` (streaming) · `skill` (marketplace)
</details>
<h2 id="security-by-design">Security by Design</h2>
`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.
<details>
<summary><strong>For Developers</strong></summary>
| 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 |
</details>
<details>
<summary><strong>For Enterprise Admins</strong></summary>
| 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 |
</details>
<details>
<summary><strong>For ISVs</strong></summary>
| 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 |
</details>
> 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
# Bash
dws completion bash > /etc/bash_completion.d/dws
# Zsh
dws completion zsh > "${fpath[1]}/_dws"
# Fish
dws completion fish > ~/.config/fish/completions/dws.fish
# 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
| 变量 | 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | 覆盖默认配置目录 |
| `DWS_SERVERS_URL` | 将服务发现指向自定义服务器注册端点 |
| `DWS_CLIENT_ID` | OAuth client ID(钉钉 AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret(钉钉 AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Bearer token 允许发送的域名列表,逗号分隔(默认 `*.dingtalk.com`)。仅开发环境可设为 `*` |
| `DWS_ALLOW_HTTP_ENDPOINTS` | 设为 `1` 允许对回环地址使用 HTTP(非 TLS),仅用于开发调试 |
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.
| 退出码 | 类别 | 描述 |
|--------|------|------|
| 0 | 成功 | 命令执行成功 |
| 1 | API | MCP 工具调用或上游 API 失败 |
| 2 | 认证 | 身份认证或授权失败 |
| 3 | 校验 | 输入参数、命令行标志或参数 schema 不匹配 |
| 4 | 发现 | 服务发现、缓存或协议协商失败 |
| 5 | 内部 | 未预期的内部错误 |
## Reference & Docs
使用 `-f json` 时,错误响应包含结构化信息(`category`、`reason`、`hint`、`actions` 字段),便于机器消费。
- [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
`dws` 使用 **发现驱动的管道** — 不硬编码任何产品命令:
See [CONTRIBUTING.md](./CONTRIBUTING.md) for build instructions, testing, and development workflow.
```
Market Registry ──► Discovery ──► IR (规范化目录) ──► CLI (Cobra) ──► Transport (MCP JSON-RPC)
│ │
▼ ▼
mcp.dingtalk.com 缓存(TTL + 过期降级)
```
1. **Market** — 从 `mcp.dingtalk.com` 获取 MCP 服务注册表
2. **Discovery** — 解析服务运行时能力,支持磁盘缓存和过期降级保证离线可用
3. **IR** — 将服务规范化为统一的产品/工具目录
4. **CLI** — 将目录挂载到 Cobra 命令树,映射 flag 到 MCP 输入参数
5. **Transport** — 执行 MCP JSON-RPC 调用,支持重试、认证注入和响应大小限制
使用 `-f json` 时,所有输出 — 成功、错误和元数据 — 都是结构化 JSON。
## 开发指南
```bash
make build # 开发构建
make test # 单元测试
make lint # 格式化 + lint 检查
make package # 本地构建所有发布产物(goreleaser snapshot)
make release # 通过 goreleaser 构建和发布
make publish-homebrew-formula # 将 dist/homebrew/dingtalk-workspace-cli.rb 推送到 tap 仓库
```
### 包管理器产物
构建并验证本地包管理器产物:
```bash
make package # 生成所有平台归档、npm 资源、Homebrew formula
./scripts/release/verify-package-managers.sh # 验证 dws 二进制文件和 skills 包含在内
```
## 测试
### CLI 测试
运行完整的 CLI 测试套件(单元测试、golden 测试和集成测试):
```bash
bash test/scripts/run_all_tests.sh --jobs 8
```
### 打包测试
运行打包契约测试和本地包管理器验证:
```bash
go test ./test/scripts/... -count=1
make package
./scripts/release/verify-package-managers.sh
```
### Skills 测试
安装 skills 后,使用 [`test/skill_tests.md`](./test/skill_tests.md) 进行验证。将该文件中的测试提示输入您的 AI agent 并确认预期输出。
## 更新日志
参见 [CHANGELOG.md](./CHANGELOG.md) 了解版本历史和迁移说明。
## 安全策略
报告安全漏洞请参见 [SECURITY.md](./SECURITY.md)。
## 贡献指南
参见 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解开发工作流和本地验证步骤。
## 许可证
## License
Apache-2.0
-360
View File
@@ -1,360 +0,0 @@
<h1 align="center">DingTalk Workspace CLI (dws)</h1>
**One CLI for all of DingTalk Workspace — built for humans and AI agents.**<br>
Access contacts, calendar, todos, attendance, AI tables and more with zero boilerplate, get structured JSON responses ready for automation, and leverage built-in Agent Skills for seamless AI integration.
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01oKAc2r28jOyyspcQt_!!6000000007968-2-tps-4096-1701.png" alt="DWS Product Overview" width="100%">
</p>
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. We are currently in a gray-scale co-creation phase. Please join the DingTalk DWS co-creation group and provide the following materials to the official staff for whitelist configuration: ① Your DingTalk application's Client ID; ② Confirmation from the enterprise admin to enable access. Self-service approval by enterprise admins will be supported in the future.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.0.0-red" alt="v1.0.0"></a>
</p>
## Contents
- [Why dws?](#why-dws)
- [Key Services](#key-services)
- [Installation](#installation)
- [Getting Started](#getting-started)
- [Quick Start](#quick-start)
- [AI Agent Skills](#ai-agent-skills)
- [Advanced Usage](#advanced-usage)
- [Environment Variables](#environment-variables)
- [Exit Codes](#exit-codes)
- [Architecture](#architecture)
- [Development](#development)
- [Testing](#testing)
- [Changelog](#changelog)
- [Security](#security)
- [Contributing](#contributing)
<h2 id="why-dws">Why dws?</h2>
**For humans** — stop writing raw API calls. `dws` gives you `--help` on every resource, `--dry-run` to preview requests, and structured output in table/JSON/raw formats.
**For AI agents** — every response is structured JSON. Pair it with the included agent skills and your LLM can manage DingTalk Workspace without custom tooling.
```bash
# Search for a contact
dws contact user search --keyword "Alice"
# Create a todo item
dws todo task create --title "Prepare quarterly report" --executors "<userId>"
# Preview an operation without executing
dws todo task list --dry-run
# JSON output for agent consumption
dws contact user search --keyword "Alice" -f json
```
## Key Services
`dws` covers DingTalk products through a unified command surface:
| Service | Command | Description |
|---------|---------|-------------|
| Contact | `contact` | Contacts / users / departments |
| Chat | `chat` | Bot messaging / webhook / bot management |
| Calendar | `calendar` | Calendar events / meeting rooms / free-busy |
| Todo | `todo` | Todo task management |
| Approval | `approval` | Approval processes / forms / instances |
| Attendance | `attendance` | Attendance / shifts / statistics |
| Ding | `ding` | DING messages / send / recall |
| Report | `report` | Report / template / statistics |
| AITable | `aitable` | AI table operations |
| Workbench | `workbench` | Workbench app query |
| DevDoc | `devdoc` | Open platform docs search |
| Doc | `doc` | Document operations (coming soon) |
| Mail | `mail` | Email management (coming soon) |
| Minutes | `minutes` | AI meeting transcription (coming soon) |
| Drive | `drive` | Cloud drive / file storage (coming soon) |
| Conference | `conference` | Video conferencing (coming soon) |
| Teambition | `tb` | Project management (coming soon) |
| AI App | `aiapp` | AI application management (coming soon) |
| Live | `live` | Live streaming (coming soon) |
| Skill | `skill` | Skill marketplace (coming soon) |
Run `dws --help` for the complete list, or `dws <service> --help` for service-specific commands.
## Installation
### One-line install (recommended)
**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
```
> Auto-detects OS and architecture, downloads the pre-built binary from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases), and installs Agent Skills to `~/.agents/skills/dws` — no Go, Node.js, or other dependencies required. Most AI agents (Claude Code, Cursor, Windsurf, etc.) can discover skills from the `.agents/skills/` directory.
> [!TIP]
> The binary is installed to `~/.local/bin` by default. If `dws` is not found after installation, add it to your PATH:
> ```bash
> export PATH="$HOME/.local/bin:$PATH"
> ```
> Add this line to your `~/.bashrc` or `~/.zshrc` to make it permanent.
### Pre-built binary (manual)
Download the latest binary for your platform 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
./dws version
```
This builds the binary only. To also install agent skills into your home directory:
```bash
sh scripts/install.sh
```
This detects the local source checkout and installs both the binary and skills without downloading from GitHub.
## Getting Started
### Step 1: Create a DingTalk Application
Go to the [Open Platform App Development Console](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app). Under "Internal Enterprise Apps - DingTalk Apps", click **Create App** in the top right corner to create a new application.
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="Create Application" width="600">
</p>
### Step 2: Configure Redirect URL
After creating the app, go into the app settings and click **Security Settings**. In the "Redirect URL (Callback Settings)" section, enter `http://127.0.0.1` and save.
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="Configure Redirect URL" width="600">
</p>
### Step 3: Publish the Application
Click "App Release - Version Management & Release", publish a version to make the app go live.
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="Publish Application" width="600">
</p>
### Step 4: Request Whitelist Access
Refer to the [Co-creation Phase notice](#important) at the top of this page to join the DingTalk DWS co-creation group and complete whitelist configuration.
### Step 5: Login with Credentials
Once you have the AppKey and AppSecret, specify them via CLI flags:
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
Alternatively, set via environment variables:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
> [!NOTE]
> CLI flags take precedence over environment variables. These credentials are used for the OAuth device flow authentication with DingTalk.
### Token Encryption
Tokens are encrypted at rest using **PBKDF2 (600,000 iterations) + AES-256-GCM**, keyed by your device MAC address.
## Quick Start
```bash
dws auth login # authenticate with DingTalk
dws contact user search --keyword "Alice" # search contacts
dws calendar event list # list calendar events
dws todo task create --title "Prepare quarterly report" --executors "<userId>" # create a todo
```
## AI Agent Skills
The repo ships agent skills (`SKILL.md` files) for every supported DingTalk product.
Skills are installed automatically by the [Installation](#installation) scripts. To install skills separately into an existing project:
```bash
# macOS / Linux — install only skills into the current project
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
The one-line installer (`install.sh`) installs skills to `~/.agents/skills/dws` (home directory).
Use `install-skills.sh` when you want to seed a specific project repository with `./.agents/skills/dws` (current working directory).
> [!NOTE]
> **Home vs. project skills**: `install.sh` places skills in `$HOME/.agents/skills/dws`. `install-skills.sh` installs into the **current working directory** (`./.agents/skills/dws`), which is useful for seeding a specific project repository.
## Advanced Usage
### Output Formats
All commands support multiple output formats:
```bash
# Table (default, human-friendly)
dws contact user search --keyword "Alice" -f table
# JSON (for agents and piping)
dws contact user search --keyword "Alice" -f json
# Raw API response
dws contact user search --keyword "Alice" -f raw
```
### Dry Run
Preview the MCP tool invocation without executing:
```bash
dws todo task list --dry-run
```
### Output to File
```bash
dws contact user search --keyword "Alice" -o result.json
```
### Shell Completion
```bash
# Bash
dws completion bash > /etc/bash_completion.d/dws
# Zsh
dws completion zsh > "${fpath[1]}/_dws"
# Fish
dws completion fish > ~/.config/fish/completions/dws.fish
```
## Environment Variables
Common runtime and development overrides:
| Variable | Purpose |
|---------|---------|
| `DWS_CONFIG_DIR` | Overrides the default config directory |
| `DWS_SERVERS_URL` | Points discovery at a custom server registry endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated list of trusted domains for bearer token injection (default: `*.dingtalk.com`). Set to `*` for development only |
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set to `1` to allow HTTP (non-TLS) for loopback addresses during development |
## Exit Codes
| Code | Category | Description |
|------|----------|-------------|
| 0 | Success | Command completed successfully |
| 1 | API | MCP tool call or upstream API failure |
| 2 | Auth | Authentication or authorization failure |
| 3 | Validation | Invalid input, flags, or parameter schema mismatch |
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure |
| 5 | Internal | Unexpected internal error |
When `-f json` is used, error responses include structured payloads with `category`, `reason`, `hint`, and optional `actions` fields for machine consumption.
## Architecture
`dws` uses a **discovery-driven pipeline** — no product commands are hardcoded:
```
Market Registry ──► Discovery ──► IR (Canonical Catalog) ──► CLI (Cobra) ──► Transport (MCP JSON-RPC)
│ │
▼ ▼
mcp.dingtalk.com Cache (TTL + stale fallback)
```
1. **Market** — fetches the MCP server registry from `mcp.dingtalk.com`
2. **Discovery** — resolves runtime server capabilities with disk cache and stale-fallback for offline resilience
3. **IR** — normalizes servers into a canonical product/tool catalog
4. **CLI** — mounts the catalog onto a Cobra command tree, maps flags to MCP input parameters
5. **Transport** — executes MCP JSON-RPC calls with retries, auth injection, and response size limits
All output — success, errors, and metadata — is structured JSON when using `-f json`.
## Development
```bash
make build # dev build
make test # unit tests
make lint # formatting + lint checks
make package # build all release artifacts locally (goreleaser snapshot)
make release # build and publish a release via goreleaser
make publish-homebrew-formula # push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo
```
### Package Manager Artifacts
Build and verify local package-manager artifacts:
```bash
make package # generates all platform archives, npm assets, Homebrew formulas
./scripts/release/verify-package-managers.sh # verifies dws binary + skills are included
```
## Testing
### CLI Tests
Run the full CLI test suite (unit, golden, and integration tests):
```bash
bash test/scripts/run_all_tests.sh --jobs 8
```
### Packaging Tests
Run packaging contract tests and local package-manager verification:
```bash
go test ./test/scripts/... -count=1
make package
./scripts/release/verify-package-managers.sh
```
### Skill Tests
After installing the skills, use [`test/skill_tests.md`](./test/skill_tests.md) to verify them. Feed the test prompts from that file to your AI agent and confirm the expected outputs.
## Changelog
See [CHANGELOG.md](./CHANGELOG.md) for release history and migration notes.
## Security
To report a vulnerability, see [SECURITY.md](./SECURITY.md).
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development workflow and local verification steps.
## License
Apache-2.0
+367
View File
@@ -0,0 +1,367 @@
<h1 align="center">DingTalk Workspace CLI (dws)</h1>
<p align="center"><code>dws</code> — 钉钉工作台命令行工具,为人类和 AI Agent 而生。</p>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i1/O1CN01oKAc2r28jOyyspcQt_!!6000000007968-2-tps-4096-1701.png" alt="DWS Product Overview" width="100%">
</p>
<p align="center">
<img src="https://img.shields.io/badge/Go-1.25+-green?logo=go&logoColor=white" alt="Go 1.25+">
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-Apache_2.0-blue" alt="License Apache-2.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases"><img src="https://img.shields.io/badge/release-v1.1.0-red" alt="v1.1.0"></a>
<a href="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml"><img src="https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src=".github/badges/coverage.svg" alt="Coverage">
</p>
<p align="center">
<a href="./README_zh.md">中文版</a> · <a href="./README.md">English</a> · <a href="./docs/reference.md">参考手册</a> · <a href="./CHANGELOG.md">更新日志</a>
</p>
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。当前为灰度共创阶段,请加入钉钉 DWS 共创群完成白名单配置。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i1/O1CN01ZqtgeV1cImFmTZPAH_!!6000000003578-2-tps-398-372.png" alt="DingTalk Group QR Code" width="150"></a>
<details>
<summary><strong>目录</strong></summary>
- [为什么选择 dws?](#why-dws)
- [安装](#安装)
- [开始使用](#开始使用)
- [快速开始](#快速开始)
- [在 Agent 中使用](#在-agent-中使用)
- [新功能](#新功能)
- [核心服务](#核心服务)
- [安全设计](#安全设计)
- [AI Agent Skills](#ai-agent-skills)
- [参考与文档](#参考与文档)
- [贡献指南](#贡献指南)
</details>
---
<h2 id="why-dws">为什么选择 dws?</h2>
- **为人类而设计** — `--help` 查看用法,`--dry-run` 预览请求,`-f table/json/raw` 切换格式。
- **为 AI Agent 而设计** — 结构化 JSON 响应 + 内置 Agent Skills,开箱即用。
- **为企业管理员而设计** — 零信任架构:OAuth 设备流认证 + 域名白名单 + 权限最小化。**没有一个字节能绕过安全鉴权和审计。**
## 安装
**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
```
<details>
<summary>其他安装方式</summary>
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
**从源码构建**:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
cd dingtalk-workspace-cli
go build -o dws ./cmd # 编译到当前目录
cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
</details>
## 开始使用
### 步骤 1:创建钉钉应用
进入 [开放平台应用开发后台](https://open-dev.dingtalk.com/fe/app?hash=%23%2Fcorp%2Fapp#/corp/app),在「企业内部应用 - 钉钉应用」点击**创建应用**。
<details>
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01VIkwvV1a5NQzCIFO0_!!6000000003278-2-tps-2690-1462.png" alt="创建应用" width="600">
</p>
</details>
### 步骤 2:配置重定向 URL
进入应用 → **安全设置**,在「重定向 URL」中添加以下地址并保存:
```
http://127.0.0.1
https://login.dingtalk.com
```
> `http://127.0.0.1` 用于本地浏览器登录;`https://login.dingtalk.com` 用于 `--device` 设备流登录(Docker 容器、远程服务器等无浏览器环境)。建议两个都配置。
<details>
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN017xQGWb1ycrAG0uxBO_!!6000000006600-2-tps-2000-1032.png" alt="配置重定向URL" width="600">
</p>
</details>
### 步骤 3:发布应用
点击「应用发布 - 版本管理与发布」,发布版本使应用上线。
<details>
<summary>查看截图</summary>
<p align="center">
<img src="https://img.alicdn.com/imgextra/i4/O1CN01WOLZFz244P46B3FPu_!!6000000007337-2-tps-2000-1100.png" alt="发布应用" width="600">
</p>
</details>
### 步骤 4:申请白名单
加入钉钉 DWS 共创群,提供 **Client ID** 和**管理员确认凭证**完成白名单配置。
### 步骤 5:登录认证
```bash
dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
```
或通过环境变量:
```bash
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
> CLI 参数优先于环境变量。凭证用于钉钉 OAuth 设备流认证。
## 快速开始
```bash
dws contact user search --keyword "悟空" # 搜索联系人
dws calendar event list # 查看日历日程
dws todo task create --title "季度汇报" --executors "<userId>" # 创建待办
dws todo task list --dry-run # 预览操作但不执行
```
## 在 Agent 中使用
dws 是为 AI Agent 设计的 CLI 工具。以下是接入指南。
### 1. 环境配置
```bash
# 安装 dws
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
# 通过环境变量配置认证(Agent 推荐方式,无需交互式登录)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
```
### 2. Agent 调用模式
```bash
# 使用 --yes 跳过确认提示(Agent 必须)
dws todo task create --title "Review PR" --yes
# 使用 --dry-run 预览操作(安全执行)
dws contact user search --keyword "test" --dry-run
# 使用 --jq 精确提取(节省 token)
dws contact user search --keyword "test" --jq '.response.content'
# 使用 --fields 只返回需要的字段
dws calendar event list --fields response
```
### 3. Schema 发现(Agent 自主探索)
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
```bash
# 第一步:发现所有可用产品
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# 第二步:查看目标工具的参数结构
dws schema aitable.query_records --jq '.tool.input_schema'
# 第三步:构造正确的调用
dws aitable record query --base-id "xxx" --table-id "yyy" --limit 10
```
### 4. 错误容忍
dws 的智能纠错引擎对 Agent 生成的常见错误有高容忍度:
| Agent 输出 | dws 自动纠正为 |
|-----------|--------------|
| `--userId` | `--user-id` |
| `--limit100` | `--limit 100` |
| `--tabel-id` | `--table-id` |
| `--USER-ID` | `--user-id` |
| `--user_name` | `--user-name` |
### 5. Agent Skill 集成
安装 Agent Skills 后,Claude Code / Cursor 等工具可直接使用钉钉能力:
```bash
# 安装 skills 到当前项目
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> 详见 [AI Agent Skills](#ai-agent-skills) 章节。
## 新功能
### 智能输入纠错
dws 内置 Pipeline 纠错引擎,自动修正 AI 模型常见的参数错误,无需人工干预:
```bash
# 命名风格自动转换 (camelCase / snake_case / UPPER → kebab-case)
dws aitable record query --baseId x --tableId y # 自动纠正为 --base-id --table-id
# 粘连参数自动拆分
dws contact user search --keyword "test" --timeout30 # 自动拆分为 --timeout 30
# 拼写错误模糊匹配
dws aitable record query --base-id x --tabel-id y # --tabel-id → --table-id
# 参数值归一化 (布尔 / 数字 / 日期 / 枚举)
# "yes" → true, "1,000" → 1000, "2024/03/29" → "2024-03-29", "ACTIVE" → "active"
```
### JSON 字段筛选 & jq 过滤
精确控制输出内容,减少 token 消耗:
```bash
# 只返回指定字段
dws aitable record query --base-id x --table-id y --fields invocation,response
# 内置 jq 表达式
dws aitable record query --base-id x --table-id y --jq '.invocation.params'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
```
### Schema 自省
Agent 可以在调用前查询任何工具的参数结构:
```bash
dws schema # 列出所有产品和工具
dws schema aitable.query_records # 查看参数 Schema
dws schema aitable.query_records --jq '.tool.input_schema.required' # 查看必填字段
dws schema --jq '.products[].id' # 提取所有产品 ID
```
## 核心服务
| 服务 | 命令 | 描述 |
|---------|---------|-------------|
| 通讯录 | `contact` | 用户 / 部门 |
| 群聊 | `chat` | 群管理 / 群成员 / 机器人消息 / Webhook |
| 日历 | `calendar` | 日程 / 会议室 / 闲忙 |
| 待办 | `todo` | 任务管理 |
| 审批 | `approval` | 流程 / 表单 / 实例 |
| 考勤 | `attendance` | 打卡 / 排班 / 统计 |
| DING | `ding` | DING 消息 / 发送 / 撤回 |
| 日志 | `report` | 日志 / 模版 / 统计 |
| 智能表格 | `aitable` | AI 表格操作 |
| 工作台 | `workbench` | 应用查询 |
| 开发者文档 | `devdoc` | 开放平台文档搜索 |
运行 `dws --help` 查看完整列表,或 `dws <service> --help` 查看子命令。
<details>
<summary>即将推出</summary>
`doc`(文档)· `mail`(邮箱)· `minutes`(AI 听记)· `drive`(钉盘)· `conference`(视频会议)· `tb`(Teambition)· `aiapp`(AI 应用)· `live`(直播)· `skill`(技能市场)
</details>
## 安全设计
`dws` 从架构层面将安全作为一等公民,而非事后补丁。**凭证不落盘、Token 不出域、权限不越界、操作不脱审** — 每一次 API 调用都必须经过钉钉开放平台的鉴权和审计链路,无例外。
<details>
<summary><strong>开发者安全机制</strong></summary>
| 机制 | 说明 |
|------|------|
| **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 仅在内存中使用,不写入配置文件或日志 |
</details>
<details>
<summary><strong>企业管理员安全机制</strong></summary>
| 机制 | 说明 |
|------|------|
| **OAuth 设备流认证** | 用户必须通过管理员授权的钉钉应用认证,未授权应用无法获取 Token |
| **权限最小化** | CLI 仅能调用管理员授予该应用的 API 权限范围,无法越权 |
| **白名单准入** | 共创阶段需管理员主动确认开通,后续支持自助审批 |
| **操作全链路审计** | 每一次数据读写都经过钉钉开放平台 API,企业管理员可在管理后台实时追溯完整调用日志,任何异常操作无处隐藏 |
</details>
<details>
<summary><strong>ISV / 企业服务商安全机制</strong></summary>
| 机制 | 说明 |
|------|------|
| **租户数据隔离** | 以已授权应用身份调用 API,不同租户数据严格隔离 |
| **Skill 沙箱** | Agent Skills 是 Markdown 文档(`SKILL.md`),仅提供 prompt 描述,不执行任意代码 |
| **集成链路零盲区** | ISV Skill 与 dws Skill 联调时,每一次 API 调用都强制经过钉钉开放平台鉴权,完整调用链路可追溯,不存在绕过审计的旁路 |
</details>
> 发现安全漏洞?请通过 [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`。
```bash
# 仅安装 skills 到当前项目
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`(当前项目)。
### ISV Skill 联调
编写您自己的 Agent Skill,与 dws 内置 skill 搭配构建跨产品工作流:**ISV Skill → dws Skill → 钉钉开放平台 API(强制鉴权 + 全链路审计)**。
**示例**:CRM Skill 调用日历 Skill 为客户创建会议,再通过待办 Skill 分配跟进任务 — AI Agent 在一次对话中完成跨系统协作。
## 参考与文档
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
## 贡献指南
参见 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解构建、测试和开发工作流。
## 许可证
Apache-2.0
+21 -2
View File
@@ -1,8 +1,24 @@
{
"name": "dingtalk-workspace-cli",
"version": "__VERSION__",
"description": "DingTalk Workspace CLI",
"description": "DingTalk Workspace CLI - AI-powered productivity tools",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/open-dingtalk/dingtalk-workspace-cli.git"
},
"homepage": "https://github.com/open-dingtalk/dingtalk-workspace-cli",
"bugs": {
"url": "https://github.com/open-dingtalk/dingtalk-workspace-cli/issues"
},
"keywords": [
"dingtalk",
"dws",
"cli",
"workspace",
"ai",
"productivity"
],
"bin": {
"dws": "./bin/dws.js"
},
@@ -14,5 +30,8 @@
"bin",
"install.js",
"README.md"
]
],
"engines": {
"node": ">=16"
}
}
BIN
View File
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
# Reference / 参考手册
## Environment Variables / 环境变量
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
## Exit Codes / 退出码
| Code | Category | Description / 描述 |
|------|----------|-------------|
| 0 | Success | Command completed successfully / 命令执行成功 |
| 1 | API | MCP tool call or upstream API failure / MCP 工具调用或上游 API 失败 |
| 2 | Auth | Authentication or authorization failure / 身份认证或授权失败 |
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现失败 |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
使用 `-f json` 时,错误响应包含结构化字段:`category`、`reason`、`hint`、`actions`。
## Output Formats / 输出格式
```bash
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
```
## Dry Run / 试运行
```bash
dws todo task list --dry-run # Preview MCP call without executing / 预览但不执行
```
## Output to File / 输出到文件
```bash
dws contact user search --keyword "Alice" -o result.json
```
## Shell Completion / 自动补全
```bash
# Bash
dws completion bash > /etc/bash_completion.d/dws
# Zsh
dws completion zsh > "${fpath[1]}/_dws"
# Fish
dws completion fish > ~/.config/fish/completions/dws.fish
```
+6
View File
@@ -4,13 +4,19 @@ go 1.25.8
require (
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
golang.org/x/text v0.35.0
)
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
)
+20
View File
@@ -1,18 +1,38 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/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=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
+140 -53
View File
@@ -15,7 +15,9 @@ package app
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
@@ -50,7 +52,6 @@ func buildAuthCommand() *cobra.Command {
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
newAuthImportCommand(),
newAuthExchangeCommand(),
newAuthResetCommand(),
)
@@ -59,8 +60,23 @@ func buildAuthCommand() *cobra.Command {
func newAuthLoginCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "login",
Short: "登录钉钉(自动刷新 token,必要时扫码)",
Use: "login",
Short: "登录钉钉(自动刷新 token,必要时扫码)",
Long: `登录钉钉并获取认证凭证。
支持的登录方式:
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
- 直接提供 Token: 通过 --token 参数传入已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
示例:
dws auth login # 扫码登录
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := resolveAuthLoginConfig(cmd)
@@ -105,6 +121,14 @@ func newAuthLoginCommand() *cobra.Command {
clearCompatCache()
w := cmd.OutOrStdout()
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeAuthLoginJSON(w, tokenData, cfg.Force)
}
// Default table output
fmt.Fprintln(w)
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
fmt.Fprintf(w, "[OK] Token 有效,无需重新登录\n")
@@ -130,15 +154,23 @@ func newAuthLoginCommand() *cobra.Command {
},
}
cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow (compatibility flag)")
cmd.Flags().Bool("force", false, "Force interactive login flow (compatibility flag)")
cmd.Flags().String("redirect-url", "", "Loopback redirect URL compatibility flag")
cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
// Hidden compatibility flags
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
cmd.Flags().String("scopes", "", "Space-separated DingTalk OAuth scopes")
cmd.Flags().String("authorize-url", "", "Override DingTalk authorization URL")
cmd.Flags().String("token-url", "", "Override DingTalk token exchange URL")
cmd.Flags().String("refresh-url", "", "Override DingTalk refresh token URL")
cmd.Flags().Int("login-timeout", 0, "Compatibility flag for login timeout seconds")
cmd.Flags().Bool("no-browser", false, "Compatibility flag for browser launch suppression")
cmd.Flags().Int("login-timeout", 0, "Login timeout seconds")
cmd.Flags().Bool("no-browser", false, "Suppress browser launch")
_ = cmd.Flags().MarkHidden("redirect-url")
_ = cmd.Flags().MarkHidden("scopes")
_ = cmd.Flags().MarkHidden("authorize-url")
_ = cmd.Flags().MarkHidden("token-url")
_ = cmd.Flags().MarkHidden("refresh-url")
_ = cmd.Flags().MarkHidden("login-timeout")
_ = cmd.Flags().MarkHidden("no-browser")
return cmd
}
@@ -176,28 +208,35 @@ func newAuthStatusCommand() *cobra.Command {
configDir := defaultConfigDir()
authenticated := false
updatedAt := ""
refreshed := false
var tokenData *authpkg.TokenData
provider := authpkg.NewOAuthProvider(configDir, nil)
configureOAuthProviderCompatibility(provider, configDir)
if data, err := provider.Status(); err == nil {
tokenData = data
if !data.IsAccessTokenValid() && data.IsRefreshTokenValid() {
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
_, refreshErr := provider.GetAccessToken(refreshCtx)
cancel()
if refreshErr == nil {
if updatedData, statusErr := provider.Status(); statusErr == nil {
data = updatedData
tokenData = updatedData
refreshed = true
}
}
}
if authStatusAuthenticated(data) {
if authStatusAuthenticated(tokenData) {
authenticated = true
updatedAt = authStatusUpdatedAt(data)
}
}
// Check if JSON output is requested
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
}
// Default table output
w := cmd.OutOrStdout()
if authenticated {
if refreshed {
@@ -206,7 +245,7 @@ func newAuthStatusCommand() *cobra.Command {
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
}
if updatedAt != "" {
if updatedAt := authStatusUpdatedAt(tokenData); updatedAt != "" {
fmt.Fprintf(w, "%-16s%s\n", "有效期:", updatedAt)
}
} else {
@@ -218,46 +257,6 @@ func newAuthStatusCommand() *cobra.Command {
}
}
func newAuthImportCommand() *cobra.Command {
return &cobra.Command{
Use: "import <file>",
Short: "导入认证信息",
Hidden: true,
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := validateOptionalPath("credentials file", args[0]); err != nil {
return err
}
if _, err := authpkg.LoadExportedCredentials(cmd.Context(), args[0], configDir); err != nil {
return apperrors.NewValidation(fmt.Sprintf("failed to import credentials: %v", err))
}
provider := authpkg.NewOAuthProvider(configDir, nil)
refreshCtx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
token, refreshErr := provider.GetAccessToken(refreshCtx)
tokenData, statusErr := provider.Status()
if statusErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load imported token data: %v", statusErr))
}
if refreshErr == nil {
tokenData.AccessToken = token
}
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 认证信息导入成功")
if refreshErr != nil {
fmt.Fprintf(w, "[WARN] 凭证暂时无法刷新: %v\n", refreshErr)
}
fmt.Fprintln(w, "Token 将自动刷新,无需重复登录")
return nil
},
}
}
func newAuthExchangeCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "exchange",
@@ -415,3 +414,91 @@ func authStatusUpdatedAt(data *authpkg.TokenData) string {
}
return ""
}
// authStatusResponse is the JSON response for auth status command.
type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
Message string `json:"message,omitempty"`
Refreshed bool `json:"refreshed,omitempty"`
TokenValid bool `json:"token_valid,omitempty"`
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
CorpID string `json:"corp_id,omitempty"`
CorpName string `json:"corp_name,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
}
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
resp := authStatusResponse{
Success: true,
Authenticated: authenticated,
}
if !authenticated {
resp.Message = "未登录"
} else if data != nil {
resp.Refreshed = refreshed
resp.TokenValid = data.IsAccessTokenValid()
resp.RefreshTokenValid = data.IsRefreshTokenValid()
if !data.ExpiresAt.IsZero() {
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
}
if !data.RefreshExpAt.IsZero() {
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
}
resp.CorpID = data.CorpID
resp.CorpName = data.CorpName
resp.UserID = data.UserID
resp.UserName = data.UserName
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
// authLoginResponse is the JSON response for auth login command.
type authLoginResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
TokenValid bool `json:"token_valid,omitempty"`
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
RefreshExpiresAt string `json:"refresh_expires_at,omitempty"`
CorpID string `json:"corp_id,omitempty"`
CorpName string `json:"corp_name,omitempty"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
}
func writeAuthLoginJSON(w io.Writer, data *authpkg.TokenData, forced bool) error {
resp := authLoginResponse{
Success: true,
Message: "登录成功",
}
if data != nil {
if data.IsAccessTokenValid() && !forced {
resp.Message = "Token 有效,无需重新登录"
}
resp.TokenValid = data.IsAccessTokenValid()
resp.RefreshTokenValid = data.IsRefreshTokenValid()
if !data.ExpiresAt.IsZero() {
resp.ExpiresAt = data.ExpiresAt.Format(time.RFC3339Nano)
}
if !data.RefreshExpAt.IsZero() {
resp.RefreshExpiresAt = data.RefreshExpAt.Format(time.RFC3339Nano)
}
resp.CorpID = data.CorpID
resp.CorpName = data.CorpName
resp.UserID = data.UserID
resp.UserName = data.UserName
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
+10 -4
View File
@@ -17,15 +17,20 @@ import (
"bytes"
"errors"
"net/http"
"os"
"path/filepath"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
// Cleanup keychain after test
t.Cleanup(func() {
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
root := t.TempDir()
configDir := filepath.Join(root, "config")
@@ -60,11 +65,12 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if _, err := os.Stat(filepath.Join(configDir, ".data")); err != nil {
t.Fatalf("secure token data should remain after refresh failure: %v", err)
// Verify token data still exists in keychain after refresh failure
if !authpkg.TokenDataExistsKeychain() {
t.Fatal("secure token data should remain in keychain after refresh failure")
}
if !bytes.Contains(out.Bytes(), []byte("已登录")) {
if !bytes.Contains(out.Bytes(), []byte("\"authenticated\"")) {
t.Fatalf("output should still report authenticated status:\n%s", out.String())
}
}
+43 -8
View File
@@ -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_<PRODUCT>_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
+5 -1
View File
@@ -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().StringVarP(&flags.Format, "format", "f", "table", "输出格式: json|table|raw")
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")
+2 -4
View File
@@ -332,10 +332,8 @@ func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partiti
}
}
func newLegacyHiddenCommands(runner executor.Runner) []*cobra.Command {
var commands []*cobra.Command
commands = append(commands, helpers.NewHiddenVendorCommands(runner)...)
return commands
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
return nil
}
func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
+4
View File
@@ -277,6 +277,10 @@ func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
}
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
// This test requires background refresh which only runs in production mode.
t.Skip("async revalidation disabled in test mode")
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
+501
View File
@@ -0,0 +1,501 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
)
func newRecoveryCommand(_ context.Context, loader cli.CatalogLoader, flags *GlobalFlags) *cobra.Command {
var (
planUseLast bool
planEventID string
executeUseLast bool
executeEventID string
finalEventID string
finalOutcome string
executionFile string
)
runtime := newRecoveryRuntime(loader, flags)
cmd := &cobra.Command{
Use: "recovery",
Short: "错误恢复辅助命令",
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
planCmd := &cobra.Command{
Use: "plan",
Short: "基于失败快照生成恢复计划",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
if err := store.SavePlan(last.EventID, plan); err != nil {
return fmt.Errorf("保存恢复计划失败: %w", err)
}
payload := map[string]any{
"event_id": last.EventID,
"context": last.Context,
"plan": plan,
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
executeCmd := &cobra.Command{
Use: "execute",
Short: "生成面向 Agent 的恢复分析包",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
store := recovery.NewStore(defaultConfigDir())
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
if err != nil {
return err
}
planner := recovery.NewPlanner(runtime)
executor := recovery.NewExecutor(planner, runtime)
bundle := executor.Execute(cmd.Context(), *last)
if err := store.SaveAnalysis(last.EventID, bundle.Plan, bundle); err != nil {
return fmt.Errorf("保存恢复分析失败: %w", err)
}
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
},
}
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
finalizeCmd := &cobra.Command{
Use: "finalize",
Short: "回写恢复闭环结果",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(finalEventID) == "" {
return fmt.Errorf("必须提供 --event-id")
}
if strings.TrimSpace(finalOutcome) == "" {
return fmt.Errorf("必须提供 --outcome")
}
switch finalOutcome {
case "recovered", "failed", "handoff":
default:
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
}
store := recovery.NewStore(defaultConfigDir())
var execution *recovery.RecoveryExecution
if strings.TrimSpace(executionFile) != "" {
loaded, err := loadRecoveryExecution(executionFile)
if err != nil {
return err
}
execution = &loaded
}
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
return fmt.Errorf("回写恢复结果失败: %w", err)
}
payload := map[string]any{
"event_id": finalEventID,
"outcome": finalOutcome,
"success": true,
}
if execution != nil {
payload["execution_recorded"] = true
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
return cmd
}
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
if useLast && strings.TrimSpace(eventID) != "" {
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
}
switch {
case useLast:
last, err := store.LoadLastError()
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return last, nil
case strings.TrimSpace(eventID) != "":
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
if err != nil {
return nil, fmt.Errorf("读取失败快照失败: %w", err)
}
return last, nil
default:
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
}
}
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
var execution recovery.RecoveryExecution
data, err := os.ReadFile(path)
if err != nil {
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
}
var payload recoveryExecutionPayload
if err := json.Unmarshal(data, &payload); err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
execution.Actions = append([]string(nil), payload.Actions...)
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
execution.Actions = []string{strings.TrimSpace(payload.Action)}
}
execution.Result = strings.TrimSpace(payload.Result)
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
if execution.ErrorSummary == "" {
execution.ErrorSummary = strings.TrimSpace(payload.Error)
}
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
if err != nil {
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
}
if len(attempts) == 0 && payload.Attempt > 0 {
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
}
execution.Attempts = attempts
return execution, nil
}
type recoveryExecutionPayload struct {
Action string `json:"action,omitempty"`
Actions []string `json:"actions,omitempty"`
Attempt int `json:"attempt,omitempty"`
Attempts json.RawMessage `json:"attempts,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorSummary string `json:"error_summary,omitempty"`
}
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return nil, nil
}
if strings.HasPrefix(trimmed, "[") {
var attempts []recovery.RecoveryAttempt
if err := json.Unmarshal(raw, &attempts); err != nil {
return nil, err
}
return attempts, nil
}
var count int
if err := json.Unmarshal(raw, &count); err != nil {
return nil, err
}
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
}
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
if count <= 0 {
return nil
}
summary := strings.TrimSpace(strings.Join(actions, ", "))
if summary == "" {
summary = "legacy execution attempt"
}
attempts := make([]recovery.RecoveryAttempt, 0, count)
for i := 0; i < count; i++ {
attempts = append(attempts, recovery.RecoveryAttempt{
CommandSummary: summary,
Result: result,
ErrorSummary: errorSummary,
Source: "legacy_execution_file",
})
}
return attempts
}
type recoveryRuntime struct {
loader cli.CatalogLoader
transport *transport.Client
flags *GlobalFlags
}
func newRecoveryRuntime(loader cli.CatalogLoader, flags *GlobalFlags) *recoveryRuntime {
var httpClient *http.Client
if flags != nil && flags.Timeout > 0 {
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
}
client := transport.NewClient(httpClient)
client.ExtraHeaders = resolveIdentityHeaders()
return &recoveryRuntime{
loader: loader,
transport: client,
flags: flags,
}
}
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
const (
searchPage = 1
searchSize = 5
)
requestArgs := map[string]any{
"keyword": query,
"page": searchPage,
"size": searchSize,
}
retrieval := recovery.KnowledgeRetrieval{
DocSearch: recovery.DocSearch{
Provider: "open_platform_docs",
Query: query,
Page: searchPage,
Size: searchSize,
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
}
if r == nil || strings.TrimSpace(query) == "" {
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
if err != nil {
retrieval.DocSearch.Status = "error"
retrieval.DocSearch.Error = err.Error()
return retrieval, err
}
retrieval.DocSearch.Items = parseDocSearchItems(result)
if len(retrieval.DocSearch.Items) > 0 {
retrieval.DocSearch.Status = "success"
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
}
return retrieval, nil
}
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
if r == nil || r.transport == nil {
return nil, fmt.Errorf("recovery runtime not initialized")
}
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
if err != nil {
return nil, err
}
tc := r.transport.WithAuth(resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags)), resolveIdentityHeaders())
result, err := tc.CallTool(ctx, endpoint, toolName, args)
if err != nil {
return nil, err
}
if result.IsError {
return &result, apperrors.NewAPI(
extractMCPErrorMessage(result),
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(serverID),
)
}
return &result, nil
}
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 {
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
}
catalog, err := r.loader.Load(ctx)
if err != nil {
return "", err
}
product, ok := catalog.FindProduct(productID)
if !ok || strings.TrimSpace(product.Endpoint) == "" {
return "", fmt.Errorf("未找到服务 %s 的 endpoint", productID)
}
return strings.TrimSpace(product.Endpoint), nil
}
func recoveryRuntimeToken(flags *GlobalFlags) string {
if flags == nil {
return ""
}
return strings.TrimSpace(flags.Token)
}
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
if result == nil {
return nil
}
response := &recovery.ToolResponse{IsError: result.IsError}
if len(result.Blocks) > 0 {
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
for _, block := range result.Blocks {
response.Content = append(response.Content, recovery.ToolResponseBlock{
Type: block.Type,
Text: block.Text,
})
}
}
return response
}
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
if result == nil {
return nil
}
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
return items
}
for _, block := range result.Blocks {
var payload map[string]any
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
return items
}
}
}
return nil
}
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
if len(payload) == 0 {
return nil
}
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
return items
}
if data, ok := payload["data"].(map[string]any); ok {
if items := toDocSearchItems(data["items"]); len(items) > 0 {
return items
}
}
if result, ok := payload["result"].(map[string]any); ok {
if items := toDocSearchItems(result["items"]); len(items) > 0 {
return items
}
}
return nil
}
func toDocSearchItems(raw any) []recovery.DocSearchItem {
list, ok := raw.([]any)
if !ok {
return nil
}
items := make([]recovery.DocSearchItem, 0, len(list))
for _, entry := range list {
object, ok := entry.(map[string]any)
if !ok {
continue
}
item := recovery.DocSearchItem{}
if title, ok := object["title"].(string); ok {
item.Title = title
}
if url, ok := object["url"].(string); ok {
item.URL = url
}
if desc, ok := object["desc"].(string); ok {
item.Desc = desc
}
if item.Title != "" || item.URL != "" || item.Desc != "" {
items = append(items, item)
}
}
return items
}
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
if len(items) == 0 {
return nil
}
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
type scoredHit struct {
hit recovery.KBHit
score float64
}
scored := make([]scoredHit, 0, len(items))
for _, item := range items {
text := strings.ToLower(strings.Join(append([]string{
item.Title,
item.URL,
item.Desc,
rc.ToolName,
}, rc.CommandPath...), " "))
score := 0.0
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
score += 1
}
}
scored = append(scored, scoredHit{
hit: recovery.KBHit{
Source: "open_platform_docs",
Title: item.Title,
URL: item.URL,
Snippet: item.Desc,
Score: score,
},
score: score,
})
}
sort.SliceStable(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
limit := len(scored)
if limit > 3 {
limit = 3
}
hits := make([]recovery.KBHit, 0, limit)
for _, item := range scored[:limit] {
hits = append(hits, item.hit)
}
return hits
}
+324
View File
@@ -0,0 +1,324 @@
package app
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_test",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-1",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery plan) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_test"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"category": "resource"`) {
t.Fatalf("output missing resource category:\n%s", out.String())
}
}
func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_exec",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-2",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery execute) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_exec"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"status": "needs_agent_action"`) {
t.Fatalf("output missing bundle status:\n%s", out.String())
}
}
func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) {
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "finalize"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery finalize) error = nil, want validation")
}
if !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want event-id requirement", err)
}
}
func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_conflict",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-conflict",
},
})
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery plan) error = nil, want conflict validation")
}
if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want mutually exclusive flags", err)
}
}
func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_legacy_finalize",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationUnknown,
RawError: "unexpected upstream failure",
Fingerprint: "fp-legacy-finalize",
},
})
executionPath := filepath.Join(configDir, "legacy_execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil {
t.Fatalf("WriteFile(legacy execution) error = %v", err)
}
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"recovery", "finalize",
"--event-id", "evt_legacy_finalize",
"--outcome", "failed",
"--execution-file", executionPath,
"-f", "json",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery finalize) error = %v", err)
}
if !strings.Contains(out.String(), `"execution_recorded": true`) {
t.Fatalf("output missing execution_recorded flag:\n%s", out.String())
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl"))
if err != nil {
t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
lastLine := lines[len(lines)-1]
if !strings.Contains(lastLine, `"phase":"finalized"`) {
t.Fatalf("expected finalized event, got %s", lastLine)
}
if !strings.Contains(lastLine, `"legacy_execution_file"`) {
t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine)
}
}
func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
setupRuntimeCommandTest(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
switch req["method"] {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
},
})
case "notifications/initialized":
w.WriteHeader(http.StatusNoContent)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"tools": []map[string]any{
{
"name": "search_documents",
"title": "Search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object"},
},
},
},
})
case "tools/call":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": "baseId is required",
},
},
"isError": true,
},
})
}
}))
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stdout) error = %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stderr) error = %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
os.Stdout = stdoutW
os.Stderr = stderrW
exitCode := Execute()
_ = stdoutW.Close()
_ = stderrW.Close()
stdoutData, _ := io.ReadAll(stdoutR)
stderrData, _ := io.ReadAll(stderrR)
if exitCode == 0 {
t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData)
}
if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") {
t.Fatalf("stderr missing recovery event id:\n%s", stderrData)
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json"))
if err != nil {
t.Fatalf("ReadFile(last_error.json) error = %v", err)
}
var last recovery.LastError
if err := json.Unmarshal(data, &last); err != nil {
t.Fatalf("json.Unmarshal(last_error) error = %v", err)
}
if last.EventID == "" || last.Context.ToolName != "search_documents" {
t.Fatalf("unexpected recovery snapshot %#v", last)
}
}
func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) {
t.Helper()
recoveryDir := filepath.Join(configDir, "recovery")
if err := os.MkdirAll(recoveryDir, 0o700); err != nil {
t.Fatalf("MkdirAll(recovery) error = %v", err)
}
data, err := json.MarshalIndent(last, "", " ")
if err != nil {
t.Fatalf("json.MarshalIndent() error = %v", err)
}
if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("WriteFile(last_error.json) error = %v", err)
}
}
+94
View File
@@ -0,0 +1,94 @@
package app
import (
"os"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
if rawErr == nil && wrappedErr == nil {
return
}
store := recovery.NewStore(defaultConfigDir())
if store == nil || !store.Enabled() {
return
}
input := recovery.CaptureInput{
CommandPath: runtimeCommandPath(invocation),
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
ToolName: strings.TrimSpace(invocation.Tool),
Args: cloneRecoveryArgs(invocation.Params),
Argv: append([]string(nil), os.Args[1:]...),
RawErr: rawErr,
WrappedErr: wrappedErr,
}
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
}
func runtimeCommandPath(invocation executor.Invocation) []string {
if path := currentCommandPath(); len(path) > 0 {
return path
}
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
return legacy
}
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
return []string{product, tool}
}
return []string{product}
}
return nil
}
func currentCommandPath() []string {
boolFlags := map[string]struct{}{
"--verbose": {},
"-v": {},
"--debug": {},
"--mock": {},
"--dry-run": {},
"--yes": {},
"-y": {},
"--help": {},
"-h": {},
"--json": {},
}
path := make([]string, 0, len(os.Args))
skipNext := false
for _, arg := range os.Args[1:] {
if skipNext {
skipNext = false
continue
}
if arg == "--" {
break
}
if strings.HasPrefix(arg, "-") {
if strings.Contains(arg, "=") {
continue
}
if _, ok := boolFlags[arg]; ok {
continue
}
skipNext = true
continue
}
path = append(path, arg)
}
return path
}
func cloneRecoveryArgs(args map[string]any) map[string]any {
if len(args) == 0 {
return nil
}
out := make(map[string]any, len(args))
for key, value := range args {
out[key] = value
}
return out
}
+109 -10
View File
@@ -34,8 +34,12 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator"
"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"
"github.com/spf13/pflag"
@@ -43,12 +47,22 @@ import (
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
root := NewRootCommand(ctx)
recovery.ResetRuntimeState()
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 {
@@ -60,6 +74,9 @@ func Execute() int {
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
@@ -69,6 +86,33 @@ func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
errMsg := err.Error()
// Common flag aliases and suggestions
suggestions := map[string]string{
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
"--password": "提示: dws 不支持密码登录,请使用 dws auth login 进行扫码登录",
"--phone": "提示: dws 不支持手机号登录,请使用 dws auth login 进行扫码登录",
"--app-key": "提示: 请使用环境变量 DWS_CLIENT_ID 或 --client-id 设置 AppKey",
"--app-secret": "提示: 请使用环境变量 DWS_CLIENT_SECRET 或 --client-secret 设置 AppSecret",
}
for flag, suggestion := range suggestions {
if strings.Contains(errMsg, "unknown flag: "+flag) {
return fmt.Errorf("%w\n%s", err, suggestion)
}
}
return err
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
if wantsJSONErrors(root) {
return apperrors.PrintJSON(stdout, err)
@@ -130,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{}
@@ -166,6 +218,7 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
return configureOutputSink(cmd)
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
CloseFileLogger()
return closeOutputSink(cmd)
},
}
@@ -173,16 +226,16 @@ 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{
newAuthCommand(),
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newVersionCommand(),
schemaCmd,
genSkillsCmd,
@@ -194,6 +247,8 @@ func NewRootCommand(ctx ...context.Context) *cobra.Command {
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
root.SetContext(rootCtx)
return root
@@ -473,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
@@ -721,7 +776,12 @@ func cleanCacheFiles(root, product string, staleOnly bool) (int, error) {
return removed, nil
}
// configureLogLevel sets the global slog level based on --debug and --verbose flags.
// fileLogger holds the package-level file logger for diagnostics.
// It is initialized by configureLogLevel and closed by CloseFileLogger.
var fileLogger *logging.FileLogger
// configureLogLevel sets the global slog level based on --debug and --verbose flags
// and initializes the file logger for diagnostics.
// --debug → slog.LevelDebug; --verbose → slog.LevelInfo; default → slog.LevelWarn.
func configureLogLevel(flags *GlobalFlags) {
if flags == nil {
@@ -736,7 +796,46 @@ func configureLogLevel(flags *GlobalFlags) {
default:
level = slog.LevelWarn
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: level,
})))
stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
// regardless of stderr level. All slog calls are captured for diagnostics.
fileLogger = logging.Setup(defaultConfigDir())
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
}
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
func FileLoggerInstance() *slog.Logger {
if fileLogger == nil {
return nil
}
return fileLogger.Logger
}
// CloseFileLogger flushes and closes the file logger.
func CloseFileLogger() {
if fileLogger != nil {
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
}
+9 -12
View File
@@ -29,7 +29,7 @@ import (
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
func TestPrintExecutionErrorDefaultsToHumanReadable(t *testing.T) {
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
t.Parallel()
root := NewRootCommand()
@@ -43,14 +43,11 @@ func TestPrintExecutionErrorDefaultsToHumanReadable(t *testing.T) {
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty for human-readable error output", stdout.String())
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty for JSON error output", stderr.String())
}
if !strings.Contains(stderr.String(), "Error: [VALIDATION] bad flag") {
t.Fatalf("stderr = %q, want human-readable header", stderr.String())
}
if !strings.Contains(stderr.String(), "Hint: Pass the required flag and retry.") {
t.Fatalf("stderr = %q, want hint line", stderr.String())
if !strings.Contains(stdout.String(), "\"category\": \"validation\"") {
t.Fatalf("stdout = %q, want JSON error payload", stdout.String())
}
}
@@ -175,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if !strings.Contains(out.String(), "版本:") {
t.Fatalf("version output missing version header:\n%s", out.String())
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\n%s", out.String())
}
}
@@ -216,8 +213,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
}
if !strings.Contains(out.String(), "版本:") {
t.Fatalf("version output missing version header:\n%s", out.String())
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\n%s", out.String())
}
}
+4 -1
View File
@@ -51,6 +51,7 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
}
transportClient := transport.NewClient(httpClient)
transportClient.ExtraHeaders = resolveIdentityHeaders()
transportClient.FileLogger = FileLoggerInstance()
return &runtimeRunner{
loader: loader,
transport: transportClient,
@@ -87,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)
}
}
@@ -149,6 +150,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
if err != nil {
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -160,6 +162,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
)
captureRuntimeFailure(invocation, mcpErr, mcpErr)
return executor.Result{}, mcpErr
}
+1 -1
View File
@@ -13,7 +13,7 @@
package app
var version = "v1.0.0"
var version = "v1.0.2"
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
-68
View File
@@ -1,7 +1,6 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -296,73 +295,6 @@ func TestRevokeTokenRemote(t *testing.T) {
// Can't easily test since LogoutURL is a const. Just test that it doesn't panic with real URL.
}
// ─── export.go ─────────────────────────────────────────────────────────
func TestLoadExportedCredentials_ValidPersistentCode(t *testing.T) {
dir := t.TempDir()
creds := ExportedCredentials{
PersistentCode: "pcode-123",
CorpID: "corp1",
ExportedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.Marshal(creds)
path := filepath.Join(dir, "creds.json")
_ = os.WriteFile(path, data, 0o600)
_, err := LoadExportedCredentials(context.Background(), path, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadExportedCredentials_ValidRefreshToken(t *testing.T) {
dir := t.TempDir()
creds := ExportedCredentials{
RefreshToken: "refresh-123",
CorpID: "corp1",
ExportedAt: time.Now().Format(time.RFC3339),
}
data, _ := json.Marshal(creds)
path := filepath.Join(dir, "creds.json")
_ = os.WriteFile(path, data, 0o600)
_, err := LoadExportedCredentials(context.Background(), path, dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestLoadExportedCredentials_NoCredential(t *testing.T) {
dir := t.TempDir()
creds := ExportedCredentials{CorpID: "corp1"}
data, _ := json.Marshal(creds)
path := filepath.Join(dir, "creds.json")
os.WriteFile(path, data, 0o600)
_, err := LoadExportedCredentials(context.Background(), path, dir)
if err == nil {
t.Fatal("expected error for missing credentials")
}
}
func TestLoadExportedCredentials_InvalidJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "creds.json")
os.WriteFile(path, []byte("not json"), 0o600)
_, err := LoadExportedCredentials(context.Background(), path, dir)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestLoadExportedCredentials_MissingFile(t *testing.T) {
_, err := LoadExportedCredentials(context.Background(), "/nonexistent/path", t.TempDir())
if err == nil {
t.Fatal("expected error for missing file")
}
}
// ─── oauth_helpers.go ──────────────────────────────────────────────────
type tokenResponse struct {
-64
View File
@@ -1,64 +0,0 @@
// 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 auth
import (
"context"
"encoding/json"
"fmt"
"os"
"time"
)
// ExportedCredentials represents the JSON structure of an exported credentials file,
// used by auth import to restore credentials on another machine.
type ExportedCredentials struct {
RefreshToken string `json:"refresh_token,omitempty"`
PersistentCode string `json:"persistent_code,omitempty"`
CorpID string `json:"corp_id"`
UserID string `json:"user_id,omitempty"`
UserName string `json:"user_name,omitempty"`
CorpName string `json:"corp_name,omitempty"`
ExportedAt string `json:"exported_at"`
}
func LoadExportedCredentials(ctx context.Context, path, configDir string) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("reading credentials file: %w", err)
}
var creds ExportedCredentials
if err := json.Unmarshal(b, &creds); err != nil {
return "", fmt.Errorf("parsing credentials file: %w", err)
}
// Accept either persistent_code or refresh_token as a valid credential.
if creds.PersistentCode == "" && creds.RefreshToken == "" {
return "", fmt.Errorf("credentials file has no usable credential (need persistent_code or refresh_token)")
}
data := &TokenData{
PersistentCode: creds.PersistentCode,
RefreshToken: creds.RefreshToken,
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: creds.CorpID,
UserID: creds.UserID,
UserName: creds.UserName,
CorpName: creds.CorpName,
}
if err := SaveTokenData(configDir, data); err != nil {
return "", fmt.Errorf("saving imported credentials: %w", err)
}
return "", nil
}
+103 -4
View File
@@ -14,9 +14,11 @@
package auth
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
@@ -27,13 +29,61 @@ const (
lockRetryDelay = 50 * time.Millisecond
)
// tokenFileLock provides cross-process file locking for token operations.
// It prevents concurrent refresh from multiple CLI processes,
// which can corrupt token data when two processes refresh simultaneously.
//
// ── Process-level lock ──────────────────────────────────────────────────
// Prevents multiple goroutines within the same process from refreshing
// simultaneously. Uses sync.Map with channel signaling for efficient waiting.
var processLocks sync.Map // map[string]chan struct{}
// processLockKey generates a unique key for process-level locking.
func processLockKey(configDir string) string {
return "refresh:" + configDir
}
// acquireProcessLock attempts to acquire the process-level lock.
// If another goroutine holds it, this blocks until that goroutine releases.
// Returns a release function that MUST be called when done.
func acquireProcessLock(ctx context.Context, configDir string) (release func(), waited bool, err error) {
key := processLockKey(configDir)
done := make(chan struct{})
for {
// Try to store our channel; if successful, we own the lock
if existing, loaded := processLocks.LoadOrStore(key, done); !loaded {
// We got the lock
return func() {
close(done)
processLocks.Delete(key)
}, waited, nil
} else {
// Another goroutine holds the lock; wait for it
ch, ok := existing.(chan struct{})
if !ok {
// Unexpected type; delete and retry
processLocks.Delete(key)
continue
}
waited = true
select {
case <-ch:
// Lock released; retry to acquire
continue
case <-ctx.Done():
return nil, waited, ctx.Err()
}
}
}
}
// ── File-level lock ─────────────────────────────────────────────────────
// Prevents multiple CLI processes from refreshing simultaneously.
// Platform support:
// - Unix/macOS: flock(2) system call
// - Windows: LockFileEx / UnlockFileEx from kernel32.dll
// tokenFileLock provides cross-process file locking for token operations.
// It prevents concurrent refresh from multiple CLI processes,
// which can corrupt token data when two processes refresh simultaneously.
type tokenFileLock struct {
path string
file *os.File
@@ -76,3 +126,52 @@ func (l *tokenFileLock) release() {
l.file = nil
}
}
// ── Dual-layer lock ─────────────────────────────────────────────────────
// Combines process-level and file-level locks for comprehensive protection.
// DualLock holds both process-level and file-level locks.
type DualLock struct {
processRelease func()
fileLock *tokenFileLock
Waited bool // true if we waited for another goroutine/process
}
// AcquireDualLock acquires both process-level and file-level locks.
// This provides comprehensive protection against:
// 1. Multiple goroutines in the same process (sync.Map)
// 2. Multiple CLI processes (file lock)
//
// The caller MUST call Release() when done.
func AcquireDualLock(ctx context.Context, configDir string) (*DualLock, error) {
// 1. Acquire process-level lock first (fast, in-memory)
processRelease, waited, err := acquireProcessLock(ctx, configDir)
if err != nil {
return nil, fmt.Errorf("acquiring process lock: %w", err)
}
// 2. Acquire file-level lock (cross-process)
fileLock, err := acquireTokenLock(configDir)
if err != nil {
processRelease() // Release process lock on failure
return nil, fmt.Errorf("acquiring file lock: %w", err)
}
return &DualLock{
processRelease: processRelease,
fileLock: fileLock,
Waited: waited,
}, nil
}
// Release releases both locks in reverse order.
func (d *DualLock) Release() {
if d.fileLock != nil {
d.fileLock.release()
d.fileLock = nil
}
if d.processRelease != nil {
d.processRelease()
d.processRelease = nil
}
}
+202
View File
@@ -1,9 +1,11 @@
package auth
import (
"context"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -145,3 +147,203 @@ func TestAcquireTokenLock_LockFilePermissions(t *testing.T) {
t.Fatalf("lock file permissions = %o, want 0600", perm)
}
}
// ─── Process-level lock tests ───────────────────────────────────────────
func TestAcquireProcessLock_Basic(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
release, waited, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() error = %v", err)
}
if waited {
t.Fatal("should not have waited on first acquisition")
}
release()
// Should be able to re-acquire after release
release2, waited2, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("re-acquire after release error = %v", err)
}
if waited2 {
t.Fatal("should not have waited on re-acquisition")
}
release2()
}
func TestAcquireProcessLock_Contention(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
// Goroutine 1 acquires the lock first
release1, _, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() g1 error = %v", err)
}
acquired := make(chan bool, 1)
var g2Waited bool
var wg sync.WaitGroup
wg.Add(1)
// Goroutine 2 tries to acquire — should block until g1 releases
go func() {
defer wg.Done()
release2, waited, err := acquireProcessLock(ctx, configDir)
if err != nil {
acquired <- false
return
}
g2Waited = waited
acquired <- true
release2()
}()
// Give goroutine 2 a moment to start blocking
time.Sleep(50 * time.Millisecond)
// Verify goroutine 2 has not acquired yet
select {
case <-acquired:
t.Fatal("goroutine 2 should not have acquired the lock while goroutine 1 holds it")
default:
// Expected: goroutine 2 is still waiting
}
// Release lock1 so goroutine 2 can proceed
release1()
// Wait for goroutine 2 to finish
wg.Wait()
if !g2Waited {
t.Fatal("goroutine 2 should have reported that it waited")
}
}
func TestAcquireProcessLock_ContextCancellation(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx, cancel := context.WithCancel(context.Background())
// Goroutine 1 holds the lock
release1, _, err := acquireProcessLock(ctx, configDir)
if err != nil {
t.Fatalf("acquireProcessLock() g1 error = %v", err)
}
defer release1()
// Goroutine 2 tries to acquire with a cancellable context
done := make(chan error, 1)
go func() {
_, _, err := acquireProcessLock(ctx, configDir)
done <- err
}()
// Give goroutine 2 time to start waiting
time.Sleep(50 * time.Millisecond)
// Cancel the context
cancel()
// Goroutine 2 should return with context.Canceled
select {
case err := <-done:
if err != context.Canceled {
t.Fatalf("expected context.Canceled, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("goroutine 2 did not return after context cancellation")
}
}
// ─── Dual-layer lock tests ──────────────────────────────────────────────
func TestAcquireDualLock_Basic(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("AcquireDualLock() error = %v", err)
}
if lock.Waited {
t.Fatal("should not have waited on first acquisition")
}
lock.Release()
// Should be able to re-acquire after release
lock2, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("re-acquire after release error = %v", err)
}
lock2.Release()
}
func TestAcquireDualLock_DoubleRelease(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Fatalf("AcquireDualLock() error = %v", err)
}
// First release should work fine
lock.Release()
// Second release should not panic
lock.Release()
}
func TestAcquireDualLock_ConcurrentGoroutines(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
ctx := context.Background()
const numGoroutines = 10
var counter int64
var wg sync.WaitGroup
wg.Add(numGoroutines)
// Launch multiple goroutines that all try to increment a counter
// while holding the dual lock. If locking works correctly,
// the final counter value should be numGoroutines.
for i := 0; i < numGoroutines; i++ {
go func() {
defer wg.Done()
lock, err := AcquireDualLock(ctx, configDir)
if err != nil {
t.Errorf("AcquireDualLock() error = %v", err)
return
}
defer lock.Release()
// Critical section: read-modify-write
current := atomic.LoadInt64(&counter)
time.Sleep(time.Millisecond) // Simulate some work
atomic.StoreInt64(&counter, current+1)
}()
}
wg.Wait()
if counter != numGoroutines {
t.Fatalf("counter = %d, want %d (race condition detected)", counter, numGoroutines)
}
}
+107
View File
@@ -0,0 +1,107 @@
// 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 auth
import (
"encoding/json"
"fmt"
"log/slog"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
var (
migrationOnce sync.Once
migrationDone bool
)
// SaveTokenDataKeychain saves TokenData to the platform keychain.
// This is the new secure storage method using random master key.
func SaveTokenDataKeychain(data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
}
// Zero sensitive data after use
defer func() {
for i := range jsonData {
jsonData[i] = 0
}
}()
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
}
// LoadTokenDataKeychain loads TokenData from the platform keychain.
func LoadTokenDataKeychain() (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("no token data in keychain")
}
var data TokenData
if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
return nil, fmt.Errorf("parse token data: %w", err)
}
return &data, nil
}
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return keychain.Exists(keychain.Service, keychain.AccountToken)
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
// This should be called early in the auth flow (e.g., during GetAccessToken).
// The migration is idempotent and thread-safe.
func EnsureMigration(configDir string, logger *slog.Logger) {
migrationOnce.Do(func() {
result := keychain.MigrateFromLegacy(configDir)
migrationDone = true
if result.Migrated {
if logger != nil {
logger.Info("migrated token data to secure keychain storage",
"from", result.FromPath,
"backup", result.BackupPath)
}
} else if result.NeedRelogin {
if logger != nil {
logger.Warn("cannot migrate legacy token data, please re-login",
"error", result.Error)
}
} else if result.Error != nil {
if logger != nil {
logger.Error("migration failed", "error", result.Error)
}
}
})
}
// IsMigrationDone returns true if migration has been attempted.
func IsMigrationDone() bool {
return migrationDone
}
+32 -12
View File
@@ -17,13 +17,14 @@ import (
"context"
"errors"
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"io"
"log/slog"
"net"
"net/http"
"os"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
)
// oauthHTTPClient is a dedicated HTTP client for OAuth operations with
@@ -209,19 +210,34 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
}
// lockedRefresh attempts to refresh the token while holding a cross-process file lock.
// It uses a double-check pattern: after acquiring the lock it re-loads from disk,
// because another process may have already completed the refresh while we waited.
// This prevents the classic race where two CLI processes both see an expired token
// and both call the refresh API, invalidating each other's refresh_token.
// lockedRefresh attempts to refresh the token while holding dual-layer locks.
// It uses a double-check pattern with both process-level and file-level locking:
//
// Layer 1 (Process Lock - sync.Map):
//
// Prevents multiple goroutines within the same process from refreshing simultaneously.
// If another goroutine is already refreshing, we wait for it and then re-check.
//
// Layer 2 (File Lock - flock/LockFileEx):
//
// Prevents multiple CLI processes from refreshing simultaneously.
// If another process is refreshing, we wait for the file lock and then re-check.
//
// Double-Check Pattern:
//
// After acquiring the lock, we re-load from disk because another goroutine/process
// may have already completed the refresh while we were waiting. This prevents the
// classic race where two callers both see an expired token and both call the
// refresh API, invalidating each other's refresh_token.
func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
lock, err := acquireTokenLock(p.configDir)
// Acquire dual-layer lock (process-level + file-level)
lock, err := AcquireDualLock(ctx, p.configDir)
if err != nil {
return nil, fmt.Errorf("acquiring token lock: %w", err)
return nil, fmt.Errorf("acquiring dual lock: %w", err)
}
defer lock.release()
defer lock.Release()
// Double-check: re-load from disk — another process may have refreshed
// Double-check: re-load from disk — another goroutine/process may have refreshed
// while we were waiting for the lock.
data, err := LoadTokenData(p.configDir)
if err != nil {
@@ -229,7 +245,11 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
}
if data.IsAccessTokenValid() {
if p.logger != nil {
p.logger.Debug("token already refreshed by another process")
if lock.Waited {
p.logger.Debug("token already refreshed by another goroutine/process")
} else {
p.logger.Debug("token still valid after acquiring lock")
}
}
return data, nil
}
@@ -240,7 +260,7 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
}
if p.logger != nil {
p.logger.Debug("refreshing token (locked)")
p.logger.Debug("refreshing token (dual-locked)")
}
return p.refreshWithRefreshToken(ctx, data)
}
+22 -1
View File
@@ -101,13 +101,34 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
finalPath := filepath.Join(configDir, secureDataFile)
tmpPath := finalPath + ".tmp"
if err := os.WriteFile(tmpPath, ciphertext, config.FilePerm); err != nil {
// Atomic write with fsync to ensure data durability
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
if err != nil {
return fmt.Errorf("creating tmp file: %w", err)
}
writeSuccess := false
defer func() {
if !writeSuccess {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(ciphertext); err != nil {
return fmt.Errorf("writing tmp file: %w", err)
}
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("syncing tmp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("closing tmp file: %w", err)
}
if err := os.Rename(tmpPath, finalPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("renaming tmp to final: %w", err)
}
writeSuccess = true
return nil
}
+35 -7
View File
@@ -58,20 +58,48 @@ func (t *TokenData) HasPersistentCode() bool {
return t != nil && t.PersistentCode != ""
}
// SaveTokenData encrypts and saves TokenData to .data file.
// Uses AES-256-GCM encryption with a key derived from device MAC address.
// SaveTokenData saves TokenData to the platform keychain.
// Uses the new keychain-based storage with random master key for better security.
func SaveTokenData(configDir string, data *TokenData) error {
return SaveSecureTokenData(configDir, data)
return SaveTokenDataKeychain(data)
}
// LoadTokenData reads TokenData from encrypted .data file.
// LoadTokenData reads TokenData from the platform keychain.
// On first call, it attempts to migrate legacy .data file if present.
func LoadTokenData(configDir string) (*TokenData, error) {
return LoadSecureTokenData(configDir)
// Try loading from new keychain first
if TokenDataExistsKeychain() {
return LoadTokenDataKeychain()
}
// Fallback: try legacy .data file and migrate
data, err := LoadSecureTokenData(configDir)
if err != nil {
return nil, err
}
// Migrate to keychain for future use
if err := SaveTokenDataKeychain(data); err == nil {
// Successfully migrated, delete legacy file
_ = DeleteSecureData(configDir)
}
return data, nil
}
// DeleteTokenData removes encrypted .data file from configDir.
// DeleteTokenData removes token data from both keychain and legacy storage.
func DeleteTokenData(configDir string) error {
return DeleteSecureData(configDir)
// Delete from keychain
keychainErr := DeleteTokenDataKeychain()
// Also clean up any legacy .data file
legacyErr := DeleteSecureData(configDir)
// Return keychain error if any, otherwise legacy error
if keychainErr != nil {
return keychainErr
}
return legacyErr
}
// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token.
+59 -46
View File
@@ -14,19 +14,22 @@
package auth
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
)
func setupTestMAC(t *testing.T) {
// cleanupKeychain removes test data from keychain after test completes.
func cleanupKeychain(t *testing.T) {
t.Helper()
t.Cleanup(func() {
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
}
func TestTokenSaveLoadAndDelete(t *testing.T) {
setupTestMAC(t)
cleanupKeychain(t)
configDir := t.TempDir()
now := time.Now().UTC()
@@ -42,33 +45,17 @@ func TestTokenSaveLoadAndDelete(t *testing.T) {
CorpName: "测试科技",
}
// Save to keychain
if err := SaveTokenData(configDir, original); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
// Verify .data file was created with correct permissions.
dataPath := filepath.Join(configDir, secureDataFile)
info, err := os.Stat(dataPath)
if err != nil {
t.Fatalf("Stat(.data) error = %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf(".data perms = %o, want 600", info.Mode().Perm())
}
if _, err := os.Stat(dataPath + ".tmp"); !os.IsNotExist(err) {
t.Fatalf(".data.tmp should not remain, stat err = %v", err)
}
// Verify .data file is NOT valid plaintext JSON (it's encrypted).
raw, err := os.ReadFile(dataPath)
if err != nil {
t.Fatalf("ReadFile(.data) error = %v", err)
}
var probe map[string]any
if json.Unmarshal(raw, &probe) == nil {
t.Fatal("saved .data should be encrypted, not plain JSON")
// Verify data exists in keychain
if !TokenDataExistsKeychain() {
t.Fatal("TokenDataExistsKeychain() should be true after save")
}
// Load and verify
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
@@ -83,47 +70,71 @@ func TestTokenSaveLoadAndDelete(t *testing.T) {
t.Fatalf("loaded corp_id = %q, want %q", loaded.CorpID, original.CorpID)
}
// Delete and verify
if err := DeleteTokenData(configDir); err != nil {
t.Fatalf("DeleteTokenData() error = %v", err)
}
if TokenDataExistsKeychain() {
t.Fatal("TokenDataExistsKeychain() should be false after delete")
}
if _, err := LoadTokenData(configDir); err == nil {
t.Fatal("LoadTokenData() error = nil after delete, want failure")
}
}
func TestTokenDecryptionFailsWithCorruptedData(t *testing.T) {
func TestTokenOverwrite(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
data := &TokenData{
AccessToken: "at_test",
RefreshToken: "rt_test",
// Save first version
data1 := &TokenData{
AccessToken: "at_v1",
RefreshToken: "rt_v1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp_v1",
}
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
if err := SaveTokenData(configDir, data1); err != nil {
t.Fatalf("SaveTokenData(v1) error = %v", err)
}
dataPath := filepath.Join(configDir, secureDataFile)
raw, err := os.ReadFile(dataPath)
// Save second version (overwrite)
data2 := &TokenData{
AccessToken: "at_v2",
RefreshToken: "rt_v2",
ExpiresAt: time.Now().Add(2 * time.Hour),
RefreshExpAt: time.Now().Add(48 * time.Hour),
CorpID: "corp_v2",
}
if err := SaveTokenData(configDir, data2); err != nil {
t.Fatalf("SaveTokenData(v2) error = %v", err)
}
// Load should return v2
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("ReadFile(.data) error = %v", err)
t.Fatalf("LoadTokenData() error = %v", err)
}
raw[len(raw)-1] ^= 0xFF
if err := os.WriteFile(dataPath, raw, 0o600); err != nil {
t.Fatalf("WriteFile(.data) error = %v", err)
if loaded.AccessToken != "at_v2" {
t.Fatalf("access_token = %q, want %q", loaded.AccessToken, "at_v2")
}
if _, err := LoadTokenData(configDir); err == nil {
t.Fatal("LoadTokenData with corrupted ciphertext should fail")
if loaded.CorpID != "corp_v2" {
t.Fatalf("corp_id = %q, want %q", loaded.CorpID, "corp_v2")
}
}
func TestSecureDataExists(t *testing.T) {
func TestTokenDataExistsKeychain(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if SecureDataExists(configDir) {
t.Fatal("SecureDataExists() should be false before save")
// Should be false before save
if TokenDataExistsKeychain() {
t.Fatal("TokenDataExistsKeychain() should be false before save")
}
// Save data
data := &TokenData{
AccessToken: "at_test",
ExpiresAt: time.Now().Add(time.Hour),
@@ -131,8 +142,10 @@ func TestSecureDataExists(t *testing.T) {
if err := SaveTokenData(configDir, data); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
if !SecureDataExists(configDir) {
t.Fatal("SecureDataExists() should be true after save")
// Should be true after save
if !TokenDataExistsKeychain() {
t.Fatal("TokenDataExistsKeychain() should be true after save")
}
}
+31 -1
View File
@@ -258,7 +258,37 @@ func (s *Store) saveJSON(path string, value any) error {
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
// Atomic write with fsync to ensure data durability
tmpPath := path + ".tmp"
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
if err != nil {
return err
}
writeSuccess := false
defer func() {
if !writeSuccess {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
if _, err := tmpFile.Write(data); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
_ = os.Remove(tmpPath)
return err
}
writeSuccess = true
return nil
}
func (s *Store) loadJSON(path string, out any) error {
+129 -37
View File
@@ -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,43 +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")
}
// 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),
)
},
}
@@ -399,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)
@@ -417,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 {
@@ -511,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
}
@@ -537,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 {
+530 -11
View File
@@ -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
}
+16
View File
@@ -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
}
+185
View File
@@ -0,0 +1,185 @@
// 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 cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
const (
// maxStdinSize limits the amount of data read from stdin or @file
// to prevent memory exhaustion from accidental large pipes.
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) {
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, apperrors.NewValidation("@file: " + err.Error())
}
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())
}
if int64(len(data)) > maxStdinSize {
return "", apperrors.NewValidation("stdin input exceeds 10 MB limit")
}
return string(data), nil
}
// 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
}
path := value[1:]
if path == "" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
// @- is stdin, not a file — callers should use ResolveInputSource.
if path == "-" {
return value, false, nil
}
data, err := readFileBounded(path)
if err != nil {
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)
// - "@<path>" 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()
}
// @<path> reads from file.
data, err := readFileBounded(path)
if err != nil {
return "", fmt.Errorf("--%s: %w", flagName, err)
}
return string(data), nil
}
+684
View File
@@ -0,0 +1,684 @@
// 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 cli
import (
"os"
"path/filepath"
"strings"
"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")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if isFile {
t.Error("plain value should not be detected as file")
}
if val != "hello world" {
t.Errorf("got %q, want %q", val, "hello world")
}
}
func TestReadFileArgReadsFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "input.txt")
writeTestFile(t, path, []byte(`{"title":"test"}`))
val, isFile, err := ReadFileArg("@" + path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !isFile {
t.Error("@file value should be detected as file")
}
if val != `{"title":"test"}` {
t.Errorf("got %q, want %q", val, `{"title":"test"}`)
}
}
func TestReadFileArgEmptyFilename(t *testing.T) {
t.Parallel()
_, _, err := ReadFileArg("@")
if err == nil {
t.Fatal("expected error for empty filename")
}
if !strings.Contains(err.Error(), "must not be empty") {
t.Errorf("unexpected error: %v", err)
}
}
func TestReadFileArgMissingFile(t *testing.T) {
t.Parallel()
_, _, err := ReadFileArg("@/nonexistent/path/file.txt")
if err == nil {
t.Fatal("expected error for missing file")
}
}
func TestReadFileArgSizeLimit(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "huge.txt")
// Create a file slightly over maxStdinSize (write 10MB + 1 byte)
f, err := os.Create(path)
if err != nil {
t.Fatalf("failed to create test file: %v", err)
}
data := strings.Repeat("x", maxStdinSize+1)
if _, err := f.WriteString(data); err != nil {
f.Close()
t.Fatalf("failed to write test data: %v", err)
}
f.Close()
_, _, err = ReadFileArg("@" + path)
if err == nil {
t.Fatal("expected error for oversized file")
}
if !strings.Contains(err.Error(), "10 MB") {
t.Errorf("unexpected error: %v", err)
}
}
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.
val, err := ReadStdinIfPiped()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != "" {
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)
}
}
+41 -2
View File
@@ -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
}
+145
View File
@@ -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))
}
}
+200
View File
@@ -15,6 +15,8 @@ package errors
import (
stderrors "errors"
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
@@ -44,6 +46,57 @@ func ResourceName(name string) error {
return nil
}
// isDangerousUnicode identifies Unicode code points used for visual spoofing attacks.
// These characters are invisible or alter text direction, allowing attackers to make
// "report.exe" display as "report.txt" (Bidi override) or insert hidden content
// (zero-width characters).
func isDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / ZWNBSP
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}
// rejectControlChars rejects control characters in a string.
// Rejects C0 control characters (except \t and \n) and dangerous Unicode.
// Tab and newline are allowed as they may appear in legitimate multi-line input.
func rejectControlChars(s, fieldName string) error {
for _, r := range s {
// Allow tab (\t = 0x09) and newline (\n = 0x0A)
if r != '\t' && r != '\n' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("%s contains control characters", fieldName)
}
if isDangerousUnicode(r) {
return fmt.Errorf("%s contains dangerous Unicode characters", fieldName)
}
}
return nil
}
// RejectControlChars rejects C0 control characters (except \t and \n) and
// dangerous Unicode characters from user input.
//
// Control characters cause subtle security issues:
// - Null bytes truncate strings at the C layer
// - \r\n enables HTTP header injection
// - Unicode Bidi characters allow visual spoofing (e.g. making "report.exe" display as "report.txt")
//
// Tab and newline are allowed as they may appear in legitimate multi-line input.
func RejectControlChars(value, flagName string) error {
return rejectControlChars(value, flagName)
}
// SafePath performs basic path validation checking for dangerous patterns.
// For full security (symlink resolution, CWD containment), use SafeOutputPath or SafeInputPath.
func SafePath(path string) error {
if path == "" {
return stderrors.New("path cannot be empty")
@@ -57,6 +110,13 @@ func SafePath(path string) error {
return ErrUnsafePath
}
// Check for dangerous Unicode
for _, r := range path {
if isDangerousUnicode(r) {
return ErrUnsafePath
}
}
lowerPath := strings.ToLower(path)
for _, pattern := range []string{
"..",
@@ -77,3 +137,143 @@ func SafePath(path string) error {
}
return nil
}
// SafeOutputPath validates a download/export target path for --output flags.
// It rejects absolute paths, resolves symlinks to their real location, and
// verifies the canonical result is still under the current working directory.
// This prevents an AI Agent from being tricked into writing files outside the
// working directory (e.g. "../../.ssh/authorized_keys") or following symlinks
// to sensitive locations.
//
// The returned absolute path MUST be used for all subsequent I/O to prevent
// time-of-check-to-time-of-use (TOCTOU) race conditions.
func SafeOutputPath(path string) (string, error) {
return safePath(path, "--output")
}
// SafeInputPath validates an upload/read source path for --file flags.
// It applies the same rules as SafeOutputPath — rejecting absolute paths,
// resolving symlinks, and enforcing working directory containment — to prevent
// an AI Agent from being tricked into reading sensitive files like /etc/passwd.
func SafeInputPath(path string) (string, error) {
return safePath(path, "--file")
}
// safePath is the shared implementation for SafeOutputPath and SafeInputPath.
func safePath(raw, flagName string) (string, error) {
if err := rejectControlChars(raw, flagName); err != nil {
return "", err
}
path := filepath.Clean(raw)
// Reject absolute paths - force relative paths within CWD
if filepath.IsAbs(path) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("cannot determine working directory: %w", err)
}
resolved := filepath.Join(cwd, path)
// Resolve symlinks: for existing paths, follow to real location;
// for non-existing paths, walk up to the nearest existing ancestor,
// resolve its symlinks, and re-attach the remaining tail segments.
// This prevents TOCTOU attacks where a non-existent intermediate
// directory is replaced with a symlink between check and use.
if _, err := os.Lstat(resolved); err == nil {
resolved, err = filepath.EvalSymlinks(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
} else {
resolved, err = resolveNearestAncestor(resolved)
if err != nil {
return "", fmt.Errorf("cannot resolve symlinks: %w", err)
}
}
canonicalCwd, _ := filepath.EvalSymlinks(cwd)
if !isUnderDir(resolved, canonicalCwd) {
return "", fmt.Errorf("%s %q resolves outside the current working directory (hint: the path must stay within the working directory after resolving .. and symlinks)", flagName, raw)
}
return resolved, nil
}
// resolveNearestAncestor walks up from path until it finds an existing
// ancestor, resolves that ancestor's symlinks, and re-joins the tail.
// This ensures even deeply nested non-existent paths are anchored to a
// real filesystem location, closing the TOCTOU symlink gap.
func resolveNearestAncestor(path string) (string, error) {
var tail []string
cur := path
for {
if _, err := os.Lstat(cur); err == nil {
real, err := filepath.EvalSymlinks(cur)
if err != nil {
return "", err
}
parts := append([]string{real}, tail...)
return filepath.Join(parts...), nil
}
parent := filepath.Dir(cur)
if parent == cur {
// Reached filesystem root without finding an existing ancestor;
// return path as-is and let the containment check reject it.
parts := append([]string{cur}, tail...)
return filepath.Join(parts...), nil
}
tail = append([]string{filepath.Base(cur)}, tail...)
cur = parent
}
}
// isUnderDir checks whether child is under parent directory.
func isUnderDir(child, parent string) bool {
rel, err := filepath.Rel(parent, child)
if err != nil {
return false
}
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}
// SafeLocalFlagPath validates a flag value as a local file path.
// Empty values and http/https URLs are returned unchanged without validation,
// allowing the caller to handle non-path inputs (e.g. API keys, URLs) upstream.
// For all other values, SafeInputPath rules apply.
// The original relative path is returned unchanged (not resolved to absolute) so
// upload helpers can re-validate at the actual I/O point via SafeUploadPath.
func SafeLocalFlagPath(flagName, value string) (string, error) {
if value == "" || strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
}
return value, nil
}
// RejectCRLF rejects strings containing carriage return (\r) or line feed (\n).
// These characters enable MIME/HTTP header injection and must never appear in
// header field names, values, Content-ID, or filename parameters.
func RejectCRLF(value, fieldName string) error {
if strings.ContainsAny(value, "\r\n") {
return fmt.Errorf("%s contains invalid line break characters", fieldName)
}
return nil
}
// StripQueryFragment removes any ?query or #fragment suffix from a URL path.
// API parameters must go through structured --params flags, not embedded in
// the path, to prevent parameter injection and behaviour confusion.
func StripQueryFragment(path string) string {
for i := 0; i < len(path); i++ {
if path[i] == '?' || path[i] == '#' {
return path[:i]
}
}
return path
}
+159
View File
@@ -73,3 +73,162 @@ func TestSafePath(t *testing.T) {
})
}
}
func TestSafeLocalFlagPath(t *testing.T) {
t.Parallel()
cases := []struct {
name string
flagName string
value string
want string
wantErr bool
}{
// URL pass-through
{name: "http-url", flagName: "--source", value: "http://example.com/file", want: "http://example.com/file"},
{name: "https-url", flagName: "--source", value: "https://api.example.com/data", want: "https://api.example.com/data"},
// Empty pass-through
{name: "empty", flagName: "--file", value: "", want: ""},
// Valid relative paths (returns original relative path)
{name: "relative-file", flagName: "--file", value: "data.json", want: "data.json"},
{name: "relative-nested", flagName: "--file", value: "dir/file.txt", want: "dir/file.txt"},
// Invalid paths
{name: "absolute", flagName: "--file", value: "/etc/passwd", wantErr: true},
{name: "traversal", flagName: "--file", value: "../secret", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := SafeLocalFlagPath(tc.flagName, tc.value)
if tc.wantErr {
if err == nil {
t.Fatalf("SafeLocalFlagPath(%q, %q) error = nil, want failure", tc.flagName, tc.value)
}
return
}
if err != nil {
t.Fatalf("SafeLocalFlagPath(%q, %q) error = %v, want nil", tc.flagName, tc.value, err)
}
if got != tc.want {
t.Fatalf("SafeLocalFlagPath(%q, %q) = %q, want %q", tc.flagName, tc.value, got, tc.want)
}
})
}
}
func TestRejectCRLF(t *testing.T) {
t.Parallel()
cases := []struct {
name string
value string
wantErr bool
}{
{name: "clean", value: "normal text"},
{name: "with-space", value: "text with spaces"},
{name: "with-CR", value: "text\rwith CR", wantErr: true},
{name: "with-LF", value: "text\nwith LF", wantErr: true},
{name: "with-CRLF", value: "text\r\nwith CRLF", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := RejectCRLF(tc.value, "--header")
if tc.wantErr && err == nil {
t.Fatalf("RejectCRLF(%q) error = nil, want failure", tc.value)
}
if !tc.wantErr && err != nil {
t.Fatalf("RejectCRLF(%q) error = %v, want nil", tc.value, err)
}
})
}
}
func TestStripQueryFragment(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want string
}{
{name: "no-query", input: "/api/v1/users", want: "/api/v1/users"},
{name: "with-query", input: "/api/v1/users?page=1", want: "/api/v1/users"},
{name: "with-fragment", input: "/docs#section", want: "/docs"},
{name: "query-and-fragment", input: "/api?a=1#sec", want: "/api"},
{name: "fragment-before-query", input: "/path#frag?notquery", want: "/path"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := StripQueryFragment(tc.input)
if got != tc.want {
t.Fatalf("StripQueryFragment(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
func TestRejectControlChars(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
wantErr bool
}{
// ── Normal text: allowed ──
{name: "plain-text", input: "hello world"},
{name: "with-tab", input: "hello\tworld"},
{name: "with-newline", input: "hello\nworld"},
{name: "unicode-text", input: "你好世界"},
{name: "mixed-unicode", input: "Hello 世界 123"},
{name: "emoji", input: "test 😀 emoji"},
// ── C0 control characters (except tab/newline): rejected ──
{name: "null-byte", input: "bad\x00path", wantErr: true},
{name: "bell", input: "alert\x07here", wantErr: true},
{name: "backspace", input: "back\x08space", wantErr: true},
{name: "form-feed", input: "form\x0cfeed", wantErr: true},
{name: "carriage-return", input: "cr\rhere", wantErr: true},
{name: "escape", input: "esc\x1bhere", wantErr: true},
{name: "delete", input: "del\x7fete", wantErr: true},
// ── Dangerous Unicode: rejected ──
{name: "zero-width-space", input: "foo\u200Bbar", wantErr: true},
{name: "zero-width-non-joiner", input: "foo\u200Cbar", wantErr: true},
{name: "zero-width-joiner", input: "foo\u200Dbar", wantErr: true},
{name: "bom", input: "\uFEFFstart", wantErr: true},
{name: "bidi-lre", input: "foo\u202Abar", wantErr: true},
{name: "bidi-rle", input: "foo\u202Bbar", wantErr: true},
{name: "bidi-pdf", input: "foo\u202Cbar", wantErr: true},
{name: "bidi-lro", input: "foo\u202Dbar", wantErr: true},
{name: "bidi-rlo", input: "foo\u202Ebar", wantErr: true},
{name: "line-separator", input: "foo\u2028bar", wantErr: true},
{name: "paragraph-separator", input: "foo\u2029bar", wantErr: true},
{name: "bidi-lri", input: "foo\u2066bar", wantErr: true},
{name: "bidi-rli", input: "foo\u2067bar", wantErr: true},
{name: "bidi-fsi", input: "foo\u2068bar", wantErr: true},
{name: "bidi-pdi", input: "foo\u2069bar", wantErr: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
err := RejectControlChars(tc.input, "--test")
if tc.wantErr && err == nil {
t.Fatalf("RejectControlChars(%q) error = nil, want failure", tc.input)
}
if !tc.wantErr && err != nil {
t.Fatalf("RejectControlChars(%q) error = %v, want nil", tc.input, err)
}
})
}
}
+2 -3
View File
@@ -41,7 +41,7 @@ type frontmatterSpec struct {
}
const (
skillVersion = "1.0.0"
skillVersion = "1.1.0"
skillCategoryService = "service"
skillCategoryHelper = "helper"
skillCategoryPersona = "persona"
@@ -123,7 +123,6 @@ var extended22CoverageTargets = []string{
"finance",
"law",
"docparse",
"credit",
}
func Generate(catalog ir.Catalog) ([]Artifact, error) {
@@ -395,7 +394,7 @@ func renderSharedSkill(catalog ir.Catalog) string {
builder.WriteString("dws auth status\n")
builder.WriteString("```\n\n")
builder.WriteString("## Global Rules\n\n")
builder.WriteString("- Always prefer `--format json` for agent-readable output.\n")
builder.WriteString("- Output defaults to JSON. Use `--format table` for human-readable output.\n")
builder.WriteString("- Confirm with user before any write/delete/revoke action.\n")
builder.WriteString("- Never fabricate IDs; always extract from command output.\n")
builder.WriteString("- For risky operations, run a read/list check before executing write operations.\n\n")
-1
View File
@@ -67,7 +67,6 @@ var knownRegistryProducts = map[string]struct{}{
"chat": {},
"conference": {},
"contact": {},
"credit": {},
"devdoc": {},
"ding": {},
"doc": {},
+3
View File
@@ -424,6 +424,9 @@ func newAITableUploadFileCommand(runner executor.Runner) *cobra.Command {
}
resultMap := result.Response
if content, ok := resultMap["content"].(map[string]any); ok && len(content) > 0 {
resultMap = content
}
if resultMap == nil {
return apperrors.NewValidation(i18n.T("prepare_attachment_upload 返回格式异常"))
}
@@ -0,0 +1,94 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
type uploadFileRunner struct {
last executor.Invocation
result executor.Result
}
func (r *uploadFileRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.last = invocation
return r.result, nil
}
func TestAITableUploadFileUnwrapsRuntimeContent(t *testing.T) {
filePath := filepath.Join(t.TempDir(), "report.txt")
wantBody := []byte("hello from upload-file")
if err := os.WriteFile(filePath, wantBody, 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
var gotBody []byte
var gotContentType string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPut {
t.Fatalf("method = %s, want PUT", req.Method)
}
gotContentType = req.Header.Get("Content-Type")
body, err := io.ReadAll(req.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
gotBody = body
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
runner := &uploadFileRunner{
result: executor.Result{
Response: map[string]any{
"content": map[string]any{
"data": map[string]any{
"uploadUrl": server.URL,
"fileToken": "ft_test_123",
},
},
},
},
}
cmd := newAITableUploadFileCommand(runner)
var out bytes.Buffer
var errOut bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errOut)
cmd.SetArgs([]string{"--base-id", "BASE_001", "--file", filePath})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\nstderr:\n%s", err, errOut.String())
}
if runner.last.Tool != "prepare_attachment_upload" {
t.Fatalf("tool = %q, want prepare_attachment_upload", runner.last.Tool)
}
if got := runner.last.Params["fileName"]; got != "report.txt" {
t.Fatalf("fileName = %#v, want report.txt", got)
}
if string(gotBody) != string(wantBody) {
t.Fatalf("uploaded body = %q, want %q", string(gotBody), string(wantBody))
}
if gotContentType != "text/plain; charset=utf-8" {
t.Fatalf("content-type = %q, want text/plain; charset=utf-8", gotContentType)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if got := payload["fileToken"]; got != "ft_test_123" {
t.Fatalf("fileToken = %#v, want ft_test_123", got)
}
}
+97
View File
@@ -0,0 +1,97 @@
// 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 (
"fmt"
"io"
"os"
"path/filepath"
)
// AtomicWrite writes data to path atomically by creating a temp file in the
// same directory, writing and fsyncing the data, then renaming over the target.
// It replaces os.WriteFile for all config and download file writes.
//
// os.WriteFile truncates the target before writing, so a process kill (CI timeout,
// OOM, Ctrl+C) between truncate and completion leaves the file empty or partial.
// AtomicWrite avoids this: on any failure the temp file is cleaned up and the
// original file remains untouched.
func AtomicWrite(path string, data []byte, perm os.FileMode) error {
return atomicWrite(path, perm, func(tmp *os.File) error {
_, err := tmp.Write(data)
return err
})
}
// AtomicWriteFromReader atomically copies reader contents into path.
func AtomicWriteFromReader(path string, reader io.Reader, perm os.FileMode) (int64, error) {
var copied int64
err := atomicWrite(path, perm, func(tmp *os.File) error {
n, err := io.Copy(tmp, reader)
copied = n
return err
})
if err != nil {
return 0, err
}
return copied, nil
}
// AtomicWriteJSON is a convenience wrapper for writing JSON data atomically.
// It uses 0600 permissions by default for sensitive data.
func AtomicWriteJSON(path string, data []byte) error {
return AtomicWrite(path, data, 0600)
}
func atomicWrite(path string, perm os.FileMode, writeFn func(tmp *os.File) error) error {
dir := filepath.Dir(path)
// Ensure directory exists with secure permissions
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("create directory: %w", err)
}
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temp file: %w", err)
}
tmpName := tmp.Name()
success := false
defer func() {
if !success {
tmp.Close()
os.Remove(tmpName)
}
}()
if err := tmp.Chmod(perm); err != nil {
return fmt.Errorf("set permissions: %w", err)
}
if err := writeFn(tmp); err != nil {
return fmt.Errorf("write data: %w", err)
}
if err := tmp.Sync(); err != nil {
return fmt.Errorf("sync to disk: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temp file: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("rename to final: %w", err)
}
success = true
return nil
}
+160
View File
@@ -0,0 +1,160 @@
package helpers
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
func TestAtomicWrite_Basic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
data := []byte("hello world")
if err := AtomicWrite(path, data, 0600); err != nil {
t.Fatalf("AtomicWrite() error = %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("got %q, want %q", got, data)
}
// Check permissions
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
}
}
func TestAtomicWrite_CreatesDirectory(t *testing.T) {
t.Parallel()
base := t.TempDir()
path := filepath.Join(base, "a", "b", "c", "test.txt")
data := []byte("nested content")
if err := AtomicWrite(path, data, 0644); err != nil {
t.Fatalf("AtomicWrite() error = %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("got %q, want %q", got, data)
}
}
func TestAtomicWrite_Overwrite(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
// Write initial content
if err := AtomicWrite(path, []byte("initial"), 0600); err != nil {
t.Fatalf("AtomicWrite() initial error = %v", err)
}
// Overwrite
newData := []byte("overwritten content")
if err := AtomicWrite(path, newData, 0600); err != nil {
t.Fatalf("AtomicWrite() overwrite error = %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(got, newData) {
t.Fatalf("got %q, want %q", got, newData)
}
}
func TestAtomicWrite_NoTempFileOnSuccess(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
if err := AtomicWrite(path, []byte("content"), 0600); err != nil {
t.Fatalf("AtomicWrite() error = %v", err)
}
// Check no .tmp files remain
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir() error = %v", err)
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("temp file remains: %s", e.Name())
}
}
}
func TestAtomicWriteFromReader_Basic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "test.txt")
data := "reader content"
reader := strings.NewReader(data)
n, err := AtomicWriteFromReader(path, reader, 0600)
if err != nil {
t.Fatalf("AtomicWriteFromReader() error = %v", err)
}
if n != int64(len(data)) {
t.Fatalf("written bytes = %d, want %d", n, len(data))
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(got) != data {
t.Fatalf("got %q, want %q", got, data)
}
}
func TestAtomicWriteJSON_Basic(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data := []byte(`{"key": "value"}`)
if err := AtomicWriteJSON(path, data); err != nil {
t.Fatalf("AtomicWriteJSON() error = %v", err)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if !bytes.Equal(got, data) {
t.Fatalf("got %q, want %q", got, data)
}
// Check permissions are 0600
info, err := os.Stat(path)
if err != nil {
t.Fatalf("Stat() error = %v", err)
}
if perm := info.Mode().Perm(); perm != 0600 {
t.Fatalf("permissions = %o, want 0600", perm)
}
}
+54 -169
View File
@@ -15,9 +15,9 @@ package helpers
import (
"context"
"fmt"
"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"
@@ -60,8 +60,6 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
},
}
message.AddCommand(
newChatMessageListCommand(runner),
newChatMessageSendCommand(runner),
newChatMessageSendByBotCommand(runner),
newChatMessageRecallByBotCommand(runner),
newChatMessageSendByWebhookCommand(runner),
@@ -83,40 +81,6 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
return root
}
func newChatMessageListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "拉取会话消息内容",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
params, tool, err := buildChatMessageListInvocation(cmd)
if err != nil {
return err
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
tool,
params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openconversation_id (群聊必填)")
cmd.Flags().Bool("forward", true, "true=正序拉取, false=倒序拉取")
cmd.Flags().Int("limit", 0, "返回条数,不传为不限制")
cmd.Flags().String("time", "", "起始时间,格式: yyyy-MM-dd HH:mm:ss")
cmd.Flags().String("user", "", "单聊对方 userId (单聊必填)")
return cmd
}
func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "send-by-bot",
@@ -129,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
}
@@ -266,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",
@@ -274,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
}
@@ -289,48 +257,9 @@ func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func buildChatMessageListInvocation(cmd *cobra.Command) (map[string]any, string, error) {
group, err := cmd.Flags().GetString("group")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --group")
}
user, err := cmd.Flags().GetString("user")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --user")
}
timeValue, err := cmd.Flags().GetString("time")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --time")
}
if strings.TrimSpace(timeValue) == "" {
return nil, "", apperrors.NewValidation("--time is required")
}
if err := ensureExactlyOneTarget(group, user, "--group", "--user"); err != nil {
return nil, "", err
}
params := map[string]any{
"forward": cmd.Flag("forward").Value.String() == "true",
"time": timeValue,
}
limit, err := cmd.Flags().GetInt("limit")
if err != nil {
return nil, "", apperrors.NewInternal("failed to read --limit")
}
if limit > 0 {
params["limit"] = limit
}
if strings.TrimSpace(group) != "" {
params["openconversation_id"] = group
return params, "list_conversation_message_v2", nil
}
params["userId"] = user
return params, "list_individual_chat_message", nil
}
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")
@@ -343,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 {
@@ -382,17 +312,6 @@ func buildChatMessageSendByBotInvocation(cmd *cobra.Command) (map[string]any, st
return params, "batch_send_robot_msg_to_users", nil
}
func ensureExactlyOneTarget(left, right, leftFlag, rightFlag string) error {
switch {
case strings.TrimSpace(left) == "" && strings.TrimSpace(right) == "":
return apperrors.NewValidation(fmt.Sprintf("either %s or %s is required", leftFlag, rightFlag))
case strings.TrimSpace(left) != "" && strings.TrimSpace(right) != "":
return apperrors.NewValidation(fmt.Sprintf("%s and %s are mutually exclusive", leftFlag, rightFlag))
default:
return nil
}
}
func splitCSV(raw string) []any {
parts := strings.Split(raw, ",")
values := make([]any, 0, len(parts))
@@ -509,62 +428,6 @@ func helperResponseContent(result executor.Result) map[string]any {
return content
}
// ── message send (user identity) ───────────────────────────
func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "以当前用户身份发送消息(--group 群聊 / --user 单聊)",
Long: "--group 指定群会话 ID 发送群消息;--user 指定用户 ID 发送单聊消息。二者只能选其一。",
Example: ` dws chat message send --group <openconversation_id> "hello"
dws chat message send --user <userId> "请查收"`,
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("group")
userID, _ := cmd.Flags().GetString("user")
if err := ensureExactlyOneTarget(groupID, userID, "--group", "--user"); err != nil {
return err
}
text := args[0]
title, _ := cmd.Flags().GetString("title")
if strings.TrimSpace(groupID) != "" {
params := map[string]any{
"openConversation_id": groupID,
"title": title,
"text": text,
"clawType": "default",
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "send_message_as_user", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
params := map[string]any{
"receiverUserId": userID,
"title": title,
"text": text,
"clawType": "default",
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "send_direct_message_as_user", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openconversation_id (群聊必填)")
cmd.Flags().String("user", "", "接收者 userId (单聊必填)")
cmd.Flags().String("title", "Message", "消息标题 (可选, 默认 'Message')")
return cmd
}
// ── message recall-by-bot ──────────────────────────────────
func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
@@ -593,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
}
@@ -605,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
}
@@ -633,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")
}
@@ -659,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
}
@@ -724,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
}
@@ -761,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
}
@@ -798,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
}
@@ -835,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
}
+456
View File
@@ -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")
}
}
+13 -5
View File
@@ -17,22 +17,30 @@ func (r *captureRunner) Run(_ context.Context, invocation executor.Invocation) (
return executor.Result{Invocation: invocation}, nil
}
func TestChatMessageSendIgnoresLegacyRealBuildModeEnv(t *testing.T) {
func TestChatMessageSendByBotIgnoresLegacyRealBuildModeEnv(t *testing.T) {
t.Setenv("DWS_"+"BUILD_MODE", "real")
runner := &captureRunner{}
cmd := newChatMessageSendCommand(runner)
cmd := newChatMessageSendByBotCommand(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--user", "user-001", "hello"})
cmd.SetArgs([]string{
"--users", "user-001",
"--robot-code", "robot-001",
"--title", "Greeting",
"--text", "hello",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if got := runner.last.Params["clawType"]; got != "default" {
t.Fatalf("clawType = %#v, want default", got)
if got := runner.last.Tool; got != "batch_send_robot_msg_to_users" {
t.Fatalf("tool = %q, want batch_send_robot_msg_to_users", got)
}
if got := runner.last.Params["robotCode"]; got != "robot-001" {
t.Fatalf("robotCode = %#v, want robot-001", got)
}
}
+32
View File
@@ -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
-217
View File
@@ -1,217 +0,0 @@
// 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 (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return creditHelper{}
})
}
type creditHelper struct{}
func (creditHelper) Name() string {
return "credit"
}
func (creditHelper) Command(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "credit",
Short: "Enterprise credit search and risk helpers",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(
newCreditSearchCommand(runner),
newCreditRiskCommand(runner),
newCreditEquityCommand(runner),
)
return cmd
}
func newCreditSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "Enterprise name search",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name, err := cmd.Flags().GetString("name")
if err != nil {
return err
}
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("--name is required")
}
params := map[string]any{
"company_name": name,
}
if cmd.Flags().Changed("page") {
page, err := cmd.Flags().GetInt("page")
if err != nil {
return err
}
params["page"] = page
}
if cmd.Flags().Changed("size") {
size, err := cmd.Flags().GetInt("size")
if err != nil {
return err
}
params["size"] = size
}
return runHelper(cmd, runner, "credit-ep", "ep_info_search_query", params)
},
}
cmd.Flags().String("name", "", "Enterprise name keyword")
cmd.Flags().Int("page", 0, "Page offset")
cmd.Flags().Int("size", 0, "Page size")
return cmd
}
func newCreditRiskCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "risk",
Short: "Enterprise risk information",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
court := &cobra.Command{
Use: "court",
Short: "Court notice",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
cert, err := cmd.Flags().GetString("cert")
if err != nil {
return err
}
cert = strings.TrimSpace(cert)
if cert == "" {
return fmt.Errorf("--cert is required")
}
params := map[string]any{
"ep_cert_no": cert,
}
if cmd.Flags().Changed("page") {
page, err := cmd.Flags().GetInt("page")
if err != nil {
return err
}
params["page"] = page
}
if cmd.Flags().Changed("size") {
size, err := cmd.Flags().GetInt("size")
if err != nil {
return err
}
params["size"] = size
}
return runHelper(cmd, runner, "credit-risk", "ep_dossier_courtnotice_query", params)
},
}
court.Flags().String("cert", "", "Enterprise registration number or credit code")
court.Flags().Int("page", 0, "Page offset")
court.Flags().Int("size", 0, "Page size")
cmd.AddCommand(court)
return cmd
}
func newCreditEquityCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "equity",
Short: "Enterprise equity information",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
shareholder := &cobra.Command{
Use: "shareholder",
Short: "Shareholder information",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
cert, err := cmd.Flags().GetString("cert")
if err != nil {
return err
}
cert = strings.TrimSpace(cert)
if cert == "" {
return fmt.Errorf("--cert is required")
}
params := map[string]any{
"ep_cert_no": cert,
}
if cmd.Flags().Changed("page") {
page, err := cmd.Flags().GetInt("page")
if err != nil {
return err
}
params["page"] = page
}
if cmd.Flags().Changed("size") {
size, err := cmd.Flags().GetInt("size")
if err != nil {
return err
}
params["size"] = size
}
return runHelper(cmd, runner, "credit-equity", "ep_dossier_shareholder_query", params)
},
}
shareholder.Flags().String("cert", "", "Enterprise registration number or credit code")
shareholder.Flags().Int("page", 0, "Page offset")
shareholder.Flags().Int("size", 0, "Page size")
cmd.AddCommand(shareholder)
return cmd
}
func runHelper(cmd *cobra.Command, runner executor.Runner, canonicalProduct, tool string, params map[string]any) error {
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), canonicalProduct, tool, params))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
-379
View File
@@ -1,379 +0,0 @@
// 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 (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/spf13/cobra"
)
type dingtalkVendorHandler struct {
subtree string
}
func init() {
for _, subtree := range []string{"discovery", "oa-plus", "ai-sincere-hire"} {
subtree := subtree
RegisterHiddenDingTalk(func() Handler {
return dingtalkVendorHandler{subtree: subtree}
})
}
}
func (d dingtalkVendorHandler) Name() string {
return d.subtree
}
func (d dingtalkVendorHandler) Command(runner executor.Runner) *cobra.Command {
switch d.subtree {
case "discovery":
return newDiscoveryCommand(runner)
case "oa-plus":
return newOAPlusCommand(runner)
case "ai-sincere-hire":
return newAISincereHireCommand(runner)
default:
return newHiddenGroup("dingtalk", "Hidden DingTalk vendor extensions")
}
}
func registerDingTalkFlags(cmd *cobra.Command) {
cmd.PersistentFlags().String("json", "", "NewsFeedPushRequest JSON payload")
cmd.PersistentFlags().String("source", "", "Crawl source ID")
cmd.PersistentFlags().String("filenames", "", "Comma-separated file names")
cmd.PersistentFlags().String("keyword", "", "Subscription keyword")
cmd.PersistentFlags().String("instance-id", "", "Approval instance ID")
cmd.PersistentFlags().String("process-code", "", "Approval process code")
cmd.PersistentFlags().String("size", "20", "Result size")
cmd.PersistentFlags().String("cursor", "", "Cursor")
}
func newHiddenGroup(use, short string) *cobra.Command {
return cobracmd.NewHiddenGroupCommand(use, short)
}
func newVisibleGroup(use, short string) *cobra.Command {
return cobracmd.NewGroupCommand(use, short)
}
func newDiscoveryCommand(runner executor.Runner) *cobra.Command {
cmd := newVisibleGroup("discovery", "Content discovery")
registerDingTalkFlags(cmd)
media := newVisibleGroup("media", "Media content")
media.AddCommand(&cobra.Command{
Use: "save",
Short: "Save media content",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDiscoveryMediaSave(cmd, runner)
},
})
media.AddCommand(&cobra.Command{
Use: "subscribe",
Short: "Subscribe to media source",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDiscoveryMediaSubscribe(cmd, runner)
},
})
oss := newVisibleGroup("oss", "Upload credentials")
oss.AddCommand(&cobra.Command{
Use: "get-upload-url",
Short: "Get OSS upload URL",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDiscoveryOSSGetUploadURL(cmd, runner)
},
})
subscribe := newVisibleGroup("subscribe", "Keyword subscription")
subscribe.AddCommand(&cobra.Command{
Use: "save",
Short: i18n.T("保存Keyword subscription规则"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDiscoverySubscribeSave(cmd, runner)
},
})
cmd.AddCommand(media, oss, subscribe)
return cmd
}
func runDiscoveryMediaSave(cmd *cobra.Command, runner executor.Runner) error {
payload, err := cmd.Flags().GetString("json")
if err != nil {
return errors.NewInternal("failed to read --json")
}
if strings.TrimSpace(payload) == "" {
return errors.NewValidation("--json is required")
}
var request any
if err := json.Unmarshal([]byte(payload), &request); err != nil {
return errors.NewValidation(fmt.Sprintf("--json must be valid JSON: %v", err))
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk discovery media save",
"dingtalk-discovery",
"save_video_and_image",
map[string]any{
"NewsFeedPushRequest": request,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runDiscoveryMediaSubscribe(cmd *cobra.Command, runner executor.Runner) error {
source, err := cmd.Flags().GetString("source")
if err != nil {
return errors.NewInternal("failed to read --source")
}
if strings.TrimSpace(source) == "" {
return errors.NewValidation("--source is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk discovery media subscribe",
"dingtalk-discovery",
"save_media_subscribe_rule",
map[string]any{
"skillCrawlId": source,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runDiscoveryOSSGetUploadURL(cmd *cobra.Command, runner executor.Runner) error {
filenames, err := cmd.Flags().GetString("filenames")
if err != nil {
return errors.NewInternal("failed to read --filenames")
}
if strings.TrimSpace(filenames) == "" {
return errors.NewValidation("--filenames is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk discovery oss get-upload-url",
"dingtalk-discovery",
"batch_get_oss_temp_upload_url",
map[string]any{
"filenames": filenames,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runDiscoverySubscribeSave(cmd *cobra.Command, runner executor.Runner) error {
keyword, err := cmd.Flags().GetString("keyword")
if err != nil {
return errors.NewInternal("failed to read --keyword")
}
if strings.TrimSpace(keyword) == "" {
return errors.NewValidation("--keyword is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk discovery subscribe save",
"dingtalk-discovery",
"save_keyword_subscribe_rule",
map[string]any{
"keyword": keyword,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func newOAPlusCommand(runner executor.Runner) *cobra.Command {
cmd := newVisibleGroup("oa-plus", "OA approval enhanced")
registerDingTalkFlags(cmd)
approval := newVisibleGroup("approval", i18n.T("审批实例管理"))
approval.AddCommand(&cobra.Command{
Use: "get",
Short: i18n.T("获取审批实例详情"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runOAPlusApprovalGet(cmd, runner)
},
})
approval.AddCommand(&cobra.Command{
Use: "list",
Short: i18n.T("分页查询审批实例"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runOAPlusApprovalList(cmd, runner)
},
})
cmd.AddCommand(approval)
return cmd
}
func runOAPlusApprovalGet(cmd *cobra.Command, runner executor.Runner) error {
instanceID, err := cmd.Flags().GetString("instance-id")
if err != nil {
return errors.NewInternal("failed to read --instance-id")
}
if strings.TrimSpace(instanceID) == "" {
return errors.NewValidation("--instance-id is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk oa-plus approval get",
"dingtalk-oa-plus",
"get_approval_instance",
map[string]any{
"instanceId": instanceID,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runOAPlusApprovalList(cmd *cobra.Command, runner executor.Runner) error {
processCode, err := cmd.Flags().GetString("process-code")
if err != nil {
return errors.NewInternal("failed to read --process-code")
}
if strings.TrimSpace(processCode) == "" {
return errors.NewValidation("--process-code is required")
}
size, err := cmd.Flags().GetString("size")
if err != nil {
return errors.NewInternal("failed to read --size")
}
cursor, err := cmd.Flags().GetString("cursor")
if err != nil {
return errors.NewInternal("failed to read --cursor")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk oa-plus approval list",
"dingtalk-oa-plus",
"list_approval_instances",
map[string]any{
"processCode": processCode,
"size": size,
"cursor": cursor,
},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func newAISincereHireCommand(runner executor.Runner) *cobra.Command {
cmd := newVisibleGroup("ai-sincere-hire", i18n.T("AI诚聘"))
registerDingTalkFlags(cmd)
cmd.AddCommand(&cobra.Command{
Use: "guide",
Short: i18n.T("获取使用指引"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAISincereHireGuide(cmd, runner)
},
})
job := newVisibleGroup("job", i18n.T("岗位查询"))
job.AddCommand(&cobra.Command{
Use: "list",
Short: i18n.T("查询在招岗位"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAISincereHireJobList(cmd, runner)
},
})
talent := newVisibleGroup("talent", i18n.T("人才查询"))
talent.AddCommand(&cobra.Command{
Use: "list",
Short: i18n.T("查询入职人才"),
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runAISincereHireTalentList(cmd, runner)
},
})
cmd.AddCommand(job, talent)
return cmd
}
func runAISincereHireGuide(cmd *cobra.Command, runner executor.Runner) error {
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk ai-sincere-hire guide",
"dingtalk-ai-sincere-hire",
"query_guide_url",
map[string]any{},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runAISincereHireJobList(cmd *cobra.Command, runner executor.Runner) error {
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk ai-sincere-hire job list",
"dingtalk-ai-sincere-hire",
"query_opening_job_list",
map[string]any{},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func runAISincereHireTalentList(cmd *cobra.Command, runner executor.Runner) error {
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
"dingtalk ai-sincere-hire talent list",
"dingtalk-ai-sincere-hire",
"query_success_talent_list",
map[string]any{},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
+2 -97
View File
@@ -50,15 +50,9 @@ const (
nameMaxLen = 50
)
type hiddenExtensionFactory struct {
manifest Manifest
factory Factory
}
var (
registryMu sync.Mutex
publicFactories []Factory
hiddenVendorFactories []hiddenExtensionFactory
registryMu sync.Mutex
publicFactories []Factory
)
func RegisterPublic(factory Factory) {
@@ -67,99 +61,10 @@ func RegisterPublic(factory Factory) {
publicFactories = append(publicFactories, factory)
}
func RegisterHiddenVendor(vendor string, factory Factory) {
if factory == nil {
panic("helpers: hidden vendor factory is nil")
}
handler := factory()
if handler == nil {
panic("helpers: hidden vendor handler is nil")
}
manifest := Manifest{
Vendor: strings.TrimSpace(vendor),
Name: strings.TrimSpace(handler.Name()),
}
if err := ValidateNaming(manifest.Vendor, manifest.Name); err != nil {
panic(fmt.Sprintf("helpers: invalid hidden vendor extension %s: %v", manifest.FullName(), err))
}
registryMu.Lock()
defer registryMu.Unlock()
hiddenVendorFactories = append(hiddenVendorFactories, hiddenExtensionFactory{
manifest: manifest,
factory: factory,
})
}
func RegisterHiddenDingTalk(factory Factory) {
RegisterHiddenVendor("dingtalk", factory)
}
func NewPublicCommands(runner executor.Runner) []*cobra.Command {
return buildCommands(publicFactories, runner)
}
func NewHiddenVendorCommands(runner executor.Runner) []*cobra.Command {
registryMu.Lock()
factories := append([]hiddenExtensionFactory(nil), hiddenVendorFactories...)
registryMu.Unlock()
if len(factories) == 0 {
return nil
}
byVendor := make(map[string][]*cobra.Command)
for _, registered := range factories {
handler := registered.factory()
if handler == nil {
continue
}
command := handler.Command(runner)
if command == nil {
continue
}
byVendor[registered.manifest.Vendor] = append(byVendor[registered.manifest.Vendor], command)
}
vendors := make([]string, 0, len(byVendor))
for vendor := range byVendor {
vendors = append(vendors, vendor)
}
sort.Strings(vendors)
roots := make([]*cobra.Command, 0, len(vendors))
for _, vendor := range vendors {
commands := byVendor[vendor]
sort.Slice(commands, func(i, j int) bool {
return commands[i].Use < commands[j].Use
})
root := &cobra.Command{
Use: vendor,
Short: fmt.Sprintf("Hidden %s vendor extensions", vendor),
Hidden: true,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(commands...)
roots = append(roots, root)
}
return roots
}
func NewHiddenDingTalkCommand(runner executor.Runner) *cobra.Command {
for _, root := range NewHiddenVendorCommands(runner) {
if root != nil && root.Name() == "dingtalk" {
return root
}
}
return nil
}
func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command {
registryMu.Lock()
defer registryMu.Unlock()
+1 -30
View File
@@ -13,11 +13,7 @@
package helpers
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
import "testing"
func TestValidateNaming(t *testing.T) {
t.Parallel()
@@ -50,28 +46,3 @@ func TestValidateNaming(t *testing.T) {
})
}
}
func TestNewHiddenVendorCommandsIncludesDingTalk(t *testing.T) {
roots := NewHiddenVendorCommands(executor.EchoRunner{})
if len(roots) == 0 {
t.Fatal("NewHiddenVendorCommands() = empty, want hidden vendor roots")
}
var dingtalkFound bool
for _, root := range roots {
if root == nil || root.Name() != "dingtalk" {
continue
}
dingtalkFound = true
if !root.Hidden {
t.Fatalf("dingtalk root hidden = false, want true")
}
if len(root.Commands()) == 0 {
t.Fatalf("dingtalk root children = 0, want registered hidden extensions")
}
}
if !dingtalkFound {
t.Fatal("NewHiddenVendorCommands() missing dingtalk root")
}
}
+56
View File
@@ -0,0 +1,56 @@
// 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 keychain provides cross-platform secure storage for secrets.
// - macOS: System Keychain stores DEK (Data Encryption Key), data encrypted with AES-256-GCM
// - Linux: File-based DEK storage with AES-256-GCM encryption
// - Windows: DPAPI + Registry storage
package keychain
const (
// Service is the unified keychain service name for all secrets.
Service = "dws-cli"
// AccountToken is the account key for storing auth token data.
AccountToken = "auth-token"
)
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
type KeychainAccess interface {
Get(service, account string) (string, error)
Set(service, account, value string) error
Remove(service, account string) error
}
// Get retrieves a value from the keychain.
// Returns empty string and nil error if the entry does not exist.
func Get(service, account string) (string, error) {
return platformGet(service, account)
}
// Set stores a value in the keychain, overwriting any existing entry.
func Set(service, account, data string) error {
return platformSet(service, account, data)
}
// Remove deletes an entry from the keychain.
// Returns nil if the entry does not exist.
func Remove(service, account string) error {
return platformRemove(service, account)
}
// Exists checks if an entry exists in the keychain.
func Exists(service, account string) bool {
val, err := Get(service, account)
return err == nil && val != ""
}
+200
View File
@@ -0,0 +1,200 @@
// 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.
//go:build darwin
package keychain
import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"regexp"
"time"
"github.com/google/uuid"
"github.com/zalando/go-keyring"
)
const (
keychainTimeout = 5 * time.Second
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
ivBytes = 12
tagBytes = 16
)
// StorageDir returns the storage directory for a given service name on macOS.
// Uses ~/Library/Application Support/<service> following Apple conventions.
func StorageDir(service string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return filepath.Join(".dws", "keychain", service)
}
return filepath.Join(home, "Library", "Application Support", service)
}
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
// getDEK retrieves or generates the Data Encryption Key from system Keychain.
func getDEK(service string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
type result struct {
key []byte
err error
}
resCh := make(chan result, 1)
go func() {
defer func() { recover() }()
// Try to get existing DEK from system Keychain
encodedKey, err := keyring.Get(service, "dek")
if err == nil {
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
if decodeErr == nil && len(key) == dekBytes {
resCh <- result{key: key, err: nil}
return
}
}
// Generate new DEK if not found or invalid
key := make([]byte, dekBytes)
if _, randErr := rand.Read(key); randErr != nil {
resCh <- result{key: nil, err: randErr}
return
}
// Store in system Keychain
encodedKey = base64.StdEncoding.EncodeToString(key)
setErr := keyring.Set(service, "dek", encodedKey)
resCh <- result{key: key, err: setErr}
}()
select {
case res := <-resCh:
return res.key, res.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
iv := make([]byte, ivBytes)
if _, err := rand.Read(iv); err != nil {
return nil, err
}
ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil)
result := make([]byte, 0, ivBytes+len(ciphertext))
result = append(result, iv...)
result = append(result, ciphertext...)
return result, nil
}
func decryptData(data []byte, key []byte) (string, error) {
if len(data) < ivBytes+tagBytes {
return "", fmt.Errorf("ciphertext too short")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := data[:ivBytes]
ciphertext := data[ivBytes:]
plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
func platformGet(service, account string) (string, error) {
key, err := getDEK(service)
if err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
if os.IsNotExist(err) {
return "", nil // Not found is not an error
}
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
return plaintext, nil
}
func platformSet(service, account, data string) error {
key, err := getDEK(service)
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
encrypted, err := encryptData(data, key)
if err != nil {
return err
}
targetPath := filepath.Join(dir, safeFileName(account))
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
defer os.Remove(tmpPath)
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return err
}
// Atomic rename to prevent file corruption during multi-process writes
if err := os.Rename(tmpPath, targetPath); err != nil {
return err
}
return nil
}
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+194
View File
@@ -0,0 +1,194 @@
// 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.
//go:build linux
package keychain
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"os"
"path/filepath"
"regexp"
"github.com/google/uuid"
)
const (
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
ivBytes = 12
tagBytes = 16
)
// StorageDir returns the storage directory for a given service name.
// Follows XDG Base Directory Specification: ~/.local/share/<service>
func StorageDir(service string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
fmt.Fprintf(os.Stderr, "warning: unable to determine home directory: %v\n", err)
return filepath.Join(".dws", "keychain", service)
}
xdgData := filepath.Join(home, ".local", "share")
return filepath.Join(xdgData, service)
}
var safeFileNameRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
// getDEK retrieves or generates the Data Encryption Key from local file.
func getDEK(service string) ([]byte, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, "dek")
// Try to read existing DEK
key, err := os.ReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
// Create directory if needed
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create keychain dir: %w", err)
}
// Generate new random DEK
key = make([]byte, dekBytes)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate dek: %w", err)
}
// Atomic write to prevent multi-process initialization collision
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
defer os.Remove(tmpKeyPath)
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
return nil, fmt.Errorf("write dek: %w", err)
}
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
// If rename fails, another process might have created it. Try reading again.
existingKey, readErr := os.ReadFile(keyPath)
if readErr == nil && len(existingKey) == dekBytes {
return existingKey, nil
}
return nil, fmt.Errorf("save dek: %w", err)
}
return key, nil
}
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
iv := make([]byte, ivBytes)
if _, err := rand.Read(iv); err != nil {
return nil, err
}
ciphertext := aesGCM.Seal(nil, iv, []byte(plaintext), nil)
result := make([]byte, 0, ivBytes+len(ciphertext))
result = append(result, iv...)
result = append(result, ciphertext...)
return result, nil
}
func decryptData(data []byte, key []byte) (string, error) {
if len(data) < ivBytes+tagBytes {
return "", fmt.Errorf("ciphertext too short")
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
aesGCM, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := data[:ivBytes]
ciphertext := data[ivBytes:]
plaintext, err := aesGCM.Open(nil, iv, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
func platformGet(service, account string) (string, error) {
key, err := getDEK(service)
if err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil {
if os.IsNotExist(err) {
return "", nil // Not found is not an error
}
return "", err
}
plaintext, err := decryptData(data, key)
if err != nil {
return "", err
}
return plaintext, nil
}
func platformSet(service, account, data string) error {
key, err := getDEK(service)
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
encrypted, err := encryptData(data, key)
if err != nil {
return err
}
targetPath := filepath.Join(dir, safeFileName(account))
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
defer os.Remove(tmpPath)
if err := os.WriteFile(tmpPath, encrypted, 0600); err != nil {
return err
}
// Atomic rename to prevent file corruption during multi-process writes
if err := os.Rename(tmpPath, targetPath); err != nil {
return err
}
return nil
}
func platformRemove(service, account string) error {
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
if err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+159
View File
@@ -0,0 +1,159 @@
// 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 keychain
import (
"os"
"path/filepath"
"testing"
)
func TestKeychainBasicOperations(t *testing.T) {
t.Parallel()
service := "test-service-" + t.Name()
account := "test-account"
testData := `{"access_token":"test123","refresh_token":"refresh456"}`
// Clean up after test
defer func() {
_ = Remove(service, account)
}()
// Test Set
if err := Set(service, account, testData); err != nil {
t.Fatalf("Set() error = %v", err)
}
// Test Exists
if !Exists(service, account) {
t.Fatal("Exists() = false, want true")
}
// Test Get
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != testData {
t.Fatalf("Get() = %q, want %q", got, testData)
}
// Test Remove
if err := Remove(service, account); err != nil {
t.Fatalf("Remove() error = %v", err)
}
// Verify removal
if Exists(service, account) {
t.Fatal("Exists() after Remove() = true, want false")
}
}
func TestKeychainNonExistentAccount(t *testing.T) {
t.Parallel()
service := "test-service-nonexistent"
account := "nonexistent-account"
// Get should return empty string for non-existent
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v, want nil", err)
}
if got != "" {
t.Fatalf("Get() = %q, want empty string", got)
}
// Exists should return false
if Exists(service, account) {
t.Fatal("Exists() = true for non-existent, want false")
}
// Remove should not error for non-existent
if err := Remove(service, account); err != nil {
t.Fatalf("Remove() error = %v for non-existent", err)
}
}
func TestKeychainOverwrite(t *testing.T) {
t.Parallel()
service := "test-service-" + t.Name()
account := "test-account"
defer func() {
_ = Remove(service, account)
}()
// Set initial value
if err := Set(service, account, "initial"); err != nil {
t.Fatalf("Set() initial error = %v", err)
}
// Overwrite
if err := Set(service, account, "overwritten"); err != nil {
t.Fatalf("Set() overwrite error = %v", err)
}
// Verify overwrite
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != "overwritten" {
t.Fatalf("Get() = %q, want %q", got, "overwritten")
}
}
func TestMigrationNoLegacyData(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
// Migration should be a no-op when no legacy data exists
result := MigrateFromLegacy(configDir)
if result.Migrated {
t.Fatal("Migrated = true when no legacy data, want false")
}
if result.NeedRelogin {
t.Fatal("NeedRelogin = true when no legacy data, want false")
}
if result.Error != nil {
t.Fatalf("Error = %v when no legacy data, want nil", result.Error)
}
}
func TestHasLegacyData(t *testing.T) {
t.Parallel()
configDir := t.TempDir()
// Should be false when no .data file
if HasLegacyData(configDir) {
t.Fatal("HasLegacyData() = true for empty dir, want false")
}
// Create a fake .data file
legacyPath := filepath.Join(configDir, ".data")
if err := os.WriteFile(legacyPath, []byte("fake"), 0600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
// Should be true now
if !HasLegacyData(configDir) {
t.Fatal("HasLegacyData() = false after creating .data, want true")
}
}
+182
View File
@@ -0,0 +1,182 @@
// 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.
//go:build windows
package keychain
import (
"encoding/base64"
"fmt"
"regexp"
"strings"
"unsafe"
"golang.org/x/sys/windows"
"golang.org/x/sys/windows/registry"
)
// ---------------------------------------------------------------------------
// Windows backend: DPAPI + HKCU registry
// ---------------------------------------------------------------------------
const regRootPath = `Software\DwsCli\keychain`
func registryPathForService(service string) string {
return regRootPath + `\` + safeRegistryComponent(service)
}
var safeRegRe = regexp.MustCompile(`[^a-zA-Z0-9._-]`)
func safeRegistryComponent(s string) string {
// Registry key path uses '\\' separators; avoid accidental nesting and odd chars.
s = strings.ReplaceAll(s, "\\", "_")
return safeRegRe.ReplaceAllString(s, "_")
}
func valueNameForAccount(account string) string {
// Avoid any special characters; keep deterministic.
return base64.RawURLEncoding.EncodeToString([]byte(account))
}
func dpapiEntropy(service, account string) *windows.DataBlob {
// Bind ciphertext to (service, account) to reduce swap/replay risks.
data := []byte(service + "\x00" + account)
if len(data) == 0 {
return nil
}
return &windows.DataBlob{Size: uint32(len(data)), Data: &data[0]}
}
func dpapiProtect(plaintext []byte, entropy *windows.DataBlob) ([]byte, error) {
var in windows.DataBlob
if len(plaintext) > 0 {
in = windows.DataBlob{Size: uint32(len(plaintext)), Data: &plaintext[0]}
}
var out windows.DataBlob
err := windows.CryptProtectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out)
if err != nil {
return nil, err
}
defer freeDataBlob(&out)
if out.Data == nil || out.Size == 0 {
return []byte{}, nil
}
buf := unsafe.Slice(out.Data, int(out.Size))
res := make([]byte, len(buf))
copy(res, buf)
return res, nil
}
func dpapiUnprotect(ciphertext []byte, entropy *windows.DataBlob) ([]byte, error) {
var in windows.DataBlob
if len(ciphertext) > 0 {
in = windows.DataBlob{Size: uint32(len(ciphertext)), Data: &ciphertext[0]}
}
var out windows.DataBlob
err := windows.CryptUnprotectData(&in, nil, entropy, 0, nil, windows.CRYPTPROTECT_UI_FORBIDDEN, &out)
if err != nil {
return nil, err
}
defer freeDataBlob(&out)
if out.Data == nil || out.Size == 0 {
return []byte{}, nil
}
buf := unsafe.Slice(out.Data, int(out.Size))
res := make([]byte, len(buf))
copy(res, buf)
return res, nil
}
func freeDataBlob(b *windows.DataBlob) {
if b == nil || b.Data == nil {
return
}
// Per DPAPI contract, output buffers must be freed with LocalFree.
_, _ = windows.LocalFree(windows.Handle(unsafe.Pointer(b.Data)))
b.Data = nil
b.Size = 0
}
func platformGet(service, account string) (string, error) {
v, ok := registryGet(service, account)
if !ok {
return "", nil // Not found is not an error
}
return v, nil
}
func platformSet(service, account, data string) error {
entropy := dpapiEntropy(service, account)
protected, err := dpapiProtect([]byte(data), entropy)
if err != nil {
return fmt.Errorf("dpapi protect failed: %w", err)
}
return registrySet(service, account, protected)
}
func platformRemove(service, account string) error {
return registryRemove(service, account)
}
func registryGet(service, account string) (string, bool) {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err != nil {
return "", false
}
defer k.Close()
b64, _, err := k.GetStringValue(valueNameForAccount(account))
if err != nil || b64 == "" {
return "", false
}
blob, err := base64.StdEncoding.DecodeString(b64)
if err != nil {
return "", false
}
entropy := dpapiEntropy(service, account)
plain, err := dpapiUnprotect(blob, entropy)
if err != nil {
return "", false
}
return string(plain), true
}
func registrySet(service, account string, protected []byte) error {
keyPath := registryPathForService(service)
k, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
if err != nil {
return fmt.Errorf("registry create/open failed: %w", err)
}
defer k.Close()
b64 := base64.StdEncoding.EncodeToString(protected)
if err := k.SetStringValue(valueNameForAccount(account), b64); err != nil {
return fmt.Errorf("registry set failed: %w", err)
}
return nil
}
func registryRemove(service, account string) error {
keyPath := registryPathForService(service)
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.SET_VALUE)
if err != nil {
return nil
}
defer k.Close()
_ = k.DeleteValue(valueNameForAccount(account))
return nil
}
+144
View File
@@ -0,0 +1,144 @@
// 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 keychain
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security"
)
const (
legacyDataFile = ".data"
legacyBackupFile = ".data.migrated.bak"
)
// MigrationResult contains the result of a migration attempt.
type MigrationResult struct {
Migrated bool // true if migration was performed
FromPath string // source file path
BackupPath string // backup file path (if migrated)
Error error // error if migration failed
NeedRelogin bool // true if user needs to re-login
}
// MigrateFromLegacy attempts to migrate from the legacy MAC-based encryption
// to the new keychain-based storage. It:
// 1. Checks if legacy .data file exists
// 2. Tries to decrypt with MAC address
// 3. Re-encrypts and stores in keychain
// 4. Backs up the old file
//
// If the keychain already has data, migration is skipped.
// If the legacy file doesn't exist, migration is skipped.
// If decryption fails (wrong MAC/corrupted), returns NeedRelogin=true.
func MigrateFromLegacy(configDir string) *MigrationResult {
result := &MigrationResult{}
// Check if keychain already has data - skip migration
if Exists(Service, AccountToken) {
return result // Already migrated or fresh install
}
// Check if legacy .data file exists
legacyPath := filepath.Join(configDir, legacyDataFile)
if _, err := os.Stat(legacyPath); os.IsNotExist(err) {
return result // No legacy data to migrate
}
result.FromPath = legacyPath
// Try to decrypt legacy data using MAC address
legacyData, err := loadLegacyData(configDir)
if err != nil {
// Decryption failed - likely different machine or corrupted
result.Error = fmt.Errorf("cannot decrypt legacy data: %w", err)
result.NeedRelogin = true
return result
}
// Store in new keychain
jsonData, err := json.Marshal(legacyData)
if err != nil {
result.Error = fmt.Errorf("marshal token data: %w", err)
return result
}
if err := Set(Service, AccountToken, string(jsonData)); err != nil {
result.Error = fmt.Errorf("store in keychain: %w", err)
return result
}
// Backup old file instead of deleting
backupPath := filepath.Join(configDir, legacyBackupFile)
if err := os.Rename(legacyPath, backupPath); err != nil {
// Non-fatal - data is already migrated
_ = os.Remove(legacyPath)
} else {
result.BackupPath = backupPath
}
result.Migrated = true
return result
}
// loadLegacyData loads and decrypts the legacy .data file using MAC address.
func loadLegacyData(configDir string) (map[string]interface{}, error) {
// Get MAC address for decryption
mac, err := security.GetMACAddress()
if err != nil {
return nil, fmt.Errorf("get MAC address: %w", err)
}
// Read encrypted file
path := filepath.Join(configDir, legacyDataFile)
ciphertext, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read legacy file: %w", err)
}
// Decrypt using legacy method
plaintext, err := security.Decrypt(ciphertext, []byte(mac))
if err != nil {
return nil, fmt.Errorf("decrypt legacy data: %w", err)
}
// Parse JSON
var data map[string]interface{}
if err := json.Unmarshal(plaintext, &data); err != nil {
return nil, fmt.Errorf("parse legacy JSON: %w", err)
}
return data, nil
}
// CleanupLegacyBackup removes the backup file created during migration.
// Call this after confirming the new keychain storage works correctly.
func CleanupLegacyBackup(configDir string) error {
backupPath := filepath.Join(configDir, legacyBackupFile)
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// HasLegacyData checks if legacy .data file exists.
func HasLegacyData(configDir string) bool {
legacyPath := filepath.Join(configDir, legacyDataFile)
_, err := os.Stat(legacyPath)
return err == nil
}
+222
View File
@@ -0,0 +1,222 @@
// 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 logging provides file-based structured logging for diagnostics.
// Logs are written as JSON lines to ~/.dws/logs/dws.log with automatic
// size-based rotation. Sensitive values (tokens, secrets) are never logged.
package logging
import (
"io"
"log/slog"
"os"
"path/filepath"
"strconv"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
)
const (
// logSubDir is the subdirectory under configDir for log files.
logSubDir = "logs"
// logFileName is the active log file name.
logFileName = "dws.log"
// maxLogSize is the maximum size of a single log file (5 MB).
maxLogSize = 5 * 1024 * 1024
// maxBackups is the number of rotated backup files to keep.
maxBackups = 2
)
// FileLogger wraps a rotatingWriter and provides a structured slog.Logger
// that writes JSON lines to disk.
type FileLogger struct {
writer *rotatingWriter
Logger *slog.Logger
}
// Setup creates the log directory and returns a FileLogger. If directory
// creation fails, it returns a no-op logger that discards all output.
func Setup(configDir string) *FileLogger {
logDir := filepath.Join(configDir, logSubDir)
if err := os.MkdirAll(logDir, config.DirPerm); err != nil {
return newNopFileLogger()
}
logPath := filepath.Join(logDir, logFileName)
w := newRotatingWriter(logPath, maxLogSize, maxBackups)
if err := w.open(); err != nil {
return newNopFileLogger()
}
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
return &FileLogger{
writer: w,
Logger: slog.New(handler),
}
}
// Writer returns the underlying io.Writer for use with slog handlers.
// Returns io.Discard if the logger is not initialized.
func (fl *FileLogger) Writer() io.Writer {
if fl == nil || fl.writer == nil {
return io.Discard
}
return fl.writer
}
// Close flushes and closes the underlying log file.
func (fl *FileLogger) Close() error {
if fl == nil || fl.writer == nil {
return nil
}
return fl.writer.close()
}
func newNopFileLogger() *FileLogger {
return &FileLogger{
Logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
}
}
// rotatingWriter is a simple size-based log file rotator.
// When the active file exceeds maxSize, it renames:
//
// dws.log → dws.log.1
// dws.log.1 → dws.log.2
// dws.log.2 → (deleted)
//
// Then opens a fresh dws.log.
type rotatingWriter struct {
path string
maxSize int64
maxBackups int
mu sync.Mutex
file *os.File
written int64
}
func newRotatingWriter(path string, maxSize int64, maxBackups int) *rotatingWriter {
return &rotatingWriter{
path: path,
maxSize: maxSize,
maxBackups: maxBackups,
}
}
func (w *rotatingWriter) open() error {
w.mu.Lock()
defer w.mu.Unlock()
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
w.file = f
w.written = info.Size()
return nil
}
func (w *rotatingWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
// Attempt to recover if file was lost (e.g. rotation failed due to full disk).
if w.file == nil {
if err := w.reopenLocked(); err != nil {
return 0, err
}
}
if w.written+int64(len(p)) > w.maxSize {
w.rotate()
}
// rotate may have failed, check again.
if w.file == nil {
return 0, os.ErrClosed
}
n, err := w.file.Write(p)
w.written += int64(n)
return n, err
}
// reopenLocked tries to reopen the log file. Caller must hold w.mu.
func (w *rotatingWriter) reopenLocked() error {
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm)
if err != nil {
return err
}
info, err := f.Stat()
if err != nil {
f.Close()
return err
}
w.file = f
w.written = info.Size()
return nil
}
func (w *rotatingWriter) close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotatingWriter) rotate() {
w.file.Close()
w.file = nil
// Shift existing backups: .2 → delete, .1 → .2, active → .1
for i := w.maxBackups; i >= 1; i-- {
src := w.backupName(i - 1)
dst := w.backupName(i)
if i == w.maxBackups {
os.Remove(dst)
}
os.Rename(src, dst)
}
f, err := os.OpenFile(w.path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, config.FilePerm)
if err != nil {
return
}
w.file = f
w.written = 0
}
func (w *rotatingWriter) backupName(index int) string {
if index == 0 {
return w.path
}
return w.path + "." + itoa(index)
}
func itoa(i int) string {
return strconv.Itoa(i)
}
+123
View File
@@ -0,0 +1,123 @@
// 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 logging
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestSetupCreatesLogFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
fl := Setup(dir)
defer fl.Close()
fl.Logger.Info("test message", "key", "value")
fl.Close()
logPath := filepath.Join(dir, logSubDir, logFileName)
data, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("failed to read log file: %v", err)
}
if !strings.Contains(string(data), "test message") {
t.Errorf("log file does not contain 'test message': %s", data)
}
if !strings.Contains(string(data), `"key":"value"`) {
t.Errorf("log file does not contain structured key: %s", data)
}
}
func TestSetupInvalidDirReturnsNopLogger(t *testing.T) {
t.Parallel()
fl := Setup("/nonexistent/path/that/cannot/be/created")
defer fl.Close()
// Should not panic
fl.Logger.Info("should be discarded")
}
func TestRotatingWriterRotatesOnSize(t *testing.T) {
t.Parallel()
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
w := newRotatingWriter(logPath, 100, 2) // 100 bytes max
if err := w.open(); err != nil {
t.Fatalf("open: %v", err)
}
// Write enough data to trigger rotation
payload := strings.Repeat("x", 60) + "\n"
w.Write([]byte(payload)) // 61 bytes
w.Write([]byte(payload)) // triggers rotate at 122 bytes
// Should have created a backup
if _, err := os.Stat(logPath + ".1"); err != nil {
t.Errorf("backup .1 not created: %v", err)
}
// Write more to trigger second rotation
w.Write([]byte(payload))
w.Write([]byte(payload))
w.close()
if _, err := os.Stat(logPath + ".2"); err != nil {
t.Errorf("backup .2 not created: %v", err)
}
// Active log should be small (last write only)
info, _ := os.Stat(logPath)
if info.Size() > 100 {
t.Errorf("active log too large after rotation: %d bytes", info.Size())
}
}
func TestRotatingWriterMaxBackups(t *testing.T) {
t.Parallel()
dir := t.TempDir()
logPath := filepath.Join(dir, "test.log")
w := newRotatingWriter(logPath, 50, 2)
if err := w.open(); err != nil {
t.Fatalf("open: %v", err)
}
payload := strings.Repeat("a", 51) + "\n"
// Force multiple rotations
for i := 0; i < 5; i++ {
w.Write([]byte(payload))
}
w.close()
// .3 should NOT exist (maxBackups=2)
if _, err := os.Stat(logPath + ".3"); err == nil {
t.Error("backup .3 should not exist with maxBackups=2")
}
}
func TestCloseOnNilLogger(t *testing.T) {
t.Parallel()
var fl *FileLogger
if err := fl.Close(); err != nil {
t.Errorf("Close on nil should not error: %v", err)
}
}
+66
View File
@@ -0,0 +1,66 @@
// 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 logging
import (
"context"
"log/slog"
)
// MultiHandler fans out slog records to multiple handlers.
// Each handler applies its own level filter independently.
type MultiHandler struct {
handlers []slog.Handler
}
// NewMultiHandler creates a handler that writes to all provided handlers.
func NewMultiHandler(handlers ...slog.Handler) *MultiHandler {
return &MultiHandler{handlers: handlers}
}
func (h *MultiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, handler := range h.handlers {
if handler.Enabled(ctx, level) {
return true
}
}
return false
}
func (h *MultiHandler) Handle(ctx context.Context, record slog.Record) error {
for _, handler := range h.handlers {
if handler.Enabled(ctx, record.Level) {
if err := handler.Handle(ctx, record); err != nil {
return err
}
}
}
return nil
}
func (h *MultiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
handlers := make([]slog.Handler, len(h.handlers))
for i, handler := range h.handlers {
handlers[i] = handler.WithAttrs(attrs)
}
return &MultiHandler{handlers: handlers}
}
func (h *MultiHandler) WithGroup(name string) slog.Handler {
handlers := make([]slog.Handler, len(h.handlers))
for i, handler := range h.handlers {
handlers[i] = handler.WithGroup(name)
}
return &MultiHandler{handlers: handlers}
}
+114
View File
@@ -0,0 +1,114 @@
// 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 logging
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
)
func TestMultiHandlerWritesToBoth(t *testing.T) {
t.Parallel()
var buf1, buf2 bytes.Buffer
h1 := slog.NewTextHandler(&buf1, &slog.HandlerOptions{Level: slog.LevelDebug})
h2 := slog.NewJSONHandler(&buf2, &slog.HandlerOptions{Level: slog.LevelDebug})
logger := slog.New(NewMultiHandler(h1, h2))
logger.Info("hello", "key", "value")
if !strings.Contains(buf1.String(), "hello") {
t.Errorf("handler1 missing message: %s", buf1.String())
}
if !strings.Contains(buf2.String(), "hello") {
t.Errorf("handler2 missing message: %s", buf2.String())
}
}
func TestMultiHandlerRespectsIndividualLevels(t *testing.T) {
t.Parallel()
var debugBuf, warnBuf bytes.Buffer
debugHandler := slog.NewTextHandler(&debugBuf, &slog.HandlerOptions{Level: slog.LevelDebug})
warnHandler := slog.NewTextHandler(&warnBuf, &slog.HandlerOptions{Level: slog.LevelWarn})
logger := slog.New(NewMultiHandler(debugHandler, warnHandler))
logger.Debug("debug msg")
logger.Warn("warn msg")
// debugBuf should have both
if !strings.Contains(debugBuf.String(), "debug msg") {
t.Error("debug handler should capture debug msg")
}
if !strings.Contains(debugBuf.String(), "warn msg") {
t.Error("debug handler should capture warn msg")
}
// warnBuf should only have warn
if strings.Contains(warnBuf.String(), "debug msg") {
t.Error("warn handler should NOT capture debug msg")
}
if !strings.Contains(warnBuf.String(), "warn msg") {
t.Error("warn handler should capture warn msg")
}
}
func TestMultiHandlerEnabled(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
warnHandler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn})
debugHandler := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
mh := NewMultiHandler(warnHandler, debugHandler)
// Should be enabled at DEBUG because at least one handler accepts it
if !mh.Enabled(context.Background(), slog.LevelDebug) {
t.Error("should be enabled at DEBUG when one handler accepts it")
}
}
func TestMultiHandlerWithAttrs(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
mh := NewMultiHandler(h)
logger := slog.New(mh.WithAttrs([]slog.Attr{slog.String("component", "test")}))
logger.Info("with attrs")
if !strings.Contains(buf.String(), "component") {
t.Error("WithAttrs not propagated")
}
}
func TestMultiHandlerWithGroup(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
mh := NewMultiHandler(h)
logger := slog.New(mh.WithGroup("grp"))
logger.Info("grouped", "k", "v")
if !strings.Contains(buf.String(), "grp") {
t.Error("WithGroup not propagated")
}
}
+44
View File
@@ -0,0 +1,44 @@
// 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 logging
import "strings"
// sensitiveKeys are header/field names whose values must be redacted in logs.
var sensitiveKeys = map[string]bool{
"authorization": true,
"x-user-access-token": true,
"client_secret": true,
"client-secret": true,
"token": true,
"secret": true,
"password": true,
"cookie": true,
}
// IsSensitiveKey returns true if the key (case-insensitive) refers to a
// credential or secret that must not appear in log files.
func IsSensitiveKey(key string) bool {
return sensitiveKeys[strings.ToLower(key)]
}
// RedactValue replaces a sensitive value with a safe placeholder.
// It preserves the first 4 characters for identification if the value is
// long enough, otherwise fully redacts.
func RedactValue(value string) string {
if len(value) <= 8 {
return "***"
}
return value[:4] + "***"
}
+71
View File
@@ -0,0 +1,71 @@
// 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 logging
import "testing"
func TestIsSensitiveKey(t *testing.T) {
t.Parallel()
tests := []struct {
key string
want bool
}{
{"Authorization", true},
{"authorization", true},
{"x-user-access-token", true},
{"X-User-Access-Token", true},
{"client_secret", true},
{"client-secret", true},
{"token", true},
{"password", true},
{"cookie", true},
{"Content-Type", false},
{"X-Cli-Source", false},
{"method", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
t.Parallel()
if got := IsSensitiveKey(tt.key); got != tt.want {
t.Errorf("IsSensitiveKey(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
func TestRedactValue(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"short", "***"},
{"12345678", "***"},
{"123456789", "1234***"},
{"a-very-long-token-value", "a-ve***"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
if got := RedactValue(tt.input); got != tt.want {
t.Errorf("RedactValue(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+63
View File
@@ -0,0 +1,63 @@
// 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 logging
import (
"context"
"log/slog"
"time"
)
// LogRequest logs a JSON-RPC request at Debug level.
func LogRequest(logger *slog.Logger, method, endpoint, executionId string, bodySize int) {
if logger == nil {
return
}
logger.Debug("jsonrpc_request",
slog.String("method", method),
slog.String("endpoint", redactEndpoint(endpoint)),
slog.String("execution_id", executionId),
slog.Int("body_size", bodySize),
)
}
// LogResponse logs a JSON-RPC response at Debug level.
func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, respSize int, duration time.Duration, err error) {
if logger == nil {
return
}
attrs := []slog.Attr{
slog.String("method", method),
slog.String("endpoint", redactEndpoint(endpoint)),
slog.Int("status", statusCode),
slog.Int("resp_size", respSize),
slog.String("duration", duration.Truncate(time.Millisecond).String()),
}
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
logger.LogAttrs(context.TODO(), slog.LevelWarn, "jsonrpc_response", attrs...)
return
}
logger.LogAttrs(context.TODO(), slog.LevelDebug, "jsonrpc_response", attrs...)
}
// redactEndpoint removes query parameters from endpoint URLs in logs.
func redactEndpoint(endpoint string) string {
for i := 0; i < len(endpoint); i++ {
if endpoint[i] == '?' || endpoint[i] == '#' {
return endpoint[:i]
}
}
return endpoint
}
+114
View File
@@ -0,0 +1,114 @@
// 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 logging
import (
"bytes"
"errors"
"log/slog"
"strings"
"testing"
"time"
)
func TestLogRequestWritesStructuredEntry(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogRequest(logger, "tools/call", "https://mcp.dingtalk.com/api?token=secret", "exec-123", 256)
out := buf.String()
if !strings.Contains(out, "jsonrpc_request") {
t.Error("missing message 'jsonrpc_request'")
}
if !strings.Contains(out, "tools/call") {
t.Error("missing method")
}
if !strings.Contains(out, "exec-123") {
t.Error("missing execution_id")
}
// Query should be stripped
if strings.Contains(out, "token=secret") {
t.Error("endpoint query params should be redacted")
}
}
func TestLogResponseSuccess(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogResponse(logger, "tools/call", "https://mcp.dingtalk.com/api", 200, 1024, 150*time.Millisecond, nil)
out := buf.String()
if !strings.Contains(out, "jsonrpc_response") {
t.Error("missing message")
}
if !strings.Contains(out, "DEBUG") {
t.Error("success should be DEBUG level")
}
if strings.Contains(out, "error") {
t.Error("success response should not contain error field")
}
}
func TestLogResponseError(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
LogResponse(logger, "initialize", "https://mcp.dingtalk.com", 500, 0, 2*time.Second, errors.New("connection refused"))
out := buf.String()
if !strings.Contains(out, "WARN") {
t.Error("error response should be WARN level")
}
if !strings.Contains(out, "connection refused") {
t.Error("error message missing")
}
}
func TestLogRequestNilLogger(t *testing.T) {
t.Parallel()
// Should not panic
LogRequest(nil, "test", "http://localhost", "", 0)
LogResponse(nil, "test", "http://localhost", 200, 0, 0, nil)
}
func TestRedactEndpoint(t *testing.T) {
t.Parallel()
tests := []struct {
input string
want string
}{
{"https://mcp.dingtalk.com/api", "https://mcp.dingtalk.com/api"},
{"https://mcp.dingtalk.com/api?token=abc", "https://mcp.dingtalk.com/api"},
{"https://mcp.dingtalk.com/api#section", "https://mcp.dingtalk.com/api"},
{"", ""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
t.Parallel()
if got := redactEndpoint(tt.input); got != tt.want {
t.Errorf("redactEndpoint(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
+1
View File
@@ -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"`
+146
View File
@@ -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
}
+326
View File
@@ -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"])
}
}
+55
View File
@@ -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, "", " ")
+6 -34
View File
@@ -13,39 +13,11 @@
package output
// SanitizeForTerminal strips control characters and dangerous Unicode markers
// before untrusted text is printed to a terminal or log sink.
func SanitizeForTerminal(text string) string {
out := make([]rune, 0, len(text))
for _, r := range text {
if r == '\n' || r == '\t' {
out = append(out, r)
continue
}
if r < 0x20 || r == 0x7f {
continue
}
if isDangerousUnicode(r) {
continue
}
out = append(out, r)
}
return string(out)
}
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
func isDangerousUnicode(r rune) bool {
switch {
case r >= '\u200B' && r <= '\u200D':
return true
case r == '\uFEFF':
return true
case r >= '\u202A' && r <= '\u202E':
return true
case r >= '\u2028' && r <= '\u2029':
return true
case r >= '\u2066' && r <= '\u2069':
return true
default:
return false
}
// SanitizeForTerminal strips ANSI escape sequences, control characters, and
// dangerous Unicode from text before it is printed to a terminal.
// Delegates to the validate package which provides the canonical implementation.
func SanitizeForTerminal(text string) string {
return validate.SanitizeForTerminal(text)
}
+2 -1
View File
@@ -18,8 +18,9 @@ import "testing"
func TestSanitizeForTerminalStripsControlCharacters(t *testing.T) {
t.Parallel()
// ANSI escape sequences and control characters are fully stripped.
got := SanitizeForTerminal("hello\x1b[31mworld\x07")
want := "hello[31mworld"
want := "helloworld"
if got != want {
t.Fatalf("SanitizeForTerminal() = %q, want %q", got, want)
}
+115
View File
@@ -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
}
+98
View File
@@ -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
}
+300
View File
@@ -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)
}
}
+37
View File
@@ -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
}
+146
View File
@@ -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(), "-")
}
+186
View File
@@ -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())
}
}
@@ -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)
}
}
}
+164
View File
@@ -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]
}
@@ -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())
}
}
+312
View File
@@ -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"
}
@@ -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())
}
}
+139
View File
@@ -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
}
+183
View File
@@ -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())
}
}
+158
View File
@@ -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,
})
}
+338
View File
@@ -0,0 +1,338 @@
package recovery
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
type ToolInvoker interface {
CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error)
}
type Probe func(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult
type Executor struct {
planner *Planner
invoker ToolInvoker
probes []Probe
}
func NewExecutor(planner *Planner, invoker ToolInvoker) *Executor {
return &Executor{
planner: planner,
invoker: invoker,
probes: defaultProbes(),
}
}
func (e *Executor) Execute(ctx context.Context, last LastError) RecoveryBundle {
bundle := RecoveryBundle{
Status: "needs_agent_action",
EventID: last.EventID,
Context: cloneRecoveryContext(last.Context),
Replay: cloneReplay(last.Replay),
}
if e == nil || e.planner == nil {
bundle.Status = "analysis_failed"
bundle.AnalysisError = "recovery executor unavailable"
return bundle
}
plan := e.planner.PlanWithOptions(ctx, last.Context, PlanOptions{
EventID: last.EventID,
EnableDocSearch: true,
})
HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
bundle.Plan = plan
bundle.DocSearch = cloneDocSearch(plan.DocSearch)
bundle.KBHits = cloneKBHits(plan.KBHits)
bundle.DocActions = cloneDocActions(plan.DocActions)
bundle.HumanActions = nonNilStrings(plan.HumanActions)
bundle.ProbeResults = e.runProbes(ctx, last.Context, last.Replay, plan)
if bundle.Plan.AgentRoute.Payload != nil {
bundle.Plan.AgentRoute.Payload.ProbeResults = cloneProbeResults(bundle.ProbeResults)
}
bundle.AgentTask = buildAgentTask(last.EventID, last.Context, last.Replay, plan, bundle.ProbeResults)
bundle.FinalizeHint = buildFinalizeHint(last.EventID)
analysisErrors := make([]string, 0, 3)
if isEmptyReplay(last.Replay) {
analysisErrors = append(analysisErrors, "replay data missing")
}
if plan.DocSearch.Status == "error" {
analysisErrors = append(analysisErrors, "doc search failed")
}
for _, probe := range bundle.ProbeResults {
if probe.Status == "error" {
analysisErrors = append(analysisErrors, fmt.Sprintf("probe %s failed", probe.Name))
}
}
if len(analysisErrors) > 0 {
bundle.Status = "analysis_failed"
bundle.AnalysisError = strings.Join(uniqueStrings(analysisErrors), "; ")
}
return bundle
}
func (e *Executor) runProbes(ctx context.Context, rc RecoveryContext, replay Replay, plan RecoveryPlan) []ProbeResult {
if len(e.probes) == 0 {
return nil
}
results := make([]ProbeResult, 0, len(e.probes))
for _, probe := range e.probes {
result := probe(ctx, e.invoker, rc, replay, plan)
if result == nil {
continue
}
results = append(results, *result)
}
return results
}
func defaultProbes() []Probe {
return []Probe{probeUnknownContextAudit, probeAITableBaseCatalog}
}
func probeUnknownContextAudit(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
if normalizeDecisionOwner(plan) != DecisionOwnerAgent {
return nil
}
sourcesChecked := []string{
"recovery_context.command_path",
"recovery_context.args_summary",
"replay.tool_args",
"replay.redacted_command",
"plan.doc_search",
"plan.kb_hits",
"plan.doc_actions",
}
availableSources := make([]string, 0, len(sourcesChecked))
missingSources := make([]string, 0, len(sourcesChecked))
candidateIdentifiers := make([]map[string]any, 0)
appendCandidates := func(source string, values map[string]any) {
for key, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
continue
}
lowerKey := strings.ToLower(key)
if !strings.Contains(lowerKey, "id") && !strings.Contains(lowerKey, "uuid") {
continue
}
candidateIdentifiers = append(candidateIdentifiers, map[string]any{
"source": source,
"field": key,
"value": text,
})
}
}
appendCandidates("context.args_summary", rc.ArgsSummary)
appendCandidates("replay.tool_args", replay.ToolArgs)
if len(rc.CommandPath) > 0 {
availableSources = append(availableSources, "recovery_context.command_path")
} else {
missingSources = append(missingSources, "recovery_context.command_path")
}
if len(rc.ArgsSummary) > 0 {
availableSources = append(availableSources, "recovery_context.args_summary")
} else {
missingSources = append(missingSources, "recovery_context.args_summary")
}
if len(replay.ToolArgs) > 0 {
availableSources = append(availableSources, "replay.tool_args")
} else {
missingSources = append(missingSources, "replay.tool_args")
}
if strings.TrimSpace(replay.RedactedCommand) != "" {
availableSources = append(availableSources, "replay.redacted_command")
} else {
missingSources = append(missingSources, "replay.redacted_command")
}
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
availableSources = append(availableSources, "plan.doc_search")
} else {
missingSources = append(missingSources, "plan.doc_search")
}
if len(plan.KBHits) > 0 {
availableSources = append(availableSources, "plan.kb_hits")
} else {
missingSources = append(missingSources, "plan.kb_hits")
}
if len(plan.DocActions) > 0 {
availableSources = append(availableSources, "plan.doc_actions")
} else {
missingSources = append(missingSources, "plan.doc_actions")
}
summary := fmt.Sprintf("audited %d local recovery source(s)", len(sourcesChecked))
if len(candidateIdentifiers) == 0 {
summary += "; no identifier candidates found"
} else {
summary += fmt.Sprintf("; found %d identifier candidate(s)", len(candidateIdentifiers))
}
return &ProbeResult{
Name: "unknown_context_audit",
Status: "success",
Summary: summary,
Output: map[string]any{
"sources_checked": sourcesChecked,
"available_sources": availableSources,
"missing_sources": missingSources,
"candidate_identifiers": candidateIdentifiers,
"decision_owner": string(normalizeDecisionOwner(plan)),
},
}
}
func probeAITableBaseCatalog(ctx context.Context, invoker ToolInvoker, rc RecoveryContext, replay Replay, plan RecoveryPlan) *ProbeResult {
if len(rc.CommandPath) < 2 || rc.CommandPath[0] != "aitable" || rc.CommandPath[1] != "base" {
return nil
}
if len(rc.CommandPath) < 3 || rc.CommandPath[2] != "get" {
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "no registered read-only probe for this aitable command"}
}
if invoker == nil {
return &ProbeResult{Name: "aitable_base_catalog_probe", Status: "skipped", Summary: "tool invoker unavailable"}
}
result, err := invoker.CallToolDirect(ctx, replay.ServerID, "list_bases", map[string]any{"limit": 5})
if err != nil {
return &ProbeResult{
Name: "aitable_base_catalog_probe",
Status: "error",
ServerID: replay.ServerID,
ToolName: "list_bases",
Error: err.Error(),
Summary: "failed to run read-only aitable base catalog probe",
}
}
payload, summary := summarizeProbeOutput(result)
return &ProbeResult{
Name: "aitable_base_catalog_probe",
Status: "success",
ServerID: replay.ServerID,
ToolName: "list_bases",
ArgsSummary: SummarizeArgs(map[string]any{"limit": 5}),
Summary: summary,
Output: payload,
}
}
func summarizeProbeOutput(result *transport.ToolCallResult) (any, string) {
if result == nil {
return nil, "probe returned no output"
}
for _, block := range result.Blocks {
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
continue
}
var parsed any
if err := json.Unmarshal([]byte(block.Text), &parsed); err == nil {
return parsed, "probe returned JSON payload"
}
return block.Text, "probe returned text payload"
}
if len(result.Content) > 0 {
return result.Content, "probe returned content payload"
}
return result, "probe returned non-text payload"
}
func buildAgentTask(eventID string, rc RecoveryContext, replay Replay, plan RecoveryPlan, probes []ProbeResult) AgentTask {
whyParts := []string{fmt.Sprintf("recovery category=%s", plan.Category)}
if len(probes) > 0 {
whyParts = append(whyParts, fmt.Sprintf("probe results=%d", len(probes)))
}
if plan.DocSearch.Status != "" && plan.DocSearch.Status != "skipped" {
whyParts = append(whyParts, "doc search results included")
}
if replay.RedactedCommand != "" {
whyParts = append(whyParts, "failed command="+replay.RedactedCommand)
}
return AgentTask{
Goal: "阅读完整 RecoveryBundle,并基于 dws skill 对 unknown 恢复场景做整体分析后再决定是否发起 grounded 的下一次 dws 尝试",
Why: strings.Join(whyParts, "; "),
MustReadRefs: buildMustReadRefs(rc.CommandPath),
AllowedActions: []string{
"读取完整 recovery bundle、recovery-guide 和对应产品参考文档",
"基于 doc_actions、kb_hits、probe_results 与 replay/context 整体判断下一步",
"仅在修复依据明确时重新发起新的 dws 命令",
},
ForbiddenActions: []string{
"编造 ID、UUID、token、URL 或其他业务参数",
"绕过 dws 直接调用 HTTP API、curl 或浏览器",
"未确认前把失败命令替换成另一套业务流程",
},
StopConditions: []string{
"新的 dws 命令已经成功并可回写 recovered",
"没有 grounded 修复依据,只能回写 handoff",
"继续尝试会要求猜测或扩大副作用边界",
},
FinalizeRequirement: buildFinalizeHint(eventID).Command,
}
}
func buildFinalizeHint(eventID string) FinalizeHint {
return FinalizeHint{
Required: true,
Command: fmt.Sprintf(
"dws recovery finalize --event-id %s --outcome <recovered|failed|handoff> --execution-file <file.json> --format json",
eventID,
),
ExecutionFileFields: []string{"actions", "attempts", "result", "error_summary"},
AllowedOutcomes: []string{"recovered", "failed", "handoff"},
}
}
func buildMustReadRefs(commandPath []string) []string {
refs := []string{
"skills/references/recovery-guide.md",
"skills/references/error-codes.md",
"skills/references/global-reference.md",
}
if len(commandPath) > 0 {
if ref := productReferenceFor(commandPath[0]); ref != "" {
refs = append(refs, ref)
}
}
return refs
}
func productReferenceFor(commandRoot string) string {
refs := map[string]string{
"aitable": "skills/references/products/aitable.md",
"attendance": "skills/references/products/attendance.md",
"calendar": "skills/references/products/calendar.md",
"chat": "skills/references/products/chat.md",
"contact": "skills/references/products/contact.md",
"devdoc": "skills/references/products/simple.md",
"ding": "skills/references/products/ding.md",
"report": "skills/references/products/report.md",
"todo": "skills/references/products/todo.md",
"workbench": "skills/references/products/workbench.md",
"approval": "skills/references/products/simple.md",
}
return refs[commandRoot]
}
func cloneProbeResults(results []ProbeResult) []ProbeResult {
if len(results) == 0 {
return nil
}
out := make([]ProbeResult, len(results))
for i, result := range results {
out[i] = result
out[i].ArgsSummary = cloneMap(result.ArgsSummary)
}
return out
}

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