feat: 修复配置路径BUG并迁移onboard到huh
All checks were successful
Release / build (push) Successful in 12m14s

- 新增路径解析工具 internal/config/path.go
- 配置查找优先级: --config > ~/.config/yoyo/config.yaml > ./configs/config.yaml
- onboard配置保存到 ~/.config/yoyo/config.yaml (符合XDG规范)
- .env文件从 ~/.config/yoyo/.env 加载
- onboard使用huh替代survey库,更现代的交互体验
- 添加Ctrl+C取消支持,打印'你已取消本次配置'
- 保存前增加确认步骤
- 版本号 v0.5.1 -> v1.1.0
This commit is contained in:
2026-04-07 23:51:33 +08:00
parent 21e4710829
commit c0156a88d6
7 changed files with 326 additions and 232 deletions

View File

@@ -469,3 +469,33 @@ yoyo onboard --force
- 版本号需与 git 标签、changelog.md 中的版本号保持三方同步
**讨论记录**: [taolun.md#版本-100-beta-Logo和信息栏改造](taolun.md#版本-100-beta-logo和信息栏改造)
---
## v1.1.0 (2026-04-07)
### BUG修复
- 修复配置文件路径使用相对路径导致管道模式下无法找到配置的问题
- 修复onboard配置保存到错误路径的问题
- 修复.env文件只在当前目录加载的问题
### 新功能
- 配置文件路径智能解析:`~/.config/yoyo/config.yaml``./configs/config.yaml`
- onboard配置向导迁移到 `charm.land/huh/v2`,替代 `survey`
- 新增路径解析工具 `internal/config/path.go`
### 改进
- 配置查找优先级:`--config` 参数 > `~/.config/yoyo/config.yaml` > `./configs/config.yaml`
- 配置文件统一保存到 `~/.config/yoyo/config.yaml`符合XDG规范
- .env文件统一从 `~/.config/yoyo/.env` 加载
- onboard使用huh的Form+Group模式更美观的交互体验
- 移除 `github.com/AlecAivazis/survey/v2` 依赖
### 技术细节
- 新增 `config.ResolveConfigPath()` 函数处理路径解析
- 新增 `config.GetUserConfigPath()` 返回标准配置路径
- 新增 `config.GetUserEnvPath()` 返回标准环境变量路径
- 支持 `~` 路径展开
- huh使用v2版本支持泛型和链式API
**讨论记录**: [taolun.md#2026-04-07-配置路径修复和huh迁移](taolun.md)

View File

@@ -38,7 +38,7 @@ var (
interactiveShort = flag.Bool("i", false, "启动交互式翻译界面(-i的短格式")
)
const versionString = "YOYO翻译工具 v0.5.1"
const versionString = "YOYO翻译工具 v1.1.0"
// isPipeInput 检测是否有管道输入
func isPipeInput() bool {
@@ -192,12 +192,13 @@ func main() {
}
// 加载环境变量文件
_ = godotenv.Load() // 忽略错误,如果文件不存在
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath := *configFile
if configPath == "" {
configPath = "configs/config.yaml" // 默认配置文件路径
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
@@ -300,10 +301,14 @@ func runOnboard(force bool) {
// runCacheCommand 运行缓存命令
func runCacheCommand(subcommand string) {
// 加载环境变量文件
_ = godotenv.Load()
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath := "configs/config.yaml"
configPath, err := config.ResolveConfigPath("")
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}
cfg, err := configLoader.Load(configPath)
if err != nil {
@@ -434,9 +439,9 @@ func printHelp() {
yoyo --interactive # 启动交互式翻译界面
yoyo -i # 启动交互式翻译界面(短格式)
配置:
- 配置文件: configs/config.yaml
- 环境变量: .env 文件
配置:
- 配置文件: ~/.config/yoyo/config.yaml
- 环境变量: ~/.config/yoyo/.env
- 默认厂商: siliconflow
- 默认目标语言: zh-CN (简体中文)
@@ -463,12 +468,13 @@ func startInteractiveMode() {
}
// 加载环境变量文件
_ = godotenv.Load() // 忽略错误,如果文件不存在
_ = godotenv.Load(config.GetUserEnvPath())
// 加载配置
configPath := *configFile
if configPath == "" {
configPath = "configs/config.yaml" // 默认配置文件路径
configPath, err := config.ResolveConfigPath(*configFile)
if err != nil {
fmt.Printf("解析配置路径失败: %v\n", err)
os.Exit(1)
}
configLoader := &config.YAMLConfigLoader{}

22
go.mod
View File

@@ -5,9 +5,8 @@ go 1.26.1
require (
charm.land/bubbles/v2 v2.1.0
charm.land/bubbletea/v2 v2.0.2
charm.land/huh/v2 v2.0.3
charm.land/lipgloss/v2 v2.0.2
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/charmbracelet/bubbles v1.0.0
github.com/go-enry/go-enry/v2 v2.9.5
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.37
@@ -16,34 +15,25 @@ require (
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/catppuccin/go v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-enry/go-oniguruma v1.2.1 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
golang.org/x/text v0.4.0 // indirect
)

85
go.sum
View File

@@ -2,84 +2,69 @@ charm.land/bubbles/v2 v2.1.0 h1:YSnNh5cPYlYjPxRrzs5VEn3vwhtEn3jVGRBT3M7/I0g=
charm.land/bubbles/v2 v2.1.0/go.mod h1:l97h4hym2hvWBVfmJDtrEHHCtkIKeTEb3TTJ4ZOB3wY=
charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs=
charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-enry/go-enry/v2 v2.9.5 h1:HPhAQQHYwJgihL2PxBZiUMFWiROsGwOBdB6/D8zCUhY=
github.com/go-enry/go-enry/v2 v2.9.5/go.mod h1:9yrj4ES1YrbNb1Wb7/PWYr2bpaCXUGRt0uafN0ISyG8=
github.com/go-enry/go-oniguruma v1.2.1 h1:k8aAMuJfMrqm/56SG2lV9Cfti6tC4x8673aHCcBk+eo=
github.com/go-enry/go-oniguruma v1.2.1/go.mod h1:bWDhYP+S6xZQgiRL7wlTScFYBe023B6ilRZbCAD5Hf4=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -87,48 +72,18 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

76
internal/config/path.go Normal file
View File

@@ -0,0 +1,76 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// ConfigDirName 配置目录名称
ConfigDirName = "yoyo"
// ConfigFileName 配置文件名
ConfigFileName = "config.yaml"
// EnvFileName 环境变量文件名
EnvFileName = ".env"
)
// ResolveConfigPath 解析配置文件路径
// 优先级: 用户指定路径 > ~/.config/yoyo/config.yaml > ./configs/config.yaml
func ResolveConfigPath(userPath string) (string, error) {
// 1. 用户通过 --config 指定的路径
if userPath != "" {
return expandPath(userPath)
}
// 2. 标准用户配置目录 ~/.config/yoyo/config.yaml
userConfigPath := GetUserConfigPath()
if _, err := os.Stat(userConfigPath); err == nil {
return userConfigPath, nil
}
// 3. 项目本地配置 ./configs/config.yaml向后兼容
localConfigPath := "configs/config.yaml"
if _, err := os.Stat(localConfigPath); err == nil {
return localConfigPath, nil
}
// 4. 都不存在返回标准路径onboard 会创建)
return userConfigPath, nil
}
// GetUserConfigDir 获取用户配置目录路径
// 返回 ~/.config/yoyo
func GetUserConfigDir() string {
home, err := os.UserHomeDir()
if err != nil {
// 降级到当前目录
return ".config/" + ConfigDirName
}
return filepath.Join(home, ".config", ConfigDirName)
}
// GetUserConfigPath 获取用户配置文件路径
// 返回 ~/.config/yoyo/config.yaml
func GetUserConfigPath() string {
return filepath.Join(GetUserConfigDir(), ConfigFileName)
}
// GetUserEnvPath 获取用户环境变量文件路径
// 返回 ~/.config/yoyo/.env
func GetUserEnvPath() string {
return filepath.Join(GetUserConfigDir(), EnvFileName)
}
// expandPath 展开路径中的 ~ 符号
func expandPath(path string) (string, error) {
if strings.HasPrefix(path, "~") {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("无法获取用户主目录: %w", err)
}
path = filepath.Join(home, path[1:])
}
return path, nil
}

View File

@@ -1,30 +1,39 @@
package onboard
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"charm.land/huh/v2"
"github.com/titor/fanyi/internal/config"
"github.com/titor/fanyi/internal/lang"
)
// RunOnboard 启动配置向导
func RunOnboard(force bool) error {
fmt.Println("欢迎使用YOYO翻译工具配置向导!")
fmt.Println("这个向导将帮助您配置翻译工具。")
fmt.Println()
configPath := config.GetUserConfigPath()
// 检查配置文件是否存在
configPath := "configs/config.yaml"
if _, err := os.Stat(configPath); err == nil && !force {
overwrite := false
prompt := &survey.Confirm{
Message: "检测到配置文件已存在,是否要重新配置?",
Default: false,
var overwrite bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("检测到配置文件已存在,是否要重新配置?").
Affirmative("是").
Negative("否").
Value(&overwrite),
),
)
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
if err := survey.AskOne(prompt, &overwrite); err != nil {
return fmt.Errorf("用户输入错误: %w", err)
}
if !overwrite {
@@ -34,30 +43,75 @@ func RunOnboard(force bool) error {
}
// 步骤1: 选择主要厂商
fmt.Println("步骤1: 选择主要翻译服务提供商")
providerName, err := SelectProvider()
if err != nil {
var providerName string
providerForm := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("请选择要使用的翻译服务提供商").
Options(
huh.NewOption("硅基流动 (推荐,免费额度)", "siliconflow"),
huh.NewOption("火山引擎", "volcano"),
huh.NewOption("国家超算", "national"),
huh.NewOption("Qwen (通义千问)", "qwen"),
huh.NewOption("OpenAI兼容格式", "openai"),
).
Value(&providerName),
),
)
if err := providerForm.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("选择厂商失败: %w", err)
}
// 步骤2: 配置主要厂商
fmt.Println("\n步骤2: 配置主要厂商")
providerConfig, err := ConfigureProvider(providerName)
providerConfig, err := ConfigureProviderHuh(providerName)
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("配置厂商失败: %w", err)
}
// 步骤3: 全局设置
fmt.Println("\n步骤3: 全局设置")
globalConfig, err := GlobalSettings()
globalConfig, err := GlobalSettingsHuh()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("全局设置失败: %w", err)
}
// 步骤4: 确认并保存配置
fmt.Println("\n步骤4: 保存配置")
configData := BuildConfig(providerName, providerConfig, globalConfig)
var confirmSave bool
confirmForm := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("确认保存配置?").
Description(fmt.Sprintf("配置文件将保存到: %s", configPath)).
Affirmative("是,保存").
Negative("否,取消").
Value(&confirmSave),
),
)
if err := confirmForm.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n你已取消本次配置")
return nil
}
return fmt.Errorf("用户输入错误: %w", err)
}
if !confirmSave {
fmt.Println("配置已取消。")
return nil
}
if err := SaveConfig(configData, configPath); err != nil {
return fmt.Errorf("保存配置失败: %w", err)
}
@@ -71,54 +125,17 @@ func RunOnboard(force bool) error {
return nil
}
// SelectProvider 选择主要厂商
func SelectProvider() (string, error) {
providers := []string{
"siliconflow",
"volcano",
"national",
"qwen",
"openai",
}
providerNames := map[string]string{
"siliconflow": "硅基流动 (推荐,免费额度)",
"volcano": "火山引擎",
"national": "国家超算",
"qwen": "Qwen (通义千问)",
"openai": "OpenAI兼容格式",
}
var selected string
prompt := &survey.Select{
Message: "请选择要使用的翻译服务提供商:",
Options: func() []string {
var opts []string
for _, p := range providers {
opts = append(opts, providerNames[p])
}
return opts
}(),
Default: providerNames["siliconflow"],
}
if err := survey.AskOne(prompt, &selected); err != nil {
return "", err
}
// 返回对应的厂商名称
for name, displayName := range providerNames {
if displayName == selected {
return name, nil
}
}
return "siliconflow", nil
// GlobalConfig 全局设置配置
type GlobalConfig struct {
DefaultProvider string
DefaultModel string
Timeout int
DefaultSourceLang string
DefaultTargetLang string
}
// ConfigureProvider 配置厂商
func ConfigureProvider(providerName string) (config.ProviderConfig, error) {
// 厂商默认配置
// ConfigureProviderHuh 使用 huh 配置厂商
func ConfigureProviderHuh(providerName string) (config.ProviderConfig, error) {
defaults := map[string]config.ProviderConfig{
"siliconflow": {
APIHost: "https://api.siliconflow.cn/v1",
@@ -154,47 +171,43 @@ func ConfigureProvider(providerName string) (config.ProviderConfig, error) {
Enabled: defaultConfig.Enabled,
}
// 输入API密钥
apiKeyPrompt := &survey.Input{
Message: fmt.Sprintf("请输入 %s 的API密钥:", providerName),
Help: "API密钥用于身份验证将存储在配置文件中",
var apiKey string
apiKeyForm := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title(fmt.Sprintf("请输入 %s 的API密钥", providerName)).
Description("API密钥用于身份验证将存储在配置文件中").
Value(&apiKey).
Validate(func(str string) error {
if strings.TrimSpace(str) == "" {
return fmt.Errorf("API密钥不能为空")
}
if err := survey.AskOne(apiKeyPrompt, &cfg.APIKey, survey.WithValidator(survey.Required)); err != nil {
return config.ProviderConfig{}, err
}
// 确认API HOST
apiHostPrompt := &survey.Input{
Message: "API HOST (直接回车使用默认值):",
Default: cfg.APIHost,
}
if err := survey.AskOne(apiHostPrompt, &cfg.APIHost); err != nil {
return config.ProviderConfig{}, err
}
// 确认默认模型
modelPrompt := &survey.Input{
Message: "默认模型 (直接回车使用默认值):",
Default: cfg.Model,
}
if err := survey.AskOne(modelPrompt, &cfg.Model); err != nil {
return nil
}),
huh.NewInput().
Title("API HOST").
Description("直接回车使用默认值").
Value(&cfg.APIHost).
Placeholder(defaultConfig.APIHost),
huh.NewInput().
Title("默认模型").
Description("直接回车使用默认值").
Value(&cfg.Model).
Placeholder(defaultConfig.Model),
),
)
if err := apiKeyForm.Run(); err != nil {
return config.ProviderConfig{}, err
}
cfg.APIKey = apiKey
return cfg, nil
}
// GlobalSettings 全局设置
type GlobalConfig struct {
DefaultProvider string
DefaultModel string
Timeout int
DefaultSourceLang string
DefaultTargetLang string
}
// GlobalSettings 全局设置
func GlobalSettings() (*GlobalConfig, error) {
// GlobalSettingsHuh 使用 huh 进行全局设置
func GlobalSettingsHuh() (*GlobalConfig, error) {
cfg := &GlobalConfig{
DefaultProvider: "siliconflow",
DefaultModel: "siliconflow-base",
@@ -203,43 +216,33 @@ func GlobalSettings() (*GlobalConfig, error) {
DefaultTargetLang: "zh-CN",
}
// 选择默认语言
targetLangOptions := lang.GetCommonLanguages()
var targetLangDisplay []string
var options []huh.Option[string]
for _, code := range targetLangOptions {
targetLangDisplay = append(targetLangDisplay, fmt.Sprintf("%s (%s)", code, lang.GetLanguageName(code)))
options = append(options, huh.NewOption(
fmt.Sprintf("%s (%s)", code, lang.GetLanguageName(code)),
code,
))
}
targetLangPrompt := &survey.Select{
Message: "请选择默认目标语言:",
Options: targetLangDisplay,
Default: fmt.Sprintf("%s (%s)", "zh-CN", lang.GetLanguageName("zh-CN")),
}
var selectedTarget string
if err := survey.AskOne(targetLangPrompt, &selectedTarget); err != nil {
return nil, err
}
// 从选择中提取语言代码
for i, display := range targetLangDisplay {
if display == selectedTarget {
cfg.DefaultTargetLang = targetLangOptions[i]
break
}
}
// 设置超时时间
timeoutPrompt := &survey.Input{
Message: "API超时时间(秒):",
Default: fmt.Sprintf("%d", cfg.Timeout),
}
var timeoutStr string
if err := survey.AskOne(timeoutPrompt, &timeoutStr); err != nil {
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("请选择默认目标语言").
Options(options...).
Value(&cfg.DefaultTargetLang),
huh.NewInput().
Title("API超时时间(秒)").
Value(&timeoutStr).
Placeholder("30"),
),
)
if err := form.Run(); err != nil {
return nil, err
}
// 解析超时时间
if timeout := parseIntOrDefault(timeoutStr, 30); timeout > 0 {
cfg.Timeout = timeout
}
@@ -249,12 +252,10 @@ func GlobalSettings() (*GlobalConfig, error) {
// BuildConfig 构建配置对象
func BuildConfig(providerName string, providerConfig config.ProviderConfig, globalConfig *GlobalConfig) *config.Config {
// 创建厂商配置
providers := map[string]config.ProviderConfig{
providerName: providerConfig,
}
// 创建Prompt配置
prompts := map[string]string{
"technical": "你是一位专业的技术翻译,请准确翻译以下技术文档,保持专业术语的准确性。",
"creative": "你是一位富有创造力的翻译家,请用优美流畅的语言翻译以下内容。",
@@ -275,25 +276,24 @@ func BuildConfig(providerName string, providerConfig config.ProviderConfig, glob
// SaveConfig 保存配置文件
func SaveConfig(cfg *config.Config, path string) error {
// 确保目录存在
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建配置目录失败: %w", err)
}
// 使用config包的Save方法
loader := &config.YAMLConfigLoader{}
return loader.Save(cfg, path)
}
// parseIntOrDefault 解析整数,失败时返回默认值
func parseIntOrDefault(s string, defaultValue int) int {
s = strings.TrimSpace(s)
if s == "" {
return defaultValue
}
var result int
if _, err := fmt.Sscanf(s, "%d", &result); err != nil {
result, err := strconv.Atoi(s)
if err != nil {
return defaultValue
}

View File

@@ -795,3 +795,40 @@ ta.SetHeight(5) // 固定高度,不动态调整
- beta版使用 `-beta` 后缀
**关联版本**: [changelog.md#1.0.0-beta](changelog.md#100-beta-2026-04-07)
---
### [2026-04-07] 版本 1.1.0 - 配置路径修复和huh迁移
**原因**:
1. 管道模式下(如 `cd /docs && cat readme.md | yoyo`)找不到配置文件
2. onboard配置保存到错误的相对路径
3. 希望用 `charmbracelet/huh` 替代 `survey` 获得更好的UX
**分析**:
- 所有配置路径硬编码为 `configs/config.yaml`相对CWD
- 从不同目录运行程序时路径解析失败
- survey库API较老huh提供更现代的表单体验
**解决方案**:
1. 新增 `internal/config/path.go` 路径解析工具
2. 配置查找优先级:`--config` > `~/.config/yoyo/config.yaml` > `./configs/config.yaml`
3. onboard保存到 `~/.config/yoyo/config.yaml`
4. .env从 `~/.config/yoyo/.env` 加载
5. onboard使用huh重写Form+Group模式链式API泛型支持
**技术细节**:
```go
// 路径解析
config.ResolveConfigPath(userPath) // 智能查找配置
config.GetUserConfigPath() // ~/.config/yoyo/config.yaml
config.GetUserEnvPath() // ~/.config/yoyo/.env
```
**huh迁移要点**:
- `survey.Select` → `huh.NewSelect[string]().Options(huh.NewOption(...)...)`
- `survey.Input` → `huh.NewInput().Value(&var).Validate(fn)`
- `survey.Confirm` → `huh.NewConfirm().Affirmative("是").Negative("否")`
- 分步表单 → `huh.NewForm(huh.NewGroup(...), huh.NewGroup(...))`
**关联版本**: [changelog.md#1.1.0](changelog.md#110-2026-04-07)