Files
dingtalk-workspace-cli/internal/app/root.go
T
audanye-sudo 9c69dcc3e5 chore: wire pipeline engine into root command and bump version to v1.0.2
- Integrate pipeline engine with pre-parse input correction
- Unhide schema command
- Update CHANGELOG for v1.0.2
- Update README documentation
2026-03-29 16:30:20 +08:00

842 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"path"
"path/filepath"
"strings"
"syscall"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type outputFileContextKey struct{}
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
// Execute runs the root command and returns the process exit code.
func Execute() int {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
recovery.ResetRuntimeState()
engine := newPipelineEngine()
root := NewRootCommandWithEngine(ctx, engine)
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
pipeline.RunPreParse(root, engine)
executed, err := root.ExecuteC()
if err != nil {
if executed == nil {
executed = root
}
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
_, _ = fmt.Fprintln(os.Stderr)
}
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
if last := recovery.LatestCapture(); last != nil && last.EventID != "" {
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
}
return apperrors.ExitCode(err)
}
return 0
}
func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
// flagErrorWithSuggestions provides helpful suggestions for common flag mistakes.
func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
errMsg := err.Error()
// Common flag aliases and suggestions
suggestions := map[string]string{
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
"--password": "提示: dws 不支持密码登录,请使用 dws auth login 进行扫码登录",
"--phone": "提示: dws 不支持手机号登录,请使用 dws auth login 进行扫码登录",
"--app-key": "提示: 请使用环境变量 DWS_CLIENT_ID 或 --client-id 设置 AppKey",
"--app-secret": "提示: 请使用环境变量 DWS_CLIENT_SECRET 或 --client-secret 设置 AppSecret",
}
for flag, suggestion := range suggestions {
if strings.Contains(errMsg, "unknown flag: "+flag) {
return fmt.Errorf("%w\n%s", err, suggestion)
}
}
return err
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
if wantsJSONErrors(root) {
return apperrors.PrintJSON(stdout, err)
}
return apperrors.PrintHuman(stderr, err)
}
func wantsJSONErrors(root *cobra.Command) bool {
if root == nil {
return false
}
if commandRequestsJSONErrors(root) {
return true
}
if rootCmd := root.Root(); rootCmd != nil && rootCmd != root {
return commandRequestsJSONErrors(rootCmd)
}
return false
}
func commandRequestsJSONErrors(cmd *cobra.Command) bool {
if cmd == nil {
return false
}
for _, flags := range []interface {
Lookup(string) *pflag.Flag
GetString(string) (string, error)
GetBool(string) (bool, error)
}{
cmd.Flags(),
cmd.InheritedFlags(),
cmd.PersistentFlags(),
} {
if flags == nil {
continue
}
if flag := flags.Lookup("format"); flag != nil {
if value, err := flags.GetString("format"); err == nil && strings.EqualFold(strings.TrimSpace(value), "json") {
return true
}
}
if flag := flags.Lookup("json"); flag != nil && flag.Changed {
if value, err := flags.GetBool("json"); err == nil {
if value {
return true
}
continue
}
return true
}
}
return false
}
// NewRootCommand constructs the root CLI command. The provided context
// is propagated to background goroutines and the Cobra command tree so
// that SIGINT/SIGTERM can cancel in-flight work.
func NewRootCommand(ctx ...context.Context) *cobra.Command {
var rootCtx context.Context
if len(ctx) > 0 && ctx[0] != nil {
rootCtx = ctx[0]
}
return NewRootCommandWithEngine(rootCtx, nil)
}
// NewRootCommandWithEngine constructs the root CLI command with an
// optional pipeline engine for input correction. When engine is nil,
// no pipeline processing is applied.
func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) *cobra.Command {
if rootCtx == nil {
rootCtx = context.Background()
}
flags := &GlobalFlags{}
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
}
runner := newCommandRunnerWithFlags(loader, flags)
root := &cobra.Command{
Use: "dws",
Short: "DWS CLI",
Args: cobra.NoArgs,
SilenceErrors: true,
SilenceUsage: true,
DisableAutoGenTag: true,
Version: Version(),
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
authpkg.SetClientID(flags.ClientID)
}
if flags.ClientSecret != "" {
authpkg.SetClientSecret(flags.ClientSecret)
}
// Configure global slog level based on --debug / --verbose flags.
configureLogLevel(flags)
return configureOutputSink(cmd)
},
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
CloseFileLogger()
return closeOutputSink(cmd)
},
}
bindPersistentFlags(root, flags)
schemaCmd := newSchemaCommand(loader)
genSkillsCmd := newGenerateSkillsCommand()
genSkillsCmd.Hidden = true
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
mcpCmd.Hidden = true
utilityCommands := []*cobra.Command{
newAuthCommand(),
newCacheCommand(),
newCompletionCommand(root),
newRecoveryCommand(rootCtx, loader, flags),
newVersionCommand(),
schemaCmd,
genSkillsCmd,
mcpCmd,
}
root.AddCommand(utilityCommands...)
root.AddCommand(newLegacyPublicCommands(rootCtx, runner)...)
root.AddCommand(newLegacyHiddenCommands(runner)...)
hideNonDirectRuntimeCommands(root)
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
root.SetContext(rootCtx)
return root
}
func newAuthCommand() *cobra.Command {
return buildAuthCommand()
}
func newCacheCommand() *cobra.Command {
cacheCmd := newPlaceholderParent("cache", "缓存管理")
statusCmd := &cobra.Command{
Use: "status",
Short: "查看缓存状态",
RunE: func(cmd *cobra.Command, args []string) error {
jsonOut, err := cmd.Flags().GetBool("json")
if err != nil {
return apperrors.NewInternal("failed to read cache status flags")
}
store := cacheStoreFromEnv()
files, bytes, err := cacheDirectoryStats(store.Root)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to read cache status: %v", err))
}
// Enumerate per-server tools cache entries.
partition := config.DefaultPartition
entries, _ := store.ListToolsCacheEntries(partition)
payload := map[string]any{
"kind": "cache_status",
"cache_root": store.Root,
"files": files,
"bytes": bytes,
}
if len(entries) > 0 {
toolEntries := make([]map[string]any, 0, len(entries))
for _, e := range entries {
toolEntries = append(toolEntries, map[string]any{
"server_key": e.ServerKey,
"freshness": string(e.Freshness),
"saved_at": e.SavedAt.Format(time.RFC3339),
"tool_count": e.ToolCount,
"ttl_remaining": e.TTLRemaining,
})
}
payload["tools"] = toolEntries
}
if jsonOut {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "缓存目录: %s\n文件数: %d 大小: %d 字节\n", store.Root, files, bytes)
if len(entries) > 0 {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "\n工具缓存:")
for _, e := range entries {
age := ""
if !e.SavedAt.IsZero() {
dur := time.Since(e.SavedAt).Truncate(time.Minute)
age = fmt.Sprintf(",%s 前保存", dur)
}
ttl := ""
if e.TTLRemaining != "" {
ttl = fmt.Sprintf(",剩余 TTL %s", e.TTLRemaining)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), " %s (%s%s,%d 个工具%s)\n",
e.ServerKey, string(e.Freshness), age, e.ToolCount, ttl)
}
}
return nil
},
}
statusCmd.Flags().Bool("json", false, "Emit cache status as JSON")
refreshCmd := &cobra.Command{
Use: "refresh",
Short: "强制刷新工具缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache refresh flags")
}
baseURL := DiscoveryBaseURL()
store := cacheStoreFromEnv()
transportClient := transport.NewClient(nil)
transportClient.AuthToken = resolveRuntimeAuthToken(cmd.Context(), "")
service := discovery.NewService(
market.NewClient(baseURL, nil),
transportClient,
store,
)
servers, err := service.DiscoverServers(cmd.Context())
if err != nil {
return err
}
selected := selectServersForProduct(servers, product)
if strings.TrimSpace(product) != "" && len(selected) == 0 {
return apperrors.NewValidation(fmt.Sprintf("no market server matched product %q", product))
}
if len(selected) == 0 {
selected = servers
}
if err := clearRuntimeCacheForServers(store, service.CachePartition(), selected); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear cache before refresh: %v", err))
}
refreshable := filterRefreshableServers(selected)
_, failures := service.DiscoverAllRuntime(cmd.Context(), refreshable)
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存刷新完成:已刷新 %d 个服务,失败 %d 个\n缓存目录: %s\n",
len(refreshable),
len(failures),
store.Root,
)
return err
},
}
refreshCmd.Flags().String("product", "", "Refresh only the selected canonical product")
_ = refreshCmd.Flags().MarkHidden("product")
cleanCmd := &cobra.Command{
Use: "clean",
Short: "清理缓存",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
staleOnly, err := cmd.Flags().GetBool("stale")
if err != nil {
return apperrors.NewInternal("failed to read cache clean stale flag")
}
product, err := cmd.Flags().GetString("product")
if err != nil {
return apperrors.NewInternal("failed to read cache clean product flag")
}
store := cacheStoreFromEnv()
removed, err := cleanCacheFiles(store.Root, product, staleOnly)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clean cache: %v", err))
}
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"[OK] 缓存清理完成:已删除 %d 个文件\n",
removed,
)
return err
},
}
cleanCmd.Flags().Bool("stale", false, "Only remove stale cache entries")
cleanCmd.Flags().String("product", "", "Clean only the selected canonical product")
cleanCmd.Hidden = true
cacheCmd.AddCommand(statusCmd, refreshCmd, cleanCmd)
return cacheCmd
}
func newVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "显示版本信息",
Example: " dws version\n dws version --format json",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
format, err := cmd.Flags().GetString("format")
if err != nil {
return apperrors.NewInternal("failed to read format flag")
}
payload := map[string]any{
"version": Version(),
"go": "1.24+",
}
if format == "json" {
return output.WriteJSON(cmd.OutOrStdout(), payload)
}
_, err = fmt.Fprintf(
cmd.OutOrStdout(),
"版本: %s\nGo: %s\n",
Version(),
"1.24+",
)
return err
},
}
}
func newSchemaCommand(loader cli.CatalogLoader) *cobra.Command {
return cli.NewSchemaCommand(loader)
}
func newGenerateSkillsCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "generate-skills",
Short: "Generate agent skills from canonical metadata",
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
source, err := cmd.Flags().GetString("source")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills source flag")
}
outputRoot, err := cmd.Flags().GetString("output-root")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills output-root flag")
}
withDocs, err := cmd.Flags().GetBool("with-docs")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills with-docs flag")
}
fixture, err := cmd.Flags().GetString("fixture")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills fixture flag")
}
snapshot, err := cmd.Flags().GetString("snapshot")
if err != nil {
return apperrors.NewInternal("failed to read generate-skills snapshot flag")
}
catalogPath := fixture
if strings.EqualFold(strings.TrimSpace(source), string(generator.CatalogSourceSnapshot)) {
catalogPath = snapshot
}
for flagName, raw := range map[string]string{
"--output-root": outputRoot,
"--fixture": fixture,
"--snapshot": snapshot,
} {
if err := validateOptionalPath(flagName, raw); err != nil {
return err
}
}
catalog, err := generator.LoadCatalogWithSource(cmd.Context(), source, catalogPath)
if err != nil {
return apperrors.NewDiscovery(fmt.Sprintf("failed to load canonical catalog: %v", err))
}
artifacts, err := generator.Generate(catalog)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to generate skill artifacts: %v", err))
}
if withDocs {
if err := generator.WriteArtifacts(outputRoot, artifacts); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated artifacts: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d artifact(s) in %s\n", len(artifacts), outputRoot)
return err
}
targets := make([]generator.Artifact, 0)
for _, artifact := range artifacts {
if !strings.HasPrefix(artifact.Path, "skills/") {
continue
}
targets = append(targets, artifact)
}
if len(targets) == 0 {
return apperrors.NewInternal("no generated skill artifacts were produced")
}
if err := generator.WriteArtifacts(outputRoot, targets); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to write generated skills: %v", err))
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "generated %d skill artifact(s) in %s\n", len(targets), outputRoot)
return err
},
}
cmd.Flags().String("output-root", ".", "Directory root for generated artifacts")
cmd.Flags().Bool("with-docs", true, "Write docs/schema artifacts in addition to skills")
cmd.Flags().String("source", string(generator.CatalogSourceFixture), "Catalog source for skill generation: fixture, env, or snapshot")
cmd.Flags().String("fixture", "", "Optional path to a catalog fixture; used by --source fixture")
cmd.Flags().String("snapshot", "", "Optional path to a catalog snapshot; used by --source snapshot")
return cmd
}
func newMCPCommand(ctx context.Context, loader cli.CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command {
return cli.NewMCPCommand(ctx, loader, runner, engine)
}
// hideNonDirectRuntimeCommands marks top-level product commands as hidden
// unless they correspond to a product discovered via dynamic server discovery.
// Public utility commands (auth, cache, completion, version) are always kept
// visible; explicitly hidden commands stay hidden.
func hideNonDirectRuntimeCommands(root *cobra.Command) {
allowedProducts := DirectRuntimeProductIDs()
staticCommands := map[string]bool{
"auth": true,
"cache": true,
"completion": true,
"version": true,
"help": true,
}
for _, cmd := range root.Commands() {
name := cmd.Name()
if cmd.Hidden {
continue
}
if staticCommands[name] {
continue
}
if allowedProducts[name] {
continue
}
cmd.Hidden = true
}
}
func cacheStoreFromEnv() *cache.Store {
cacheDir := strings.TrimSpace(os.Getenv(cli.CacheDirEnv))
return cache.NewStore(cacheDir)
}
func configureOutputSink(cmd *cobra.Command) error {
if local := cmd.LocalFlags().Lookup("output"); local != nil {
return nil
}
outputPath, err := cmd.Flags().GetString("output")
if err != nil {
return apperrors.NewInternal("failed to read output flag")
}
outputPath = strings.TrimSpace(outputPath)
if outputPath == "" {
return nil
}
if err := validateOptionalPath("--output", outputPath); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to prepare output directory: %v", err))
}
file, err := os.Create(outputPath)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to create output file: %v", err))
}
cmd.SetOut(file)
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, file))
return nil
}
func closeOutputSink(cmd *cobra.Command) error {
file, ok := cmd.Context().Value(outputFileContextKey{}).(*os.File)
if !ok || file == nil {
return nil
}
if err := file.Close(); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to close output file: %v", err))
}
return nil
}
func validateOptionalPath(flagName, path string) error {
path = strings.TrimSpace(path)
if path == "" {
return nil
}
if err := apperrors.SafePath(path); err != nil {
return apperrors.NewValidation(fmt.Sprintf("%s contains an unsafe path: %v", flagName, err))
}
return nil
}
func cacheDirectoryStats(root string) (int, int64, error) {
if strings.TrimSpace(root) == "" {
return 0, 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, 0, nil
}
return 0, 0, err
}
files := 0
var bytes int64
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
files++
bytes += info.Size()
return nil
})
return files, bytes, err
}
func selectServersForProduct(servers []market.ServerDescriptor, product string) []market.ServerDescriptor {
product = strings.TrimSpace(strings.ToLower(product))
if product == "" {
return servers
}
selected := make([]market.ServerDescriptor, 0)
for _, server := range servers {
candidates := []string{
strings.ToLower(strings.TrimSpace(server.DisplayName)),
strings.ToLower(strings.TrimSpace(server.Key)),
strings.ToLower(strings.TrimSpace(path.Base(server.Endpoint))),
}
for _, candidate := range candidates {
if candidate == "" {
continue
}
if candidate == product || strings.Contains(candidate, product) {
selected = append(selected, server)
break
}
}
}
return selected
}
func filterRefreshableServers(servers []market.ServerDescriptor) []market.ServerDescriptor {
filtered := make([]market.ServerDescriptor, 0, len(servers))
for _, server := range servers {
if server.CLI.Skip {
continue
}
filtered = append(filtered, server)
}
return filtered
}
func clearRuntimeCacheForServers(store *cache.Store, partition string, servers []market.ServerDescriptor) error {
for _, server := range servers {
for _, cacheKey := range cacheKeysForServer(server) {
if err := store.DeleteTools(partition, cacheKey); err != nil {
return err
}
}
for _, cacheKey := range detailCacheKeysForServer(server) {
if err := store.DeleteDetail(partition, cacheKey); err != nil {
return err
}
}
}
return nil
}
func cacheKeysForServer(server market.ServerDescriptor) []string {
seen := make(map[string]struct{}, 2)
keys := make([]string, 0, 2)
for _, candidate := range []string{
strings.TrimSpace(server.Key),
strings.TrimSpace(server.CLI.ID),
} {
if candidate == "" {
continue
}
if _, ok := seen[candidate]; ok {
continue
}
seen[candidate] = struct{}{}
keys = append(keys, candidate)
}
return keys
}
func detailCacheKeysForServer(server market.ServerDescriptor) []string {
key := strings.TrimSpace(server.Key)
if key != "" {
return []string{key}
}
id := strings.TrimSpace(server.CLI.ID)
if id != "" {
return []string{id}
}
return nil
}
func cleanCacheFiles(root, product string, staleOnly bool) (int, error) {
if strings.TrimSpace(root) == "" {
return 0, nil
}
if _, err := os.Stat(root); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
staleCutoff := time.Now().UTC().Add(-cache.ToolsTTL)
product = strings.TrimSpace(strings.ToLower(product))
removed := 0
err := filepath.WalkDir(root, func(entryPath string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
normalizedPath := strings.ToLower(filepath.ToSlash(entryPath))
if product != "" && !strings.Contains(normalizedPath, product) {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if staleOnly && info.ModTime().After(staleCutoff) {
return nil
}
if err := os.Remove(entryPath); err != nil && !os.IsNotExist(err) {
return err
}
removed++
return nil
})
if err != nil {
return 0, err
}
return removed, nil
}
// fileLogger holds the package-level file logger for diagnostics.
// It is initialized by configureLogLevel and closed by CloseFileLogger.
var fileLogger *logging.FileLogger
// configureLogLevel sets the global slog level based on --debug and --verbose flags
// and initializes the file logger for diagnostics.
// --debug → slog.LevelDebug; --verbose → slog.LevelInfo; default → slog.LevelWarn.
func configureLogLevel(flags *GlobalFlags) {
if flags == nil {
return
}
var level slog.Level
switch {
case flags.Debug:
level = slog.LevelDebug
case flags.Verbose:
level = slog.LevelInfo
default:
level = slog.LevelWarn
}
stderrHandler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})
// Initialize file logger — writes to ~/.dws/logs/dws.log at DEBUG level
// regardless of stderr level. All slog calls are captured for diagnostics.
fileLogger = logging.Setup(defaultConfigDir())
fileHandler := slog.NewJSONHandler(fileLogger.Writer(), &slog.HandlerOptions{Level: slog.LevelDebug})
slog.SetDefault(slog.New(logging.NewMultiHandler(stderrHandler, fileHandler)))
}
// FileLoggerInstance returns the package-level file logger, or nil if not initialized.
func FileLoggerInstance() *slog.Logger {
if fileLogger == nil {
return nil
}
return fileLogger.Logger
}
// CloseFileLogger flushes and closes the file logger.
func CloseFileLogger() {
if fileLogger != nil {
fileLogger.Close()
}
}
// newPipelineEngine creates and configures the pipeline engine with
// the standard set of handlers for model input correction.
func newPipelineEngine() *pipeline.Engine {
engine := pipeline.NewEngine()
engine.RegisterAll(
// PreParse handlers run in order: alias → sticky → paramname.
// Alias normalises case first (--userId → --user-id), then
// sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit).
handlers.AliasHandler{},
handlers.StickyHandler{},
handlers.ParamNameHandler{},
// PostParse handlers normalise structured values.
handlers.ParamValueHandler{},
)
return engine
}