From 5908332f6ae3e24e9a5ca1601ae5254732ca93ed Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:40:46 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 Cobra CLI 根命令,并新增 --workdir 启动参数。 将 Windows UTF-8 控制台初始化前置到 CLI 与 TUI 共用启动路径,保证中文路径与中文输出兼容。 会话存储目录切换为 ~/.neocode/projects//sessions,并保持 session.Workdir 仅表示运行目录。 补充工作区路径规范化、中文路径分桶、启动覆盖与相关回归测试,同时更新 README 与文档说明。 --- README.md | 14 ++++ cmd/neocode/main.go | 10 +-- docs/guides/configuration.md | 14 ++++ docs/session-persistence-design.md | 7 ++ go.mod | 21 +++-- go.sum | 74 +++++++++++++----- internal/app/bootstrap.go | 101 ++++++++++++++++++++---- internal/app/bootstrap_test.go | 46 ++++++++++- internal/cli/root.go | 58 ++++++++++++++ internal/cli/root_test.go | 64 +++++++++++++++ internal/runtime/runtime_test.go | 2 +- internal/session/store.go | 22 +++--- internal/session/store_test.go | 121 +++++++++++++++++++++++------ internal/session/workspace.go | 48 ++++++++++++ 14 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/session/workspace.go diff --git a/README.md b/README.md index 33fdaa73..95135439 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,17 @@ MIT ## Manual Compact NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。配置项见 `docs/guides/configuration.md`,流程和摘要约定见 `docs/context-compact.md`。 + +## CLI Workdir + +NeoCode 现在支持通过 CLI 启动参数覆盖本次运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +说明: + +- `--workdir` 只影响当前进程,不会写回 `config.yaml` +- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- session 历史现在按工作区隔离存储,不同工作区默认互不可见 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 68d741d3..1926ff8e 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,17 +5,11 @@ import ( "fmt" "os" - "neo-code/internal/app" + "neo-code/internal/cli" ) func main() { - program, err := app.NewProgram(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "neocode: %v\n", err) - os.Exit(1) - } - - if _, err := program.Run(); err != nil { + if err := cli.Execute(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) os.Exit(1) } diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 26a5800e..07d3e01b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -330,3 +330,17 @@ context: 新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 更多行为说明见 [context-compact.md](../context-compact.md)。 + +## CLI 工作区覆盖 + +NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +补充说明: + +- `--workdir` 只影响本次启动,不会持久化到 `config.yaml` +- 运行时工具根目录与 session 存储分桶都会使用该工作区 +- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index a3f40511..fb22d26d 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -35,3 +35,10 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 兼容性与演进说明 - 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` - 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 + +## 工作区隔离 + +- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` +- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` +- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/go.mod b/go.mod index 671203ae..7d1726d8 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,37 @@ module neo-code go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -39,12 +43,19 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/sys v0.42.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 2c5bd376..56abd244 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= @@ -14,16 +18,12 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f 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/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -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/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -36,26 +36,37 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Y github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 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/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -65,8 +76,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -79,37 +88,60 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ef3a7c9d..29fa456a 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -2,6 +2,10 @@ package app import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -27,46 +31,62 @@ var ( setConsoleInputCodePage = platformSetConsoleInputCodePage ) -// ensureConsoleUTF8 is best-effort and should never block app startup. -func ensureConsoleUTF8() { +// BootstrapOptions 描述应用启动时可注入的运行时选项。 +type BootstrapOptions struct { + Workdir string +} + +// RuntimeBundle 聚合 CLI 与 TUI 共享的运行时依赖。 +type RuntimeBundle struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderSelection *config.SelectionService +} + +// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 +func EnsureConsoleUTF8() { if err := setConsoleOutputCodePage(utf8CodePage); err != nil { return } _ = setConsoleInputCodePage(utf8CodePage) } -func NewProgram(ctx context.Context) (*tea.Program, error) { - - ensureConsoleUTF8() +// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。 +func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) { + defaultCfg, err := bootstrapDefaultConfig(opts) + if err != nil { + return RuntimeBundle{}, err + } - loader := config.NewLoader("", config.DefaultConfig()) + loader := config.NewLoader("", defaultCfg) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } providerRegistry, err := builtin.NewRegistry() if err != nil { - return nil, err + return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) providerSelection := config.NewSelectionService(manager, providerRegistry, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } cfg := manager.Get() toolRegistry, err := buildToolRegistry(cfg) if err != nil { - return nil, err + return RuntimeBundle{}, err } toolManager, err := buildToolManager(toolRegistry) if err != nil { - return nil, err + return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, @@ -75,7 +95,22 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { agentcontext.NewBuilderWithToolPolicies(toolRegistry), ) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) + return RuntimeBundle{ + Config: cfg, + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderSelection: providerSelection, + }, nil +} + +// NewProgram 基于共享运行时依赖构建并返回 TUI 程序。 +func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error) { + bundle, err := BuildRuntime(ctx, opts) + if err != nil { + return nil, err + } + + tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) if err != nil { return nil, err } @@ -86,6 +121,46 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { ), nil } +// bootstrapDefaultConfig 负责计算本次启动应使用的默认配置快照。 +func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { + defaultCfg := config.DefaultConfig() + workdir := strings.TrimSpace(opts.Workdir) + if workdir == "" { + return defaultCfg, nil + } + + resolved, err := resolveBootstrapWorkdir(workdir) + if err != nil { + return nil, err + } + defaultCfg.Workdir = resolved + return defaultCfg, nil +} + +// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 +func resolveBootstrapWorkdir(workdir string) (string, error) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return "", fmt.Errorf("app: workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + absolute = filepath.Clean(absolute) + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("app: workdir %q is not a directory", absolute) + } + + return absolute, nil +} + func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { toolRegistry := tools.NewRegistry() toolRegistry.Register(filesystem.New(cfg.Workdir)) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a81903c0..f0a598ec 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -29,7 +29,7 @@ func TestNewProgram(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -61,7 +61,7 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { t.Fatalf("write config: %v", err) } - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -515,6 +515,44 @@ func TestBuildToolManagerAllowsWebfetchWhitelist(t *testing.T) { } } +func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + override := filepath.Join(t.TempDir(), "中文工作区") + if err := os.MkdirAll(override, 0o755); err != nil { + t.Fatalf("mkdir override workdir: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: override}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Config.Workdir != filepath.Clean(override) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(override), bundle.Config.Workdir) + } + if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil { + t.Fatalf("expected runtime bundle dependencies, got %+v", bundle) + } +} + +func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + invalid := filepath.Join(t.TempDir(), "missing", "中文") + _, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: invalid}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage @@ -539,7 +577,7 @@ func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { t.Fatalf("expected output->input order, got %+v", calls) @@ -564,7 +602,7 @@ func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if inputCalled { t.Fatalf("expected input code page setup to be skipped when output setup fails") diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 00000000..094135c5 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "neo-code/internal/app" +) + +var launchRootProgram = defaultRootProgramLauncher + +// GlobalFlags 描述 CLI 根命令当前支持的全局参数。 +type GlobalFlags struct { + Workdir string +} + +// Execute 负责执行 NeoCode 的 CLI 根命令。 +func Execute(ctx context.Context) error { + app.EnsureConsoleUTF8() + return NewRootCommand().ExecuteContext(ctx) +} + +// NewRootCommand 创建 NeoCode 的 CLI 根命令。 +func NewRootCommand() *cobra.Command { + settings := viper.New() + flags := &GlobalFlags{} + + cmd := &cobra.Command{ + Use: "neocode", + Short: "NeoCode coding agent", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) + return launchRootProgram(cmd.Context(), app.BootstrapOptions{ + Workdir: flags.Workdir, + }) + }, + } + + cmd.PersistentFlags().String("workdir", "", "工作目录(覆盖本次运行工作区)") + _ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir")) + + return cmd +} + +// defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 +func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { + program, err := app.NewProgram(ctx, opts) + if err != nil { + return err + } + _, err = program.Run() + return err +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..5a63c566 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/app" +) + +func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"--workdir", `D:\项目\中文目录`}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected workdir to be forwarded, got %q", captured.Workdir) + } +} + +func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != "" { + t.Fatalf("expected empty workdir override, got %q", captured.Workdir) + } +} + +func TestNewRootCommandReturnsLauncherError(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + expected := errors.New("launch failed") + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + return expected + } + + cmd := NewRootCommand() + err := cmd.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected launcher error %v, got %v", expected, err) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 97be69ed..fbcc27f0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2311,7 +2311,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := agentsession.NewStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir(), t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } diff --git a/internal/session/store.go b/internal/session/store.go index fb660dd2..190618ff 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -25,13 +25,13 @@ type Session struct { // Provider 记录最近一次成功运行会话时使用的 provider,用于 compact 优先复用历史配置。 Provider string `json:"provider,omitempty"` // Model 记录最近一次成功运行会话时使用的 model,用于 compact 优先复用历史配置。 - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` - TokenInputTotal int `json:"token_input_total,omitempty"` - TokenOutputTotal int `json:"token_output_total,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 @@ -56,15 +56,15 @@ type JSONStore struct { } // NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 -func NewJSONStore(baseDir string) *JSONStore { +func NewJSONStore(baseDir string, workspaceRoot string) *JSONStore { return &JSONStore{ - baseDir: filepath.Join(baseDir, sessionsDirName), + baseDir: sessionDirectory(baseDir, workspaceRoot), } } // NewStore 返回默认会话存储实现(当前为 JSONStore)。 -func NewStore(baseDir string) *JSONStore { - return NewJSONStore(baseDir) +func NewStore(baseDir string, workspaceRoot string) *JSONStore { + return NewJSONStore(baseDir, workspaceRoot) } // Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 6b412322..aab03df2 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -17,7 +17,11 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspaceRoot, 0o755); err != nil { + t.Fatalf("mkdir workspace root: %v", err) + } + store := NewJSONStore(baseDir, workspaceRoot) older := &Session{ ID: "session-old", @@ -61,7 +65,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } - rawPath := filepath.Join(baseDir, sessionsDirName, newer.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), newer.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read saved session: %v", err) @@ -70,8 +74,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to include workdir, got:\n%s", string(raw)) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") - if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid.json"), "{invalid") + if err := os.MkdirAll(filepath.Join(sessionDirectory(baseDir, workspaceRoot), "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -87,11 +91,78 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { } } +func TestJSONStoreScopesSessionsByWorkspaceRoot(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceA := filepath.Join(t.TempDir(), "中文项目A") + workspaceB := filepath.Join(t.TempDir(), "中文项目B") + if err := os.MkdirAll(workspaceA, 0o755); err != nil { + t.Fatalf("mkdir workspaceA: %v", err) + } + if err := os.MkdirAll(workspaceB, 0o755); err != nil { + t.Fatalf("mkdir workspaceB: %v", err) + } + + storeA := NewJSONStore(baseDir, workspaceA) + storeB := NewJSONStore(baseDir, workspaceB) + + sessionA := &Session{ID: "session-a", Title: "A", CreatedAt: time.Now(), UpdatedAt: time.Now()} + sessionB := &Session{ID: "session-b", Title: "B", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := storeA.Save(context.Background(), sessionA); err != nil { + t.Fatalf("save sessionA: %v", err) + } + if err := storeB.Save(context.Background(), sessionB); err != nil { + t.Fatalf("save sessionB: %v", err) + } + + summariesA, err := storeA.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeA error: %v", err) + } + if len(summariesA) != 1 || summariesA[0].ID != sessionA.ID { + t.Fatalf("expected storeA to only list sessionA, got %+v", summariesA) + } + + summariesB, err := storeB.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeB error: %v", err) + } + if len(summariesB) != 1 || summariesB[0].ID != sessionB.ID { + t.Fatalf("expected storeB to only list sessionB, got %+v", summariesB) + } + + if _, err := storeA.Load(context.Background(), sessionB.ID); err == nil { + t.Fatalf("expected storeA to fail loading session from another workspace bucket") + } +} + +func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { + t.Parallel() + + base := filepath.Join(t.TempDir(), "中文项目") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + + normalized := normalizeWorkspaceRoot(base) + slashVariant := strings.ReplaceAll(normalized, `\`, `/`) + if got, want := hashWorkspaceRoot(normalized), hashWorkspaceRoot(slashVariant); got != want { + t.Fatalf("expected slash variants to hash equally, got %q and %q", got, want) + } + + upperVariant := strings.ToUpper(normalized) + lowerVariant := strings.ToLower(normalized) + if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { + t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -114,7 +185,8 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-session", @@ -127,7 +199,7 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -152,7 +224,7 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONStore(baseFile) + store := NewJSONStore(baseFile, t.TempDir()) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -195,7 +267,7 @@ func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { t.Parallel() - tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + tooLong := strings.Repeat("测", 45) workdir := " /tmp/workdir " session := NewWithWorkdir(tooLong, workdir) @@ -221,7 +293,7 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { func TestNewStoreReturnsJSONStore(t *testing.T) { t.Parallel() - store := NewStore(t.TempDir()) + store := NewStore(t.TempDir(), t.TempDir()) if store == nil { t.Fatalf("expected non-nil store") } @@ -231,13 +303,11 @@ func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 - sessionsPath := filepath.Join(baseDir, sessionsDirName) - if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatalf("write %s: %v", sessionsPath, err) - } + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + mustWriteSessionFile(t, sessionsPath, "not-a-dir") _, err := store.ListSummaries(context.Background()) if err == nil || !strings.Contains(err.Error(), "create sessions dir") { @@ -249,7 +319,7 @@ func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) for i := 0; i < 10; i++ { s := &Session{ @@ -276,9 +346,10 @@ func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "decode-bad.json"), "{not-json") _, err := store.Load(context.Background(), "decode-bad") if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { @@ -290,7 +361,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-summary", @@ -302,8 +374,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Fatalf("save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "empty-id.json"), `{"id":" ","title":"x"}`) summaries, err := store.ListSummaries(context.Background()) if err != nil { @@ -318,7 +390,8 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) session := &Session{ ID: "persist-full-fields", @@ -345,7 +418,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Fatalf("save session: %v", err) } - rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), session.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read raw file: %v", err) diff --git a/internal/session/workspace.go b/internal/session/workspace.go new file mode 100644 index 00000000..686883e7 --- /dev/null +++ b/internal/session/workspace.go @@ -0,0 +1,48 @@ +package session + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strings" +) + +const projectsDirName = "projects" + +// sessionDirectory 负责根据工作区根目录计算会话分桶目录。 +func sessionDirectory(baseDir string, workspaceRoot string) string { + return filepath.Join(baseDir, projectsDirName, hashWorkspaceRoot(workspaceRoot), sessionsDirName) +} + +// hashWorkspaceRoot 负责为规范化后的工作区根目录生成稳定哈希。 +func hashWorkspaceRoot(workspaceRoot string) string { + key := workspacePathKey(workspaceRoot) + if key == "" { + key = "unknown" + } + sum := sha1.Sum([]byte(key)) + return hex.EncodeToString(sum[:8]) +} + +// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +func workspacePathKey(workspaceRoot string) string { + normalized := normalizeWorkspaceRoot(workspaceRoot) + if normalized == "" { + return "" + } + return strings.ToLower(normalized) +} + +// normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。 +func normalizeWorkspaceRoot(workspaceRoot string) string { + trimmed := strings.TrimSpace(workspaceRoot) + if trimmed == "" { + return "" + } + + absolute, err := filepath.Abs(trimmed) + if err == nil { + trimmed = absolute + } + return filepath.Clean(trimmed) +} From 31c0473224ea89a4a8a7d44e63681fa341aa6996 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:40:46 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=AD=98=E5=82=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入 Cobra CLI 根命令,并新增 --workdir 启动参数。 将 Windows UTF-8 控制台初始化前置到 CLI 与 TUI 共用启动路径,保证中文路径与中文输出兼容。 会话存储目录切换为 ~/.neocode/projects//sessions,并保持 session.Workdir 仅表示运行目录。 补充工作区路径规范化、中文路径分桶、启动覆盖与相关回归测试,同时更新 README 与文档说明。 --- README.md | 14 ++++ cmd/neocode/main.go | 10 +-- docs/guides/configuration.md | 14 ++++ docs/session-persistence-design.md | 7 ++ go.mod | 21 +++-- go.sum | 74 +++++++++++++----- internal/app/bootstrap.go | 101 ++++++++++++++++++++---- internal/app/bootstrap_test.go | 46 ++++++++++- internal/cli/root.go | 58 ++++++++++++++ internal/cli/root_test.go | 64 +++++++++++++++ internal/runtime/runtime_test.go | 2 +- internal/session/store.go | 22 +++--- internal/session/store_test.go | 121 +++++++++++++++++++++++------ internal/session/workspace.go | 48 ++++++++++++ 14 files changed, 515 insertions(+), 87 deletions(-) create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/session/workspace.go diff --git a/README.md b/README.md index 33fdaa73..95135439 100644 --- a/README.md +++ b/README.md @@ -192,3 +192,17 @@ MIT ## Manual Compact NeoCode 支持通过 `/compact` 手动压缩当前会话上下文。配置项见 `docs/guides/configuration.md`,流程和摘要约定见 `docs/context-compact.md`。 + +## CLI Workdir + +NeoCode 现在支持通过 CLI 启动参数覆盖本次运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +说明: + +- `--workdir` 只影响当前进程,不会写回 `config.yaml` +- 当前工作区会同时用于工具执行根目录与 session 存储分桶 +- session 历史现在按工作区隔离存储,不同工作区默认互不可见 diff --git a/cmd/neocode/main.go b/cmd/neocode/main.go index 68d741d3..1926ff8e 100644 --- a/cmd/neocode/main.go +++ b/cmd/neocode/main.go @@ -5,17 +5,11 @@ import ( "fmt" "os" - "neo-code/internal/app" + "neo-code/internal/cli" ) func main() { - program, err := app.NewProgram(context.Background()) - if err != nil { - fmt.Fprintf(os.Stderr, "neocode: %v\n", err) - os.Exit(1) - } - - if _, err := program.Run(); err != nil { + if err := cli.Execute(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "neocode: %v\n", err) os.Exit(1) } diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 26a5800e..07d3e01b 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -330,3 +330,17 @@ context: 新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。 更多行为说明见 [context-compact.md](../context-compact.md)。 + +## CLI 工作区覆盖 + +NeoCode 支持在启动时通过 CLI 参数覆盖当前运行工作区: + +```bash +go run ./cmd/neocode --workdir /path/to/workspace +``` + +补充说明: + +- `--workdir` 只影响本次启动,不会持久化到 `config.yaml` +- 运行时工具根目录与 session 存储分桶都会使用该工作区 +- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见 diff --git a/docs/session-persistence-design.md b/docs/session-persistence-design.md index a3f40511..fb22d26d 100644 --- a/docs/session-persistence-design.md +++ b/docs/session-persistence-design.md @@ -35,3 +35,10 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优 ## 兼容性与演进说明 - 会话持久化能力已从 runtime 侧实现中彻底收口到 `internal/session` - 新增会话存储实现时,应优先在 `internal/session` 内扩展并通过接口注入 runtime,避免跨层实现 + +## 工作区隔离 + +- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects//sessions/` +- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir` +- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改 +- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读 diff --git a/go.mod b/go.mod index 671203ae..7d1726d8 100644 --- a/go.mod +++ b/go.mod @@ -3,33 +3,37 @@ module neo-code go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/glamour v1.0.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 - github.com/joho/godotenv v1.5.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/viper v1.21.0 golang.org/x/net v0.52.0 + golang.org/x/sys v0.42.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/alecthomas/chroma/v2 v2.20.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/glamour v1.0.0 // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -39,12 +43,19 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yuin/goldmark v1.7.13 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect - golang.org/x/sys v0.42.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 2c5bd376..56abd244 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw= github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= +github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= +github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 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= @@ -14,16 +18,12 @@ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5f 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/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= -github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08= github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo= github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= -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/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= @@ -36,26 +36,37 @@ github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Y github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= 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/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= -github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= 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/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= -github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -65,8 +76,6 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= @@ -79,37 +88,60 @@ github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 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/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 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.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index ef3a7c9d..29fa456a 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -2,6 +2,10 @@ package app import ( "context" + "fmt" + "os" + "path/filepath" + "strings" "time" tea "github.com/charmbracelet/bubbletea" @@ -27,46 +31,62 @@ var ( setConsoleInputCodePage = platformSetConsoleInputCodePage ) -// ensureConsoleUTF8 is best-effort and should never block app startup. -func ensureConsoleUTF8() { +// BootstrapOptions 描述应用启动时可注入的运行时选项。 +type BootstrapOptions struct { + Workdir string +} + +// RuntimeBundle 聚合 CLI 与 TUI 共享的运行时依赖。 +type RuntimeBundle struct { + Config config.Config + ConfigManager *config.Manager + Runtime agentruntime.Runtime + ProviderSelection *config.SelectionService +} + +// EnsureConsoleUTF8 负责在 Windows 控制台中尽量启用 UTF-8 编码。 +func EnsureConsoleUTF8() { if err := setConsoleOutputCodePage(utf8CodePage); err != nil { return } _ = setConsoleInputCodePage(utf8CodePage) } -func NewProgram(ctx context.Context) (*tea.Program, error) { - - ensureConsoleUTF8() +// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。 +func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) { + defaultCfg, err := bootstrapDefaultConfig(opts) + if err != nil { + return RuntimeBundle{}, err + } - loader := config.NewLoader("", config.DefaultConfig()) + loader := config.NewLoader("", defaultCfg) manager := config.NewManager(loader) if _, err := manager.Load(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } providerRegistry, err := builtin.NewRegistry() if err != nil { - return nil, err + return RuntimeBundle{}, err } modelCatalogs := providercatalog.NewService(manager.BaseDir(), providerRegistry, nil) providerSelection := config.NewSelectionService(manager, providerRegistry, providerRegistry, modelCatalogs) if _, err := providerSelection.EnsureSelection(ctx); err != nil { - return nil, err + return RuntimeBundle{}, err } cfg := manager.Get() toolRegistry, err := buildToolRegistry(cfg) if err != nil { - return nil, err + return RuntimeBundle{}, err } toolManager, err := buildToolManager(toolRegistry) if err != nil { - return nil, err + return RuntimeBundle{}, err } - sessionStore := agentsession.NewStore(loader.BaseDir()) + sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir) runtimeSvc := agentruntime.NewWithFactory( manager, toolManager, @@ -75,7 +95,22 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { agentcontext.NewBuilderWithToolPolicies(toolRegistry), ) - tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection) + return RuntimeBundle{ + Config: cfg, + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderSelection: providerSelection, + }, nil +} + +// NewProgram 基于共享运行时依赖构建并返回 TUI 程序。 +func NewProgram(ctx context.Context, opts BootstrapOptions) (*tea.Program, error) { + bundle, err := BuildRuntime(ctx, opts) + if err != nil { + return nil, err + } + + tuiApp, err := tui.New(&bundle.Config, bundle.ConfigManager, bundle.Runtime, bundle.ProviderSelection) if err != nil { return nil, err } @@ -86,6 +121,46 @@ func NewProgram(ctx context.Context) (*tea.Program, error) { ), nil } +// bootstrapDefaultConfig 负责计算本次启动应使用的默认配置快照。 +func bootstrapDefaultConfig(opts BootstrapOptions) (*config.Config, error) { + defaultCfg := config.DefaultConfig() + workdir := strings.TrimSpace(opts.Workdir) + if workdir == "" { + return defaultCfg, nil + } + + resolved, err := resolveBootstrapWorkdir(workdir) + if err != nil { + return nil, err + } + defaultCfg.Workdir = resolved + return defaultCfg, nil +} + +// resolveBootstrapWorkdir 将 CLI 传入的工作区解析为存在的绝对目录。 +func resolveBootstrapWorkdir(workdir string) (string, error) { + trimmed := strings.TrimSpace(workdir) + if trimmed == "" { + return "", fmt.Errorf("app: workdir is empty") + } + + absolute, err := filepath.Abs(trimmed) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + absolute = filepath.Clean(absolute) + + info, err := os.Stat(absolute) + if err != nil { + return "", fmt.Errorf("app: resolve workdir %q: %w", workdir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("app: workdir %q is not a directory", absolute) + } + + return absolute, nil +} + func buildToolRegistry(cfg config.Config) (*tools.Registry, error) { toolRegistry := tools.NewRegistry() toolRegistry.Register(filesystem.New(cfg.Workdir)) diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index a81903c0..f0a598ec 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -29,7 +29,7 @@ func TestNewProgram(t *testing.T) { t.Setenv("HOME", home) t.Setenv("USERPROFILE", home) - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -61,7 +61,7 @@ func TestNewProgramNormalizesInvalidCurrentModelOnStartup(t *testing.T) { t.Fatalf("write config: %v", err) } - program, err := NewProgram(context.Background()) + program, err := NewProgram(context.Background(), BootstrapOptions{}) if err != nil { t.Fatalf("NewProgram() error = %v", err) } @@ -515,6 +515,44 @@ func TestBuildToolManagerAllowsWebfetchWhitelist(t *testing.T) { } } +func TestBuildRuntimeUsesWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + override := filepath.Join(t.TempDir(), "中文工作区") + if err := os.MkdirAll(override, 0o755); err != nil { + t.Fatalf("mkdir override workdir: %v", err) + } + + bundle, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: override}) + if err != nil { + t.Fatalf("BuildRuntime() error = %v", err) + } + if bundle.Config.Workdir != filepath.Clean(override) { + t.Fatalf("expected workdir %q, got %q", filepath.Clean(override), bundle.Config.Workdir) + } + if bundle.ConfigManager == nil || bundle.Runtime == nil || bundle.ProviderSelection == nil { + t.Fatalf("expected runtime bundle dependencies, got %+v", bundle) + } +} + +func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + invalid := filepath.Join(t.TempDir(), "missing", "中文") + _, err := BuildRuntime(context.Background(), BootstrapOptions{Workdir: invalid}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected resolve workdir error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage @@ -539,7 +577,7 @@ func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if len(calls) != 2 || calls[0] != "output" || calls[1] != "input" { t.Fatalf("expected output->input order, got %+v", calls) @@ -564,7 +602,7 @@ func TestEnsureConsoleUTF8SkipsInputWhenOutputFails(t *testing.T) { return nil } - ensureConsoleUTF8() + EnsureConsoleUTF8() if inputCalled { t.Fatalf("expected input code page setup to be skipped when output setup fails") diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 00000000..094135c5 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,58 @@ +package cli + +import ( + "context" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "neo-code/internal/app" +) + +var launchRootProgram = defaultRootProgramLauncher + +// GlobalFlags 描述 CLI 根命令当前支持的全局参数。 +type GlobalFlags struct { + Workdir string +} + +// Execute 负责执行 NeoCode 的 CLI 根命令。 +func Execute(ctx context.Context) error { + app.EnsureConsoleUTF8() + return NewRootCommand().ExecuteContext(ctx) +} + +// NewRootCommand 创建 NeoCode 的 CLI 根命令。 +func NewRootCommand() *cobra.Command { + settings := viper.New() + flags := &GlobalFlags{} + + cmd := &cobra.Command{ + Use: "neocode", + Short: "NeoCode coding agent", + SilenceUsage: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + flags.Workdir = strings.TrimSpace(settings.GetString("workdir")) + return launchRootProgram(cmd.Context(), app.BootstrapOptions{ + Workdir: flags.Workdir, + }) + }, + } + + cmd.PersistentFlags().String("workdir", "", "工作目录(覆盖本次运行工作区)") + _ = settings.BindPFlag("workdir", cmd.PersistentFlags().Lookup("workdir")) + + return cmd +} + +// defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 +func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { + program, err := app.NewProgram(ctx, opts) + if err != nil { + return err + } + _, err = program.Run() + return err +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 00000000..5a63c566 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,64 @@ +package cli + +import ( + "context" + "errors" + "testing" + + "neo-code/internal/app" +) + +func TestNewRootCommandPassesWorkdirFlagToLauncher(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + cmd.SetArgs([]string{"--workdir", `D:\项目\中文目录`}) + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected workdir to be forwarded, got %q", captured.Workdir) + } +} + +func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + + cmd := NewRootCommand() + if err := cmd.ExecuteContext(context.Background()); err != nil { + t.Fatalf("ExecuteContext() error = %v", err) + } + if captured.Workdir != "" { + t.Fatalf("expected empty workdir override, got %q", captured.Workdir) + } +} + +func TestNewRootCommandReturnsLauncherError(t *testing.T) { + originalLauncher := launchRootProgram + t.Cleanup(func() { launchRootProgram = originalLauncher }) + + expected := errors.New("launch failed") + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + return expected + } + + cmd := NewRootCommand() + err := cmd.ExecuteContext(context.Background()) + if !errors.Is(err, expected) { + t.Fatalf("expected launcher error %v, got %v", expected, err) + } +} diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 97be69ed..fbcc27f0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -2311,7 +2311,7 @@ func TestServiceConstructorsAndDelegates(t *testing.T) { t.Fatalf("expected loaded session %q, got %q", session.ID, loaded.ID) } - sessionStore := agentsession.NewStore(t.TempDir()) + sessionStore := agentsession.NewStore(t.TempDir(), t.TempDir()) if sessionStore == nil { t.Fatalf("expected JSON session store") } diff --git a/internal/session/store.go b/internal/session/store.go index fb660dd2..190618ff 100644 --- a/internal/session/store.go +++ b/internal/session/store.go @@ -25,13 +25,13 @@ type Session struct { // Provider 记录最近一次成功运行会话时使用的 provider,用于 compact 优先复用历史配置。 Provider string `json:"provider,omitempty"` // Model 记录最近一次成功运行会话时使用的 model,用于 compact 优先复用历史配置。 - Model string `json:"model,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Workdir string `json:"workdir,omitempty"` - Messages []providertypes.Message `json:"messages"` - TokenInputTotal int `json:"token_input_total,omitempty"` - TokenOutputTotal int `json:"token_output_total,omitempty"` + Model string `json:"model,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Workdir string `json:"workdir,omitempty"` + Messages []providertypes.Message `json:"messages"` + TokenInputTotal int `json:"token_input_total,omitempty"` + TokenOutputTotal int `json:"token_output_total,omitempty"` } // Summary 表示会话列表视图所需的轻量摘要信息。 @@ -56,15 +56,15 @@ type JSONStore struct { } // NewJSONStore 创建 JSONStore,实际会话目录为 {baseDir}/sessions。 -func NewJSONStore(baseDir string) *JSONStore { +func NewJSONStore(baseDir string, workspaceRoot string) *JSONStore { return &JSONStore{ - baseDir: filepath.Join(baseDir, sessionsDirName), + baseDir: sessionDirectory(baseDir, workspaceRoot), } } // NewStore 返回默认会话存储实现(当前为 JSONStore)。 -func NewStore(baseDir string) *JSONStore { - return NewJSONStore(baseDir) +func NewStore(baseDir string, workspaceRoot string) *JSONStore { + return NewJSONStore(baseDir, workspaceRoot) } // Save 持久化会话到 JSON 文件,采用临时文件 + 原子替换策略。 diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 6b412322..aab03df2 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -17,7 +17,11 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := filepath.Join(t.TempDir(), "workspace") + if err := os.MkdirAll(workspaceRoot, 0o755); err != nil { + t.Fatalf("mkdir workspace root: %v", err) + } + store := NewJSONStore(baseDir, workspaceRoot) older := &Session{ ID: "session-old", @@ -61,7 +65,7 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("unexpected loaded messages: %+v", loaded.Messages) } - rawPath := filepath.Join(baseDir, sessionsDirName, newer.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), newer.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read saved session: %v", err) @@ -70,8 +74,8 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { t.Fatalf("expected persisted session file to include workdir, got:\n%s", string(raw)) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "invalid.json"), "{invalid") - if err := os.MkdirAll(filepath.Join(baseDir, sessionsDirName, "directory"), 0o755); err != nil { + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "invalid.json"), "{invalid") + if err := os.MkdirAll(filepath.Join(sessionDirectory(baseDir, workspaceRoot), "directory"), 0o755); err != nil { t.Fatalf("mkdir stray directory: %v", err) } @@ -87,11 +91,78 @@ func TestJSONStoreSaveLoadAndListSummaries(t *testing.T) { } } +func TestJSONStoreScopesSessionsByWorkspaceRoot(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceA := filepath.Join(t.TempDir(), "中文项目A") + workspaceB := filepath.Join(t.TempDir(), "中文项目B") + if err := os.MkdirAll(workspaceA, 0o755); err != nil { + t.Fatalf("mkdir workspaceA: %v", err) + } + if err := os.MkdirAll(workspaceB, 0o755); err != nil { + t.Fatalf("mkdir workspaceB: %v", err) + } + + storeA := NewJSONStore(baseDir, workspaceA) + storeB := NewJSONStore(baseDir, workspaceB) + + sessionA := &Session{ID: "session-a", Title: "A", CreatedAt: time.Now(), UpdatedAt: time.Now()} + sessionB := &Session{ID: "session-b", Title: "B", CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := storeA.Save(context.Background(), sessionA); err != nil { + t.Fatalf("save sessionA: %v", err) + } + if err := storeB.Save(context.Background(), sessionB); err != nil { + t.Fatalf("save sessionB: %v", err) + } + + summariesA, err := storeA.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeA error: %v", err) + } + if len(summariesA) != 1 || summariesA[0].ID != sessionA.ID { + t.Fatalf("expected storeA to only list sessionA, got %+v", summariesA) + } + + summariesB, err := storeB.ListSummaries(context.Background()) + if err != nil { + t.Fatalf("ListSummaries() for storeB error: %v", err) + } + if len(summariesB) != 1 || summariesB[0].ID != sessionB.ID { + t.Fatalf("expected storeB to only list sessionB, got %+v", summariesB) + } + + if _, err := storeA.Load(context.Background(), sessionB.ID); err == nil { + t.Fatalf("expected storeA to fail loading session from another workspace bucket") + } +} + +func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { + t.Parallel() + + base := filepath.Join(t.TempDir(), "中文项目") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatalf("mkdir base: %v", err) + } + + normalized := normalizeWorkspaceRoot(base) + slashVariant := strings.ReplaceAll(normalized, `\`, `/`) + if got, want := hashWorkspaceRoot(normalized), hashWorkspaceRoot(slashVariant); got != want { + t.Fatalf("expected slash variants to hash equally, got %q and %q", got, want) + } + + upperVariant := strings.ToUpper(normalized) + lowerVariant := strings.ToLower(normalized) + if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { + t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) cancelledCtx, cancel := context.WithCancel(context.Background()) cancel() @@ -114,7 +185,8 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-session", @@ -127,7 +199,7 @@ func TestJSONStoreCorruptedSessionBehaviors(t *testing.T) { t.Fatalf("Save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "broken.json"), "{broken") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "broken.json"), "{broken") _, err := store.Load(context.Background(), "broken") if err == nil || !strings.Contains(err.Error(), "decode session broken") { @@ -152,7 +224,7 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { t.Fatalf("write base file: %v", err) } - store := NewJSONStore(baseFile) + store := NewJSONStore(baseFile, t.TempDir()) err := store.Save(context.Background(), &Session{ ID: "session-x", Title: "Broken Save", @@ -195,7 +267,7 @@ func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { func TestNewWithWorkdirTrimAndTitleSanitize(t *testing.T) { t.Parallel() - tooLong := strings.Repeat("中", 45) // rune 长度 > 40 + tooLong := strings.Repeat("测", 45) workdir := " /tmp/workdir " session := NewWithWorkdir(tooLong, workdir) @@ -221,7 +293,7 @@ func TestNewWithWorkdirFallsBackDefaultTitle(t *testing.T) { func TestNewStoreReturnsJSONStore(t *testing.T) { t.Parallel() - store := NewStore(t.TempDir()) + store := NewStore(t.TempDir(), t.TempDir()) if store == nil { t.Fatalf("expected non-nil store") } @@ -231,13 +303,11 @@ func TestJSONStoreListSummariesReadDirFailure(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - // 把 sessions 目录位置占成普通文件,触发 ReadDir 失败路径。 - sessionsPath := filepath.Join(baseDir, sessionsDirName) - if err := os.WriteFile(sessionsPath, []byte("not-a-dir"), 0o644); err != nil { - t.Fatalf("write %s: %v", sessionsPath, err) - } + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + mustWriteSessionFile(t, sessionsPath, "not-a-dir") _, err := store.ListSummaries(context.Background()) if err == nil || !strings.Contains(err.Error(), "create sessions dir") { @@ -249,7 +319,7 @@ func TestJSONStoreListSummariesContextCanceledDuringIteration(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + store := NewJSONStore(baseDir, t.TempDir()) for i := 0; i < 10; i++ { s := &Session{ @@ -276,9 +346,10 @@ func TestJSONStoreLoadDecodeErrorWithNonJSONPayload(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "decode-bad.json"), "{not-json") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "decode-bad.json"), "{not-json") _, err := store.Load(context.Background(), "decode-bad") if err == nil || !strings.Contains(err.Error(), "decode session decode-bad") { @@ -290,7 +361,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) valid := &Session{ ID: "valid-summary", @@ -302,8 +374,8 @@ func TestJSONStoreListSummariesSkipsUnreadableAndMalformedEntries(t *testing.T) t.Fatalf("save valid session: %v", err) } - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "malformed.json"), "{malformed") - mustWriteSessionFile(t, filepath.Join(baseDir, sessionsDirName, "empty-id.json"), `{"id":" ","title":"x"}`) + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "malformed.json"), "{malformed") + mustWriteSessionFile(t, filepath.Join(sessionDirectory(baseDir, workspaceRoot), "empty-id.json"), `{"id":" ","title":"x"}`) summaries, err := store.ListSummaries(context.Background()) if err != nil { @@ -318,7 +390,8 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Parallel() baseDir := t.TempDir() - store := NewJSONStore(baseDir) + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) session := &Session{ ID: "persist-full-fields", @@ -345,7 +418,7 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { t.Fatalf("save session: %v", err) } - rawPath := filepath.Join(baseDir, sessionsDirName, session.ID+".json") + rawPath := filepath.Join(sessionDirectory(baseDir, workspaceRoot), session.ID+".json") raw, err := os.ReadFile(rawPath) if err != nil { t.Fatalf("read raw file: %v", err) diff --git a/internal/session/workspace.go b/internal/session/workspace.go new file mode 100644 index 00000000..686883e7 --- /dev/null +++ b/internal/session/workspace.go @@ -0,0 +1,48 @@ +package session + +import ( + "crypto/sha1" + "encoding/hex" + "path/filepath" + "strings" +) + +const projectsDirName = "projects" + +// sessionDirectory 负责根据工作区根目录计算会话分桶目录。 +func sessionDirectory(baseDir string, workspaceRoot string) string { + return filepath.Join(baseDir, projectsDirName, hashWorkspaceRoot(workspaceRoot), sessionsDirName) +} + +// hashWorkspaceRoot 负责为规范化后的工作区根目录生成稳定哈希。 +func hashWorkspaceRoot(workspaceRoot string) string { + key := workspacePathKey(workspaceRoot) + if key == "" { + key = "unknown" + } + sum := sha1.Sum([]byte(key)) + return hex.EncodeToString(sum[:8]) +} + +// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +func workspacePathKey(workspaceRoot string) string { + normalized := normalizeWorkspaceRoot(workspaceRoot) + if normalized == "" { + return "" + } + return strings.ToLower(normalized) +} + +// normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。 +func normalizeWorkspaceRoot(workspaceRoot string) string { + trimmed := strings.TrimSpace(workspaceRoot) + if trimmed == "" { + return "" + } + + absolute, err := filepath.Abs(trimmed) + if err == nil { + trimmed = absolute + } + return filepath.Clean(trimmed) +} From 9f116580ac46331bfd39db6d5cd678271dc33886 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 15:51:43 +0800 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20=E5=BC=BA=E5=8C=96=20AGENTS=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E8=A6=86=E7=9B=96=E8=A6=81=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在测试规则中新增整体测试覆盖率 100% 目标。 要求改动必须覆盖正常路径、边界条件、异常分支、回归场景以及必要的跨模块交互。 同时在提交前最低检查中补充测试覆盖完整性核对项。 --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index d3f8d794..1002316b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ ## 5. 测试规则 - 测试文件命名为 `*_test.go`,测试函数命名为 `TestXxx`。 +- 所有改动必须以整体测试覆盖率 100% 为硬性目标;新增、修改或修复的逻辑必须同步补齐测试,覆盖正常路径、边界条件、异常分支、回归场景以及必要的跨模块交互,确保测试场景完整、结果可验证。 - 优先覆盖以下边界: - 配置校验 - provider 请求/响应转换 @@ -113,6 +114,7 @@ - 确认改动没有破坏 `TUI / Runtime / Provider / Tools / Config` 的职责分工。 - 确认新增能力已经接到正确层级,而不是临时跨层实现。 - 运行必要的格式化和测试。 +- 确认本次改动对应的测试已补齐,并满足整体测试覆盖率 100% 目标,不得遗漏关键路径、边界分支和回归场景。 - 检查 `git status`,确保没有无关文件、密钥、本地配置或临时数据混入。 ## 10. 常用命令 From e286ecf4c1e1e5f323a963fb0bed452399bf8f68 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 16:16:31 +0800 Subject: [PATCH 4/5] =?UTF-8?q?test:=20=E8=A1=A5=E9=BD=90=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E9=9A=94=E7=A6=BB=E6=94=B9=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 CLI 执行入口、默认启动链路和启动参数转发测试。 补充启动装配、中文路径兼容、MCP 配置失败分支与 Windows 控制台编码测试。 补充 session 工作区分桶、覆盖写入和持久化异常分支测试,并验证全量测试通过。 --- internal/app/bootstrap_test.go | 102 +++++++++++++++ internal/app/console_encoding_windows_test.go | 27 ++++ internal/cli/root.go | 3 +- internal/cli/root_test.go | 70 +++++++++++ internal/session/store_test.go | 116 ++++++++++++++++++ 5 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 internal/app/console_encoding_windows_test.go diff --git a/internal/app/bootstrap_test.go b/internal/app/bootstrap_test.go index f0a598ec..51be1490 100644 --- a/internal/app/bootstrap_test.go +++ b/internal/app/bootstrap_test.go @@ -323,6 +323,25 @@ func TestBuildToolRegistryIncludesMCPFromConfig(t *testing.T) { } } +func TestBuildToolRegistryReturnsMCPSourceError(t *testing.T) { + t.Parallel() + + cfg := config.Default().Clone() + cfg.Workdir = t.TempDir() + cfg.Tools.MCP.Servers = []config.MCPServerConfig{ + { + ID: "docs", + Enabled: true, + Source: "sse", + }, + } + + _, err := buildToolRegistry(cfg) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "unsupported mcp source") { + t.Fatalf("expected unsupported mcp source error, got %v", err) + } +} + func TestResolveMCPServerEnvAndWorkdir(t *testing.T) { t.Setenv("MCP_TOKEN", "secret") env, err := resolveMCPServerEnv(config.MCPServerConfig{ @@ -553,6 +572,89 @@ func TestBuildRuntimeRejectsInvalidWorkdirOverride(t *testing.T) { } } +func TestBuildRuntimeRejectsInvalidConfigFile(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configDir := filepath.Join(home, ".neocode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configPath, []byte("workdir: legacy\n"), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err == nil || !strings.Contains(err.Error(), "no longer supported") { + t.Fatalf("expected legacy config error, got %v", err) + } +} + +func TestBuildRuntimeRejectsUnsupportedMCPSource(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + configDir := filepath.Join(home, ".neocode") + if err := os.MkdirAll(configDir, 0o755); err != nil { + t.Fatalf("mkdir config dir: %v", err) + } + configPath := filepath.Join(configDir, "config.yaml") + raw := []byte(strings.Join([]string{ + "selected_provider: openai", + "current_model: " + config.OpenAIDefaultModel, + "shell: powershell", + "tools:", + " mcp:", + " servers:", + " - id: docs", + " enabled: true", + " source: sse", + }, "\n") + "\n") + if err := os.WriteFile(configPath, raw, 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + _, err := BuildRuntime(context.Background(), BootstrapOptions{}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "not supported") { + t.Fatalf("expected unsupported mcp source validation error, got %v", err) + } +} + +func TestNewProgramRejectsInvalidWorkdirOverride(t *testing.T) { + disableBuiltinProviderAPIKeys(t) + + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + _, err := NewProgram(context.Background(), BootstrapOptions{Workdir: filepath.Join(t.TempDir(), "missing", "中文")}) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "resolve workdir") { + t.Fatalf("expected invalid workdir error, got %v", err) + } +} + +func TestResolveBootstrapWorkdirRejectsEmptyAndFile(t *testing.T) { + if _, err := resolveBootstrapWorkdir(" "); err == nil || !strings.Contains(err.Error(), "workdir is empty") { + t.Fatalf("expected empty workdir error, got %v", err) + } + + filePath := filepath.Join(t.TempDir(), "note.txt") + if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + if _, err := resolveBootstrapWorkdir(filePath); err == nil || !strings.Contains(err.Error(), "is not a directory") { + t.Fatalf("expected file path error, got %v", err) + } +} + func TestEnsureConsoleUTF8SetsOutputThenInput(t *testing.T) { originalOutput := setConsoleOutputCodePage originalInput := setConsoleInputCodePage diff --git a/internal/app/console_encoding_windows_test.go b/internal/app/console_encoding_windows_test.go new file mode 100644 index 00000000..e502df4b --- /dev/null +++ b/internal/app/console_encoding_windows_test.go @@ -0,0 +1,27 @@ +//go:build windows + +package app + +import ( + "testing" + + "golang.org/x/sys/windows" +) + +func TestPlatformSetConsoleCodePagesWithCurrentValues(t *testing.T) { + output, err := windows.GetConsoleOutputCP() + if err != nil { + t.Fatalf("GetConsoleOutputCP() error = %v", err) + } + if err := platformSetConsoleOutputCodePage(output); err != nil { + t.Fatalf("platformSetConsoleOutputCodePage() error = %v", err) + } + + input, err := windows.GetConsoleCP() + if err != nil { + t.Fatalf("GetConsoleCP() error = %v", err) + } + if err := platformSetConsoleInputCodePage(input); err != nil { + t.Fatalf("platformSetConsoleInputCodePage() error = %v", err) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 094135c5..b48d8600 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,6 +11,7 @@ import ( ) var launchRootProgram = defaultRootProgramLauncher +var newRootProgram = app.NewProgram // GlobalFlags 描述 CLI 根命令当前支持的全局参数。 type GlobalFlags struct { @@ -49,7 +50,7 @@ func NewRootCommand() *cobra.Command { // defaultRootProgramLauncher 负责在默认根命令路径下启动 TUI。 func defaultRootProgramLauncher(ctx context.Context, opts app.BootstrapOptions) error { - program, err := app.NewProgram(ctx, opts) + program, err := newRootProgram(ctx, opts) if err != nil { return err } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 5a63c566..06538080 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -3,8 +3,12 @@ package cli import ( "context" "errors" + "io" + "os" "testing" + tea "github.com/charmbracelet/bubbletea" + "neo-code/internal/app" ) @@ -62,3 +66,69 @@ func TestNewRootCommandReturnsLauncherError(t *testing.T) { t.Fatalf("expected launcher error %v, got %v", expected, err) } } + +func TestExecuteUsesOSArgs(t *testing.T) { + originalLauncher := launchRootProgram + originalArgs := os.Args + t.Cleanup(func() { + launchRootProgram = originalLauncher + os.Args = originalArgs + }) + + var captured app.BootstrapOptions + launchRootProgram = func(ctx context.Context, opts app.BootstrapOptions) error { + captured = opts + return nil + } + os.Args = []string{"neocode", "--workdir", `D:\项目\中文目录`} + + if err := Execute(context.Background()); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if captured.Workdir != `D:\项目\中文目录` { + t.Fatalf("expected Execute to forward workdir, got %q", captured.Workdir) + } +} + +func TestDefaultRootProgramLauncherRunsProgram(t *testing.T) { + originalNewProgram := newRootProgram + t.Cleanup(func() { newRootProgram = originalNewProgram }) + + newRootProgram = func(ctx context.Context, opts app.BootstrapOptions) (*tea.Program, error) { + model := quitModel{} + return tea.NewProgram(model, tea.WithInput(nil), tea.WithOutput(io.Discard)), nil + } + + if err := defaultRootProgramLauncher(context.Background(), app.BootstrapOptions{Workdir: `D:\项目\中文目录`}); err != nil { + t.Fatalf("defaultRootProgramLauncher() error = %v", err) + } +} + +func TestDefaultRootProgramLauncherReturnsNewProgramError(t *testing.T) { + originalNewProgram := newRootProgram + t.Cleanup(func() { newRootProgram = originalNewProgram }) + + expected := errors.New("new program failed") + newRootProgram = func(ctx context.Context, opts app.BootstrapOptions) (*tea.Program, error) { + return nil, expected + } + + err := defaultRootProgramLauncher(context.Background(), app.BootstrapOptions{}) + if !errors.Is(err, expected) { + t.Fatalf("expected new program error %v, got %v", expected, err) + } +} + +type quitModel struct{} + +func (quitModel) Init() tea.Cmd { + return tea.Quit +} + +func (quitModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return quitModel{}, nil +} + +func (quitModel) View() string { + return "" +} diff --git a/internal/session/store_test.go b/internal/session/store_test.go index aab03df2..16bd6ce3 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -158,6 +158,31 @@ func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { } } +func TestWorkspaceHelpersHandleEmptyAndRelativePath(t *testing.T) { + t.Parallel() + + if got := workspacePathKey(" "); got != "" { + t.Fatalf("expected empty workspace key, got %q", got) + } + if got := normalizeWorkspaceRoot(" "); got != "" { + t.Fatalf("expected empty normalized workspace root, got %q", got) + } + + workingDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + relative := "." + normalized := normalizeWorkspaceRoot(relative) + if normalized != filepath.Clean(workingDir) { + t.Fatalf("expected relative path to normalize to %q, got %q", filepath.Clean(workingDir), normalized) + } + + if got, want := hashWorkspaceRoot(""), hashWorkspaceRoot(" "); got != want { + t.Fatalf("expected empty workspace root variants to share fallback hash, got %q want %q", got, want) + } +} + func TestJSONStoreErrors(t *testing.T) { t.Parallel() @@ -236,6 +261,97 @@ func TestJSONStoreSaveInvalidBaseDir(t *testing.T) { } } +func TestJSONStoreSaveReplaceFailureWhenTargetIsNonEmptyDirectory(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + targetDir := filepath.Join(sessionDirectory(baseDir, workspaceRoot), "blocked.json") + if err := os.MkdirAll(targetDir, 0o755); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + if err := os.WriteFile(filepath.Join(targetDir, "child.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write child file: %v", err) + } + + err := store.Save(context.Background(), &Session{ + ID: "blocked", + Title: "Blocked", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil || !strings.Contains(err.Error(), "replace session file") { + t.Fatalf("expected replace failure, got %v", err) + } +} + +func TestJSONStoreSaveOverwritesExistingSessionFile(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + session := &Session{ + ID: "overwrite", + Title: "First", + CreatedAt: time.Now().Add(-time.Minute), + UpdatedAt: time.Now().Add(-time.Minute), + } + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save initial session: %v", err) + } + + session.Title = "Second" + session.UpdatedAt = time.Now() + if err := store.Save(context.Background(), session); err != nil { + t.Fatalf("save updated session: %v", err) + } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load updated session: %v", err) + } + if loaded.Title != "Second" { + t.Fatalf("expected overwritten session title %q, got %q", "Second", loaded.Title) + } +} + +func TestJSONStoreSaveWriteTempFailure(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + workspaceRoot := t.TempDir() + store := NewJSONStore(baseDir, workspaceRoot) + sessionsPath := sessionDirectory(baseDir, workspaceRoot) + if err := os.MkdirAll(sessionsPath, 0o755); err != nil { + t.Fatalf("mkdir sessions path: %v", err) + } + tempDir := filepath.Join(sessionsPath, "temp-blocked.json.tmp") + if err := os.MkdirAll(tempDir, 0o755); err != nil { + t.Fatalf("mkdir temp dir: %v", err) + } + + err := store.Save(context.Background(), &Session{ + ID: "temp-blocked", + Title: "Temp Blocked", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + }) + if err == nil || !strings.Contains(err.Error(), "write temp session") { + t.Fatalf("expected temp write failure, got %v", err) + } +} + +func TestJSONStoreLoadMissingFileReturnsError(t *testing.T) { + t.Parallel() + + store := NewJSONStore(t.TempDir(), t.TempDir()) + if _, err := store.Load(context.Background(), "missing"); err == nil { + t.Fatalf("expected missing file load to fail") + } +} + func TestNewUsesDefaultWorkdirAndEmptyMessages(t *testing.T) { t.Parallel() From 11cded5c7de683c7fdcb00a591fe3f1a1ae5d2b1 Mon Sep 17 00:00:00 2001 From: Yumiue <229866007@qq.com> Date: Wed, 8 Apr 2026 16:34:03 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E6=A1=B6=E7=9A=84=E5=A4=A7=E5=B0=8F=E5=86=99?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E5=8C=96=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仅在 Windows 下对工作区路径做大小写归一化,避免大小写敏感文件系统上的不同目录落入同一个 session bucket。 同步更新工作区哈希测试,区分 Windows 与大小写敏感平台的预期行为。 --- internal/session/store_test.go | 13 +++++++++++-- internal/session/workspace.go | 8 ++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 16bd6ce3..4e08448f 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + goruntime "runtime" "strings" "testing" "time" @@ -153,8 +154,16 @@ func TestHashWorkspaceRootNormalizesChinesePathVariants(t *testing.T) { upperVariant := strings.ToUpper(normalized) lowerVariant := strings.ToLower(normalized) - if got, want := hashWorkspaceRoot(upperVariant), hashWorkspaceRoot(lowerVariant); got != want { - t.Fatalf("expected case variants to hash equally, got %q and %q", got, want) + gotCaseUpper := hashWorkspaceRoot(upperVariant) + gotCaseLower := hashWorkspaceRoot(lowerVariant) + if goruntime.GOOS == "windows" { + if gotCaseUpper != gotCaseLower { + t.Fatalf("expected case variants to hash equally on windows, got %q and %q", gotCaseUpper, gotCaseLower) + } + } else { + if gotCaseUpper == gotCaseLower { + t.Fatalf("expected case variants to hash differently on case-sensitive platforms, got %q", gotCaseUpper) + } } } diff --git a/internal/session/workspace.go b/internal/session/workspace.go index 686883e7..dd244204 100644 --- a/internal/session/workspace.go +++ b/internal/session/workspace.go @@ -4,6 +4,7 @@ import ( "crypto/sha1" "encoding/hex" "path/filepath" + goruntime "runtime" "strings" ) @@ -24,13 +25,16 @@ func hashWorkspaceRoot(workspaceRoot string) string { return hex.EncodeToString(sum[:8]) } -// workspacePathKey 负责生成工作区路径的稳定比较键,并与项目级 transcript 哈希规则保持一致。 +// workspacePathKey 负责生成工作区路径的稳定比较键,并在 Windows 下兼容大小写不敏感路径。 func workspacePathKey(workspaceRoot string) string { normalized := normalizeWorkspaceRoot(workspaceRoot) if normalized == "" { return "" } - return strings.ToLower(normalized) + if goruntime.GOOS == "windows" { + return strings.ToLower(normalized) + } + return normalized } // normalizeWorkspaceRoot 负责将工作区根目录规范化为绝对清洗路径。