Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31eb10985e | ||
|
|
1436b62a80 | ||
|
|
ec6a27635b | ||
|
|
1727744691 | ||
|
|
afdd47b5a5 | ||
|
|
d968e8e551 | ||
|
|
c649d1a762 | ||
|
|
a1f5d97345 | ||
|
|
58062515a5 | ||
|
|
5614b508f2 | ||
|
|
c8e3ac21c2 | ||
|
|
c38892b7cf | ||
|
|
1a0a5324f0 | ||
|
|
c1e9e9e0d6 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.6%"><title>coverage: 52.6%</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.6%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.6%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.5%"><title>coverage: 52.5%</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.5%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.5%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -34,7 +34,7 @@ jobs:
|
||||
body: issue.body,
|
||||
state: issue.state,
|
||||
html_url: issue.html_url,
|
||||
labels: (issue.labels || []).map(label => label.name)
|
||||
labels: (issue.labels || []).map(label => label.name).join(', ') || '无标签'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -66,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
<details>
|
||||
<summary>Other install methods</summary>
|
||||
|
||||
**npm** (requires Node.js (npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -88,6 +94,8 @@ cp dws ~/.local/bin/ # install to PATH
|
||||
|
||||
## Upgrade
|
||||
|
||||
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
|
||||
|
||||
dws has built-in self-upgrade capability. Updates are pulled directly from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) with SHA256 integrity verification and automatic backup.
|
||||
|
||||
```bash
|
||||
|
||||
@@ -66,6 +66,12 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
<details>
|
||||
<summary>其他安装方式</summary>
|
||||
|
||||
**npm**(需要 Node.js(npm/npx)):
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -88,6 +94,8 @@ cp dws ~/.local/bin/ # 安装到 PATH
|
||||
|
||||
## 升级
|
||||
|
||||
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
|
||||
|
||||
dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 拉取更新,支持 SHA256 完整性校验和自动备份。
|
||||
|
||||
```bash
|
||||
|
||||
@@ -52,6 +52,7 @@ __KEG_ONLY_LINE__
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
|
||||
@@ -7,6 +7,7 @@ const os = require("os");
|
||||
const path = require("path");
|
||||
const childProcess = require("child_process");
|
||||
|
||||
// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync.
|
||||
const AGENT_DIRS = [
|
||||
".agents/skills",
|
||||
".claude/skills",
|
||||
@@ -20,6 +21,7 @@ const AGENT_DIRS = [
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
];
|
||||
|
||||
const PLATFORM_MAP = {
|
||||
|
||||
+5
-32
@@ -16,7 +16,6 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -89,13 +88,6 @@ func injectStaticServers(servers []edition.ServerInfo) {
|
||||
// Tests may override discoveryBaseURLOverride to redirect to a local server;
|
||||
// in that case the registry cache is always bypassed.
|
||||
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
|
||||
totalStart := time.Now()
|
||||
defer func() {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] loadDynamicCommands total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
}()
|
||||
|
||||
store := cacheStoreFromEnv()
|
||||
partition := config.DefaultPartition
|
||||
|
||||
@@ -108,18 +100,13 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
// --- Cache-first server registry ---
|
||||
cacheLoadStart := time.Now()
|
||||
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache load: %v (err=%v)\n", time.Since(cacheLoadStart), cacheErr)
|
||||
}
|
||||
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
|
||||
|
||||
var servers []market.ServerDescriptor
|
||||
now := store.Now().UTC()
|
||||
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
|
||||
|
||||
if usingCachedRegistry {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] using cached registry: servers=%d, freshness=%s\n", len(snapshot.Servers), freshness)
|
||||
}
|
||||
servers = snapshot.Servers
|
||||
// Only trigger async revalidation in production (no URL override).
|
||||
// Tests set discoveryBaseURLOverride and control cache expiry directly,
|
||||
@@ -135,15 +122,10 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
if discoveryBaseURLOverride != "" {
|
||||
baseURL = discoveryBaseURLOverride
|
||||
}
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] fetching from market API: %s\n", baseURL)
|
||||
}
|
||||
fetchStart := time.Now()
|
||||
client := market.NewClient(baseURL, ipv4OnlyHTTPClient())
|
||||
resp, fetchErr := client.FetchServers(ctx, config.DefaultFetchServersLimit)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] market API fetch: %v (err=%v)\n", time.Since(fetchStart), fetchErr)
|
||||
}
|
||||
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
|
||||
if fetchErr != nil {
|
||||
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
|
||||
// Degrade to stale cache if available (production only).
|
||||
@@ -155,18 +137,13 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
}
|
||||
} else {
|
||||
servers = market.NormalizeServers(resp, "market")
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] normalized servers: %d\n", len(servers))
|
||||
}
|
||||
// Persist fresh data (only in non-test mode).
|
||||
if useCache {
|
||||
saveStart := time.Now()
|
||||
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
|
||||
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
|
||||
}
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cache save: %v\n", time.Since(saveStart))
|
||||
}
|
||||
RecordTiming(ctx, "cache_save", time.Since(saveStart))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,15 +156,11 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
|
||||
|
||||
detailStart := time.Now()
|
||||
detailsByID := loadCachedDetailsFast(store, servers)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] load details: %v\n", time.Since(detailStart))
|
||||
}
|
||||
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
|
||||
|
||||
buildStart := time.Now()
|
||||
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] build commands: %v (count=%d)\n", time.Since(buildStart), len(cmds))
|
||||
}
|
||||
RecordTiming(ctx, "build_commands", time.Since(buildStart))
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
+3
-18
@@ -52,14 +52,8 @@ const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
totalStart := time.Now()
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
timing.PrintIfEnabled()
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] Execute total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
}()
|
||||
defer func() { timing.PrintIfEnabled() }()
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
@@ -71,24 +65,14 @@ func Execute() int {
|
||||
recovery.ResetRuntimeState()
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(ctx, engine)
|
||||
initDuration := time.Since(initStart)
|
||||
timing.Record("cmd_init", initDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] command init: %v\n", initDuration)
|
||||
}
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
// This corrects model-generated errors like --userId → --user-id
|
||||
// and --limit100 → --limit 100.
|
||||
pipeline.RunPreParse(root, engine)
|
||||
|
||||
execStart := time.Now()
|
||||
executed, err := root.ExecuteC()
|
||||
execDuration := time.Since(execStart)
|
||||
timing.Record("cobra_exec", execDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] cobra ExecuteC: %v\n", execDuration)
|
||||
}
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
@@ -231,6 +215,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
AuthTokenFunc: func(ctx context.Context) string {
|
||||
return resolveRuntimeAuthToken(ctx, "")
|
||||
},
|
||||
LoggerFunc: FileLoggerInstance,
|
||||
}
|
||||
runner := newCommandRunnerWithFlags(loader, flags)
|
||||
|
||||
|
||||
+57
-26
@@ -15,6 +15,8 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -29,6 +31,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
|
||||
"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"
|
||||
@@ -76,13 +79,6 @@ type runtimeRunner struct {
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
totalStart := time.Now()
|
||||
defer func() {
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] runtimeRunner.Run total: %v\n", time.Since(totalStart))
|
||||
}
|
||||
}()
|
||||
|
||||
if r.loader == nil || r.transport == nil {
|
||||
return r.fallback.Run(ctx, invocation)
|
||||
}
|
||||
@@ -96,6 +92,11 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
// Prefetch the Keychain token in the background. Keychain access costs
|
||||
// ~70ms on macOS; starting it here lets the load overlap with endpoint
|
||||
// resolution and catalog loading below.
|
||||
go getCachedRuntimeToken(ctx)
|
||||
|
||||
if shouldUseDirectRuntime(invocation) {
|
||||
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
@@ -127,20 +128,44 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (executor.Result, error) {
|
||||
func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, invocation executor.Invocation) (result executor.Result, retErr error) {
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
|
||||
// Lazy bind FileLogger: it may be nil at construction time because
|
||||
// configureLogLevel runs later in PersistentPreRunE.
|
||||
if r.transport.FileLogger == nil {
|
||||
r.transport.FileLogger = FileLoggerInstance()
|
||||
}
|
||||
|
||||
authStart := time.Now()
|
||||
fl := r.transport.FileLogger
|
||||
|
||||
defer func() {
|
||||
var errCat, errReason string
|
||||
if retErr != nil {
|
||||
var typed *apperrors.Error
|
||||
if errors.As(retErr, &typed) {
|
||||
errCat = string(typed.Category)
|
||||
errReason = typed.Reason
|
||||
} else {
|
||||
errCat = "unknown"
|
||||
errReason = retErr.Error()
|
||||
}
|
||||
}
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
}()
|
||||
|
||||
authToken := r.resolveAuthToken(ctx)
|
||||
authDuration := time.Since(authStart)
|
||||
RecordTiming(ctx, "auth_token", authDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] resolveAuthToken: %v\n", authDuration)
|
||||
|
||||
var timeoutSec int
|
||||
if r.globalFlags != nil {
|
||||
timeoutSec = r.globalFlags.Timeout
|
||||
}
|
||||
logging.LogCommandStart(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool, endpoint, version, authToken != "", timeoutSec)
|
||||
|
||||
if invocation.DryRun {
|
||||
return executor.Result{
|
||||
@@ -184,13 +209,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(ctx, endpoint, invocation.Tool, invocation.Params)
|
||||
callDuration := time.Since(callStart)
|
||||
RecordTiming(ctx, "mcp_call", callDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] MCP CallTool: %v\n", callDuration)
|
||||
callCtx := ctx
|
||||
if r.globalFlags != nil && r.globalFlags.Timeout > 0 {
|
||||
var cancel context.CancelFunc
|
||||
callCtx, cancel = context.WithTimeout(ctx, time.Duration(r.globalFlags.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
callStart := time.Now()
|
||||
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
|
||||
RecordTiming(ctx, "mcp_call", time.Since(callStart))
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
if fn := edition.Get().OnAuthError; fn != nil {
|
||||
@@ -271,13 +299,7 @@ var (
|
||||
func getCachedRuntimeToken(ctx context.Context) string {
|
||||
cachedRuntimeTokenOnce.Do(func() {
|
||||
loadStart := time.Now()
|
||||
defer func() {
|
||||
loadDuration := time.Since(loadStart)
|
||||
RecordTiming(ctx, "keychain_load", loadDuration)
|
||||
if os.Getenv("DWS_PERF_DEBUG") != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "[PERF] getCachedRuntimeToken (first load): %v\n", loadDuration)
|
||||
}
|
||||
}()
|
||||
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
|
||||
|
||||
configDir := defaultConfigDir()
|
||||
provider := authpkg.NewOAuthProvider(configDir, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
@@ -303,6 +325,15 @@ func getCachedRuntimeToken(ctx context.Context) string {
|
||||
return cachedRuntimeToken
|
||||
}
|
||||
|
||||
// generateExecutionID returns a random 16-char hex string used to correlate
|
||||
// all log entries (command_start, jsonrpc_request, command_end, etc.) belonging
|
||||
// to a single command invocation.
|
||||
func generateExecutionID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
|
||||
// This should be called after login/logout operations.
|
||||
func ResetRuntimeTokenCache() {
|
||||
|
||||
+25
-11
@@ -23,8 +23,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Environment variable to enable performance timing output.
|
||||
const PerfTimingEnv = "DWS_PERF_TIMING"
|
||||
// PerfDebugEnv is the environment variable to enable performance timing output.
|
||||
const PerfDebugEnv = "DWS_PERF_DEBUG"
|
||||
|
||||
// timingContextKey is the context key for TimingCollector.
|
||||
type timingContextKey struct{}
|
||||
@@ -107,32 +107,46 @@ func (tc *TimingCollector) Entries() []TimingEntry {
|
||||
return result
|
||||
}
|
||||
|
||||
// formatDuration returns a human-friendly duration string.
|
||||
// Sub-µs → "0µs", sub-ms → microsecond precision (e.g. "142µs"), else → ms.
|
||||
func formatDuration(d time.Duration) string {
|
||||
switch {
|
||||
case d < time.Microsecond:
|
||||
return "0µs"
|
||||
case d < time.Millisecond:
|
||||
return d.Truncate(time.Microsecond).String()
|
||||
default:
|
||||
return d.Truncate(time.Millisecond).String()
|
||||
}
|
||||
}
|
||||
|
||||
// Print writes a summary of all timing entries to the given writer.
|
||||
func (tc *TimingCollector) Print(w io.Writer) {
|
||||
if tc == nil || w == nil {
|
||||
return
|
||||
}
|
||||
entries := tc.Entries()
|
||||
total := tc.Total()
|
||||
if len(entries) == 0 {
|
||||
fmt.Fprintf(w, "\n[Timing] Total: %v (no detailed entries)\n", tc.Total().Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, "\n[Perf] Total: %v (no detailed entries)\n", formatDuration(total))
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "[Timing] Execution breakdown:")
|
||||
fmt.Fprintln(w, "[Perf] Execution breakdown:")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, e.Duration.Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, " %-30s %v\n", e.Name, formatDuration(e.Duration))
|
||||
}
|
||||
fmt.Fprintf(w, " %-30s %v\n", "──────────────────────────────", "──────────")
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", tc.Total().Truncate(time.Millisecond))
|
||||
fmt.Fprintf(w, " %-30s %v\n", "Total", formatDuration(total))
|
||||
}
|
||||
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_TIMING is set.
|
||||
// PrintIfEnabled prints timing info to stderr if DWS_PERF_DEBUG is set.
|
||||
func (tc *TimingCollector) PrintIfEnabled() {
|
||||
if tc == nil {
|
||||
return
|
||||
}
|
||||
if os.Getenv(PerfTimingEnv) == "" {
|
||||
if os.Getenv(PerfDebugEnv) == "" {
|
||||
return
|
||||
}
|
||||
tc.Print(os.Stderr)
|
||||
@@ -171,7 +185,7 @@ func StartTiming(ctx context.Context, name string) func() {
|
||||
return tc.StartTimer(name)
|
||||
}
|
||||
|
||||
// IsPerfTimingEnabled returns true if performance timing output is enabled.
|
||||
func IsPerfTimingEnabled() bool {
|
||||
return os.Getenv(PerfTimingEnv) != ""
|
||||
// IsPerfDebugEnabled returns true if performance debug output is enabled.
|
||||
func IsPerfDebugEnabled() bool {
|
||||
return os.Getenv(PerfDebugEnv) != ""
|
||||
}
|
||||
|
||||
+12
-12
@@ -87,8 +87,8 @@ func TestTimingCollector_Print(t *testing.T) {
|
||||
tc.Print(&buf)
|
||||
|
||||
output := buf.String()
|
||||
if !strings.Contains(output, "[Timing]") {
|
||||
t.Error("output should contain [Timing] header")
|
||||
if !strings.Contains(output, "[Perf]") {
|
||||
t.Error("output should contain [Perf] header")
|
||||
}
|
||||
if !strings.Contains(output, "auth_token") {
|
||||
t.Error("output should contain 'auth_token'")
|
||||
@@ -103,8 +103,8 @@ func TestTimingCollector_Print(t *testing.T) {
|
||||
|
||||
func TestTimingCollector_PrintIfEnabled(t *testing.T) {
|
||||
// Set environment variable
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
tc := NewTimingCollector()
|
||||
tc.Record("test_op", 10*time.Millisecond)
|
||||
@@ -156,18 +156,18 @@ func TestStartTiming_NoCollector(t *testing.T) {
|
||||
stop()
|
||||
}
|
||||
|
||||
func TestIsPerfTimingEnabled(t *testing.T) {
|
||||
func TestIsPerfDebugEnabled(t *testing.T) {
|
||||
// Clear the env var first
|
||||
os.Unsetenv(PerfTimingEnv)
|
||||
os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return false when env var is not set")
|
||||
if IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return false when env var is not set")
|
||||
}
|
||||
|
||||
os.Setenv(PerfTimingEnv, "1")
|
||||
defer os.Unsetenv(PerfTimingEnv)
|
||||
os.Setenv(PerfDebugEnv, "1")
|
||||
defer os.Unsetenv(PerfDebugEnv)
|
||||
|
||||
if !IsPerfTimingEnabled() {
|
||||
t.Error("IsPerfTimingEnabled should return true when env var is set")
|
||||
if !IsPerfDebugEnabled() {
|
||||
t.Error("IsPerfDebugEnabled should return true when env var is set")
|
||||
}
|
||||
}
|
||||
|
||||
+35
-8
@@ -28,6 +28,8 @@ var (
|
||||
ugBoldGrn = color.New(color.Bold, color.FgGreen).SprintFunc()
|
||||
)
|
||||
|
||||
const defaultListLimit = 10
|
||||
|
||||
func newUpgradeCommand() *cobra.Command {
|
||||
var (
|
||||
flagCheck bool
|
||||
@@ -36,6 +38,7 @@ func newUpgradeCommand() *cobra.Command {
|
||||
flagRollback bool
|
||||
flagForce bool
|
||||
flagSkipSkills bool
|
||||
flagAll bool
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
@@ -47,7 +50,8 @@ func newUpgradeCommand() *cobra.Command {
|
||||
升级前会自动备份当前版本,可通过 --rollback 回滚。`,
|
||||
Example: ` dws upgrade # 交互式升级到最新版本
|
||||
dws upgrade --check # 仅检查是否有新版本
|
||||
dws upgrade --list # 列出所有可用版本
|
||||
dws upgrade --list # 列出最近版本
|
||||
dws upgrade --list --all # 列出所有版本
|
||||
dws upgrade --version v1.0.5 # 升级到指定版本
|
||||
dws upgrade --rollback # 回滚到上一版本
|
||||
dws upgrade -y # 跳过确认直接升级`,
|
||||
@@ -57,7 +61,11 @@ func newUpgradeCommand() *cobra.Command {
|
||||
format := resolveUpgradeFormat(cmd)
|
||||
|
||||
if flagList {
|
||||
return runUpgradeList(cmd, format)
|
||||
limit := defaultListLimit
|
||||
if flagAll {
|
||||
limit = 0
|
||||
}
|
||||
return runUpgradeList(cmd, format, limit)
|
||||
}
|
||||
if flagRollback {
|
||||
return runUpgradeRollback(yes)
|
||||
@@ -75,7 +83,8 @@ func newUpgradeCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&flagCheck, "check", false, "仅检查是否有新版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出所有可用版本")
|
||||
cmd.Flags().BoolVar(&flagList, "list", false, "列出可用版本")
|
||||
cmd.Flags().BoolVar(&flagAll, "all", false, "与 --list 搭配,显示所有版本")
|
||||
cmd.Flags().StringVar(&flagVersion, "version", "", "升级到指定版本")
|
||||
cmd.Flags().BoolVar(&flagRollback, "rollback", false, "回滚到上一版本")
|
||||
cmd.Flags().BoolVar(&flagForce, "force", false, "强制重新安装当前版本")
|
||||
@@ -146,7 +155,9 @@ func runUpgradeCheck(cmd *cobra.Command, format string) error {
|
||||
|
||||
// --- dws upgrade --list ---
|
||||
|
||||
func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
// runUpgradeList displays available versions. When limit > 0, only the most
|
||||
// recent `limit` versions are shown; pass 0 to show all (--all flag).
|
||||
func runUpgradeList(cmd *cobra.Command, format string, limit int) error {
|
||||
client := upgrade.NewClient()
|
||||
|
||||
if format != "json" {
|
||||
@@ -158,6 +169,13 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
return fmt.Errorf("获取版本列表失败: %w", err)
|
||||
}
|
||||
|
||||
totalCount := len(versions)
|
||||
truncated := false
|
||||
if limit > 0 && len(versions) > limit {
|
||||
versions = versions[:limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
currentVer := strings.TrimPrefix(version, "v")
|
||||
|
||||
if format == "json" {
|
||||
@@ -171,13 +189,19 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
"changelog": parseChangelogEntries(v.Changelog, 10),
|
||||
})
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), map[string]any{
|
||||
result := map[string]any{
|
||||
"current_version": ensureV(version),
|
||||
"versions": items,
|
||||
})
|
||||
"total": totalCount,
|
||||
}
|
||||
if truncated {
|
||||
result["truncated"] = true
|
||||
result["shown"] = limit
|
||||
}
|
||||
return writeJSON(cmd.OutOrStdout(), result)
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
if totalCount == 0 {
|
||||
fmt.Printf(" %s\n", ugYellow("未找到任何版本"))
|
||||
return nil
|
||||
}
|
||||
@@ -203,7 +227,10 @@ func runUpgradeList(cmd *cobra.Command, format string) error {
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(" %s %s\n", ugBold("当前版本:"), ugBoldGrn(ensureV(version)))
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.5 安装指定版本"))
|
||||
if truncated {
|
||||
fmt.Printf(" %s\n", ugDim(fmt.Sprintf("显示最近 %d 个版本(共 %d 个),使用 --list --all 查看全部", limit, totalCount)))
|
||||
}
|
||||
fmt.Printf(" %s\n", ugDim("提示: 使用 dws upgrade --version v1.0.7 安装指定版本"))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -92,6 +93,10 @@ type EnvironmentLoader struct {
|
||||
// AuthTokenFunc returns an access token for MCP discovery requests
|
||||
// (initialize, tools/list). When nil, discovery runs without auth.
|
||||
AuthTokenFunc func(context.Context) string
|
||||
// LoggerFunc returns a structured logger for discovery diagnostics.
|
||||
// Called lazily because the file logger may not be initialized at
|
||||
// construction time (it's set up during PersistentPreRunE).
|
||||
LoggerFunc func() *slog.Logger
|
||||
}
|
||||
|
||||
type cachedCatalogState struct {
|
||||
@@ -147,6 +152,9 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) {
|
||||
transportClient,
|
||||
store,
|
||||
)
|
||||
if l.LoggerFunc != nil {
|
||||
service.Logger = l.LoggerFunc()
|
||||
}
|
||||
response, err := service.MarketClient.FetchServers(discoverCtx, 200)
|
||||
if err != nil {
|
||||
// Graceful degradation: return empty catalog on discovery failure.
|
||||
|
||||
+100
-15
@@ -21,6 +21,8 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -152,29 +154,112 @@ func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.Serve
|
||||
}, nil
|
||||
}
|
||||
|
||||
const perServerDiscoveryTimeout = 5 * time.Second
|
||||
|
||||
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
|
||||
results := make([]RuntimeServer, 0, len(servers))
|
||||
failures := make([]RuntimeFailure, 0)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
type discoveryResult struct {
|
||||
server RuntimeServer
|
||||
failure *RuntimeFailure
|
||||
}
|
||||
|
||||
filtered := make([]market.ServerDescriptor, 0, len(servers))
|
||||
for _, srv := range servers {
|
||||
if !srv.CLI.Skip {
|
||||
filtered = append(filtered, srv)
|
||||
}
|
||||
runtimeServer, err := s.DiscoverServerRuntime(ctx, server)
|
||||
if err != nil {
|
||||
if errors.Is(err, errCLIServerSkipped) {
|
||||
continue
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ch := make(chan discoveryResult, len(filtered))
|
||||
var wg sync.WaitGroup
|
||||
for _, srv := range filtered {
|
||||
wg.Add(1)
|
||||
go func(server market.ServerDescriptor) {
|
||||
defer wg.Done()
|
||||
serverCtx, cancel := context.WithTimeout(ctx, perServerDiscoveryTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
rs, err := s.DiscoverServerRuntime(serverCtx, server)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
if errors.Is(err, errCLIServerSkipped) {
|
||||
return
|
||||
}
|
||||
if s.Logger != nil {
|
||||
s.Logger.Warn("server_discovery_failed",
|
||||
slog.String("server_key", server.Key),
|
||||
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
|
||||
slog.String("error", err.Error()),
|
||||
slog.Bool("is_timeout", errors.Is(err, context.DeadlineExceeded)),
|
||||
)
|
||||
}
|
||||
// Per-server sub-context timed out but parent is still alive:
|
||||
// try cache fallback instead of reporting a hard failure.
|
||||
if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
|
||||
if cached, cacheErr := s.loadServerFromCache(server); cacheErr == nil {
|
||||
if s.Logger != nil {
|
||||
s.Logger.Info("server_discovery_cache_fallback",
|
||||
slog.String("server_key", server.Key),
|
||||
slog.String("source", cached.Source),
|
||||
)
|
||||
}
|
||||
ch <- discoveryResult{server: cached}
|
||||
return
|
||||
}
|
||||
}
|
||||
ch <- discoveryResult{failure: &RuntimeFailure{ServerKey: server.Key, Err: err}}
|
||||
return
|
||||
}
|
||||
failures = append(failures, RuntimeFailure{
|
||||
ServerKey: server.Key,
|
||||
Err: err,
|
||||
})
|
||||
continue
|
||||
if s.Logger != nil {
|
||||
s.Logger.Debug("server_discovery_ok",
|
||||
slog.String("server_key", server.Key),
|
||||
slog.String("duration", elapsed.Truncate(time.Millisecond).String()),
|
||||
slog.String("source", rs.Source),
|
||||
)
|
||||
}
|
||||
ch <- discoveryResult{server: rs}
|
||||
}(srv)
|
||||
}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
results := make([]RuntimeServer, 0, len(filtered))
|
||||
failures := make([]RuntimeFailure, 0)
|
||||
for dr := range ch {
|
||||
if dr.failure != nil {
|
||||
failures = append(failures, *dr.failure)
|
||||
} else {
|
||||
results = append(results, dr.server)
|
||||
}
|
||||
results = append(results, runtimeServer)
|
||||
}
|
||||
return results, failures
|
||||
}
|
||||
|
||||
// loadServerFromCache tries to load a server's tools from cache, returning a
|
||||
// degraded RuntimeServer. Used as fallback when a per-server discovery timeout
|
||||
// fires but the parent context is still alive.
|
||||
func (s *Service) loadServerFromCache(server market.ServerDescriptor) (RuntimeServer, error) {
|
||||
partition := s.partition()
|
||||
snapshot, freshness, err := s.Cache.LoadTools(partition, server.Key)
|
||||
if err != nil {
|
||||
return RuntimeServer{}, err
|
||||
}
|
||||
server.NegotiatedProtocolVersion = snapshot.ProtocolVersion
|
||||
server.Source = string(freshness) + "_cache"
|
||||
server.Degraded = true
|
||||
return RuntimeServer{
|
||||
Server: server,
|
||||
NegotiatedProtocolVersion: snapshot.ProtocolVersion,
|
||||
Tools: snapshot.Tools,
|
||||
Source: string(freshness) + "_cache",
|
||||
Degraded: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) DiscoverDetail(ctx context.Context, server market.ServerDescriptor) (market.DetailResponse, error) {
|
||||
partition := s.partition()
|
||||
var fetchErr error
|
||||
|
||||
@@ -66,7 +66,14 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
base.AddCommand(newAitableBaseDeleteCommand(runner))
|
||||
base.AddCommand(
|
||||
newAitableBaseListCommand(runner),
|
||||
newAitableBaseSearchCommand(runner),
|
||||
newAitableBaseGetCommand(runner),
|
||||
newAitableBaseCreateCommand(runner),
|
||||
newAitableBaseUpdateCommand(runner),
|
||||
newAitableBaseDeleteCommand(runner),
|
||||
)
|
||||
|
||||
table := &cobra.Command{
|
||||
Use: "table",
|
||||
@@ -78,7 +85,12 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
table.AddCommand(newAitableTableDeleteCommand(runner))
|
||||
table.AddCommand(
|
||||
newAitableTableGetCommand(runner),
|
||||
newAitableTableCreateCommand(runner),
|
||||
newAitableTableUpdateCommand(runner),
|
||||
newAitableTableDeleteCommand(runner),
|
||||
)
|
||||
|
||||
field := &cobra.Command{
|
||||
Use: "field",
|
||||
@@ -90,7 +102,12 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
field.AddCommand(newAitableFieldDeleteCommand(runner))
|
||||
field.AddCommand(
|
||||
newAitableFieldGetCommand(runner),
|
||||
newAitableFieldCreateCommand(runner),
|
||||
newAitableFieldUpdateCommand(runner),
|
||||
newAitableFieldDeleteCommand(runner),
|
||||
)
|
||||
|
||||
record := &cobra.Command{
|
||||
Use: "record",
|
||||
@@ -102,7 +119,24 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
record.AddCommand(newAitableRecordDeleteCommand(runner))
|
||||
record.AddCommand(
|
||||
newAitableRecordQueryCommand(runner),
|
||||
newAitableRecordCreateCommand(runner),
|
||||
newAitableRecordUpdateCommand(runner),
|
||||
newAitableRecordDeleteCommand(runner),
|
||||
)
|
||||
|
||||
template := &cobra.Command{
|
||||
Use: "template",
|
||||
Short: i18n.T("模板搜索"),
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
template.AddCommand(newAitableTemplateSearchCommand(runner))
|
||||
|
||||
attachment := &cobra.Command{
|
||||
Use: "attachment",
|
||||
@@ -114,9 +148,12 @@ func (aitableHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
attachment.AddCommand(newAITableUploadFileCommand(runner))
|
||||
attachment.AddCommand(
|
||||
newAITableAttachmentUploadCommand(runner),
|
||||
newAITableUploadFileCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(base, table, field, record, attachment)
|
||||
root.AddCommand(base, table, field, record, template, attachment)
|
||||
return root
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,727 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
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/spf13/cobra"
|
||||
)
|
||||
|
||||
// ── base ────────────────────────────────────────────────────
|
||||
|
||||
func newAitableBaseListCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: i18n.T("获取 AI 表格列表"),
|
||||
Example: " dws aitable base list\n dws aitable base list --limit 5 --cursor NEXT_CURSOR",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
params := map[string]any{}
|
||||
if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
if cursor := aitableStringFlag(cmd, "cursor"); cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
return runAitableTool(cmd, runner, "list_bases", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Int("limit", 0, i18n.T("每页数量"))
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableBaseSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: i18n.T("搜索 AI 表格"),
|
||||
Example: " dws aitable base search --query 项目管理",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query := aitableFlagOrFallback(cmd, "query", "keyword")
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
params := map[string]any{"query": query}
|
||||
if cursor := aitableStringFlag(cmd, "cursor"); cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
return runAitableTool(cmd, runner, "search_bases", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("query", "", i18n.T("Base 名称关键词 (必填)"))
|
||||
cmd.Flags().String("keyword", "", i18n.T("--query 的别名"))
|
||||
_ = cmd.Flags().MarkHidden("keyword")
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableBaseGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("获取 AI 表格信息"),
|
||||
Example: " dws aitable base get --base-id BASE_ID",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAitableTool(cmd, runner, "get_base", map[string]any{
|
||||
"baseId": baseID,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableBaseCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: i18n.T("创建 AI 表格"),
|
||||
Example: " dws aitable base create --name 项目跟踪",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name, err := aitableRequiredFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"baseName": name}
|
||||
if templateID := aitableStringFlag(cmd, "template-id"); templateID != "" {
|
||||
params["templateId"] = templateID
|
||||
}
|
||||
return runAitableTool(cmd, runner, "create_base", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("name", "", i18n.T("Base 名称 (必填)"))
|
||||
cmd.Flags().String("template-id", "", i18n.T("模板 ID"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableBaseUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: i18n.T("更新 AI 表格"),
|
||||
Example: " dws aitable base update --base-id BASE_ID --name 新名称",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := aitableRequiredFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"newBaseName": name,
|
||||
}
|
||||
if desc := aitableStringFlag(cmd, "desc"); desc != "" {
|
||||
params["description"] = desc
|
||||
}
|
||||
return runAitableTool(cmd, runner, "update_base", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("name", "", i18n.T("新名称 (必填)"))
|
||||
cmd.Flags().String("desc", "", i18n.T("备注文本"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── table ───────────────────────────────────────────────────
|
||||
|
||||
func newAitableTableGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("获取数据表"),
|
||||
Example: " dws aitable table get --base-id BASE_ID\n dws aitable table get --base-id BASE_ID --table-ids tbl1,tbl2",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{"baseId": baseID}
|
||||
if tableIDs := aitableStringFlag(cmd, "table-ids"); tableIDs != "" {
|
||||
params["tableIds"] = parseAitableCSVValues(tableIDs)
|
||||
}
|
||||
return runAitableTool(cmd, runner, "get_tables", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-ids", "", i18n.T("Table ID 列表,逗号分隔"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableTableCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: i18n.T("创建数据表"),
|
||||
Example: " dws aitable table create --base-id BASE_ID --name 任务表 --fields '[{\"fieldName\":\"名称\",\"type\":\"text\"}]'",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableName := aitableFlagOrFallback(cmd, "name", "table-name")
|
||||
if tableName == "" {
|
||||
return apperrors.NewValidation("--name is required")
|
||||
}
|
||||
fieldsRaw, err := aitableRequiredFlag(cmd, "fields")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, err := parseAitableFieldsJSON(fieldsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAitableTool(cmd, runner, "create_table", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableName": tableName,
|
||||
"fields": fields,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("name", "", i18n.T("表格名称 (必填)"))
|
||||
cmd.Flags().String("table-name", "", i18n.T("--name 的别名"))
|
||||
_ = cmd.Flags().MarkHidden("table-name")
|
||||
cmd.Flags().String("fields", "", i18n.T("字段 JSON 数组 (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableTableUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: i18n.T("更新数据表"),
|
||||
Example: " dws aitable table update --base-id BASE_ID --table-id TABLE_ID --name 新表名",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err := aitableRequiredFlag(cmd, "name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAitableTool(cmd, runner, "update_table", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
"newTableName": name,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("name", "", i18n.T("新表名 (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── field ───────────────────────────────────────────────────
|
||||
|
||||
func newAitableFieldGetCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: i18n.T("获取字段详情"),
|
||||
Example: " dws aitable field get --base-id BASE_ID --table-id TABLE_ID\n dws aitable field get --base-id BASE_ID --table-id TABLE_ID --field-ids fld1,fld2",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
}
|
||||
if fieldIDs := aitableStringFlag(cmd, "field-ids"); fieldIDs != "" {
|
||||
params["fieldIds"] = parseAitableCSVValues(fieldIDs)
|
||||
}
|
||||
return runAitableTool(cmd, runner, "get_fields", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("field-ids", "", i18n.T("Field ID 列表,逗号分隔"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableFieldCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: i18n.T("创建字段"),
|
||||
Example: " dws aitable field create --base-id BASE_ID --table-id TABLE_ID --fields '[{\"fieldName\":\"状态\",\"type\":\"singleSelect\"}]'",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fields []any
|
||||
fieldsRaw := aitableStringFlag(cmd, "fields")
|
||||
if fieldsRaw != "" {
|
||||
fields, err = parseAitableFieldsJSON(fieldsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
name, nameErr := aitableRequiredFlag(cmd, "name")
|
||||
if nameErr != nil {
|
||||
return apperrors.NewValidation("must specify either --fields or both --name and --type")
|
||||
}
|
||||
fieldType, typeErr := aitableRequiredFlag(cmd, "type")
|
||||
if typeErr != nil {
|
||||
return apperrors.NewValidation("must specify either --fields or both --name and --type")
|
||||
}
|
||||
field := map[string]any{
|
||||
"fieldName": name,
|
||||
"type": fieldType,
|
||||
}
|
||||
if configRaw := aitableStringFlag(cmd, "config"); configRaw != "" {
|
||||
configValue, err := parseAitableJSONObject(configRaw, "config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field["config"] = configValue
|
||||
}
|
||||
fields = []any{field}
|
||||
}
|
||||
|
||||
return runAitableTool(cmd, runner, "create_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
"fields": fields,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("fields", "", i18n.T("字段 JSON 数组"))
|
||||
cmd.Flags().String("name", "", i18n.T("单字段名称"))
|
||||
cmd.Flags().String("type", "", i18n.T("单字段类型"))
|
||||
cmd.Flags().String("config", "", i18n.T("字段配置 JSON"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableFieldUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: i18n.T("更新字段"),
|
||||
Example: " dws aitable field update --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --name 新字段名",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fieldID, err := aitableRequiredFlag(cmd, "field-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := aitableStringFlag(cmd, "name")
|
||||
configRaw := aitableStringFlag(cmd, "config")
|
||||
if name == "" && configRaw == "" {
|
||||
return apperrors.NewValidation("at least one of --name or --config is required")
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
"fieldId": fieldID,
|
||||
}
|
||||
if name != "" {
|
||||
params["newFieldName"] = name
|
||||
}
|
||||
if configRaw != "" {
|
||||
configValue, err := parseAitableJSONObject(configRaw, "config")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["config"] = configValue
|
||||
}
|
||||
return runAitableTool(cmd, runner, "update_field", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("field-id", "", i18n.T("Field ID (必填)"))
|
||||
cmd.Flags().String("name", "", i18n.T("新字段名"))
|
||||
cmd.Flags().String("config", "", i18n.T("字段配置 JSON"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── record ──────────────────────────────────────────────────
|
||||
|
||||
func newAitableRecordQueryCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "query",
|
||||
Short: i18n.T("查询记录"),
|
||||
Example: " dws aitable record query --base-id BASE_ID --table-id TABLE_ID --keyword 关键词 --limit 50",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
}
|
||||
if recordIDs := aitableStringFlag(cmd, "record-ids"); recordIDs != "" {
|
||||
params["recordIds"] = parseAitableCSVValues(recordIDs)
|
||||
}
|
||||
if fieldIDs := aitableStringFlag(cmd, "field-ids"); fieldIDs != "" {
|
||||
params["fieldIds"] = parseAitableCSVValues(fieldIDs)
|
||||
}
|
||||
if filtersRaw := aitableStringFlag(cmd, "filters"); filtersRaw != "" {
|
||||
filters, err := parseAitableJSONObject(filtersRaw, "filters")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["filters"] = filters
|
||||
}
|
||||
if sortRaw := aitableStringFlag(cmd, "sort"); sortRaw != "" {
|
||||
sortValue, err := parseAitableJSONArray(sortRaw, "sort")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["sort"] = sortValue
|
||||
}
|
||||
if keyword := aitableFlagOrFallback(cmd, "query", "keyword"); keyword != "" {
|
||||
params["keyword"] = keyword
|
||||
}
|
||||
if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
if cursor := aitableStringFlag(cmd, "cursor"); cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
return runAitableTool(cmd, runner, "query_records", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("record-ids", "", i18n.T("Record ID 列表,逗号分隔"))
|
||||
cmd.Flags().String("field-ids", "", i18n.T("Field ID 列表,逗号分隔"))
|
||||
cmd.Flags().String("filters", "", i18n.T("过滤条件 JSON"))
|
||||
cmd.Flags().String("sort", "", i18n.T("排序 JSON 数组"))
|
||||
cmd.Flags().String("query", "", i18n.T("全文关键词"))
|
||||
cmd.Flags().String("keyword", "", i18n.T("--query 的别名"))
|
||||
_ = cmd.Flags().MarkHidden("keyword")
|
||||
cmd.Flags().Int("limit", 0, i18n.T("单次最大记录数"))
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableRecordCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: i18n.T("新增记录"),
|
||||
Example: " dws aitable record create --base-id BASE_ID --table-id TABLE_ID --records '[{\"cells\":{\"fld1\":\"hello\"}}]'",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordsRaw, err := aitableRequiredFlag(cmd, "records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := parseAitableJSONArray(recordsRaw, "records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAitableTool(cmd, runner, "create_records", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
"records": records,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("records", "", i18n.T("记录 JSON 数组 (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAitableRecordUpdateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: i18n.T("更新记录"),
|
||||
Example: " dws aitable record update --base-id BASE_ID --table-id TABLE_ID --records '[{\"recordId\":\"rec1\",\"cells\":{\"fld1\":\"updated\"}}]'",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableID, err := aitableRequiredFlag(cmd, "table-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
recordsRaw, err := aitableRequiredFlag(cmd, "records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records, err := parseAitableJSONArray(recordsRaw, "records")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAitableTool(cmd, runner, "update_records", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": tableID,
|
||||
"records": records,
|
||||
})
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("table-id", "", i18n.T("Table ID (必填)"))
|
||||
cmd.Flags().String("records", "", i18n.T("记录 JSON 数组 (必填)"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── template ────────────────────────────────────────────────
|
||||
|
||||
func newAitableTemplateSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: i18n.T("搜索模板"),
|
||||
Example: " dws aitable template search --query 项目管理",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query := aitableFlagOrFallback(cmd, "query", "keyword")
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
params := map[string]any{"query": query}
|
||||
if limit, _ := cmd.Flags().GetInt("limit"); limit > 0 {
|
||||
params["limit"] = limit
|
||||
}
|
||||
if cursor := aitableStringFlag(cmd, "cursor"); cursor != "" {
|
||||
params["cursor"] = cursor
|
||||
}
|
||||
return runAitableTool(cmd, runner, "search_templates", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("query", "", i18n.T("模板关键词 (必填)"))
|
||||
cmd.Flags().String("keyword", "", i18n.T("--query 的别名"))
|
||||
_ = cmd.Flags().MarkHidden("keyword")
|
||||
cmd.Flags().Int("limit", 0, i18n.T("每页数量"))
|
||||
cmd.Flags().String("cursor", "", i18n.T("分页游标"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── attachment ──────────────────────────────────────────────
|
||||
|
||||
func newAITableAttachmentUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: i18n.T("准备附件上传"),
|
||||
Example: " dws aitable attachment upload --base-id BASE_ID --file-name report.pdf --size 1024",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
baseID, err := aitableRequiredFlag(cmd, "base-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fileName, err := aitableRequiredFlag(cmd, "file-name")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"fileName": fileName,
|
||||
}
|
||||
if size, _ := cmd.Flags().GetInt64("size"); size > 0 {
|
||||
params["size"] = size
|
||||
}
|
||||
if mimeType := aitableStringFlag(cmd, "mime-type"); mimeType != "" {
|
||||
params["mimeType"] = mimeType
|
||||
}
|
||||
return runAitableTool(cmd, runner, "prepare_attachment_upload", params)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("base-id", "", i18n.T("Base ID (必填)"))
|
||||
cmd.Flags().String("file-name", "", i18n.T("文件名 (必填)"))
|
||||
cmd.Flags().Int64("size", 0, i18n.T("文件大小(字节)"))
|
||||
cmd.Flags().String("mime-type", "", i18n.T("文件 MIME Type"))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────
|
||||
|
||||
func runAitableTool(cmd *cobra.Command, runner executor.Runner, tool string, params map[string]any) error {
|
||||
invocation := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"aitable",
|
||||
tool,
|
||||
params,
|
||||
)
|
||||
invocation.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), invocation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
}
|
||||
|
||||
func aitableStringFlag(cmd *cobra.Command, name string) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
if value, err := cmd.InheritedFlags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func aitableFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
|
||||
if value := aitableStringFlag(cmd, primary); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if value := aitableStringFlag(cmd, alias); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func aitableRequiredFlag(cmd *cobra.Command, name string) (string, error) {
|
||||
if value := aitableStringFlag(cmd, name); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s is required", name))
|
||||
}
|
||||
|
||||
func aitableRequiredFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
|
||||
if value := aitableFlagOrFallback(cmd, primary, aliases...); value != "" {
|
||||
return value, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s is required", primary))
|
||||
}
|
||||
|
||||
func parseAitableCSVValues(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||||
values = append(values, trimmed)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func parseAitableFieldsJSON(raw string) ([]any, error) {
|
||||
var fields []any
|
||||
if err := json.Unmarshal([]byte(raw), &fields); err == nil {
|
||||
return fields, nil
|
||||
}
|
||||
var wrapper map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &wrapper); err == nil {
|
||||
if wrappedFields, ok := wrapper["fields"].([]any); ok {
|
||||
return wrappedFields, nil
|
||||
}
|
||||
}
|
||||
return nil, apperrors.NewValidation("--fields JSON parse failed: expect a JSON array")
|
||||
}
|
||||
|
||||
func parseAitableJSONArray(raw, flagName string) ([]any, error) {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parseAitableJSONObject(raw, flagName string) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--%s JSON parse failed: %v", flagName, err))
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package logging
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -52,14 +53,15 @@ func LogRequestBody(logger *slog.Logger, method, executionId string, toolName st
|
||||
)
|
||||
}
|
||||
|
||||
// LogResponse logs a JSON-RPC response at Debug level.
|
||||
func LogResponse(logger *slog.Logger, method, endpoint string, statusCode int, respSize int, duration time.Duration, err error) {
|
||||
// LogResponse logs a JSON-RPC response at Debug level (Warn on error).
|
||||
func LogResponse(logger *slog.Logger, method, endpoint, executionId string, statusCode int, respSize int, duration time.Duration, err error) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
attrs := []slog.Attr{
|
||||
slog.String("method", method),
|
||||
slog.String("endpoint", redactEndpoint(endpoint)),
|
||||
slog.String("execution_id", executionId),
|
||||
slog.Int("status", statusCode),
|
||||
slog.Int("resp_size", respSize),
|
||||
slog.String("duration", duration.Truncate(time.Millisecond).String()),
|
||||
@@ -137,18 +139,24 @@ func LogErrorClassified(logger *slog.Logger, method, executionId, category, reas
|
||||
}
|
||||
|
||||
// LogCommandStart logs the beginning of a command execution.
|
||||
func LogCommandStart(logger *slog.Logger, executionId, command, product, tool, version string, authPresent bool) {
|
||||
func LogCommandStart(logger *slog.Logger, executionId, product, tool, endpoint, version string, authPresent bool, timeoutSec int) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
logger.Info("command_start",
|
||||
attrs := []slog.Attr{
|
||||
slog.String("execution_id", executionId),
|
||||
slog.String("command", command),
|
||||
slog.String("product", product),
|
||||
slog.String("tool", tool),
|
||||
slog.String("endpoint", redactEndpoint(endpoint)),
|
||||
slog.String("cli_version", version),
|
||||
slog.String("os", runtime.GOOS),
|
||||
slog.String("arch", runtime.GOARCH),
|
||||
slog.Bool("auth_token_present", authPresent),
|
||||
)
|
||||
}
|
||||
if timeoutSec > 0 {
|
||||
attrs = append(attrs, slog.Int("timeout_sec", timeoutSec))
|
||||
}
|
||||
logger.LogAttrs(context.TODO(), slog.LevelInfo, "command_start", attrs...)
|
||||
}
|
||||
|
||||
// LogCommandEnd logs the end of a command execution.
|
||||
|
||||
@@ -52,7 +52,7 @@ func TestLogResponseSuccess(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
LogResponse(logger, "tools/call", "https://mcp.dingtalk.com/api", 200, 1024, 150*time.Millisecond, nil)
|
||||
LogResponse(logger, "tools/call", "https://mcp.dingtalk.com/api", "exec-1", 200, 1024, 150*time.Millisecond, nil)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "jsonrpc_response") {
|
||||
@@ -72,7 +72,7 @@ func TestLogResponseError(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
|
||||
LogResponse(logger, "initialize", "https://mcp.dingtalk.com", 500, 0, 2*time.Second, errors.New("connection refused"))
|
||||
LogResponse(logger, "initialize", "https://mcp.dingtalk.com", "exec-2", 500, 0, 2*time.Second, errors.New("connection refused"))
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "WARN") {
|
||||
@@ -87,12 +87,12 @@ func TestLogRequestNilLogger(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Should not panic
|
||||
LogRequest(nil, "test", "http://localhost", "", 0)
|
||||
LogResponse(nil, "test", "http://localhost", 200, 0, 0, nil)
|
||||
LogResponse(nil, "test", "http://localhost", "", 200, 0, 0, nil)
|
||||
LogRequestBody(nil, "tools/call", "exec-1", "tool", nil)
|
||||
LogResponseBody(nil, "tools/call", "exec-1", 200, nil, "")
|
||||
LogRetryAttempt(nil, "tools/call", "exec-1", 0, 2, 429, 0, nil)
|
||||
LogErrorClassified(nil, "tools/call", "exec-1", "api", "timeout", 0, 0, true, "")
|
||||
LogCommandStart(nil, "exec-1", "dws test", "doc", "list", "1.0.0", false)
|
||||
LogCommandStart(nil, "exec-1", "doc", "list", "https://mcp.example.com", "1.0.0", false, 0)
|
||||
LogCommandEnd(nil, "exec-1", "doc", "list", true, 0, "", "")
|
||||
}
|
||||
|
||||
|
||||
@@ -148,45 +148,70 @@ func WriteFiltered(w io.Writer, format Format, payload any, fields, jq string) e
|
||||
}
|
||||
|
||||
// ResolveFields extracts the --fields flag value from the command.
|
||||
// It ensures that we do not mistakenly grab a business parameter also named "fields"
|
||||
// by matching the flag's usage string against the global root definition.
|
||||
func ResolveFields(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
rootFlags := rootPersistentFlags(cmd)
|
||||
if rootFlags == nil {
|
||||
return ""
|
||||
}
|
||||
globalFlag := rootFlags.Lookup("fields")
|
||||
if globalFlag == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, flags := range []*pflag.FlagSet{
|
||||
cmd.Flags(),
|
||||
cmd.InheritedFlags(),
|
||||
rootPersistentFlags(cmd),
|
||||
rootFlags,
|
||||
} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
if f := flags.Lookup("fields"); f != nil && f.Changed {
|
||||
if v, err := flags.GetString("fields"); err == nil {
|
||||
return v
|
||||
// To avoid collision with business flags (e.g. table create --fields),
|
||||
// verify this flag shares the same usage string as the global one.
|
||||
if f.Usage == globalFlag.Usage {
|
||||
if v, err := flags.GetString("fields"); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ResolveJQ extracts the --jq flag value from the command. It checks
|
||||
// local flags, inherited flags, and root persistent flags because
|
||||
// --jq is registered as a root PersistentFlag.
|
||||
// ResolveJQ extracts the --jq flag value from the command. It ensures
|
||||
// that we only grab the global output filter, not a similarly named business parameter.
|
||||
func ResolveJQ(cmd *cobra.Command) string {
|
||||
if cmd == nil {
|
||||
return ""
|
||||
}
|
||||
rootFlags := rootPersistentFlags(cmd)
|
||||
if rootFlags == nil {
|
||||
return ""
|
||||
}
|
||||
globalFlag := rootFlags.Lookup("jq")
|
||||
if globalFlag == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
for _, flags := range []*pflag.FlagSet{
|
||||
cmd.Flags(),
|
||||
cmd.InheritedFlags(),
|
||||
rootPersistentFlags(cmd),
|
||||
rootFlags,
|
||||
} {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
if f := flags.Lookup("jq"); f != nil && f.Changed {
|
||||
if v, err := flags.GetString("jq"); err == nil {
|
||||
return v
|
||||
if f.Usage == globalFlag.Usage {
|
||||
if v, err := flags.GetString("jq"); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveFieldsShadowing(t *testing.T) {
|
||||
rootCmd := &cobra.Command{Use: "dws"}
|
||||
var globalFields string
|
||||
// Register the global persistent flag.
|
||||
rootCmd.PersistentFlags().StringVar(&globalFields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
|
||||
|
||||
// 1. Normal command that relies on the global output filter
|
||||
normalCmd := &cobra.Command{Use: "normal"}
|
||||
rootCmd.AddCommand(normalCmd)
|
||||
rootCmd.SetArgs([]string{"normal", "--fields", "data,status"})
|
||||
rootCmd.Execute()
|
||||
|
||||
if fields := ResolveFields(normalCmd); fields != "data,status" {
|
||||
t.Errorf("expected 'data,status' for normal cmd, got %q", fields)
|
||||
}
|
||||
|
||||
// 2. Command that shadows the global format flag with its own local business logic
|
||||
bizCmd := &cobra.Command{Use: "biz"}
|
||||
var localFields string
|
||||
bizCmd.Flags().StringVar(&localFields, "fields", "", "JSON string array of objects")
|
||||
rootCmd.AddCommand(bizCmd)
|
||||
|
||||
// Reset
|
||||
rootCmd.SetArgs([]string{"biz", "--fields", "[\"fake\"]"})
|
||||
rootCmd.Execute()
|
||||
|
||||
// It should now correctly ignore the localized fields parameter!
|
||||
if fields := ResolveFields(bizCmd); fields != "" {
|
||||
t.Errorf("expected empty fields for shadowed cmd since it's a business param, got %q", fields)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnwrapAndWrite(t *testing.T) {
|
||||
// Simulate the Result
|
||||
result := executor.Result{
|
||||
Invocation: executor.Invocation{
|
||||
Implemented: true,
|
||||
Kind: "compat_invocation",
|
||||
},
|
||||
Response: map[string]any{
|
||||
"endpoint": "https://mcp-gw",
|
||||
"content": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
Write(&buf, FormatJSON, result)
|
||||
|
||||
t.Logf("Output: %s", buf.String())
|
||||
|
||||
resultNil := executor.Result{
|
||||
Invocation: executor.Invocation{
|
||||
Implemented: true,
|
||||
Kind: "compat_invocation",
|
||||
},
|
||||
Response: map[string]any{
|
||||
"endpoint": "https://mcp-gw",
|
||||
"content": nil,
|
||||
},
|
||||
}
|
||||
buf.Reset()
|
||||
Write(&buf, FormatJSON, resultNil)
|
||||
t.Logf("Output nil: %s", buf.String())
|
||||
}
|
||||
+104
-14
@@ -16,9 +16,13 @@ package transport
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -26,10 +30,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"io"
|
||||
|
||||
"log/slog"
|
||||
|
||||
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"
|
||||
@@ -45,9 +45,9 @@ const (
|
||||
defaultHTTPTimeout = 30 * time.Second
|
||||
|
||||
// Default retry parameters for JSON-RPC calls.
|
||||
defaultMaxRetries = 2
|
||||
defaultRetryDelay = 10 * time.Millisecond
|
||||
defaultRetryMaxDelay = 80 * time.Millisecond
|
||||
defaultMaxRetries = 1
|
||||
defaultRetryDelay = 500 * time.Millisecond
|
||||
defaultRetryMaxDelay = 5 * time.Second
|
||||
|
||||
// Security headers
|
||||
HeaderSource = "X-Cli-Source"
|
||||
@@ -188,15 +188,40 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
|
||||
return fmt.Errorf("unsupported tools/call content shape")
|
||||
}
|
||||
|
||||
// defaultTransport returns a tuned http.Transport for MCP JSON-RPC calls.
|
||||
// Compared to http.DefaultTransport it adds ResponseHeaderTimeout to detect
|
||||
// "accepted but never responded" servers faster, and explicit TLS/dial timeouts.
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 20 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NewClient(httpClient *http.Client) *Client {
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{
|
||||
Timeout: defaultHTTPTimeout,
|
||||
Transport: defaultTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
} else if httpClient.CheckRedirect == nil {
|
||||
// Wrap existing client with safe redirect policy
|
||||
httpClient.CheckRedirect = safeRedirectPolicy
|
||||
} else {
|
||||
if httpClient.Transport == nil {
|
||||
httpClient.Transport = defaultTransport()
|
||||
}
|
||||
if httpClient.CheckRedirect == nil {
|
||||
httpClient.CheckRedirect = safeRedirectPolicy
|
||||
}
|
||||
}
|
||||
return &Client{
|
||||
HTTPClient: httpClient,
|
||||
@@ -362,7 +387,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
headerTraceID := ExtractTraceIDFromHeaders(resp.Header)
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
||||
logging.LogResponse(c.FileLogger, request.Method, endpoint, resp.StatusCode, len(data), time.Since(callStart), err)
|
||||
logging.LogResponse(c.FileLogger, request.Method, endpoint, c.ExecutionId, resp.StatusCode, len(data), time.Since(callStart), err)
|
||||
if err != nil {
|
||||
return apperrors.NewDiscovery(
|
||||
"failed to read JSON-RPC response",
|
||||
@@ -469,6 +494,9 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if isTimeoutError(err) {
|
||||
break
|
||||
}
|
||||
} else if !retryable(resp.StatusCode) || attempt == c.MaxRetries {
|
||||
return resp, nil
|
||||
} else {
|
||||
@@ -499,12 +527,16 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte)
|
||||
}
|
||||
}
|
||||
}
|
||||
reason, hint := classifyRequestFailure(lastErr)
|
||||
logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId,
|
||||
string(apperrors.CategoryDiscovery), reason, 0, 0,
|
||||
!isTimeoutError(lastErr), "")
|
||||
return nil, apperrors.NewDiscovery(
|
||||
fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr),
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithReason("request_failed"),
|
||||
apperrors.WithRetryable(true),
|
||||
apperrors.WithHint(i18n.T("请检查网络连通性和 MCP 服务状态后重试。")),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(!isTimeoutError(lastErr)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(discoveryActions("")...),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
@@ -517,6 +549,64 @@ func retryable(statusCode int) bool {
|
||||
return statusCode == http.StatusTooManyRequests || statusCode >= http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// isTimeoutError returns true for errors caused by context deadline or HTTP
|
||||
// client timeout. These are typically deterministic (server overloaded or
|
||||
// unreachable) and retrying immediately is unlikely to help.
|
||||
func isTimeoutError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
if os.IsTimeout(err) {
|
||||
return true
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "Client.Timeout exceeded") ||
|
||||
strings.Contains(msg, "TLS handshake timeout")
|
||||
}
|
||||
|
||||
// classifyRequestFailure returns a machine-readable reason and a user-facing
|
||||
// hint tailored to the specific failure type, so users get actionable guidance
|
||||
// instead of opaque Go error strings.
|
||||
func classifyRequestFailure(err error) (reason, hint string) {
|
||||
if err == nil {
|
||||
return "request_failed", i18n.T("请检查网络连通性和 MCP 服务状态后重试。")
|
||||
}
|
||||
msg := err.Error()
|
||||
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return "request_timeout",
|
||||
i18n.T("请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。")
|
||||
case errors.Is(err, context.Canceled):
|
||||
return "request_cancelled",
|
||||
i18n.T("请求已取消。如果非手动取消,请检查调用侧超时设置。")
|
||||
case strings.Contains(msg, "Client.Timeout exceeded"):
|
||||
return "http_client_timeout",
|
||||
i18n.T("HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。")
|
||||
case strings.Contains(msg, "TLS handshake timeout"):
|
||||
return "tls_timeout",
|
||||
i18n.T("TLS 握手超时。请检查网络连接或代理设置。")
|
||||
case strings.Contains(msg, "connection refused"):
|
||||
return "connection_refused",
|
||||
i18n.T("连接被拒绝。请确认服务端已启动并正在监听。")
|
||||
case strings.Contains(msg, "no such host"):
|
||||
return "dns_resolution_failed",
|
||||
i18n.T("DNS 解析失败。请检查域名拼写和网络 DNS 配置。")
|
||||
case strings.Contains(msg, "i/o timeout"):
|
||||
return "io_timeout",
|
||||
i18n.T("网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。")
|
||||
default:
|
||||
return "request_failed",
|
||||
i18n.T("请检查网络连通性和 MCP 服务状态后重试。")
|
||||
}
|
||||
}
|
||||
|
||||
func respRetryAfter(resp *http.Response) string {
|
||||
if resp == nil {
|
||||
return ""
|
||||
|
||||
@@ -35,6 +35,7 @@ var knownSkillDirs = []string{
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
}
|
||||
|
||||
// skillDirBlacklist contains parent directories whose skills are managed by
|
||||
|
||||
+108
-26
@@ -1,21 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Install DWS agent skills from GitHub into detected agent directories.
|
||||
# Install DWS agent skills from GitHub Releases into agent skill directories.
|
||||
# Usage:
|
||||
# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
#
|
||||
# The script downloads the dws-skills.zip release asset from GitHub Releases
|
||||
# and copies it into every detected agent skills directory in the current
|
||||
# project.
|
||||
# Downloads dws-skills.zip from GitHub Releases and copies it under each target
|
||||
# path using the same rules as build/npm/install.js installSkillsToHomes
|
||||
# (AGENT_DIRS + parent-directory gate), with root defaulting to the current
|
||||
# directory. Set DWS_SKILLS_ROOT=$HOME to match npm install layout exactly.
|
||||
#
|
||||
# Environment variables (optional):
|
||||
# DWS_VERSION — release tag (default: latest)
|
||||
# DWS_SKILLS_ROOT — base path for agent dirs (default: $PWD)
|
||||
|
||||
REPO="DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
VERSION="${DWS_VERSION:-latest}"
|
||||
SKILL_NAME="dws"
|
||||
|
||||
# ── Agent directory to install skills into ───────────────────────────────────
|
||||
# Only install to .agents/skills — most agents can fall back to this directory.
|
||||
AGENT_DIR=".agents/skills"
|
||||
ROOT="${DWS_SKILLS_ROOT:-$PWD}"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,14 +53,107 @@ extract_zip() {
|
||||
exit 1
|
||||
}
|
||||
|
||||
# One-line summary copy (2nd+ targets).
|
||||
_copy_skill_summary() {
|
||||
_src="$1"
|
||||
_dest="$2"
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
|
||||
}
|
||||
|
||||
# Full copy with top-level listing (1st target).
|
||||
_copy_skill() {
|
||||
_src="$1"
|
||||
_dest="$2"
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
printf ' ✅ Skills → %s (%s files)\n' "$_label" "$file_count"
|
||||
|
||||
for entry in "$_dest"/*; do
|
||||
entry_name="$(basename "$entry")"
|
||||
if [ -d "$entry" ]; then
|
||||
sub_count="$(find "$entry" -type f | wc -l | tr -d ' ')"
|
||||
printf ' 📁 %s/ (%s files)\n' "$entry_name" "$sub_count"
|
||||
else
|
||||
printf ' 📄 %s\n' "$entry_name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Same semantics as build/npm/install.js installSkillsToHomes (root = DWS_SKILLS_ROOT or PWD).
|
||||
install_skills_to_root() {
|
||||
skill_src="$1"
|
||||
root="$2"
|
||||
installed=0
|
||||
idx=0
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
".claude/skills" \
|
||||
".cursor/skills" \
|
||||
".gemini/skills" \
|
||||
".codex/skills" \
|
||||
".github/skills" \
|
||||
".windsurf/skills" \
|
||||
".augment/skills" \
|
||||
".cline/skills" \
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
else
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
fi
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_copy_skill "$skill_src" "$dest" "$label"
|
||||
else
|
||||
_copy_skill_summary "$skill_src" "$dest" "$label"
|
||||
fi
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
if [ "$root" = "$HOME" ]; then
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
else
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
fi
|
||||
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
need_cmd curl
|
||||
resolve_version
|
||||
|
||||
CWD="$(pwd)"
|
||||
|
||||
printf '\n'
|
||||
printf ' ┌──────────────────────────────────────┐\n'
|
||||
printf ' │ DWS Skill Installer │\n'
|
||||
@@ -66,7 +161,6 @@ main() {
|
||||
printf ' └──────────────────────────────────────┘\n'
|
||||
printf '\n'
|
||||
|
||||
# Download the tarball to a temp directory
|
||||
TMPDIR_WORK="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR_WORK"' EXIT INT TERM
|
||||
|
||||
@@ -85,21 +179,9 @@ main() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install to .agents/skills only
|
||||
dest="$CWD/$AGENT_DIR/$SKILL_NAME"
|
||||
|
||||
# Remove existing installation
|
||||
if [ -d "$dest" ]; then
|
||||
rm -rf "$dest"
|
||||
fi
|
||||
|
||||
# Copy skill files
|
||||
mkdir -p "$dest"
|
||||
cp -R "$SKILL_SRC/"* "$dest/"
|
||||
file_count="$(find "$dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
printf ' ✅ Universal (.agents)\n'
|
||||
printf ' → %s/%s (%s files)\n' "$AGENT_DIR" "$SKILL_NAME" "$file_count"
|
||||
printf '\n'
|
||||
printf ' Installing under root: %s\n' "$ROOT"
|
||||
install_skills_to_root "$SKILL_SRC" "$ROOT"
|
||||
|
||||
printf '\n'
|
||||
printf ' 📖 Skill includes:\n'
|
||||
|
||||
+69
-8
@@ -17,6 +17,8 @@
|
||||
# DWS_ARCH — architecture override (amd64 or arm64)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
@@ -28,8 +30,22 @@ $NoSkills = $env:DWS_NO_SKILLS -eq "1"
|
||||
$SkillsOnly = $env:DWS_SKILLS_ONLY -eq "1"
|
||||
$SkillName = "dws"
|
||||
|
||||
# Agent directory to install skills into — most agents can fall back to .agents\skills
|
||||
$AgentDir = ".agents\skills"
|
||||
# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS).
|
||||
$AgentDirs = @(
|
||||
".agents\skills",
|
||||
".claude\skills",
|
||||
".cursor\skills",
|
||||
".gemini\skills",
|
||||
".codex\skills",
|
||||
".github\skills",
|
||||
".windsurf\skills",
|
||||
".augment\skills",
|
||||
".cline\skills",
|
||||
".amp\skills",
|
||||
".kiro\skills",
|
||||
".trae\skills",
|
||||
".openclaw\skills"
|
||||
)
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -170,6 +186,17 @@ function Copy-SkillToDir {
|
||||
}
|
||||
}
|
||||
|
||||
function Copy-SkillToDirSummary {
|
||||
param([string]$SkillSrc, [string]$Dest, [string]$Label)
|
||||
|
||||
if (Test-Path $Dest) {
|
||||
Remove-Item -Path $Dest -Recurse -Force
|
||||
}
|
||||
|
||||
$fileCount = Copy-DirRecursive -Source $SkillSrc -Destination $Dest
|
||||
Write-Say "✅ Skills → $Label ($fileCount files)"
|
||||
}
|
||||
|
||||
function Resolve-SourceRoot {
|
||||
$scriptPath = $PSScriptRoot
|
||||
if (-not $scriptPath) { return $null }
|
||||
@@ -280,9 +307,45 @@ function Install-SkillsLocal {
|
||||
Write-Say ""
|
||||
Write-Say "📦 Installing agent skills from local source: $skillSrc"
|
||||
|
||||
$dest = Join-Path (Join-Path $HOME $AgentDir) $SkillName
|
||||
$label = "~\$AgentDir\$SkillName"
|
||||
Copy-SkillToDir -SkillSrc $skillSrc -Dest $dest -Label $label
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
}
|
||||
|
||||
function Install-SkillsToHomes {
|
||||
param(
|
||||
[string]$SkillSrc,
|
||||
[string]$Root = $HOME
|
||||
)
|
||||
|
||||
$installed = 0
|
||||
for ($i = 0; $i -lt $AgentDirs.Count; $i++) {
|
||||
$agentDir = $AgentDirs[$i]
|
||||
$baseDir = Join-Path $Root $agentDir
|
||||
$parentGate = Split-Path $baseDir -Parent
|
||||
if ($i -gt 0 -and !(Test-Path $parentGate)) {
|
||||
continue
|
||||
}
|
||||
$dest = Join-Path $baseDir $SkillName
|
||||
if ($Root -eq $HOME) {
|
||||
$label = "~\$agentDir\$SkillName"
|
||||
} else {
|
||||
$label = Join-Path $Root (Join-Path $agentDir $SkillName)
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $dest -Label $label
|
||||
} else {
|
||||
Copy-SkillToDirSummary -SkillSrc $SkillSrc -Dest $dest -Label $label
|
||||
}
|
||||
$installed++
|
||||
}
|
||||
if ($installed -eq 0) {
|
||||
$fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName
|
||||
if ($Root -eq $HOME) {
|
||||
$flabel = "~\.agents\skills\$SkillName"
|
||||
} else {
|
||||
$flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName)
|
||||
}
|
||||
Copy-SkillToDir -SkillSrc $SkillSrc -Dest $fallback -Label $flabel
|
||||
}
|
||||
}
|
||||
|
||||
# ── Install Binary from Source ───────────────────────────────────────────────
|
||||
@@ -359,9 +422,7 @@ function Install-Skills {
|
||||
return
|
||||
}
|
||||
|
||||
$dest = Join-Path (Join-Path $HOME $AgentDir) $SkillName
|
||||
$label = "~\$AgentDir\$SkillName"
|
||||
Copy-SkillToDir -SkillSrc $skillSrc -Dest $dest -Label $label
|
||||
Install-SkillsToHomes -SkillSrc $skillSrc -Root $HOME
|
||||
} finally {
|
||||
Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
+78
-10
@@ -14,6 +14,8 @@
|
||||
# DWS_VERSION — version to install (default: latest)
|
||||
# DWS_NO_SKILLS — set to 1 to skip skills install
|
||||
# DWS_SKILLS_ONLY — set to 1 to install only skills (skip binary)
|
||||
#
|
||||
# Agent skills paths follow build/npm/install.js AGENT_DIRS (order and entries must match).
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -26,10 +28,6 @@ NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
||||
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
|
||||
SKILL_NAME="dws"
|
||||
|
||||
# ── Agent directory to install skills into ───────────────────────────────────
|
||||
# Only install to .agents/skills — most agents can fall back to this directory.
|
||||
AGENT_DIR=".agents/skills"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
say() {
|
||||
@@ -179,13 +177,85 @@ install_skills_local() {
|
||||
say ""
|
||||
say "📦 Installing agent skills from local source: ${skill_src}"
|
||||
|
||||
dest="$HOME/$AGENT_DIR/$SKILL_NAME"
|
||||
display_path="~/$AGENT_DIR/$SKILL_NAME"
|
||||
_copy_skill "$skill_src" "$dest" "$display_path"
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes).
|
||||
install_skills_to_homes() {
|
||||
skill_src="$1"
|
||||
root="${HOME}"
|
||||
installed=0
|
||||
idx=0
|
||||
for agent_dir in \
|
||||
".agents/skills" \
|
||||
".claude/skills" \
|
||||
".cursor/skills" \
|
||||
".gemini/skills" \
|
||||
".codex/skills" \
|
||||
".github/skills" \
|
||||
".windsurf/skills" \
|
||||
".augment/skills" \
|
||||
".cline/skills" \
|
||||
".amp/skills" \
|
||||
".kiro/skills" \
|
||||
".trae/skills" \
|
||||
".openclaw/skills"
|
||||
do
|
||||
base_dir="$root/$agent_dir"
|
||||
parent_gate="$(dirname "$base_dir")"
|
||||
if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then
|
||||
idx=$((idx + 1))
|
||||
continue
|
||||
fi
|
||||
dest="$base_dir/$SKILL_NAME"
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
label="~/$agent_dir/$SKILL_NAME"
|
||||
;;
|
||||
*)
|
||||
label="$root/$agent_dir/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
_copy_skill "$skill_src" "$dest" "$label"
|
||||
else
|
||||
_copy_skill_summary "$skill_src" "$dest" "$label"
|
||||
fi
|
||||
installed=$((installed + 1))
|
||||
idx=$((idx + 1))
|
||||
done
|
||||
if [ "$installed" -eq 0 ]; then
|
||||
case "$root" in
|
||||
"$HOME")
|
||||
flabel="~/.agents/skills/$SKILL_NAME"
|
||||
;;
|
||||
*)
|
||||
flabel="$root/.agents/skills/$SKILL_NAME"
|
||||
;;
|
||||
esac
|
||||
_copy_skill "$skill_src" "$root/.agents/skills/$SKILL_NAME" "$flabel"
|
||||
fi
|
||||
}
|
||||
|
||||
# One-line summary copy (used for 2nd+ agent targets).
|
||||
_copy_skill_summary() {
|
||||
_src="$1"
|
||||
_dest="$2"
|
||||
_label="$3"
|
||||
|
||||
if [ -d "$_dest" ]; then
|
||||
rm -rf "$_dest"
|
||||
fi
|
||||
|
||||
mkdir -p "$_dest"
|
||||
cp -R "$_src/"* "$_dest/" 2>/dev/null || cp -r "$_src/"* "$_dest/"
|
||||
file_count="$(find "$_dest" -type f | wc -l | tr -d ' ')"
|
||||
|
||||
say "✅ Skills → ${_label} (${file_count} files)"
|
||||
}
|
||||
|
||||
# Helper: copy skill files to a destination and print details
|
||||
_copy_skill() {
|
||||
_src="$1"
|
||||
@@ -349,9 +419,7 @@ install_skills() {
|
||||
fi
|
||||
fi
|
||||
|
||||
dest="$HOME/$AGENT_DIR/$SKILL_NAME"
|
||||
display_path="~/$AGENT_DIR/$SKILL_NAME"
|
||||
_copy_skill "$skill_src" "$dest" "$display_path"
|
||||
install_skills_to_homes "$skill_src"
|
||||
|
||||
rm -rf "$tmpdir_skills"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ HOME_AGENT_PARENTS="
|
||||
.amp
|
||||
.kiro
|
||||
.trae
|
||||
.openclaw
|
||||
"
|
||||
HOME_SKILL_TARGETS="
|
||||
.agents/skills/dws
|
||||
@@ -50,6 +51,7 @@ HOME_SKILL_TARGETS="
|
||||
.amp/skills/dws
|
||||
.kiro/skills/dws
|
||||
.trae/skills/dws
|
||||
.openclaw/skills/dws
|
||||
"
|
||||
cleanup() {
|
||||
if command -v brew >/dev/null 2>&1; then
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package cli_compat_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
)
|
||||
|
||||
func TestDebugAitableTableCreate(t *testing.T) {
|
||||
_ = setupTestDeps(t, "aitable")
|
||||
root := app.NewRootCommand()
|
||||
|
||||
cliArgs := []string{"-f", "json", "aitable", "table", "create",
|
||||
"--base-id", "B1", "--name", "任务表",
|
||||
"--fields", `[{"fieldName":"名称","type":"text"}]`,
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&errOut)
|
||||
root.SetArgs(cliArgs)
|
||||
|
||||
err := root.Execute()
|
||||
t.Logf("Execute error: %v", err)
|
||||
t.Logf("Stdout: [%s]", out.String())
|
||||
t.Logf("Stderr: [%s]", errOut.String())
|
||||
|
||||
// Check all aitable subcommands
|
||||
aitableCmd, _, _ := root.Find([]string{"aitable"})
|
||||
if aitableCmd != nil {
|
||||
t.Logf("aitable subcommands:")
|
||||
for _, grp := range aitableCmd.Commands() {
|
||||
t.Logf(" %s:", grp.Use)
|
||||
for _, sub := range grp.Commands() {
|
||||
t.Logf(" %s (hidden=%v)", sub.Use, sub.Hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
var expectedHomeSkillTargets = []string{
|
||||
".agents/skills/dws",
|
||||
".cursor/skills/dws",
|
||||
}
|
||||
|
||||
func TestInstallScriptSourceModeInstallsBinary(t *testing.T) {
|
||||
@@ -107,6 +108,11 @@ done
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "make"), []byte(makeStub), 0o755)
|
||||
mustWriteFile(t, filepath.Join(stubRoot, "go"), []byte("#!/bin/sh\ntrue\n"), 0o755)
|
||||
|
||||
// Gate for index>0 agent dirs (matches build/npm/install.js): parent must exist.
|
||||
if err := os.MkdirAll(filepath.Join(fakeHome, ".cursor"), 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll(.cursor) error = %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", scriptPath)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"HOME="+fakeHome,
|
||||
@@ -147,6 +153,9 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) {
|
||||
if !strings.Contains(text, ".agents\\skills") {
|
||||
t.Fatalf("install.ps1 missing .agents\\skills")
|
||||
}
|
||||
if !strings.Contains(text, ".cursor\\skills") {
|
||||
t.Fatalf("install.ps1 missing .cursor\\skills (AGENT_DIRS must match build/npm/install.js)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) {
|
||||
|
||||
@@ -22,6 +22,7 @@ var expectedPackagedSkillTargets = []string{
|
||||
".amp/skills/dws",
|
||||
".kiro/skills/dws",
|
||||
".trae/skills/dws",
|
||||
".openclaw/skills/dws",
|
||||
}
|
||||
|
||||
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type ContentBlock struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
data := []byte(`{"content":[{"type":"text","text":"{\"summary\":\"...\",\"data\":{\"tableId\":\"abc\"},\"status\":\"success\"}"}],"structuredContent":{"summary":"...","data":{"tableId":"abc"},"status":"success"},"isError":false}`)
|
||||
|
||||
type rawResult struct {
|
||||
Content json.RawMessage `json:"content"`
|
||||
StructuredContent map[string]any `json:"structuredContent"`
|
||||
IsError bool `json:"isError,omitempty"`
|
||||
}
|
||||
|
||||
var raw rawResult
|
||||
_ = json.Unmarshal(data, &raw)
|
||||
|
||||
fmt.Printf("raw.Content string: %s\n", string(raw.Content))
|
||||
|
||||
var object map[string]any
|
||||
errMap := json.Unmarshal(raw.Content, &object)
|
||||
fmt.Printf("errMap: %v\n", errMap)
|
||||
|
||||
var blocks []ContentBlock
|
||||
errBlocks := json.Unmarshal(raw.Content, &blocks)
|
||||
fmt.Printf("errBlocks: %v, len(blocks): %d\n", errBlocks, len(blocks))
|
||||
}
|
||||
Reference in New Issue
Block a user