Compare commits

...
7 Commits
Author SHA1 Message Date
修雨 0de6e46ef4 docs: cut 1.0.41 changelog + backfill 1.0.40 #488 (#495)
- 1.0.41: install auto-fallback to Gitee (#492) + CI auto-push mirror (#493)
- 1.0.40: add the missing #488 (skills embedded in binary) entry
2026-06-24 22:26:54 +08:00
修雨 9a8157a4cd ci(mirror): auto-push code to Gitee via HTTPS token (no SSH key) (#493)
Rewrite mirror-to-gitee.yml to push main + tags to the Gitee mirror using
HTTPS + GITEE_TOKEN (reusing the existing secret) instead of hub-mirror-action
+ an SSH key. Gated on GITEE_TOKEN; needs GITEE_USER + GITEE_REPO secrets.
Keeps Gitee code/raw in sync automatically on every push to main + tags, so
no more manual git push after each release.
2026-06-24 22:26:47 +08:00
修雨 b8e915edfb feat(install): auto-fallback to Gitee when GitHub unreachable (#492)
Probe GitHub Releases on startup; if it is unreachable (typical in mainland
China), automatically switch to the Gitee mirror — so a plain
`curl … | sh` (or .ps1 / install-skills.sh) works everywhere with no
DWS_GITEE_REPO env var. Explicit DWS_GITEE_REPO still wins; DWS_NO_FALLBACK=1
disables the probe; local source-checkout installs skip it.
2026-06-24 22:26:40 +08:00
修雨 bd370ed0e9 fix(doc): strip duplicate title H1 on the JSONML doc-create path (#464)
PR #448 stripped a leading H1 matching --name on the markdown path of
`dws doc create`, but the JSONML path (--content-format jsonml) was never
covered. Rich documents — tables, callouts, styled blocks — go through
create_document + update_document(jsonml=...), which writes the body
verbatim, so a leading h1 whose text equals the document name renders the
title twice (the "two headings" effect the doc platform shows because it
already renders --name as the page title).

Add stripLeadingDuplicateTitleJSONML: parse the marshaled JSONML body,
skip an optional ["root", {}, ...] wrapper, and drop the first node when
it is an h1 whose concatenated leaf text equals --name (trimmed,
case-insensitive). Any parse failure or non-match leaves the body
untouched, so a valid write is never blocked. A stderr note mirrors the
markdown path so agents learn the convention.
2026-06-24 18:34:16 +08:00
修雨 9fa76f8598 docs: cut 1.0.40 changelog + fix gitee mirror gate (#489)
- CHANGELOG: 1.0.40 (China mirror via Gitee + npmmirror, #486)
- mirror-to-gitee.yml: gate on GITEE_PRIVATE_KEY so it skips cleanly when the
  SSH key is unset (code mirror handled by Gitee-side pull-mirror) instead of
  failing the run.
2026-06-24 17:55:35 +08:00
修雨 b175acb48f feat(skill): embed skills/ into the binary, default skill setup to it (#488)
go:embed the skills/ tree (mono + multi) into the binary and make
`dws skill setup` install from the embedded bundle by default. Upgrading
the binary now refreshes the installed skill, instead of silently reusing
a stale copy probed from the current working directory. An explicit
--source / DWS_SKILL_SOURCE still overrides for development.

Replants the change from #441 onto main: #441 was stacked on the
abandoned feat/chat-bot-provisioning-openclaw base (PRs #407/#397 closed),
which is why its CI was red. The embedded-bundle test asserted a
connect.md doc that only existed on that dead base; retargeted it to a
reference doc that exists on main.
2026-06-24 17:55:23 +08:00
修雨 ed1cbd6f06 feat(install): China mirror via Gitee + npmmirror (#486)
国内安装加速:DWS_GITEE_REPO 开关从 Gitee 解析下载脚本/二进制/skill(默认仍 GitHub,向后兼容);release.yml 发版后 CI 自动镜像 release 附件到 Gitee;README 加国内安装说明。Approved-by: wxianfeng.
2026-06-24 17:14:40 +08:00
15 changed files with 783 additions and 12 deletions
+44
View File
@@ -0,0 +1,44 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
name: Mirror code to Gitee
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
jobs:
mirror:
runs-on: ubuntu-latest
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push main + tags to Gitee
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# 镜像对齐(force:Gitee 始终跟随 GitHub)
git push --force "$REMOTE" 'refs/remotes/origin/main:refs/heads/main'
git push --force --tags "$REMOTE"
echo "✅ 已镜像 main + tags 到 Gitee ${GITEE_REPO}"
+10
View File
@@ -58,6 +58,16 @@ jobs:
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
- name: Mirror release to Gitee (China)
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
+21
View File
@@ -6,6 +6,27 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.41] - 2026-06-24
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
### Added
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
### Changed
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
## [1.0.40] - 2026-06-24
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
### Added
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
+22
View File
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # install to PATH
</details>
## China mirror
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
**1. Install script + pre-built binary (Gitee mirror):**
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
**2. npm package (npmmirror mirror):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
+22
View File
@@ -113,6 +113,28 @@ cp dws ~/.local/bin/ # 安装到 PATH
</details>
## 国内加速安装
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
**2. npm 包(npmmirror 镜像):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
+2 -1
View File
@@ -95,10 +95,11 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("--skill / --exclude 仅在 --mode multi 下有效(mono 只有一个 skill,无需挑选)")
}
skillSrc, err := resolveSkillSetupSource(source, mode)
skillSrc, srcCleanup, err := resolveSkillSetupSourceOrEmbedded(source, mode)
if err != nil {
return err
}
defer srcCleanup()
dests, err := resolveSkillSetupTargets(target, mode)
if err != nil {
+86
View File
@@ -0,0 +1,86 @@
// 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 (
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
dwsroot "github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
)
// resolveSkillSetupSourceOrEmbedded resolves the skill source for `skill
// setup`. An explicit --source or DWS_SKILL_SOURCE is honored as a developer
// override (validated as an on-disk dir). Otherwise it falls back to the skill
// bundle embedded in THIS binary, so a plain `dws skill setup` always installs
// the version shipped with the running binary — upgrading the binary therefore
// refreshes the installed skill, instead of silently reusing a stale copy from
// the current working directory.
//
// The returned cleanup func removes any temp dir created for the embedded
// bundle; it is a no-op when an on-disk source is used. Always call it.
func resolveSkillSetupSourceOrEmbedded(explicit, mode string) (string, func(), error) {
noop := func() {}
explicit = strings.TrimSpace(explicit)
env := strings.TrimSpace(os.Getenv("DWS_SKILL_SOURCE"))
if explicit != "" || env != "" {
dir, err := resolveSkillSetupSource(explicit, mode)
return dir, noop, err
}
return materializeEmbeddedSkillSource(mode)
}
// materializeEmbeddedSkillSource extracts the embedded skills/<mode> subtree
// into a fresh temp dir and returns its path plus a cleanup func. Reusing a
// real directory lets the existing dir-based install/copy logic stay unchanged.
func materializeEmbeddedSkillSource(mode string) (string, func(), error) {
noop := func() {}
sub := "skills/" + mode // embed.FS always uses forward slashes
if _, err := fs.Stat(dwsroot.EmbeddedSkills, sub); err != nil {
return "", noop, fmt.Errorf("内嵌 skill 不含 %q(二进制可能未随 skills/ 重新构建): %w", sub, err)
}
tmp, err := os.MkdirTemp("", "dws-skill-"+mode+"-")
if err != nil {
return "", noop, fmt.Errorf("创建临时 skill 目录失败: %w", err)
}
cleanup := func() { _ = os.RemoveAll(tmp) }
walkErr := fs.WalkDir(dwsroot.EmbeddedSkills, sub, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel := strings.TrimPrefix(strings.TrimPrefix(p, sub), "/")
dst := filepath.Join(tmp, filepath.FromSlash(rel))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
data, readErr := dwsroot.EmbeddedSkills.ReadFile(p)
if readErr != nil {
return readErr
}
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
return mkErr
}
return os.WriteFile(dst, data, 0o644)
})
if walkErr != nil {
cleanup()
return "", noop, fmt.Errorf("展开内嵌 skill 到临时目录失败: %w", walkErr)
}
return tmp, cleanup, nil
}
+68
View File
@@ -0,0 +1,68 @@
// 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 (
"os"
"path/filepath"
"testing"
)
// TestMaterializeEmbeddedSkillSourceMono verifies that the mono skill bundle
// baked into the binary can be extracted to a temp dir and is a valid skill
// source root (so `dws skill setup` works with zero local checkout). The
// nested-reference and _common checks guard against the embed dropping nested
// docs or the `all:` prefix being lost (which would silently skip
// dot/underscore dirs).
func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMono)
if err != nil {
t.Fatalf("materializeEmbeddedSkillSource: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("extracted dir %s is not a valid mono skill source root", dir)
}
for _, rel := range []string{
"SKILL.md",
filepath.Join("references", "global-reference.md"),
filepath.Join("references", "best_practices", "_common"),
} {
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
t.Errorf("expected embedded skill to contain %s: %v", rel, err)
}
}
// cleanup must actually remove the temp dir.
cleanup()
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("cleanup did not remove temp dir %s (err=%v)", dir, err)
}
}
// TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded verifies that with
// no --source and no DWS_SKILL_SOURCE, resolution uses the embedded bundle
// rather than probing the current working directory (the stale-skill footgun).
func TestResolveSkillSetupSourceOrEmbeddedFallsBackToEmbedded(t *testing.T) {
t.Setenv("DWS_SKILL_SOURCE", "")
dir, cleanup, err := resolveSkillSetupSourceOrEmbedded("", skillSetupModeMono)
if err != nil {
t.Fatalf("resolveSkillSetupSourceOrEmbedded: %v", err)
}
defer cleanup()
if !isSkillSourceRoot(dir, skillSetupModeMono) {
t.Fatalf("embedded fallback returned non-source-root dir %s", dir)
}
}
+81
View File
@@ -335,6 +335,10 @@ func newDocCreateCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return err
}
if stripped, ok := stripLeadingDuplicateTitleJSONML(jsonml, name); ok {
fmt.Fprintln(cmd.ErrOrStderr(), `note: 正文首个与 --name 相同的一级标题已自动移除(文档标题会单独渲染为页面标题,保留会出现两个标题)。`)
jsonml = stripped
}
createResult, err := docInvocationResult(cmd, runner, "doc", "create_document", params)
if err != nil {
return err
@@ -425,6 +429,83 @@ func stripLeadingDuplicateTitleHeading(content, name string) (string, bool) {
return strings.TrimLeft(rest, "\r\n"), true
}
// stripLeadingDuplicateTitleJSONML is the JSONML-path counterpart of
// stripLeadingDuplicateTitleHeading. The rich-content path (--content-format
// jsonml) writes the body verbatim via update_document, so a leading h1 node
// whose text equals the document name renders the title twice — exactly the
// problem the markdown path already guards against.
//
// jsonmlBody is the marshaled body produced by prepareDocJSONMLBody: a JSON
// array, either a bare node list or wrapped as ["root", {}, ...nodes]. The
// first content node is removed only when it is an h1 whose concatenated text
// (trimmed, case-insensitive) exactly equals name. Any parse failure or
// non-match returns the input untouched, so this never blocks a valid write.
func stripLeadingDuplicateTitleJSONML(jsonmlBody, name string) (string, bool) {
name = strings.TrimSpace(name)
if name == "" {
return jsonmlBody, false
}
var body []any
if err := json.Unmarshal([]byte(jsonmlBody), &body); err != nil {
return jsonmlBody, false
}
// Locate the index of the first content node, skipping a ["root", {}, ...]
// wrapper if present.
first := 0
if len(body) >= 2 {
if tag, ok := body[0].(string); ok && tag == "root" {
first = 2
}
}
if first >= len(body) {
return jsonmlBody, false
}
node, ok := body[first].([]any)
if !ok || len(node) == 0 {
return jsonmlBody, false
}
tag, ok := node[0].(string)
if !ok || tag != "h1" {
return jsonmlBody, false
}
if !strings.EqualFold(strings.TrimSpace(jsonmlNodeText(node)), name) {
return jsonmlBody, false
}
body = append(body[:first], body[first+1:]...)
out, err := json.Marshal(body)
if err != nil {
return jsonmlBody, false
}
return string(out), true
}
// jsonmlNodeText concatenates all string leaves of a JSONML node, skipping the
// leading tag name and any attributes object, so ["h1", {..}, ["span", {..},
// ["span", {..}, "Title"]]] yields "Title".
func jsonmlNodeText(node any) string {
switch v := node.(type) {
case string:
return v
case []any:
var b strings.Builder
for i, child := range v {
// element[0] is the tag name; a map at element[1] is attributes.
if i == 0 {
if _, isStr := child.(string); isStr {
continue
}
}
if _, isMap := child.(map[string]any); isMap {
continue
}
b.WriteString(jsonmlNodeText(child))
}
return b.String()
default:
return ""
}
}
func newDocUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "update",
+107
View File
@@ -159,3 +159,110 @@ func TestDocCreateTitleOnlyContentOmitsMarkdown(t *testing.T) {
t.Errorf("markdown param = %v, want omitted", runner.last.Params["markdown"])
}
}
// TestDocCreateStripsDuplicateTitleJSONML verifies the end-to-end JSONML path:
// `doc create --content-format jsonml` must drop a leading h1 whose text equals
// --name before forwarding the body to update_document, otherwise the rich
// document shows the page title twice.
func TestDocCreateStripsDuplicateTitleJSONML(t *testing.T) {
runner := &docCommandRunner{responses: []map[string]any{{"nodeId": "NODE_X"}}}
root := newDocTestRoot(runner)
body := `{"jsonml":[["h1",{},"命令树参考"],["p",{},"正文"]]}`
_, errOut, err := executeDocCommand(t, root,
"create", "--name", "命令树参考",
"--content-format", "jsonml", "--content", body)
if err != nil {
t.Fatalf("execute: %v\nstderr:\n%s", err, errOut)
}
if len(runner.all) != 2 {
t.Fatalf("calls = %d, want 2 (create + update)", len(runner.all))
}
if runner.all[1].Tool != "update_document" {
t.Fatalf("second tool = %q, want update_document", runner.all[1].Tool)
}
got, _ := runner.all[1].Params["jsonml"].(string)
if strings.Contains(got, "命令树参考") {
t.Errorf("jsonml = %q, want duplicate h1 stripped", got)
}
if !strings.Contains(got, "正文") {
t.Errorf("jsonml = %q, want body content kept", got)
}
if !strings.Contains(errOut, "已自动移除") {
t.Errorf("stderr = %q, want a note about the removed heading", errOut)
}
}
// TestStripLeadingDuplicateTitleJSONML covers the JSONML-path counterpart: a
// leading h1 node whose text equals the document name is removed; everything
// else is left untouched.
func TestStripLeadingDuplicateTitleJSONML(t *testing.T) {
tests := []struct {
name string
body string
docName string
want string
stripped bool
}{
{
name: "root-wrapped duplicate h1 stripped",
body: `["root",{},["h1",{},"晚会简报"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["p",{},"正文"]]`,
stripped: true,
},
{
name: "nested leaf text matches and stripped",
body: `["root",{},["h1",{"uuid":"x"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"晚会简报"]]],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["p",{},"正文"]]`,
stripped: true,
},
{
name: "bare body without root wrapper",
body: `[["h1",{},"标题"],["p",{},"正文"]]`,
docName: "标题",
want: `[["p",{},"正文"]]`,
stripped: true,
},
{
name: "case insensitive match",
body: `["root",{},["h1",{},"Weekly REPORT"],["p",{},"x"]]`,
docName: "weekly report",
want: `["root",{},["p",{},"x"]]`,
stripped: true,
},
{
name: "distinct heading kept",
body: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["h1",{},"背景"],["p",{},"正文"]]`,
stripped: false,
},
{
name: "non-h1 leading node kept",
body: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
docName: "晚会简报",
want: `["root",{},["h2",{},"晚会简报"],["p",{},"正文"]]`,
stripped: false,
},
{
name: "invalid json untouched",
body: `not json`,
docName: "x",
want: `not json`,
stripped: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, ok := stripLeadingDuplicateTitleJSONML(tc.body, tc.docName)
if ok != tc.stripped {
t.Fatalf("stripped = %v, want %v", ok, tc.stripped)
}
if got != tc.want {
t.Fatalf("body = %q, want %q", got, tc.want)
}
})
}
}
+58 -3
View File
@@ -13,8 +13,14 @@ set -eu
# Environment variables (optional):
# DWS_VERSION — release tag (default: latest)
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via Gitee API.
GITEE_REPO="${DWS_GITEE_REPO:-}"
# Auto-fallback Gitee mirror used when GitHub is unreachable (see pick_source).
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
VERSION="${DWS_VERSION:-latest}"
SKILL_NAME="dws"
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
@@ -29,10 +35,43 @@ need_cmd() {
fi
}
# Fetch a Gitee API endpoint, retrying transient 502/503 from Gitee's gateway.
gitee_api() {
_url="$1"
_try=1
while [ "$_try" -le 4 ]; do
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
printf '%s' "$_resp"
return 0
fi
_try=$((_try + 1))
sleep 2
done
return 1
}
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
GITEE_REPO="$GITEE_FALLBACK_REPO"
printf ' ⚠ GitHub 不可达,自动切换国内 Gitee 镜像: %s\n' "$GITEE_REPO"
}
resolve_version() {
if [ "$VERSION" = "latest" ]; then
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable, so
# resolve the newest vN.N.N tag from the git tags endpoint instead.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
else
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
fi
if [ -z "$VERSION" ]; then
printf '❌ Could not determine the latest version. Set DWS_VERSION explicitly.\n' >&2
exit 1
@@ -40,6 +79,20 @@ resolve_version() {
fi
}
# Resolve a release asset's download URL by name (GitHub template vs Gitee API).
asset_url() {
_name="$1"
if [ -z "$GITEE_REPO" ]; then
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
return 0
fi
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
| tr '}' '\n' \
| grep "\"name\":[ ]*\"${_name}\"" \
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
| head -1 | sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
}
extract_zip() {
archive="$1"
dest="$2"
@@ -154,6 +207,7 @@ install_skills_to_root() {
main() {
need_cmd curl
pick_source
resolve_version
printf '\n'
@@ -166,7 +220,8 @@ main() {
TMPDIR_WORK="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
ASSET_URL="https://github.com/${REPO}/releases/download/${VERSION}/dws-skills.zip"
ASSET_URL="$(asset_url dws-skills.zip)"
[ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; }
printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION"
curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip"
extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted"
+68 -3
View File
@@ -18,6 +18,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_GITEE_REPO — "owner/repo" on Gitee; resolve version + assets via the
# Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -25,6 +27,10 @@ $ErrorActionPreference = "Stop"
$Repo = "DingTalk-Real-AI/dingtalk-workspace-cli"
$BinName = "dws"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via the Gitee API.
$GiteeRepo = if ($env:DWS_GITEE_REPO) { $env:DWS_GITEE_REPO } else { "" }
# Auto-fallback Gitee mirror used when GitHub is unreachable (see Resolve-Source).
$GiteeFallbackRepo = if ($env:DWS_GITEE_FALLBACK_REPO) { $env:DWS_GITEE_FALLBACK_REPO } else { "DingTalk-Real-AI/dingtalk-workspace-cli" }
$InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$Version = if ($env:DWS_VERSION) { $env:DWS_VERSION } else { "latest" }
$NoSkills = $env:DWS_NO_SKILLS -eq "1"
@@ -118,8 +124,61 @@ function Get-Arch {
Write-Err "Unsupported architecture: Could not detect system architecture. Please set DWS_ARCH environment variable to 'amd64' or 'arm64'."
}
function Invoke-GiteeApi {
param([string]$Uri)
# Gitee's gateway returns sporadic 502/503, so retry a few times before failing.
for ($i = 1; $i -le 4; $i++) {
try {
return Invoke-RestMethod -Uri $Uri -UseBasicParsing
} catch {
if ($i -eq 4) { throw }
Start-Sleep -Seconds 2
}
}
}
function Get-GiteeAssetUrl {
param([string]$Name)
# Resolve a release asset's download URL by name via the Gitee API
# (Gitee attachment URLs carry an unstable numeric id, so never template them).
$rel = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/releases/tags/$Version"
foreach ($a in $rel.assets) {
if ($a.name -eq $Name) { return $a.browser_download_url }
}
return ""
}
function Resolve-Source {
# Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable.
if ($GiteeRepo -ne "") { return }
if ($env:DWS_NO_FALLBACK -eq "1") { return }
try {
Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" -Method Head `
-TimeoutSec 12 -UseBasicParsing -ErrorAction Stop 2>$null | Out-Null
return
} catch {
$script:GiteeRepo = $GiteeFallbackRepo
Write-Say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: $script:GiteeRepo"
}
}
function Resolve-LatestVersion {
if ($Version -eq "latest") {
if ($GiteeRepo -ne "") {
try {
# Gitee's /releases/latest and /releases endpoints are unreliable
# (404 / empty even when releases exist), so resolve the newest
# vN.N.N tag from the git tags endpoint instead.
$tags = Invoke-GiteeApi "https://gitee.com/api/v5/repos/$GiteeRepo/tags"
$latest = $tags.name |
Where-Object { $_ -match '^v\d+\.\d+\.\d+$' } |
ForEach-Object { [version]($_.TrimStart('v')) } |
Sort-Object | Select-Object -Last 1
if ($latest) { $script:Version = "v$latest"; return }
} catch {}
Write-Err "Could not determine the latest Gitee version. Set `$env:DWS_VERSION explicitly."
return
}
try {
$response = Invoke-WebRequest -Uri "https://github.com/$Repo/releases/latest" `
-MaximumRedirection 0 -ErrorAction SilentlyContinue -UseBasicParsing 2>$null
@@ -289,7 +348,8 @@ function Install-Binary {
Resolve-LatestVersion
$archiveName = "${BinName}-windows-${arch}.zip"
$downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName"
if ($GiteeRepo -ne "") { $downloadUrl = Get-GiteeAssetUrl $archiveName } else { $downloadUrl = "https://github.com/$Repo/releases/download/$Version/$archiveName" }
if (-not $downloadUrl) { Write-Err "Could not resolve download URL for $archiveName (version $Version)." }
Write-Say "⬇ Downloading $BinName $Version (windows/$arch)..."
@@ -301,7 +361,7 @@ function Install-Binary {
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing
# Download and verify SHA256 checksum
$checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt"
if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" }
try {
$checksumPath = Join-Path $tmpDir "checksums.txt"
Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing
@@ -483,7 +543,8 @@ function Install-Skills {
Write-Say "📦 Installing agent skills from GitHub Releases..."
Resolve-LatestVersion
$zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip"
if ($GiteeRepo -ne "") { $zipUrl = Get-GiteeAssetUrl "dws-skills.zip" } else { $zipUrl = "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" }
if (-not $zipUrl) { Write-Err "Could not resolve download URL for dws-skills.zip (version $Version)." }
$tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "dws-skills-$PID"
New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null
@@ -546,6 +607,10 @@ $SourceRoot = Resolve-SourceRoot
Write-Banner
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
# Skipped when installing from a local source checkout (no download needed).
if (-not $SourceRoot) { Resolve-Source }
if (!$NoSkills) {
Resolve-SkillMode
}
+83 -5
View File
@@ -15,6 +15,8 @@
# DWS_NO_SKILLS — set to 1 to skip skills install
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
# DWS_SKILL_MODE — mono | multi (default: prompt if TTY, else mono)
# DWS_GITEE_REPO — "owner/repo" on Gitee; when set, version + assets resolve
# via the Gitee API instead of GitHub (China mirror)
#
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
@@ -22,6 +24,15 @@ set -eu
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
BIN_NAME="dws"
# China mirror: Gitee repo "owner/repo". When set, version + asset URLs resolve via
# the Gitee API (https://gitee.com/api/v5) instead of GitHub. Gitee attachment URLs
# carry an unstable numeric id, so every asset is resolved by name at install time.
GITEE_REPO="${DWS_GITEE_REPO:-}"
# Auto-fallback: when DWS_GITEE_REPO is not set, the installer probes GitHub and,
# if it is unreachable (typical in mainland China), automatically switches to this
# Gitee mirror — so a plain `curl … | sh` works everywhere with no env var.
# Set DWS_NO_FALLBACK=1 to disable the probe and force GitHub.
GITEE_FALLBACK_REPO="${DWS_GITEE_FALLBACK_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
INSTALL_NAME="${DWS_INSTALL_NAME:-$BIN_NAME}"
VERSION="${DWS_VERSION:-latest}"
@@ -77,6 +88,42 @@ download() {
fi
}
# Fetch a Gitee API endpoint, retrying transient failures. Gitee's gateway
# returns sporadic 502/503, so a single curl is unreliable; retry a few times
# before giving up. Prints the response body on success.
gitee_api() {
_url="$1"
_try=1
while [ "$_try" -le 4 ]; do
if _resp="$(curl -fsSL "$_url" 2>/dev/null)" && [ -n "$_resp" ]; then
printf '%s' "$_resp"
return 0
fi
_try=$((_try + 1))
sleep 2
done
return 1
}
# Resolve the download URL of a release asset by file name.
# GitHub: deterministic template <repo>/releases/download/<version>/<file>.
# Gitee : query the release API and pick the asset whose name matches
# (Gitee attachment URLs carry an unstable numeric id, so we never
# template them — we read browser_download_url straight from the API).
asset_url() {
_name="$1"
if [ -z "$GITEE_REPO" ]; then
printf '%s' "https://github.com/${REPO}/releases/download/${VERSION}/${_name}"
return 0
fi
gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/releases/tags/${VERSION}" \
| tr '}' '\n' \
| grep "\"name\":[ ]*\"${_name}\"" \
| grep -o '"browser_download_url":[ ]*"[^"]*"' \
| head -1 \
| sed 's/.*"browser_download_url":[ ]*"//;s/"$//'
}
extract_zip() {
archive="$1"
dest="$2"
@@ -111,11 +158,36 @@ detect_arch() {
esac
}
# Decide the download source. An explicit DWS_GITEE_REPO always wins. Otherwise
# probe GitHub Releases; if it is unreachable (typical in mainland China), switch
# GITEE_REPO to the mirror so every subsequent resolve/download uses Gitee.
pick_source() {
[ -n "$GITEE_REPO" ] && return 0
[ "${DWS_NO_FALLBACK:-0}" = "1" ] && return 0
if need_cmd curl; then
curl -fsS --connect-timeout 5 --max-time 12 -o /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
elif need_cmd wget; then
wget -q --timeout=12 --tries=1 -O /dev/null "https://github.com/${REPO}/releases/latest" 2>/dev/null && return 0
fi
GITEE_REPO="$GITEE_FALLBACK_REPO"
say "⚠ GitHub 不可达,自动切换国内 Gitee 镜像: ${GITEE_REPO}"
}
# Resolve the latest version tag from GitHub
resolve_version() {
if [ "$VERSION" = "latest" ]; then
# Follow the redirect from /releases/latest to get the tag
if need_cmd curl; then
if [ -n "$GITEE_REPO" ]; then
# Gitee's /releases/latest and /releases endpoints are unreliable — they
# return 404 / an empty list even when releases exist — so resolve the
# newest version from the git tags endpoint instead: keep only vN.N.N
# tags and pick the highest by version order.
VERSION="$(gitee_api "https://gitee.com/api/v5/repos/${GITEE_REPO}/tags" \
| grep -o '"name":[ ]*"v[0-9][0-9.]*"' \
| sed 's/.*"name":[ ]*"//;s/"$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -V | tail -1)"
elif need_cmd curl; then
# Follow the redirect from /releases/latest to get the tag
VERSION="$(curl -fsSI "https://github.com/${REPO}/releases/latest" 2>/dev/null \
| grep -i '^location:' | sed 's|.*/tag/||;s/[[:space:]]*$//')"
elif need_cmd wget; then
@@ -401,7 +473,8 @@ install_binary() {
resolve_version
archive_name="${BIN_NAME}-${os}-${arch}.tar.gz"
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${archive_name}"
download_url="$(asset_url "$archive_name")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${archive_name} (version ${VERSION})."
say "⬇ Downloading ${BIN_NAME} ${VERSION} (${os}/${arch})..."
@@ -411,7 +484,7 @@ install_binary() {
download "$download_url" "$tmpdir/$archive_name"
# Download and verify SHA256 checksum
checksum_url="https://github.com/${REPO}/releases/download/${VERSION}/checksums.txt"
checksum_url="$(asset_url checksums.txt)"
if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then
expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")"
if [ -n "$expected" ]; then
@@ -482,7 +555,8 @@ install_skills() {
resolve_version
skills_archive="dws-skills.zip"
download_url="https://github.com/${REPO}/releases/download/${VERSION}/${skills_archive}"
download_url="$(asset_url "$skills_archive")"
[ -n "$download_url" ] || err "Could not resolve download URL for ${skills_archive} (version ${VERSION})."
tmpdir_skills="$(mktemp -d)"
trap 'rm -rf "$tmpdir_skills"' EXIT INT TERM
@@ -559,6 +633,10 @@ main() {
print_banner
# Pick GitHub vs Gitee mirror (auto-fallback when GitHub is unreachable).
# Skipped when installing from a local source checkout (no download needed).
[ -z "$source_root" ] && pick_source
# Resolve skill mode only when we are actually going to touch skills.
if [ "$NO_SKILLS" != "1" ]; then
resolve_skill_mode
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Mirror release artifacts to a Gitee release so China users can install without
# hitting GitHub. The repo code itself is kept in sync by Gitee's repo-mirror
# feature; this script handles what the mirror does NOT carry — the GitHub
# Release *attachments* (binaries, checksums, skills zip) — by uploading them to
# the matching Gitee release via the Gitee OpenAPI v5.
#
# Consumed by install.sh when DWS_GITEE_REPO is set (it resolves each asset's
# real download_url from the Gitee API, since Gitee attachment URLs carry an
# unstable numeric id).
#
# Required environment (CI secrets):
# GITEE_TOKEN Gitee private access token (scopes: projects)
# GITEE_REPO "owner/repo" on Gitee, e.g. DingTalk-Real-AI/dingtalk-workspace-cli
# Optional:
# VERSION release tag (default: git describe)
# DIST_DIR artifacts dir (default: ./dist)
# GITEE_API API base (default: https://gitee.com/api/v5)
#
# Gating: if GITEE_TOKEN / GITEE_REPO are unset, exit 0 with a notice so the
# step can live in release.yml without breaking forks that lack the secret.
set -eu
DIST_DIR="${DIST_DIR:-dist}"
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
missing=""
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
[ -z "${GITEE_REPO:-}" ] && missing="$missing GITEE_REPO"
if [ -n "$missing" ]; then
echo "ℹ️ Gitee mirror sync skipped — missing:${missing}"
echo " Set these as repo secrets to auto-mirror releases to Gitee for China users."
exit 0
fi
VERSION="${VERSION:-$(git describe --tags --always 2>/dev/null || echo dev)}"
OWNER="${GITEE_REPO%%/*}"
NAME="${GITEE_REPO##*/}"
base="${GITEE_API}/repos/${OWNER}/${NAME}"
echo "📦 Mirroring release ${VERSION} → Gitee ${GITEE_REPO}"
# ── Resolve or create the Gitee release for this tag ──────────────────────────
# Gitee mirror sync brings the git tag over, so the tag should already exist.
rel_json="$(curl -fsSL "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
if [ -z "$release_id" ]; then
echo " No Gitee release for ${VERSION} yet — creating it."
rel_json="$(curl -fsSL -X POST "${base}/releases" \
-F "access_token=${GITEE_TOKEN}" \
-F "tag_name=${VERSION}" \
-F "name=${VERSION}" \
-F "body=Mirror of GitHub release ${VERSION} for China users." \
-F "target_commitish=main" 2>/dev/null || true)"
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
fi
[ -n "$release_id" ] || { echo "❌ Could not get/create Gitee release for ${VERSION}. Response: ${rel_json}" >&2; exit 1; }
echo " Gitee release id = ${release_id}"
# ── Upload each artifact as a release attachment ──────────────────────────────
uploaded=0
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt; do
[ -f "$f" ] || continue
fn="$(basename "$f")"
echo " ⬆ ${fn}"
resp="$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
-F "access_token=${GITEE_TOKEN}" \
-F "file=@${f}" 2>/dev/null || true)"
if printf '%s' "$resp" | grep -q '"browser_download_url"'; then
uploaded=$((uploaded + 1))
else
echo " ⚠ upload may have failed for ${fn}: ${resp}" >&2
fi
done
[ "$uploaded" -gt 0 ] || { echo "❌ No artifacts uploaded. Did the build (goreleaser) run?" >&2; exit 1; }
echo "✅ Uploaded ${uploaded} asset(s) to Gitee release ${VERSION}."
echo " China install: DWS_GITEE_REPO=${GITEE_REPO} \\"
echo " curl -fsSL https://gitee.com/${GITEE_REPO}/raw/main/scripts/install.sh | sh"
+27
View File
@@ -0,0 +1,27 @@
// 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 dws is the module-root package. Its sole purpose is to bake the
// skills/ documentation tree into the binary at build time so that
// `dws skill setup` installs the skill version shipped with THIS binary,
// independent of any local checkout. See internal/app/skill_setup.go.
package dws
import "embed"
// EmbeddedSkills holds the bundled skills/ tree (mono + multi) compiled into
// the binary. The `all:` prefix is required so dot/underscore entries — e.g.
// references/best_practices/_common — are included rather than skipped.
//
//go:embed all:skills
var EmbeddedSkills embed.FS