Compare commits

...
11 Commits
Author SHA1 Message Date
修雨 574d9aa2f7 docs(changelog): backfill 1.0.24 + add 1.0.25 release notes (#262)
- 1.0.24 (tag already pushed at 9e15115 on 2026-05-09): #248 embedded
  self-upgrade guard, #238 auth login help realignment, #261 release
  workflow_dispatch fallback.
- 1.0.25 (next release): #246 CLIAliases tool override + json_parse_strict
  transform, #247 Pipeline tool override + executor for multi-step
  workflows (submit → poll → download).
2026-05-11 13:56:12 +08:00
修雨 1aaaef0274 feat(schema): add CLIAliases tool override + json_parse_strict transform (#246)
Two generic CLI envelope schema enhancements that close gaps surfaced by
the cli_to_mcp test suite without resorting to product-specific helpers:

1. CLIToolOverride.CLIAliases (registry.go + dynamic_commands.go)
   - Lets a single MCP tool register additional cobra command aliases via
     envelope JSON (e.g. `range read` accepts `range get`, `member list`
     accepts `member ls`). Plumbs through the existing Route.Aliases ->
     cobra.Command.Aliases path; conflicts with siblings are silently
     skipped by cobra.

2. json_parse_strict transform (transform.go)
   - Strict JSON variant of json_parse that does NOT fall back to YAML.
     Use when the upstream tool requires a structured array/object value
     and silently coercing malformed input to a scalar string would mask
     a real user error (observed: filter-view --criteria 'NOT_VALID_JSON'
     was being accepted and quietly creating an empty-criteria view).
2026-05-11 10:56:19 +08:00
修雨 00c037b5be feat(schema): add Pipeline tool override + executor for multi-step workflows (#247)
A generic envelope schema feature that lets a single CLI command orchestrate
an ordered sequence of MCP tool calls plus optional HTTP-download sinks,
declared entirely in the envelope JSON. The motivating use case is the
"submit-job + poll-status + download-result" pattern (e.g. sheet export),
which previously required hardcoded helper commands per product.

## Schema additions

- `CLIToolOverride.Pipeline []PipelineStep` — when non-empty, dispatch
  ignores the parent map key (no single "primary tool") and walks the
  steps in order. CLI surface (CLIName / Group / Flags) still applies.
- `PipelineStep` struct — supports two step types:
  - `type:"call"` (default) invokes Tool with templated Args. Optional
    PollUntilField/Value turn it into a polling loop with configurable
    PollIntervalSec / PollTimeoutSec.
  - `type:"download"` resolves DownloadURLField, fetches via HTTP GET,
    and writes the body to the path supplied by OutputFlag's value
    (with directory-path filename inference).
- `CLIFlagOverride.PipelineLocal bool` — marks a flag as CLI-side only;
  CollectBindings skips it so the value never reaches MCP params, but
  the pipeline executor reads it via extractFlagValuesByAlias.

## Template language

Two prefixes supported in PipelineStep.Args / DownloadURLField:

  $flag.<name>           — value of the user's CLI flag whose alias is <name>
  $step.<idx>.<dotPath>  — field from a prior step's response (idx 0-based)
  literal                — passed through

dotPath walks nested map[string]any so "$step.1.content.downloadUrl"
resolves through wrapped MCP envelopes correctly.

## Stdout contract

The download step always emits machine-parseable plain-text lines
("jobId: <id>\\n", "downloadUrl: <url>\\n") in addition to the standard
JSON output, so shell pipelines and regex-based test suites can extract
key values without parsing JSON. Structured callers continue to consume
the output.WriteCommandPayload JSON envelope.

## Why this is schema enhancement, not product hardcode

- The executor is product-agnostic: any envelope can declare a Pipeline
  and benefit (sheet export today; doc/drive/aitable async patterns
  tomorrow).
- The template language is the only product-coupling, and it lives in
  the envelope JSON — not in Go code.
- No sheet/wiki/aitable-specific code in dingtalk-workspace-cli.

Files: 1 new + 3 modified (~250 LOC of executor + ~30 LOC of schema
plumbing). Existing tests pass; new pipeline tests TBA in a follow-up
once the schema fields are in upstream.
2026-05-11 10:56:10 +08:00
修雨 9e15115ad4 ci(release): add workflow_dispatch trigger as fallback (#261)
GitHub occasionally drops tag push events; this adds a manual trigger
so we can re-run the release job against any tag ref without having to
delete and re-push the tag.
2026-05-11 09:53:23 +08:00
xianfeng wangandgithub-actions[bot] 25bf3d12f2 feat(upgrade): block self-upgrade in embedded distributions (#248)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

* upgrade not in embed

* remove comment

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 20:27:20 +08:00
修雨 f78cc5c846 docs(auth): correct login help to reflect actual default + SSH guidance (#226) (#238)
* docs(auth): correct login help to reflect actual default + SSH guidance (#226)

The --help long description for `dws auth login` claimed the default mode was
"OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback
listener (oauth_provider.go:131-136) and only switches to device flow when
--device is passed. SSH-into-headless-Linux users following the docs hit a
dead end because the local browser cannot reach 127.0.0.1 on the remote box.

Rewrite the description so each method is named after its real flag:
  - OAuth Loopback 流 (默认)
  - OAuth 设备流 (--device)
  - 直接提供 Token (--token)

Add an explicit warning callout and a `--device` example for SSH/headless
environments. Also realign two flagErrorWithSuggestions strings in root.go
that propagated the same misconception ("默认使用设备流" → loopback default,
SSH 用户加 --device).

No behavioural change.

* docs(auth): drop emoji from login help warning
2026-05-09 09:25:57 +08:00
修雨 72fe795f3f docs(changelog): add 1.0.23 release notes (#241)
Backfill 1.0.23 entry covering PR #237 (HTTP_PROXY/HTTPS_PROXY support
restored across the three custom http.Transport instances). Format
follows the 1.0.22 section: narrative + Fixed + Tests.
2026-05-08 20:41:11 +08:00
修雨 0e892c7d75 fix: honour HTTP_PROXY/HTTPS_PROXY in custom HTTP transports (#236) (#237)
The three custom http.Transport instances built by the CLI
(`internal/transport/client.go` MCP transport, `internal/apiclient/client.go`
DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry
client) all set DialContext / TLSClientConfig / timeouts but omit the Proxy
field. Per Go's net/http contract, a non-nil Transport without an explicit
Proxy means "no proxy" — env vars are silently ignored, breaking sandboxed
or air-gapped deployments that route outbound through HTTP_PROXY /
HTTPS_PROXY.

Set Proxy: http.ProxyFromEnvironment on all three. Adds a regression test
per package that pointer-compares the Transport's Proxy func against
http.ProxyFromEnvironment (avoids flakiness from Go's envProxyOnce
memoisation when running alongside tests that read proxy env early).
2026-05-08 16:07:14 +08:00
修雨 8995bf65d6 docs(changelog): backfill 1.0.20 / 1.0.21 / 1.0.22 release notes (#231) 2026-05-07 21:52:09 +08:00
ybc❤️zyrand猷诺 91af2bc3b8 fix(install): add .hermes/skills to AGENT_DIRS (#188) (#221)
* fix(install): add .hermes/skills to AGENT_DIRS (#188)

dws 安装后未自动复制 skill 到 .hermes/skills/。在 4 份硬编码的
AGENT_DIRS 清单(build/npm/install.js、scripts/install.sh、
scripts/install.ps1、scripts/install-skills.sh)末尾追加
.hermes/skills,与现有'父目录守卫'逻辑天然兼容:

- 已安装 Hermes 的用户:自动获得 ~/.hermes/skills/dws
- 未安装 Hermes 的用户:父目录不存在则跳过,零副作用

Closes #188

* fix(install): cover remaining 4 AGENT_DIRS sources for .hermes/skills (review feedback)

Address review feedback from #221: the AGENT_DIRS list actually has 8 sources
in the repo, not 4. Without this commit, dws upgrade users and Homebrew users
would still NOT get .hermes/skills/dws populated even after the previous
4-script fix landed.

Fixes (functional):
- internal/upgrade/paths.go (knownSkillDirs):
    Append .hermes/skills so 'dws upgrade' refreshes ~/.hermes/skills/dws/
    for users who have Hermes installed.
- build/homebrew.rb.tmpl (post_install targets):
    Append .hermes/skills/dws so brew users also get the skill copied to
    ~/.hermes/skills/dws on post_install.

Fixes (test / smoke coverage):
- test/scripts/package_script_test.go (expectedPackagedSkillTargets):
    Append .hermes/skills/dws so future regressions (e.g. someone removes
    .hermes from install.js) are caught by CI.
- scripts/release/verify-package-managers.sh (HOME_AGENT_PARENTS / HOME_SKILL_TARGETS):
    Append .hermes to both lists so the release-time npm/brew smoke test
    actually asserts ~/.hermes/skills/dws/SKILL.md exists.

Documentation:
- internal/upgrade/paths.go: expanded the 'Kept in sync with' comment from
    a single file reference (build/npm/install.js) to all 7 in-sync sources,
    so future maintainers don't have to rediscover this list.

Verification:
- go vet ./internal/upgrade/... ./test/scripts/...   PASS
- go build ./...                                     PASS
- sh -n scripts/install.sh                           PASS
- sh -n scripts/install-skills.sh                    PASS
- sh -n scripts/release/verify-package-managers.sh   PASS
- node --check build/npm/install.js                  PASS

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 11:07:27 +08:00
ybc❤️zyrand猷诺 e2e8b3bf52 fix(attendance): summary 命令新增 --stats-type flag,修复 C0002 报错 (#228)
* fix(attendance): add --stats-type flag to summary to fix C0002 error

The MCP tool get_attendance_summary requires statsType in QueryUserAttendVO
at the server-side business layer (DingTalk schema marks it required:[] but
service rejects with C0002 'statistics type error' when omitted).

Changes:
- internal/helpers/attendance.go: add --stats-type flag (week/month);
  conditionally write statsType into QueryUserAttendVO when provided;
  update Long description and flag help to mark --stats-type as required
- test/cli_compat/attendance_test.go: add execSummaryDryRun helper and
  3 new tests verifying statsType is correctly threaded through VO
- skills/references/products/attendance.md: document --stats-type flag
  with warning about server-side mandatory enforcement

Verification:
- go build ./...                                                  PASS
- go vet ./internal/helpers/... ./test/cli_compat/...              PASS
- go test -run TestAttendanceSummary_should_pass_stats_type_*      PASS (3/3)
- go test -run TestAttendanceSummary_should_not_pass_stats_type_*  PASS
- Real CLI: dws attendance summary --stats-type month              success:true
  (C0002 fully gone; full attendance data returned)

Closes #227

* fix(attendance): enforce --stats-type as required at CLI layer (review feedback)

Address review feedback from #228:

1. Fail-fast validation: --stats-type is now required at the CLI layer
   instead of being conditionally written into VO. Previously the doc
   said 'required' but RunE silently let omission through, causing the
   same C0002 the fix was supposed to prevent.

2. Enum validation: only 'week' or 'month' are accepted; any other value
   is rejected at the CLI layer instead of being forwarded to the server.

3. Cosmetic: split the single-line Long description into multiple lines
   for readability.

Test changes:
- Replaced TestAttendanceSummary_should_not_pass_stats_type_when_omitted
  with two semantically-correct tests:
    * TestAttendanceSummary_should_error_when_stats_type_missing
    * TestAttendanceSummary_should_error_when_stats_type_invalid
- Updated other TestAttendanceSummary_* tests to pass --stats-type=month
  to match the new fail-fast contract.
- Renamed should_pass_only_user_flag to should_pass_user_id_through_vo
  (the old name no longer makes sense now that --stats-type is mandatory).

Skill doc:
- skills/references/products/attendance.md: clarified that the CLI now
  rejects missing/invalid --stats-type at the client side (won't even
  reach the server).

Verification:
- go build ./...                                    PASS
- go vet ./internal/helpers/... ./test/cli_compat/  PASS
- go test -run 'TestAttendanceSummary_should_(pass_stats_type|error_when_stats_type)' -v
                                                    PASS (4/4)
- dws attendance summary --help                     new layout shown

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 10:50:24 +08:00
28 changed files with 1087 additions and 47 deletions
+1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
+79
View File
@@ -4,6 +4,85 @@ 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.25] - 2026-05-11
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands.
### Added
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
## [1.0.24] - 2026-05-09
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
### Changed
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
### Docs
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
### CI
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
## [1.0.23] - 2026-05-08
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
### Fixed
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
### Tests
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
## [1.0.22] - 2026-05-07
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
### Fixed
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
### Tests
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
## [1.0.21] - 2026-05-05
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
### Fixed
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
## [1.0.20] - 2026-05-04
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
### Fixed
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
### Docs
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
- **CHANGELOG backfill for 1.0.19** (#204).
## [1.0.19] - 2026-04-30
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
+1
View File
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
+1
View File
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
+2
View File
@@ -210,6 +210,8 @@ func NormalisePath(path, baseURL string) string {
// defaultTransport returns a tuned http.Transport matching the project conventions.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+43
View File
@@ -0,0 +1,43 @@
// 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 apiclient
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
// a custom Transport without an explicit Proxy field silently bypasses
// HTTP_PROXY/HTTPS_PROXY.
//
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
// it, because http.ProxyFromEnvironment memoises the env on first call;
// other tests that read proxy env early would make a value-based assertion
// flaky.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+8 -3
View File
@@ -68,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
Long: `登录钉钉并获取认证凭证。
支持的登录方式:
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
- 直接提供 Token: 通过 --token 参数传入已有 token
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
- 直接提供 Token (--token): 跳过授权,使用已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 扫码登录
dws auth login # 本机扫码登录 (loopback 流)
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
+2
View File
@@ -464,6 +464,8 @@ func ipv4HTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
},
+50
View File
@@ -0,0 +1,50 @@
// 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 app
import (
"net/http"
"reflect"
"testing"
"time"
)
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
// IPv4-forcing client used by the legacy registry / discovery path. The
// custom Transport overrides DialContext to force IPv4 — without an
// explicit Proxy field it would also drop env-var proxy support.
//
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
// tests that read proxy env early would make this flaky. Asserting that the
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
// at process boot.
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
client := ipv4HTTPClient(5 * time.Second)
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+2 -2
View File
@@ -120,8 +120,8 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) 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 参数",
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
+9
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
@@ -57,6 +58,14 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if h := edition.Get(); h != nil && h.IsEmbedded {
name := h.Name
if name == "" {
name = "embedded"
}
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
}
yes, _ := cmd.Flags().GetBool("yes")
format := resolveUpgradeFormat(cmd)
@@ -0,0 +1,69 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
name string
args []string
}{
{"check", []string{"--check"}},
{"list", []string{"--list"}},
{"rollback", []string{"--rollback"}},
{"plain", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
}
msg := err.Error()
if !strings.Contains(msg, "嵌入模式") {
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
}
if !strings.Contains(msg, "embedded") {
t.Errorf("error message should include edition name, got: %q", msg)
}
if !strings.Contains(msg, "dws upgrade") {
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
}
})
}
}
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
t.Cleanup(func() { edition.Override(prev) })
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs([]string{"--check"})
err := cmd.Execute()
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
}
}
+22 -8
View File
@@ -163,9 +163,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
}
route := Route{
Use: cliName,
Short: short,
Long: long,
Use: cliName,
// CLIAliases register additional cobra command aliases for the
// same MCP tool. Empty / nil means no extra names.
Aliases: append([]string(nil), override.CLIAliases...),
Short: short,
Long: long,
// Preserve left-side indentation: cobra's Examples template
// renders {{.Example}} verbatim, and hardcoded helper commands
// rely on a 2-space prefix to look indented under "Examples:".
@@ -176,7 +179,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
CanonicalProduct: canonicalProduct,
Tool: toolName,
},
Bindings: bindings,
Bindings: bindings,
// §pipeline: when the envelope declares a multi-step
// orchestration, NewDirectCommand reroutes RunE into the
// pipeline executor instead of the single-tool flow. The
// CLIName / Group / Flags surface above still applies.
Pipeline: append([]market.PipelineStep(nil), override.Pipeline...),
Normalizer: normalizer,
}
@@ -612,10 +620,16 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
binding := FlagBinding{
FlagName: flagName,
Aliases: extraAliases,
Short: strings.TrimSpace(flagOverride.Shorthand),
Property: paramName,
Kind: kindFromTypeName(flagOverride.Type),
Usage: usage,
// §pipeline: PipelineLocal flags (e.g. `--output` in the
// sheet export pipeline) are CLI-side only — they appear in
// --help and are bindable, but CollectBindings skips them so
// the value never reaches MCP params. The pipeline executor
// reads them via extractFlagValuesByAlias.
PipelineLocal: flagOverride.PipelineLocal,
Short: strings.TrimSpace(flagOverride.Shorthand),
Property: paramName,
Kind: kindFromTypeName(flagOverride.Type),
Usage: usage,
// §P1: Required is preserved for positional bindings too. For
// pure positional, cobra arity (MinimumNArgs) enforces presence
// at parse time. For dual-mode positional (positional + alias),
+441
View File
@@ -0,0 +1,441 @@
// 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 — pipeline executor for CLIToolOverride.Pipeline.
//
// A pipeline turns a single CLI command into an ordered sequence of MCP
// tool calls plus optional HTTP-download sinks, declared entirely in the
// envelope JSON. Use cases:
//
// 1. submit-job + poll-status + download-result patterns (the canonical
// example: `dws sheet export --node X --output PATH` calls
// submit_export_job → query_export_job (poll until status=done) →
// HTTP GET downloadUrl → write to PATH).
// 2. compose-then-update flows where step 2's args reference step 1's
// response.
//
// Templates supported in PipelineStep.Args / DownloadURLField:
//
// $flag.<aliasName> — value of the user's CLI flag whose alias
// equals <aliasName>
// $step.<idx>.<dotPath> — field from a prior step's response
// literal string — passed through unchanged
//
// Limitations (intentional, to keep the executor small):
// - No conditional branching: steps run unconditionally in order.
// - No retry-on-error: the pipeline aborts on the first runner error.
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
// - Download step uses the standard library net/http with no custom
// timeout (relies on the user's Ctrl-C).
package compat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
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/market"
)
// pipelineCtx carries flag values + accumulated step responses through
// the executor. Unexported because callers always interact via runPipeline.
type pipelineCtx struct {
flags map[string]string
stepOutputs []map[string]any
}
// runPipeline executes route.Pipeline against runner, returning the last
// "call"-type step's response (or a synthesized success payload if the
// pipeline ends with a "download" step). The map is the shape returned to
// the user via the standard output formatter.
func runPipeline(
ctx context.Context,
cmd *cobra.Command,
runner executor.Runner,
route Route,
flagValues map[string]string,
) (map[string]any, error) {
pctx := &pipelineCtx{
flags: flagValues,
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
}
var lastCallResponse map[string]any
for i, step := range route.Pipeline {
stepType := strings.TrimSpace(step.Type)
if stepType == "" {
stepType = "call"
}
switch stepType {
case "call":
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
lastCallResponse = resp
case "download":
resp, err := executePipelineDownload(cmd, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
default:
return nil, apperrors.NewValidation(
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
)
}
}
if lastCallResponse != nil {
return lastCallResponse, nil
}
return map[string]any{"success": true}, nil
}
// executePipelineCall resolves args templates, then either polls or fires
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
// non-empty enable polling.
func executePipelineCall(
ctx context.Context,
runner executor.Runner,
route Route,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
if strings.TrimSpace(step.Tool) == "" {
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
}
args, err := resolveArgs(step.Args, pctx)
if err != nil {
return nil, err
}
invoke := func() (map[string]any, error) {
invocation := executor.NewCompatibilityInvocation(
route.Use,
route.Target.CanonicalProduct,
step.Tool,
args,
)
result, err := runner.Run(ctx, invocation)
if err != nil {
return nil, err
}
if result.Response == nil {
return map[string]any{}, nil
}
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
// cobra MarkFlagRequired) only checks that the flag was set, not
// that the value is non-empty — so a `--required-flag ""` reaches
// here and the upstream tool rejects with errorCode. Without this
// check the pipeline would happily proceed to poll/download and
// either spin until PollTimeout or burn through retries.
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
msg := getDotPath(result.Response, "content.errorMessage")
return nil, apperrors.NewValidation(fmt.Sprintf(
"%s rejected: %s — %v", step.Tool, errCode, msg,
))
}
return result.Response, nil
}
if strings.TrimSpace(step.PollUntilField) == "" {
return invoke()
}
// Polling loop.
interval := time.Duration(step.PollIntervalSec) * time.Second
if interval <= 0 {
interval = 2 * time.Second
}
timeoutSec := step.PollTimeoutSec
if timeoutSec <= 0 {
timeoutSec = 300
}
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for {
resp, err := invoke()
if err != nil {
return nil, err
}
actual := getDotPath(resp, step.PollUntilField)
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
return resp, nil
}
if time.Now().After(deadline) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
}
}
// executePipelineDownload resolves the URL template, fetches the body via
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
// Empty output path → print URL to stdout (terminal-friendly mode).
func executePipelineDownload(
cmd *cobra.Command,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
if err != nil {
return nil, err
}
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
if urlStr == "" {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline download: URL template %q resolved to empty value",
step.DownloadURLField,
))
}
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
// Always print machine-parseable "key: value" lines. Tests and shell
// pipelines that consume the pipeline output (regex / awk) rely on
// this exact format. The structured JSON output follows via
// output.WriteCommandPayload, so AI / SDK callers still get a typed
// response.
if jobID != "" {
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
}
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
if outputPath == "" {
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
}, nil
}
// If outputPath is a directory, infer filename from URL basename.
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
filename := inferFilenameFromURL(urlStr)
if filename == "" {
filename = fmt.Sprintf("export_%d", time.Now().Unix())
}
outputPath = filepath.Join(outputPath, filename)
}
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
if err != nil {
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("create %s: %w", outputPath, err)
}
defer out.Close()
written, err := io.Copy(out, resp.Body)
if err != nil {
return nil, fmt.Errorf("write %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
"output": outputPath,
"size": written,
}, nil
}
// resolveArgs applies resolveTemplate to every value in the map.
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
out := make(map[string]any, len(args))
for k, tmpl := range args {
v, err := resolveTemplate(tmpl, pctx)
if err != nil {
return nil, fmt.Errorf("arg %q: %w", k, err)
}
out[k] = v
}
return out, nil
}
// resolveTemplate evaluates a single template string. Returns the literal
// when input does not start with '$'.
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
s := strings.TrimSpace(tmpl)
if !strings.HasPrefix(s, "$") {
return s, nil
}
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
dot := strings.Index(s, ".")
if dot <= 0 || dot == len(s)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
}
head := s[:dot]
tail := s[dot+1:]
switch head {
case "$flag":
// tail is a flag alias name (no nested path supported)
return pctx.flags[tail], nil
case "$step":
// tail format: <idx>.<dotPath>
secondDot := strings.Index(tail, ".")
if secondDot <= 0 || secondDot == len(tail)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
}
idxStr := tail[:secondDot]
dotPath := tail[secondDot+1:]
idx, err := strconv.Atoi(idxStr)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
}
if idx < 0 || idx >= len(pctx.stepOutputs) {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
}
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
default:
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
}
}
// getDotPath walks dotPath through nested map[string]any. Returns nil if
// any segment is missing or the value isn't a map at an intermediate step.
func getDotPath(m map[string]any, dotPath string) any {
parts := strings.Split(dotPath, ".")
var current any = m
for _, p := range parts {
nested, ok := current.(map[string]any)
if !ok {
return nil
}
current = nested[p]
}
return current
}
// inferFilenameFromURL extracts the basename from a URL's path component,
// stripping query string + fragment. Returns "" if the URL doesn't parse
// or has no useful basename.
func inferFilenameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
base := path.Base(u.Path)
if base == "" || base == "/" || base == "." {
return ""
}
return base
}
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
// field at top level or one level under common MCP wrappers ("content" /
// "result"), so the synthetic download response can echo it back to the
// user. Returns "" when no jobId is present anywhere in prior responses.
func inferJobIDFromContext(pctx *pipelineCtx) any {
candidates := []string{"jobId", "content.jobId", "result.jobId"}
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
for _, p := range candidates {
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
return v
}
}
}
return ""
}
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
// the FlagBinding's primary CLI flag name, so the pipeline executor can
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
// always included (they are the whole point of the lookup).
//
// Note on key choice: buildOverrideBindings populates FlagName from the
// envelope's `alias` field (or kebab-case of the MCP property name when
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
// so $flag templates reference the user-visible CLI flag name, e.g.
// "$flag.node" matches `--node`.
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
flags := cmd.Flags()
out := make(map[string]string, len(bindings))
for _, b := range bindings {
primary := strings.TrimSpace(b.FlagName)
if primary == "" {
primary = strings.TrimSpace(b.Alias)
}
if primary == "" {
continue
}
// Try the primary flag name first, then any of the extra aliases.
// Whichever the user actually set wins; if none was set, the
// cobra-level default value is returned.
candidates := make([]string, 0, 2+len(b.Aliases))
candidates = append(candidates, primary)
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
candidates = append(candidates, a)
}
for _, a := range b.Aliases {
if a = strings.TrimSpace(a); a != "" {
candidates = append(candidates, a)
}
}
var value string
for _, c := range candidates {
f := flags.Lookup(c)
if f == nil {
continue
}
value = f.Value.String()
if f.Changed {
break
}
}
out[primary] = value
}
return out
}
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
// the other side as Go primitives where appropriate. Unused for now —
// the resolveTemplate path returns strings as-is to keep the contract
// simple; tools that need JSON-shaped values can use the existing
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
// composition is not in scope for the MVP).
var _ = json.Unmarshal
+58 -14
View File
@@ -28,6 +28,7 @@ import (
"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"
"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/pkg/convert"
"github.com/spf13/cobra"
@@ -60,12 +61,16 @@ type FlagBinding struct {
// parameter. Any of them being set satisfies Required, and the value
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
Aliases []string
Short string
Property string
Kind ValueKind
Usage string
Required bool
Aliases []string
// PipelineLocal, when true, marks this binding as CLI-side only — its
// value is consumed by the pipeline executor (e.g. as an HTTP
// download destination) and NOT forwarded to any MCP tool's params.
PipelineLocal bool
Short string
Property string
Kind ValueKind
Usage string
Required bool
// Default is the cobra-level flag default value as a string. Parsed
// into the Kind-appropriate primitive at registration time. Empty
// string keeps the existing zero-value default. This only affects
@@ -82,14 +87,19 @@ type FlagBinding struct {
type Normalizer func(cmd *cobra.Command, params map[string]any) error
type Route struct {
Use string
Aliases []string
Short string
Long string
Example string
Hidden bool
Target Target
Bindings []FlagBinding
Use string
Aliases []string
Short string
Long string
Example string
Hidden bool
Target Target
Bindings []FlagBinding
// Pipeline, when non-empty, replaces the single-tool dispatch with a
// multi-step orchestration. NewDirectCommand sees this and wires the
// pipeline executor into RunE instead of the standard
// invoke-then-output flow. See internal/compat/pipeline.go.
Pipeline []market.PipelineStep
Normalizer Normalizer
// OutputTransform, when non-nil, post-processes the MCP response payload
// (rename / drop / columns) before the formatter emits it. Wired up from
@@ -277,6 +287,33 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
delete(params, "_blocked")
}
// §pipeline: when the override declares a multi-step pipeline,
// dispatch via the pipeline executor instead of the single-tool
// invoke-then-output flow. The executor reads flag values by
// alias (so $flag.<alias> templates resolve), walks each step,
// handles polling + downloads, and returns the last "call"
// step's response as the payload to the formatter.
if len(route.Pipeline) > 0 {
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
if err != nil {
return err
}
result := executor.Result{
Invocation: executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
"pipeline",
params,
),
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
invocation := executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
@@ -706,6 +743,13 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
}
params := make(map[string]any)
for _, binding := range bindings {
// Pipeline-local flags exist purely for the pipeline executor
// (e.g. --output destination paths) and must never be forwarded
// to MCP tools as params, otherwise the upstream API would
// either reject the unknown field or silently store junk.
if binding.PipelineLocal {
continue
}
if binding.Positional {
// Pure positional (no flag aliases) is handled by
// collectPositionalBindings. Dual-mode positional bindings
+28 -1
View File
@@ -26,7 +26,8 @@ import (
)
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse, enum_map.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -37,6 +38,8 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformCSVToArray(value)
case "json_parse":
return transformJSONParse(value)
case "json_parse_strict":
return transformJSONParseStrict(value)
case "enum_map":
return transformEnumMap(value, args)
default:
@@ -151,6 +154,30 @@ func transformJSONParse(value any) (any, error) {
)
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
// structured array/object value and "garbage in → empty out" is unacceptable.
func transformJSONParseStrict(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
}
func transformEnumMap(value any, args map[string]any) (any, error) {
s, ok := toString(value)
if !ok {
+17 -4
View File
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: `查询某个人的考勤统计摘要。
--user、--date、--stats-type 均必填。
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
userID, _ := cmd.Flags().GetString("user")
workDateStr, _ := cmd.Flags().GetString("date")
statsType, _ := cmd.Flags().GetString("stats-type")
if userID == "" {
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
}
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
}
if statsType == "" {
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
}
if statsType != "week" && statsType != "month" {
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
}
// Build nested structure QueryUserAttendVO
vo := map[string]any{
"userId": userID,
"queryDate": workDateStr,
"statsType": statsType,
}
params := map[string]any{
"QueryUserAttendVO": vo,
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
}
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
preferLegacyLeaf(cmd)
return cmd
}
+66 -2
View File
@@ -173,8 +173,16 @@ type CLIOutputFormat struct {
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
type CLIToolOverride struct {
CLIName string `json:"cliName"`
Description string `json:"description,omitempty"`
CLIName string `json:"cliName"`
// CLIAliases registers additional cobra command aliases for the same MCP
// tool, so the leaf command can be invoked under multiple names without
// duplicating the override. Mirrors cobra.Command.Aliases. Each alias is
// added to the cobra Aliases slice; conflicts with existing siblings are
// silently ignored by cobra. Use for command-name normalisation (e.g.
// `range read` accepts `range get` as an alias) or hardcoded-command
// migration paths. Empty / nil means no extra aliases.
CLIAliases []string `json:"cliAliases,omitempty"`
Description string `json:"description,omitempty"`
// Example, when non-empty, is wired to cobra.Command.Example to render
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
@@ -212,6 +220,56 @@ type CLIToolOverride struct {
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
// ignored. Use for deprecated leaf commands that moved to a new path.
RedirectTo string `json:"redirectTo,omitempty"`
// Pipeline declares a multi-step orchestration: each step calls one
// MCP tool, with subsequent steps able to reference prior step outputs
// in their argument templates. PollUntilField/Value turn a step into a
// polling loop (for async jobs); type:"download" turns a step into an
// HTTP download sink that writes to a CLI-local --output flag. When
// Pipeline is non-empty, dispatch ignores the parent toolOverrides map
// key (no single "primary tool"); the executor walks the steps in
// order. CLI surface (CLIName / Group / Flags) still comes from the
// parent override; flags can be marked PipelineLocal=true to be
// consumed by the executor without being forwarded to MCP tools.
//
// See internal/compat/pipeline.go for the executor + envelope examples.
Pipeline []PipelineStep `json:"pipeline,omitempty"`
}
// PipelineStep declares one step in a multi-step CLIToolOverride.Pipeline.
// Templates supported in Args / DownloadURLField:
//
// $flag.<aliasName> — value of the user's CLI flag whose alias is
// <aliasName> (resolved at dispatch time).
// $step.<idx>.<dotPath> — field from a prior step's response, e.g.
// "$step.0.jobId" or "$step.1.result.url".
// literal value — passed through unchanged.
type PipelineStep struct {
// Type controls dispatch. Empty / "call" invokes Tool as an MCP tool.
// "download" treats this step as an HTTP GET sink (no MCP tool is
// invoked); URL is resolved from DownloadURLField.
Type string `json:"type,omitempty"`
// Tool is the MCP tool name to invoke for type=="call".
Tool string `json:"tool,omitempty"`
// Args maps MCP tool parameter names to template strings.
Args map[string]string `json:"args,omitempty"`
// PollUntilField, when non-empty (with PollUntilValue), turns this
// step into a polling loop: invoke repeatedly with the same Args
// until response[<PollUntilField>] equals PollUntilValue (string
// compare). Use for async-job patterns where a status field
// transitions to a terminal value (e.g. "done" / "succeeded").
PollUntilField string `json:"pollUntilField,omitempty"`
PollUntilValue string `json:"pollUntilValue,omitempty"`
PollIntervalSec int `json:"pollIntervalSec,omitempty"` // default 2 when polling
PollTimeoutSec int `json:"pollTimeoutSec,omitempty"` // default 300 when polling
// DownloadURLField (type=="download") is a $step.X.field template
// that resolves to an HTTP URL. The body is fetched via GET and
// written to the path given by OutputFlag's value. If OutputFlag's
// value is empty, the URL is printed to stdout for the user.
DownloadURLField string `json:"downloadURLField,omitempty"`
// OutputFlag (type=="download") names the CLI flag (alias) whose
// user-supplied value is the local destination path. When the path
// is a directory, the filename is inferred from the URL's basename.
OutputFlag string `json:"outputFlag,omitempty"`
}
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
@@ -261,6 +319,12 @@ type CLIFlagOverride struct {
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
// only recognises the placeholder set. See schema v3 §2.3.
RuntimeDefault string `json:"runtimeDefault,omitempty"`
// PipelineLocal, when true, marks this flag as CLI-side only — its
// value is consumed by the pipeline executor (e.g. as an HTTP
// download destination) and NOT forwarded to any MCP tool's params.
// Only meaningful when the enclosing CLIToolOverride.Pipeline is set.
// Use for flags like `--output` that describe local destination paths.
PipelineLocal bool `json:"pipelineLocal,omitempty"`
}
type CLITool struct {
+5
View File
@@ -211,6 +211,11 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
// "accepted but never responded" servers faster, and explicit TLS/dial timeouts.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars; a custom
// Transport without an explicit Proxy field would otherwise bypass
// proxies entirely, which breaks sandboxed/air-gapped deployments
// that rely on an outbound proxy (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+45
View File
@@ -0,0 +1,45 @@
// 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 transport
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv guards the fix for issue #236:
// the MCP HTTP transport must honour HTTP_PROXY / HTTPS_PROXY env vars.
// A custom Transport built without an explicit Proxy field defaults to
// "no proxy" — sandboxed deployments behind an outbound proxy would then
// silently bypass the proxy and fail.
//
// We assert tr.Proxy points at http.ProxyFromEnvironment rather than invoking
// it, because http.ProxyFromEnvironment memoises env on first call (Go's
// envProxyOnce). Test ordering with anything else that reads proxy env early
// would make a value-based assertion flaky. The runtime reads env at process
// boot — pointing at the stdlib resolver is the contract we need.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY/HTTPS_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+10 -1
View File
@@ -19,7 +19,15 @@ const (
)
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
// Kept in sync with build/npm/install.js AGENT_DIRS.
// Kept in sync with:
// - build/npm/install.js AGENT_DIRS
// - scripts/install.sh for-in list
// - scripts/install.ps1 $AgentDirs
// - scripts/install-skills.sh for-in list
// - build/homebrew.rb.tmpl targets
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
var knownSkillDirs = []string{
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
// skillDirBlacklist contains parent directories whose skills are managed by
+2 -1
View File
@@ -116,7 +116,8 @@ install_skills_to_root() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
+2 -1
View File
@@ -44,7 +44,8 @@ $AgentDirs = @(
".amp\skills",
".kiro\skills",
".trae\skills",
".openclaw\skills"
".openclaw\skills",
".hermes\skills"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -201,7 +201,8 @@ install_skills_to_homes() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
.kiro
.trae
.openclaw
.hermes
"
HOME_SKILL_TARGETS="
.agents/skills/dws
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
.kiro/skills/dws
.trae/skills/dws
.openclaw/skills/dws
.hermes/skills/dws
"
cleanup() {
if command -v brew >/dev/null 2>&1; then
+7 -3
View File
@@ -32,12 +32,16 @@ Flags:
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--user string 钉钉用户 ID (必填)
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
--user string 钉钉用户 ID (必填)
```
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
### 查询考勤组与考勤规则
```
Usage:
+112 -6
View File
@@ -1,6 +1,8 @@
package cli_compat_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
}
}
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U002",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
t.Fatal("no call captured")
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
}
}
// execSummaryDryRun runs attendance summary with --dry-run and returns the
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
// necessary because the attendance handler is a custom helper (not a dynamic
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
// call in non-dry-run mode.
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
t.Helper()
root := buildRoot()
_ = setupTestDeps(t, "attendance")
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
for k, v := range flags {
if v != "" {
cliArgs = append(cliArgs, "--"+k, v)
}
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&bytes.Buffer{})
root.SetArgs(cliArgs)
if err := root.Execute(); err != nil {
t.Fatalf("unexpected CLI error: %v", err)
}
var payload struct {
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
}
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
if !ok {
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
}
return vo
}
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if vo["statsType"] != "month" {
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "week",
})
if vo["statsType"] != "week" {
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
})
if err == nil {
t.Fatal("expected error when --stats-type is missing")
}
if !strings.Contains(err.Error(), "stats-type") {
t.Errorf("expected error message to mention stats-type, got: %v", err)
}
}
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "foobar",
})
if err == nil {
t.Fatal("expected error when --stats-type is neither week nor month")
}
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
t.Errorf("expected error message to mention week and month, got: %v", err)
}
}
// ── attendance rules ───────────────────────────────────────
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
+1
View File
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
".kiro/skills/dws",
".trae/skills/dws",
".openclaw/skills/dws",
".hermes/skills/dws",
}
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip