Merge pull request #71 from wxianfeng/feature/wk_merge

Feature/wk merge
This commit is contained in:
fantiu
2026-04-02 10:22:49 +08:00
committed by GitHub
50 changed files with 1033 additions and 135 deletions
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

+38
View File
@@ -141,3 +141,41 @@ jobs:
- name: Generated Drift
run: ./scripts/policy/check-generated-drift.sh
edition-tests:
name: Edition Contract Tests
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run edition contract tests
run: go test -v -count=1 ./pkg/editiontest/...
notify-downstream:
name: Notify Wukong Overlay
needs: [test, policy, edition-tests]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
steps:
- name: Trigger downstream CI
run: |
# Trigger internal GitLab CI pipeline via webhook.
# WUKONG_TRIGGER_TOKEN is a repository secret.
if [ -n "${{ secrets.WUKONG_TRIGGER_TOKEN }}" ]; then
curl --fail --silent --show-error \
-X POST \
-F "token=${{ secrets.WUKONG_TRIGGER_TOKEN }}" \
-F "ref=main" \
-F "variables[UPSTREAM_SHA]=${{ github.sha }}" \
"${{ secrets.WUKONG_TRIGGER_URL }}"
echo "Downstream CI triggered."
else
echo "No WUKONG_TRIGGER_TOKEN configured, skipping downstream notification."
fi
+1
View File
@@ -27,3 +27,4 @@ test/cli_compat/testdata/
credentials*
plans
_docs
dws.zip
+4 -1
View File
@@ -1,6 +1,6 @@
GO ?= go
.PHONY: all help build rebuild test lint fmt policy package release publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -34,6 +34,9 @@ policy:
@./scripts/policy/check-open-source-assets.sh
@./scripts/policy/check-command-surface.sh --strict
edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
package:
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
+16 -5
View File
@@ -24,8 +24,9 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -48,7 +49,9 @@ func buildAuthCommand() *cobra.Command {
},
}
cmd.AddCommand(newAuthLoginCommand())
if !edition.Get().HideAuthLogin {
cmd.AddCommand(newAuthLoginCommand())
}
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
@@ -206,7 +209,9 @@ func newAuthLogoutCommand() *cobra.Command {
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
}
return nil
},
}
@@ -236,6 +241,8 @@ func newAuthStatusCommand() *cobra.Command {
tokenData = updatedData
refreshed = true
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
}
}
if authStatusAuthenticated(tokenData) {
@@ -263,7 +270,9 @@ func newAuthStatusCommand() *cobra.Command {
}
} else {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
fmt.Fprintln(w, "运行 dws auth login 进行登录")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "运行 dws auth login 进行登录")
}
}
return nil
},
@@ -341,7 +350,9 @@ func newAuthResetCommand() *cobra.Command {
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 认证信息已重置")
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login 重新登录")
}
return nil
},
}
+5 -1
View File
@@ -16,6 +16,8 @@ package app
import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Build-time variables injected via ldflags when available.
@@ -28,7 +30,9 @@ func defaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
if fn := edition.Get().ConfigDir; fn != nil {
return fn()
}
homeDir, err := os.UserHomeDir()
if err != nil {
return exeRelativeConfigDir()
+31 -2
View File
@@ -30,16 +30,25 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
if fn := edition.Get().StaticServers; fn != nil {
injectStaticServers(fn())
// Static servers provided by the edition hook — skip Market discovery
// entirely. The overlay registers its own product commands via
// RegisterExtraCommands; we only add the open-source helpers here.
commands := helpers.NewPublicCommands(runner)
return mergeTopLevelCommands(commands)
}
var commands []*cobra.Command
// Generate commands dynamically from the market discovery API.
if dynamicCmds := loadDynamicCommands(ctx, runner); len(dynamicCmds) > 0 {
commands = append(commands, dynamicCmds...)
}
@@ -47,6 +56,26 @@ func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cob
return mergeTopLevelCommands(commands)
}
// injectStaticServers converts edition.ServerInfo entries into
// market.ServerDescriptor and feeds them into SetDynamicServers so the
// direct-runtime endpoint resolver can find them.
func injectStaticServers(servers []edition.ServerInfo) {
descriptors := make([]market.ServerDescriptor, 0, len(servers))
for _, s := range servers {
descriptors = append(descriptors, market.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
CLI: market.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
},
})
}
SetDynamicServers(descriptors)
}
// loadDynamicCommands loads the server registry and generates CLI commands
// dynamically from CLIOverlay metadata. It consults the disk cache first.
// Within the short revalidation window it uses the cached registry directly;
+82 -17
View File
@@ -29,7 +29,6 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -41,6 +40,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -287,6 +288,12 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
if fn := edition.Get().RegisterExtraCommands; fn != nil {
caller := newToolCallerAdapter(runner, flags)
fn(root, caller)
deduplicateCommands(root)
}
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
@@ -465,24 +472,51 @@ func newVersionCommand() *cobra.Command {
Example: " dws version\n dws version --format json",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
format, err := cmd.Flags().GetString("format")
if err != nil {
return apperrors.NewInternal("failed to read format flag")
wantJSON := cmd.Flags().Changed("format")
if wantJSON {
format, _ := cmd.Flags().GetString("format")
wantJSON = (format == "json")
}
payload := map[string]any{
"version": Version(),
"go": "1.24+",
editionName := edition.Get().Name
if editionName == "" {
editionName = "open"
}
if format == "json" {
ver := RawVersion()
bt := BuildTime()
gc := GitCommit()
goVer := "1.24+"
arch := "MCP Dynamic Aggregation"
if wantJSON {
payload := map[string]any{
"version": ver,
"edition": editionName,
"architecture": arch,
"go": goVer,
}
if bt != "unknown" {
payload["build"] = bt
}
if gc != "unknown" {
payload["commit"] = gc
}
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"版本: %s\nGo: %s\n",
Version(),
"1.24+",
)
return err
w := cmd.OutOrStdout()
fmt.Fprintf(w, "%-16s%s\n", "Version:", ver)
fmt.Fprintf(w, "%-16s%s\n", "Edition:", editionName)
if bt != "unknown" {
fmt.Fprintf(w, "%-16s%s\n", "Build:", bt)
}
if gc != "unknown" {
fmt.Fprintf(w, "%-16s%s\n", "Commit:", gc)
}
fmt.Fprintf(w, "%-16s%s\n", "Architecture:", arch)
fmt.Fprintf(w, "%-16s%s\n", "Go:", goVer)
return nil
},
}
}
@@ -579,17 +613,30 @@ func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executo
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
// unless they correspond to a product discovered via dynamic server discovery.
// unless they correspond to a product discovered via dynamic server discovery
// or listed in the edition's VisibleProducts hook.
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
allowedProducts := DirectRuntimeProductIDs()
var allowedProducts map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowedProducts = make(map[string]bool, len(products))
for _, p := range products {
allowedProducts[p] = true
}
} else {
allowedProducts = DirectRuntimeProductIDs()
}
staticCommands := map[string]bool{
"auth": true,
"cache": true,
"completion": true,
"version": true,
"help": true,
"recovery": true,
"schema": true,
"mcp": true,
}
for _, cmd := range root.Commands() {
name := cmd.Name()
@@ -606,6 +653,24 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
}
}
// deduplicateCommands removes duplicate top-level commands, keeping the last
// registered one. This ensures overlay commands take precedence over
// open-source defaults when both register the same product name.
func deduplicateCommands(root *cobra.Command) {
seen := make(map[string]*cobra.Command)
var dups []*cobra.Command
for _, cmd := range root.Commands() {
name := cmd.Name()
if prev, ok := seen[name]; ok {
dups = append(dups, prev)
}
seen[name] = cmd
}
for _, dup := range dups {
root.RemoveCommand(dup)
}
}
func cacheStoreFromEnv() *cache.Store {
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
return cache.NewStore(cacheDir)
+4 -4
View File
@@ -172,8 +172,8 @@ func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\n%s", out.String())
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
@@ -213,8 +213,8 @@ func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
}
if !strings.Contains(out.String(), "\"version\"") {
t.Fatalf("version output missing version key:\n%s", out.String())
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
+11 -1
View File
@@ -5,6 +5,7 @@ import (
"strings"
"text/tabwriter"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
@@ -53,7 +54,16 @@ func visibleMCPRootCommands(root *cobra.Command) []*cobra.Command {
return nil
}
allowed := DirectRuntimeProductIDs()
var allowed map[string]bool
if fn := edition.Get().VisibleProducts; fn != nil {
products := fn()
allowed = make(map[string]bool, len(products))
for _, p := range products {
allowed[p] = true
}
} else {
allowed = DirectRuntimeProductIDs()
}
if len(allowed) == 0 {
return nil
}
+17
View File
@@ -31,6 +31,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
@@ -191,6 +192,11 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
}
if err != nil {
if isAuthError(err) {
if fn := edition.Get().OnAuthError; fn != nil {
_ = fn(defaultConfigDir(), err)
}
}
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
@@ -335,6 +341,14 @@ func runtimeFlagEnabled(raw string, defaultValue bool) bool {
}
}
func isAuthError(err error) bool {
var appErr *apperrors.Error
if errors.As(err, &appErr) {
return appErr.Category == apperrors.CategoryAuth
}
return false
}
func productEndpointOverride(productID string) (string, bool) {
key := "DINGTALK_" + strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(productID), "-", "_")) + "_MCP_URL"
value := strings.TrimSpace(os.Getenv(key))
@@ -365,6 +379,9 @@ func resolveIdentityHeaders() map[string]string {
headers[k] = v
}
}
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
return headers
}
+104
View File
@@ -0,0 +1,104 @@
// 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 (
"context"
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// toolCallerAdapter bridges executor.Runner to the public edition.ToolCaller
// interface so that private overlays can invoke MCP tools without importing
// internal packages.
type toolCallerAdapter struct {
runner executor.Runner
flags *GlobalFlags
}
func newToolCallerAdapter(runner executor.Runner, flags *GlobalFlags) edition.ToolCaller {
return &toolCallerAdapter{runner: runner, flags: flags}
}
func (a *toolCallerAdapter) CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
inv := executor.NewHelperInvocation("overlay."+productID+"."+toolName, productID, toolName, args)
result, err := a.runner.Run(ctx, inv)
if err != nil {
return nil, err
}
return convertResult(result), nil
}
func (a *toolCallerAdapter) Format() string {
if a.flags != nil {
return a.flags.Format
}
return "json"
}
func (a *toolCallerAdapter) DryRun() bool {
return a.flags != nil && a.flags.DryRun
}
func convertResult(r executor.Result) *edition.ToolResult {
resp := r.Response
if resp == nil {
return &edition.ToolResult{}
}
// The runtime runner stores MCP response content under "content".
contentRaw, ok := resp["content"]
if !ok {
// Dry-run or echo mode: serialize the whole response as text.
data, _ := json.Marshal(resp)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
}
// Content may be a []any of {type, text} blocks from the MCP response,
// or a single map for mock mode.
switch v := contentRaw.(type) {
case []any:
blocks := make([]edition.ContentBlock, 0, len(v))
for _, item := range v {
if m, ok := item.(map[string]any); ok {
blocks = append(blocks, edition.ContentBlock{
Type: strVal(m, "type"),
Text: strVal(m, "text"),
})
}
}
return &edition.ToolResult{Content: blocks}
case map[string]any:
data, _ := json.Marshal(v)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
default:
data, _ := json.Marshal(contentRaw)
return &edition.ToolResult{
Content: []edition.ContentBlock{{Type: "text", Text: string(data)}},
}
}
}
func strVal(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
+24
View File
@@ -15,6 +15,21 @@ package app
var version = "dev"
// SetVersion overrides the version, build time and git commit strings.
// Called by pkg/cli.SetVersion for overlay modules that inject their own
// version info via ldflags.
func SetVersion(v, bt, gc string) {
if v != "" {
version = v
}
if bt != "" {
buildTime = bt
}
if gc != "" {
gitCommit = gc
}
}
// Version returns the current CLI version string, including build metadata
// when injected via ldflags (buildTime, gitCommit).
func Version() string {
@@ -23,3 +38,12 @@ func Version() string {
}
return version
}
// RawVersion returns the bare version string without build metadata.
func RawVersion() string { return version }
// BuildTime returns the build timestamp injected via ldflags.
func BuildTime() string { return buildTime }
// GitCommit returns the git commit hash injected via ldflags.
func GitCommit() string { return gitCommit }
+1 -1
View File
@@ -26,8 +26,8 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/fatih/color"
)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+7 -3
View File
@@ -25,7 +25,8 @@ import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const identityFile = "identity.json"
@@ -82,8 +83,11 @@ func (id *Identity) Headers() map[string]string {
if id.Source != "" {
h["x-dws-source"] = id.Source
}
// Constant headers for MCP gateway tracking
h["x-dingtalk-scenario-code"] = "com.dingtalk.cli"
scenarioCode := "com.dingtalk.cli"
if sc := edition.Get().ScenarioCode; sc != "" {
scenarioCode = sc
}
h["x-dingtalk-scenario-code"] = scenarioCode
h["x-dingtalk-source"] = "github"
return h
}
+1 -1
View File
@@ -15,8 +15,8 @@ package auth
import (
"fmt"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"log/slog"
"os"
"path/filepath"
+1 -1
View File
@@ -23,7 +23,7 @@ import (
"net/url"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
+1 -1
View File
@@ -21,8 +21,8 @@ import (
"path/filepath"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/security"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const secureDataFile = ".data"
+1 -1
View File
@@ -25,12 +25,12 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
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/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -22,11 +22,11 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+1 -1
View File
@@ -26,10 +26,10 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/convert"
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/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -26,9 +26,9 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
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/pkg/config"
"github.com/spf13/cobra"
)
+4 -34
View File
@@ -14,15 +14,14 @@
package helpers
import (
"fmt"
"strconv"
"strings"
"time"
"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/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@@ -88,7 +87,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
title := flagOrFallback(cmd, "title", "subject", "content")
title := cmdutil.FlagOrFallback(cmd, "title", "subject", "content")
if strings.TrimSpace(title) == "" {
return apperrors.NewValidation("--title is required")
}
@@ -103,7 +102,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
"executorIds": executorIds,
}
if v, _ := cmd.Flags().GetString("due"); v != "" {
ms, err := parseISOTimeToMillis("due", v)
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
if err != nil {
return err
}
@@ -270,7 +269,7 @@ func newTodoTaskUpdateCommand(runner executor.Runner) *cobra.Command {
inner["subject"] = v
}
if v, _ := cmd.Flags().GetString("due"); v != "" {
ms, err := parseISOTimeToMillis("due", v)
ms, err := cmdutil.ParseISOTimeToMillis("due", v)
if err != nil {
return err
}
@@ -444,16 +443,6 @@ func newTodoTaskDeleteCommand(runner executor.Runner) *cobra.Command {
// ── helpers ────────────────────────────────────────────────
// flagOrFallback returns the first non-empty value among the given flag names.
func flagOrFallback(cmd *cobra.Command, names ...string) string {
for _, name := range names {
if v, _ := cmd.Flags().GetString(name); strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// parseExecutorIds splits "id1,id2" into []string for the MCP executorIds array.
func parseExecutorIds(s string) []string {
s = strings.TrimSpace(s)
@@ -470,25 +459,6 @@ func parseExecutorIds(s string) []string {
return ids
}
// parseISOTimeToMillis parses an ISO-8601 datetime string and returns Unix
// milliseconds. It supports timezone offsets (e.g. +08:00) and UTC "Z" suffix.
func parseISOTimeToMillis(flagName, value string) (int64, error) {
formats := []string{
time.RFC3339,
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
}
for _, layout := range formats {
if t, err := time.Parse(layout, value); err == nil {
return t.UnixMilli(), nil
}
}
return 0, apperrors.NewValidation(
fmt.Sprintf("--%s format error, use ISO-8601 e.g. 2026-03-10T18:00:00+08:00", flagName),
)
}
// ── list pagination helpers ────────────────────────────────
func normalizePage(raw string) string {
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"strconv"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+1 -1
View File
@@ -26,8 +26,8 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
const (
+1 -1
View File
@@ -13,7 +13,7 @@
package output
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
// SanitizeForTerminal strips ANSI escape sequences, control characters, and
// dangerous Unicode from text before it is printed to a terminal.
+2 -46
View File
@@ -17,6 +17,7 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// ParamNameHandler performs fuzzy correction on flag names that are
@@ -101,7 +102,7 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
ambiguous := false
for _, candidate := range candidates {
dist := levenshtein(bare, candidate)
dist := cmdutil.LevenshteinDist(bare, candidate)
if dist < bestDist {
bestDist = dist
bestMatch = candidate
@@ -117,48 +118,3 @@ func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (stri
return "--" + bestMatch + suffix, true
}
// levenshtein computes the edit distance between two strings using
// the standard dynamic programming approach with O(min(m,n)) space.
func levenshtein(a, b string) int {
if a == b {
return 0
}
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
// Ensure a is the shorter string for O(min) space.
if la > lb {
a, b = b, a
la, lb = lb, la
}
prev := make([]int, la+1)
curr := make([]int, la+1)
for i := range prev {
prev[i] = i
}
for j := 1; j <= lb; j++ {
curr[0] = j
for i := 1; i <= la; i++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[i] = min(
prev[i]+1, // deletion
curr[i-1]+1, // insertion
prev[i-1]+cost, // substitution
)
}
prev, curr = curr, prev
}
return prev[la]
}
+4 -4
View File
@@ -18,6 +18,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
func TestLevenshtein(t *testing.T) {
@@ -40,12 +41,11 @@ func TestLevenshtein(t *testing.T) {
for _, tt := range tests {
t.Run(tt.a+"→"+tt.b, func(t *testing.T) {
got := levenshtein(tt.a, tt.b)
got := cmdutil.LevenshteinDist(tt.a, tt.b)
if got != tt.want {
t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
t.Errorf("LevenshteinDist(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want)
}
// Verify symmetry.
gotRev := levenshtein(tt.b, tt.a)
gotRev := cmdutil.LevenshteinDist(tt.b, tt.a)
if gotRev != got {
t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev)
}
+1 -1
View File
@@ -20,7 +20,7 @@ import (
"path/filepath"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// DataFileName is the encrypted token data file name.
+2 -2
View File
@@ -30,11 +30,11 @@ import (
"log/slog"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/validate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/validate"
)
const (
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Package cli exposes public entry points for the DWS CLI so that external
// overlay modules can embed and launch the CLI after customising edition hooks.
package cli
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
// SetVersion overrides the version, build time and git commit strings
// that are displayed by `dws version` and `dws --version`.
// Typically called by overlay main.go with values injected via ldflags.
func SetVersion(v, buildTime, gitCommit string) {
app.SetVersion(v, buildTime, gitCommit)
}
// Execute runs the root CLI command and returns the process exit code.
func Execute() int {
return app.Execute()
}
+166
View File
@@ -0,0 +1,166 @@
// 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 cmdutil
import (
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// FlagFixResult holds the result of SuggestFlagFix analysis.
type FlagFixResult struct {
Suggestion string
AutoFixFlag string
AutoFixValue string
}
// CommonFlagAliases maps commonly misused flag names to their correct equivalents.
var CommonFlagAliases = map[string]string{
"json": "format json",
"output": "format",
"out": "format",
"o": "format",
"silent": "quiet",
"dry": "dry-run",
"force": "yes",
"f": "yes",
"timeout-seconds": "timeout",
"device-flow": "device",
"deviceflow": "device",
}
// DetectNumericTypeError checks if err is a Cobra/pflag numeric type
// validation error. Returns the flag name and the bad value if detected.
func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
msg := err.Error()
if !strings.Contains(msg, "strconv.Parse") {
return "", "", false
}
const argPrefix = "invalid argument \""
argIdx := strings.Index(msg, argPrefix)
if argIdx < 0 {
return "", "", false
}
afterArg := msg[argIdx+len(argPrefix):]
argEnd := strings.Index(afterArg, "\"")
if argEnd < 0 {
return "", "", false
}
badVal := afterArg[:argEnd]
const marker = "\" for \"--"
idx := strings.Index(msg, marker)
if idx < 0 {
return "", "", false
}
rest := msg[idx+len(marker):]
endIdx := strings.Index(rest, "\" flag")
if endIdx < 0 {
return "", "", false
}
return rest[:endIdx], badVal, true
}
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
// and Levenshtein-close typos.
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
msg := flagErr.Error()
const prefix = "unknown flag: --"
idx := strings.Index(msg, prefix)
if idx < 0 {
return FlagFixResult{}
}
body := strings.TrimSpace(msg[idx+len(prefix):])
if alias, ok := CommonFlagAliases[body]; ok {
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s? Run '%s --help' for options", alias, cmd.CommandPath())}
}
var bestFlag, bestValue string
cmd.Flags().VisitAll(func(f *pflag.Flag) {
name := f.Name
if strings.HasPrefix(body, name) && len(body) > len(name) {
if len(name) > len(bestFlag) {
bestFlag = name
bestValue = body[len(name):]
}
}
})
if bestFlag != "" {
canAutoFix := len(bestValue) > 0
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
if canAutoFix {
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
}
return FlagFixResult{Suggestion: suggestion}
}
bestName, bestDist := "", 999
cmd.Flags().VisitAll(func(f *pflag.Flag) {
d := LevenshteinDist(body, f.Name)
if d < bestDist {
bestDist = d
bestName = f.Name
}
})
threshold := LevenshteinThreshold(len(body))
if bestDist > 0 && bestDist <= threshold && bestName != "" {
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
}
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
}
// LevenshteinThreshold returns the max edit distance allowed based on string length.
func LevenshteinThreshold(nameLen int) int {
if nameLen <= 3 {
return 1
}
if nameLen <= 8 {
return 2
}
return 3
}
// LevenshteinDist returns the edit distance between two strings.
func LevenshteinDist(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
dp := make([]int, lb+1)
for j := 0; j <= lb; j++ {
dp[j] = j
}
for i := 1; i <= la; i++ {
prev := dp[0]
dp[0] = i
for j := 1; j <= lb; j++ {
temp := dp[j]
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
prev = temp
}
}
return dp[lb]
}
+153
View File
@@ -0,0 +1,153 @@
// 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 cmdutil provides reusable CLI helper functions for Cobra commands.
// Both the open-source CLI and private overlays import this package to avoid
// duplicating flag validation, time parsing, and UX helpers.
package cmdutil
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
)
// GroupRunE is a reusable RunE for parent (group) commands that have no
// business logic of their own. With args it returns an error listing available
// subcommands; without args it shows help.
func GroupRunE(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
var names []string
for _, sub := range cmd.Commands() {
if !sub.Hidden {
names = append(names, sub.Name())
}
}
return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help",
args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath())
}
return cmd.Help()
}
// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint.
func HintSubCmd(use, hint string) *cobra.Command {
return &cobra.Command{
Use: use,
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("ambiguous command %q for %q\n hint: %s",
use, cmd.Parent().CommandPath(), hint)
},
}
}
// MustGetFlag retrieves a string flag value, checking both local and inherited flags.
func MustGetFlag(cmd *cobra.Command, name string) string {
val, _ := cmd.Flags().GetString(name)
if val == "" {
val, _ = cmd.InheritedFlags().GetString(name)
}
return val
}
// FlagOrFallback reads the primary flag; if empty, falls back through alias
// flags in order, returning the first non-empty value.
func FlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
if v, _ := cmd.Flags().GetString(primary); v != "" {
return v
}
for _, alias := range aliases {
if v, _ := cmd.Flags().GetString(alias); v != "" {
return v
}
}
return ""
}
// MustFlagOrFallback works like FlagOrFallback but returns an error when all
// flags are empty.
func MustFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
v := FlagOrFallback(cmd, primary, aliases...)
if v == "" {
return "", fmt.Errorf("flag --%s is required\n hint: %s", primary, cmd.Example)
}
return v, nil
}
// MustFlagWithHint returns an error with an explicit usage example when the
// flag is empty.
func MustFlagWithHint(cmd *cobra.Command, name, example string) (string, error) {
v := MustGetFlag(cmd, name)
if v == "" {
return "", fmt.Errorf("flag --%s is required\n hint: %s", name, example)
}
return v, nil
}
// ValidateRequiredFlags checks that all named string flags are non-empty.
// Returns a formatted error listing all missing flags, or nil.
func ValidateRequiredFlags(cmd *cobra.Command, names ...string) error {
var missing []string
for _, name := range names {
v, _ := cmd.Flags().GetString(name)
if v == "" {
missing = append(missing, "--"+name)
}
}
if len(missing) == 0 {
return nil
}
return fmt.Errorf("missing required flag(s): %s\n usage: %s\n example:\n%s",
strings.Join(missing, ", "), cmd.UseLine(), cmd.Example)
}
// ValidateRequiredFlagWithAliases checks that at least one of the primary flag
// or its aliases is non-empty.
func ValidateRequiredFlagWithAliases(cmd *cobra.Command, primary string, aliases ...string) error {
if FlagOrFallback(cmd, primary, aliases...) != "" {
return nil
}
all := append([]string{primary}, aliases...)
return fmt.Errorf("missing required flag: --%s (or %s)\n usage: %s\n example:\n%s",
primary, "--"+strings.Join(all[1:], " / --"), cmd.UseLine(), cmd.Example)
}
// ConfirmDelete asks for interactive confirmation before destructive operations.
// Returns true if --yes/-y flag is set or the user types "yes"/"y".
func ConfirmDelete(cmd *cobra.Command, resourceType, resourceName string) bool {
if yes, _ := cmd.Flags().GetBool("yes"); yes {
return true
}
for _, arg := range os.Args[1:] {
if arg == "--yes" || arg == "-y" {
return true
}
}
fmt.Fprintf(cmd.ErrOrStderr(), "About to delete %s: %s\n", resourceType, resourceName)
fmt.Fprint(cmd.ErrOrStderr(), "Confirm deletion? (yes/no): ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer == "yes" || answer == "y" {
return true
}
fmt.Fprintln(cmd.ErrOrStderr(), "Operation cancelled")
return false
}
+67
View File
@@ -0,0 +1,67 @@
// 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 cmdutil
import (
"fmt"
"strings"
"time"
)
// FlexTimeLayouts is the ordered list of time formats tried by ParseISOTimeToMillis.
var FlexTimeLayouts = []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02 15:04:05",
"2006-01-02T15:04",
"2006-01-02 15:04",
"2006-01-02",
"2006/01/02 15:04:05",
"2006/01/02",
"20060102",
}
// ParseISOTimeToMillis parses a time string into a millisecond Unix timestamp.
// Supports RFC3339, UTC Z, timezone-less, space-separated, date-only, and more.
// When the input lacks an explicit timezone, Asia/Shanghai is assumed.
func ParseISOTimeToMillis(flagName, value string) (int64, error) {
value = strings.TrimSpace(value)
value = strings.Trim(value, "\"'")
if value == "" {
return 0, fmt.Errorf("flag --%s is required\n hint: example: 2026-03-10T14:00:00+08:00", flagName)
}
loc, _ := time.LoadLocation("Asia/Shanghai")
if loc == nil {
loc = time.Local
}
for _, layout := range FlexTimeLayouts {
t, err := time.ParseInLocation(layout, value, loc)
if err == nil {
return t.UnixMilli(), nil
}
}
return 0, fmt.Errorf(
"cannot parse time for --%s (input: %q)\n hint: supported formats: 2026-03-23T14:00:00+08:00, 2026-03-23 14:00:00, 2026-03-23",
flagName, value)
}
// ValidateTimeRange checks that endMs is strictly after startMs.
func ValidateTimeRange(startMs, endMs int64) error {
if endMs <= startMs {
return fmt.Errorf("--end must be after --start\n hint: swap the values or adjust the time range")
}
return nil
}
+23
View File
@@ -0,0 +1,23 @@
// 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 edition
// defaultHooks returns the open-source edition defaults.
// All function hooks are nil, which the internal code interprets as
// "use standard open-source behaviour".
func defaultHooks() *Hooks {
return &Hooks{
Name: "open",
}
}
+106
View File
@@ -0,0 +1,106 @@
// 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 edition provides an extension-point mechanism that allows private
// overlay modules (e.g. an internal distribution) to customise CLI behaviour
// without modifying the open-source core. The open-source build uses the
// zero-value defaults; an overlay calls Override before Execute.
package edition
import (
"context"
"sync"
"github.com/spf13/cobra"
)
// ServerInfo describes a static MCP server endpoint.
type ServerInfo struct {
ID string
Name string
Endpoint string
Prefixes []string
}
// ContentBlock is a single content item in a ToolResult.
type ContentBlock struct {
Type string
Text string
}
// ToolResult holds the response from an MCP tool call.
type ToolResult struct {
Content []ContentBlock
}
// ToolCaller abstracts MCP tool invocation so private overlays can call MCP
// tools without importing internal packages. The open-source core provides a
// concrete adapter wrapping executor.Runner.
type ToolCaller interface {
// CallTool invokes an MCP tool by product ID and tool name.
CallTool(ctx context.Context, productID, toolName string, args map[string]any) (*ToolResult, error)
// Format returns the current output format ("json", "table", "raw").
Format() string
// DryRun returns true when --dry-run is active.
DryRun() bool
}
// Hooks groups all edition-specific behavioural overrides. Zero values
// fall back to open-source defaults so the struct is safe to use as-is.
type Hooks struct {
// --- identity ---
Name string // "open" (default) / overlay identifier
ScenarioCode string // injected into x-dingtalk-scenario-code header
// --- runtime mode ---
IsEmbedded bool // true when running inside a host application
HideAuthLogin bool // true suppresses the "dws auth login" command
AutoPurgeToken bool // true deletes local token data on expiry
// --- paths ---
ConfigDir func() string // custom config directory; nil → ~/.dws
// --- HTTP headers ---
MergeHeaders func(base map[string]string) map[string]string
// --- auth hooks ---
OnAuthError func(configDir string, err error) error
TokenProvider func(ctx context.Context, fallback func() (string, error)) (string, error)
// --- product & endpoint ---
StaticServers func() []ServerInfo // non-nil → skip Market discovery
VisibleProducts func() []string // non-nil → override help visibility
RegisterExtraCommands func(root *cobra.Command, caller ToolCaller) // register overlay-only commands
}
var (
mu sync.RWMutex
current = defaultHooks()
)
// Get returns the active edition hooks (never nil).
func Get() *Hooks {
mu.RLock()
defer mu.RUnlock()
return current
}
// Override replaces the active edition hooks. Must be called before Execute.
func Override(h *Hooks) {
if h == nil {
return
}
mu.Lock()
defer mu.Unlock()
current = h
}
+88
View File
@@ -0,0 +1,88 @@
// 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 editiontest provides contract-test helpers that any edition.Hooks
// implementation (open-source or overlay) must satisfy. It lives in a
// non-_test package so external modules can import and run these checks.
package editiontest
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// RunContractTests validates that the supplied Hooks implementation
// satisfies the behavioural contract required by the open-source core.
// Call this from both the open-source test suite and any overlay's tests.
func RunContractTests(t *testing.T, h *edition.Hooks) {
t.Helper()
if h == nil {
t.Fatal("Hooks must not be nil")
}
t.Run("Name", func(t *testing.T) {
if h.Name == "" {
t.Error("Hooks.Name must be non-empty")
}
})
t.Run("ConfigDir", func(t *testing.T) {
if h.ConfigDir != nil {
dir := h.ConfigDir()
if dir == "" {
t.Error("ConfigDir() must return a non-empty path when set")
}
}
})
t.Run("MergeHeaders_preserves_base", func(t *testing.T) {
if h.MergeHeaders == nil {
return
}
base := map[string]string{"x-test": "value"}
merged := h.MergeHeaders(base)
if merged == nil {
t.Fatal("MergeHeaders must return a non-nil map")
}
if merged["x-test"] != "value" {
t.Error("MergeHeaders must preserve base headers")
}
})
t.Run("StaticServers_non_empty_when_set", func(t *testing.T) {
if h.StaticServers == nil {
return
}
servers := h.StaticServers()
if len(servers) == 0 {
t.Error("StaticServers() must return at least one server when set")
}
for i, s := range servers {
if s.ID == "" || s.Endpoint == "" {
t.Errorf("StaticServers()[%d]: ID and Endpoint must be non-empty", i)
}
}
})
t.Run("VisibleProducts_non_empty_when_set", func(t *testing.T) {
if h.VisibleProducts == nil {
return
}
products := h.VisibleProducts()
if len(products) == 0 {
t.Error("VisibleProducts() must return at least one product when set")
}
})
}
+25
View File
@@ -0,0 +1,25 @@
// 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 editiontest_test
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/editiontest"
)
func TestOpenSourceDefaultHooksContract(t *testing.T) {
editiontest.RunContractTests(t, edition.Get())
}
+6 -1
View File
@@ -30,6 +30,11 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
"WriteToken" + "Marker",
"Token" + "Marker",
"tokenJSON" + "File",
"REWIND_" + "REQUEST_ID",
"REWIND_" + "SESSION_ID",
"REWIND_" + "MESSAGE_ID",
"MergeWukong" + "MCPHeaders",
"buildMode ==" + " \"real\"",
}
var matches []string
@@ -39,7 +44,7 @@ func TestOpenSourceTreeOmitsEmbeddedHostMarkers(t *testing.T) {
}
if d.IsDir() {
switch d.Name() {
case ".git", ".worktrees", "node_modules", "dist":
case ".git", ".worktrees", "node_modules", "dist", "plans":
return filepath.SkipDir
}
return nil