@shundoo-ai/dsh-cosmic 1.0.10 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shundoo-ai/dsh-cosmic",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "苍穹助手:金蝶云苍穹ERP的开发与数据分析,涵盖表单元数据建模、插件开发与热部署、数据可视化报告。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -79,6 +79,8 @@ description: >
79
79
 
80
80
  `dev.py init` 读 `<cwd>/.dsh/cosmic/.kd/config.json`(default env,复用 kd_env_loader,与 erp-analysis 同源),取 `url` 自动解析端口;**accountId 配置有值直接用,为空则调 `getAllDatacenters.do` 自动填**(优先 `default:true` 的数据中心,否则取第一个;失败保持空),**tenantId 配置有值用配置、为空默认 `ierp`**、`java_home` 从 IDEA 项目 SDK 自动发现,并**覆盖** `<cwd>/.dsh/cosmic/config/dev.json`。**若 url 为空,脚本中断并提醒「设置 erp 登录信息」。**
81
81
 
82
+ > ⚠️ **`java_home` 被静默写空 = IDEA 路径宏没展开**(实测 2026-09-24,已修):IDEA `jdk.table.xml` 的 `<homePath>` 常写成宏形式(如 `$USER_HOME$/.jdks/temurin-1.8.0_504`),若 `discover_java_home()` 直接拿它 `isdir()` 判据 → 恒 False → `java_home` 写空(表现:`init` 前有值、`init` 后被冲掉)。修复 = `dev.py` 的 `_expand_idea_macros()`(展开 `$USER_HOME$` / `$APPLICATION_HOME_DIR$` / `$PROJECT_DIR$`,再 `normpath`)。**再遇到时核对**:`.idea/misc.xml` 的 `project-jdk-name`(如 `temurin-1.8`)与 `jdk.table.xml` 里 `<name value="…">` 是否完全一致。⚠️ **该发现逻辑以 cwd 为准**(`_project_jdk_name()` 读 `<cwd>/.idea/misc.xml`)—— **必须在工作区目录下跑**,在别处跑会静默返回 `None`。⚠️ **`dev.py read` 只读不回填** —— 改了 `dev.py` 必须重跑 `init` 才生效。
83
+
82
84
  > `dev.py init` 同时按模板 `assets/gitignore.template`(Java/Gradle/IDE/Python/凭证/运行时产物完整段)确保 `<cwd>/.gitignore`:无则整体拷贝模板,有则逐条目幂等补缺(缺失项以「cosmic 开发/热部署」标记段追加,只增不删;模板后续新增条目也会补上)。
83
85
  3. **最后提醒用户**:确认 MC 热部署开关已开启——管理中心 MC → 环境公共配置项 → `mc.properties` → `jar_deploy_enable = true`(agent 提醒即可,**无需自行定位 cosmic 安装目录 / mc.properties 文件**)。
84
86
 
@@ -90,7 +92,11 @@ description: >
90
92
  > **build + 热部署**:R0 → R1 → R2 → R3 → R4 → R5。
91
93
  > 每一步都给「期望」与「失败处置」;**不做即兴发挥**,缺规则就回写手册(见文末自检)。
92
94
 
93
- > 🔁 **执行方式**:可以逐步执行,也可以把本手册**物化**成工作区内的临时脚本一次跑完(实测:Windows 会话把它写成 `<cwd>/.dsh/cosmic/scripts/hotdeploy.ps1`,此后"构建+热部署"每次只花 1 次调用)。**若物化,遵守四条**:① 放在 `.dsh/` 下(git 忽略、不入库);② **本手册是唯一权威**——手册更新后重新生成,不得反向以脚本为准;③ 脚本只做编排(发现 → 构建 → 注入),**不得引入手册之外的行为**(**允许**只读的发现/干跑模式 —— 不构建、不注入、不改环境,如 `-DryRun`);④ **不得写死本机路径**(工作区 / Gradle 根 / profile 名 / JDK 安装路径),工作区与 Gradle 根按 R1.1 推导、profile 用 `$DSH_PROFILE_DIR`、JDK 按 R2.1 顺位选择 —— 实测有脚本写死 `$cwd` 与 `profiles\web`,换机器即失效。**生成后自查**:在脚本里 grep 一遍本机绝对路径与写死的 profile 名,应当**只出现推导逻辑、不出现具体值**。
95
+ > 🔁 **执行方式**:可以逐步执行,也可以把本手册**物化**成工作区内的临时脚本一次跑完(实测:Windows 会话把它写成 `<cwd>/.dsh/cosmic/scripts/hotdeploy.ps1`,此后"构建+热部署"每次只花 1 次调用)。**若物化,遵守四条**:① 放在 `.dsh/` 下(git 忽略、不入库);② **本手册是唯一权威**——手册更新后重新生成,不得反向以脚本为准;③ 脚本只做编排(发现 → 构建 → 注入),**不得引入手册之外的行为**(**允许**只读的发现/干跑模式 —— 不构建、不注入、不改环境,如 `-DryRun`);④ **不得写死本机路径**(工作区 / Gradle 根 / profile 名 / JDK 安装路径),工作区与 Gradle 根按 R1.1 推导、profile 用 `$DSH_PROFILE_DIR`、JDK 按 R2.1 顺位选择 —— 实测有脚本写死 `$cwd` 与 `profiles\web`,换机器即失效。**生成后自查**:在脚本里 grep 一遍本机绝对路径与写死的 profile 名,应当**只出现推导逻辑、不出现具体值**(用法示例里也用占位符,否则会被自查命中)。
96
+ > 💡 **物化是可选的,先算盈亏再决定**(实测口径):物化本身要花 **4–5 次调用**(write + 补 BOM + `Parser` 语法检查 + 干跑验证),之后每轮从 ~2–6 次降到 **1 次** ⇒ **盈亏平衡约 4–5 轮**。值得物化的情形:① 要在 DSH 之外**手敲一条命令**跑;② 一轮要**连编多个模块**;③ 当天会**反复改码→热部署**很多来回。⚠️ 另注:**长期固定的值本来就在仓库文件里**(`settings.gradle` 的 `projectDir` 映射、`gradle.properties` 的 `cosmic_home`、`dev.json` 的 `java_home`)—— 那些比脚本更该信,脚本只该做编排。
97
+ >
98
+ > 🔧 **Windows 物化脚本的四个必踩点**(均实测,对应 F11–F13、F15):① 脚本含中文 ⇒ **必须存为 UTF-8 with BOM**(PS 5.1 否则按 ANSI 解码 → 中文碾碎、引号配对错乱);**任何 write/edit 都会去掉 BOM** ⇒ 每次改动后**重新加 BOM + 跑一次 `Parser::ParseFile` 语法检查**(见 F11);② **函数参数名避开只读自动变量**(`$Home`/`$Host`/`$PID`/`$Args`/`$Error`…),否则报 `Cannot overwrite variable …` 且**行号指向调用点**(见 F13);③ 别写 `Invoke-WebRequest -MaximumRedirection 0`(PS 5.1 必抛异常,见 F12);④ **`Join-Path` 只给 2 个位置参数**(`-AdditionalChildPath` 是 PS 6+ 的位置参数)—— 多给一个在 5.1 报错、在 PS 6+ 静默拼出垃圾路径,两者都会让脚本"假绿"(见 F15)。
99
+ > ⚠️ **语法检查过 ≠ 逻辑对**:`Parser::ParseFile` 只看语法。脚本**改完必须实跑一次**才算收工 —— **`-DryRun` 在 R4 之前就 `exit 0`,覆盖不到注入与验证段**(F15 就是这么溜过去的:干跑全绿 + 语法全绿,仍带着 `$null` 的 logFile 跑完并退出 0)。
94
100
 
95
101
  > **平台约定:本手册三平台通用**(Windows / macOS / Linux 各步骤都可用)。平台差异见下表;
96
102
  > 失败分支表里**未标注平台的条目三平台通用**,标 `[Win]` 的只会发生在 Windows。
@@ -105,7 +111,7 @@ description: >
105
111
  > | PID 定位 | `Get-NetTCPConnection` → `Get-CimInstance`(**`jps` 看不到 SYSTEM 账户的 cosmic**) | `lsof -tiTCP:<port> -sTCP:LISTEN` 或 `jps -l \| grep JettyServer` |
106
112
  > | classpath 分隔符 | `;` | `:` |
107
113
  > | 沙箱 | 受限沙箱**砍令牌特权**(实测只剩 `SeChangeNotifyPrivilege`)→ 端口查询被拒、attach 缺 `SeDebugPrivilege`(见 F4);构建的 `CreatePipe error=5` 是另一条(见 F3) | 无此限制 |
108
- > | shell / 变量 | PowerShell(DSH 兜底到 5.1,注意默认 ANSI 解码);`$env:VAR=` | bash/sh;`export VAR=` |
114
+ > | shell / 变量 | PowerShell(DSH 兜底到 5.1,注意默认 ANSI 解码;**物化脚本含中文须存 UTF-8 with BOM**,见 F11);`$env:VAR=` | bash/sh;`export VAR=` |
109
115
  > | 构建可执行 | `gradle.bat`(按 wrapper 定版) | `gradle`(按 wrapper 定版) |
110
116
  > | 缓存预热命令 | `robocopy`(见 R3.2) | `cp -R`(见 R3.2) |
111
117
 
@@ -115,7 +121,7 @@ description: >
115
121
  |---|------|------|----------|
116
122
  | R0.1 | `<DSH工作区>/.dsh/cosmic/config/dev.json` 存在 | 存在 | 跑 `dev.py init`(或先「设置 erp 登录信息」) |
117
123
  | R0.2 | dev.json 的 `port` / `account_id` / `tenant_id` | 非空 | `tenant_id` 空 → 默认 `ierp`;`account_id` 空 → 重跑 `dev.py init` |
118
- | R0.3 | **HTTP 探测**端口(不依赖任何权限) | `200`/`302` | 非 2xx/3xx → cosmic 没起或端口不对,停 |
124
+ | R0.3 | **HTTP 探测**端口(一般不需要特权;**受限沙箱是例外**,见下注) | 最终状态码 `200`/`302`(`Invoke-WebRequest` 默认跟随重定向,通常得 `200`;`curl` 不带 `-L` 则得 `302`) | 非 2xx/3xx → cosmic 没起或端口不对,停 |
119
125
  | R0.4 | 目标进程定位(R1.2) | 得到 PID,且命令行含 `kd.bos.service.webserver.JettyServer` | 见 F5 |
120
126
 
121
127
  ```bash
@@ -123,6 +129,11 @@ description: >
123
129
  curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<port>/ierp
124
130
  ```
125
131
 
132
+ > ⚠️ **PowerShell 写这条探测时不要加 `-MaximumRedirection 0`**(实测,见 F12):PS 5.1 下它**必然**抛
133
+ > `Operation is not valid due to the current state of the object`(与 cosmic 死活无关,`$_.Exception.Response` 也为空)—— 照抄会误判成「cosmic 没起」。**用默认(跟随重定向)即可**;判据与上表一致:**最终状态码为 2xx/3xx**。
134
+ > ⚠️ **受限沙箱下 `Invoke-WebRequest` 可能连控制台缓冲区都拿不到**(`Win32 internal error "Access is denied" 0x5 … reading the console output buffer`,**这不是 HTTP 错误码**)→ 改用 **TCP 兜底**:`(New-Object System.Net.Sockets.TcpClient).Connect('127.0.0.1',<port>)` 不抛异常即"端口有监听"(见 F14)。
135
+ > ⚠️ **TCP 兜底只证明"端口有人监听",不等于"是 cosmic"** —— 仍必须接着走 R0.4 校验命令行(含 `kd.bos.service.webserver.JettyServer`)。另注:**"HTTP 探测不需要特权"是有前提的**,受限令牌下请用 TCP 探测。
136
+
126
137
  > ⚠️ **顺序不可颠倒**:先 R0.3 再 R0.4。端口反查被沙箱拒绝 ≠ cosmic 没启动 —— 这两件事必须能区分开(Windows 上曾因 `jps` 为空误判"cosmic 没启动")。
127
138
 
128
139
  ### R1 解析布局
@@ -310,6 +321,7 @@ try { & $GRADLE ":<模块>:build" -x test --console=plain } finally { Pop-Locati
310
321
 
311
322
  - **不要 `--no-daemon`**:每次构建都要付 JVM 冷启动 + 配置开销,量级**因项目而异**:本工作区实测全新 daemon 冷启动 **3–5s**(13.9s 总 wall − daemon 内构建 8.75s,含客户端握手与本手册 R0–R2 发现 ~1.9s);另一工作区数据是 `--no-daemon` 42s vs 常驻 32s(**Δ≈10s**,见 `cosmic-dev-best-practices/references/cosmic-hotdeploy-build-slow-windows.md` §4.7/§5.1)。⚠️ 别引用 "+30s"(该文档早前版本有此处,与它自己的数据表不符,已修正)。方向不变:**保留常驻 daemon**;另⚠️ 本条与 ② 无关 —— 早前写的"否则 140s 每次都要重付"基于已被证伪的 daemon 寿命归因。
312
323
  - **隔了几小时没编译后的第一次构建会慢(~140s),属正常**,别误判成卡死、也别急着重启 daemon。想把它与正式构建分开观察时,可以先跑一次 `:<模块>:compileJava` 把冷开销付掉再跑构建。
324
+ - ⏱️ **但 `UP-TO-DATE` 的构建不付 ② 的冷开销**(实测 2026-09-24):源码自上次构建后未变(源 `9/20 11:01:56` / jar `9/20 11:02:03`,**间隔 4 天**)+ **全新 daemon** → `:X:compileJava UP-TO-DATE`,wall 仅 **10.7s**(含 daemon 冷启动与两个模块配置)。⇒ **别把"隔了几天"直接等同于 ~140s**:先看 ① 有没有 `UP-TO-DATE`;javac 没跑,快照就没被大规模读,冷开销自然不发生。
313
325
  - 🔬 **量构建 wall 的两个仪表,含义不同,别混用**:① Gradle 自报 `BUILD SUCCESSFUL in Ns` = 客户端观测的整次构建;② `daemon-<pid>.out.log` 里成对的 `has started executing the build` / `has finished executing the build` = **daemon 侧真正执行构建的时段**。实测同一次构建两者可差几秒(**8.75s vs 12s**,差在客户端握手/启动)。做分档归因(如 ② 的冷开销)用 ②,报告用户感知耗时用总 wall。注意:该文件**只有生命周期标记、没有构建期内容日志**(见下条)。
314
326
  - 判"卡住"必须**两个信号同时看**:进程 CPU 增量 ≈ 0 **且** `classAnalysis.bin` 不再增长。只看"没有输出"会把冷开销误判成死锁(这条是实测踩出来的)。
315
327
  - **"低 CPU 不是活干在别的进程"的佐证**:Gradle 7.6 默认在 daemon 进程内执行 javac(未显式配 `options.fork`),且实测构建后 `Get-Process java` 只有 Gradle daemon + cosmic + MC 三个 JVM、**无额外 worker**(Gradle worker daemon 默认 idle 3 小时不会自动退,若 fork 过必然还在)。
@@ -402,12 +414,18 @@ try { & $GRADLE ":<模块>:build" -x test --console=plain } finally { Pop-Locati
402
414
  | F8 | 构建长时间无输出 | 多半是冷缓存建快照,不一定是死锁 | 用 R3.2 判据;确卡住再清 Gradle 守护进程重试(**不要动 cosmic 进程**) |
403
415
  | F9 | `Full recompilation is required because <类> could not be analyzed` | 增量编译失效(classpath 上有注解处理器 / Java17+ 类 / MR-JAR) | 见 `cosmic-dev-best-practices/references/cosmic-hotdeploy-build-slow-windows.md` §4 |
404
416
  | F10 | 同一条命令第二次才成功(第一次报 cwd / 编码等) | 环境差异没被前移成决策规则 | **回写本手册**:补进 R1/R2 的规则或本表 |
417
+ | F11 | `[Win]` **物化脚本**报 `Unrecognized token in source text` / `The string is missing the terminator: "@`(**位置指向中文字符串中间**) | 脚本文件是 **UTF-8 无 BOM**,PS 5.1 按 ANSI 解码 → 中文被碾碎、引号配对错乱。**与脚本逻辑无关** | 把 `.ps1` 存为 **UTF-8 with BOM**:`$t=[IO.File]::ReadAllText($p); [IO.File]::WriteAllText($p,$t,(New-Object Text.UTF8Encoding($true)))`。⚠️ **任何重写该脚本的工具/编辑器(含 agent 的 write/edit)都会去掉 BOM** ⇒ 每次改动后**重新加 BOM + 跑语法检查**:`[Management.Automation.Language.Parser]::ParseFile($p,[ref]$null,[ref]$err)` |
418
+ | F12 | `[Win]` `Invoke-WebRequest … -MaximumRedirection 0` 抛 `Operation is not valid due to the current state of the object` | PS 5.1 的已知行为,**不是** cosmic 没起 | 去掉 `-MaximumRedirection 0`(见 R0.3 注) |
419
+ | F13 | `[Win]` 物化脚本报 `Cannot overwrite variable Home because it is read-only or constant`,**行号指向"调用该函数的那一行"而非函数定义** | PowerShell 函数**参数名**与**只读自动变量**同名(`$Home` / `$Host` / `$PID` / `$Input` / `$Args` / `$Error` / `$Matches` / `$Profile` …)→ 参数绑定时写只读变量失败。⚠️ 报错位置在**调用点**,极易去调用点找不存在的 bug | 改参数名(实测:`function Test-Jdk([string]$Home)` → `$JdkDir`);物化脚本自查时顺手扫一遍这些保留名 |
420
+ | F14 | `[Win]` 受限沙箱下 `Invoke-WebRequest` 抛 `Win32 internal error "Access is denied" 0x5 occurred while reading the console output buffer`(**不是** HTTP 错误码) | 受限令牌下控制台/管道被砍,**与 cosmic 死活无关**;照抄会误判成"cosmic 没起" | 用 **TCP 兜底**:`(New-Object System.Net.Sockets.TcpClient).Connect('127.0.0.1',<port>)` 不抛异常 = **端口有监听**(⚠️ **不等于"是 cosmic"**,仍须走 R0.4 校验命令行);放开权限后 HTTP 探测恢复正常(实测放开后同一 URL 返回 200) |
421
+ | F15 | `[Win]` **物化脚本**里 `Join-Path $ws 'a-' + $m + '.log'` 这类写法:**PS 5.1** 报 `A positional parameter cannot be found that accepts argument '+'`(`-AdditionalChildPath` 是 PS 6+ 才有的**位置**参数,5.1 的 `Join-Path` 只吃 2 个位置参数) | **参数模式里 `+` 不是运算符、会被当成普通实参** → 第 3 个位置参数无处可绑。**两版本都不对,但症状不同**:5.1 = 报错;**PS 6+ = 不报错,静默拼出垃圾路径**(实测 PS 7.6.6:`Join-Path "/tmp" "a-" + "m" + ".log"` → `/tmp/a-/+/m/+/.log`) | 先拼名再 Join:`$name = 'a-' + $m + '.log'; Join-Path $ws $name`。⚠️ **这是"假绿"型故障**:`$ErrorActionPreference='Continue'` 时**报错但脚本继续**、结果是 `$null`(实测)⇒ `$LogFile` 静默为空、Attacher 照旧 `SUCCESS`、R5 的 logFile 验证**空转**、脚本仍退出 0。(若 EAP=`Stop` 则绑定异常会终止脚本 —— **是否终止取决于 EAP**,别当成普适事实。)⇒ 物化脚本里每个 `Join-Path` 都自查位置参数个数 |
405
422
 
406
423
  ### 自检(每轮执行后)
407
424
 
408
425
  - **零即兴**:每一步都能在本手册里找到对应条目?
409
426
  - **调用次数**:目标 **< 15 次**(散文时代 Windows 实测 63 次);超出 → 哪个环节缺规则 → 回写。
410
427
  - **F10 事件**:同一命令跑了两次?→ 手册缺一条规则。
428
+ - **物化脚本改动 → 实跑复验**:`Parser::ParseFile` 只管语法、`-DryRun` 在 R4 之前就 `exit 0` —— **两者全绿仍可能"假绿"**(见 F15)。改完脚本必须真跑一次「构建 + 注入」,并确认 R5 的 logFile **落到盘上**(不是"读了个空")。
411
429
 
412
430
  ---
413
431
 
@@ -23,10 +23,16 @@
23
23
  # 不要写进本文件。
24
24
 
25
25
  # persona:工作区 ACP 会话的助手身份({{model}}/{{cwd}} 启动时自动替换,必须保留)
26
+ #
27
+ # ⚠️ 键名必须是 personaPrefix / personaSuffix —— dsh-system-prompt 的 Config 是
28
+ # z.object({...})(**无 .strict()**)⇒ 写错的键会被【静默剥离】、不报错也不生效。
29
+ # (2026-09 实测:旧模板写的是 `persona:`,于是 ACP 会话里 persona 从未生效,
30
+ # 表现为「agent 通用身份、无技能」——见工作区文档的排查表。)
26
31
  - id: system-prompt
27
32
  config:
28
- persona: >-
29
- You are 苍穹助手 (Cosmic Assistant), the 金蝶云星空 (Kingdee Cloud Cosmic) 开发与数据分析 agent, powered by the {{model}} model. Your working directory is {{cwd}}. Two capabilities: (1) 苍穹开发 — build and modify 苍穹 apps, menus, forms, entities, fields, plugins, APIs, workflows, and scheduling; follow the cosmic-dev-best-practices and app-build skills for that work. (2) 数据分析 — answer business-data questions through the 苍穹 OpenAPI, analyze with Python (pandas/plotly), and deliver markdown tables in chat plus interactive HTML reports; load the erp-analysis skill before any data query (its SKILL.md is the authority for the workflow and report format). Match the user's intent to the right capability and load the appropriate skill.
33
+ personaPrefix: >-
34
+ You are 苍穹助手 (Cosmic Assistant), the 金蝶云星空 (Kingdee Cloud Cosmic) 开发与数据分析 agent, powered by the {{model}} model. Two capabilities: (1) 苍穹开发 — build and modify 苍穹 apps, menus, forms, entities, fields, plugins, APIs, workflows, and scheduling; follow the cosmic-dev-best-practices and app-build skills for that work. (2) 数据分析 — answer business-data questions through the 苍穹 OpenAPI, analyze with Python (pandas/plotly), and deliver markdown tables in chat plus interactive HTML reports; load the erp-analysis skill before any data query (its SKILL.md is the authority for the workflow and report format). Match the user's intent to the right capability and load the appropriate skill.
35
+ personaSuffix: Your working directory is {{cwd}}.
30
36
 
31
37
  # 技能常驻目录:cosmic 技能基座($DSH_HOME/.agent-presets/cosmic/skills,动态解析)
32
38
  - id: skill-filesystem
@@ -35,11 +41,14 @@
35
41
  - !!js "process.getBuiltinModule('node:path').join(process.env.DSH_HOME ?? process.getBuiltinModule('node:path').join(process.getBuiltinModule('node:os').homedir(), '.dsh'), '.agent-presets/cosmic/skills')"
36
42
 
37
43
  # 模型:本工作区 ACP 会话的默认模型(overlay 覆写 dsh-acp 行;provider/model 都生效)
38
- # 可用(deepseek-official catalog,可按需替换 model):
39
- # deepseek-v4-flash 默认 —— 快
40
- # deepseek-v4-pro 更强推理(更慢/更贵)
41
- # deepseek-v4-flash-vision-exp 视觉实验版
44
+ # ⚠️ 可用 model 以 **provider 内置目录**为准(@deepseek-ai/dsh-llm-deepseek 的 DEFAULT_MODELS):
45
+ # deepseek-flash 默认 —— 快(显示名 DeepSeek-V41-Flash;text + image)
46
+ # deepseek-v4-pro 更强推理(更慢 / 更贵)
47
+ #
48
+ # 查证说明(2026-09):provider 源码的内置默认 id 是 deepseek-flash;而 GUI 模型选择器里
49
+ # 出现的是 deepseek-v4-flash(与官方 dsh-acp-app baseline 一致)—— 线上部署的目录可能被替换过,
50
+ # 两者当前都解析得通。本模板取【provider 内置默认】deepseek-flash。
42
51
  - id: acp
43
52
  config:
44
53
  provider: deepseek-official
45
- model: deepseek-v4-flash
54
+ model: deepseek-flash
@@ -100,8 +100,27 @@ def _project_jdk_name():
100
100
  return m.group(1) if m else None
101
101
 
102
102
 
103
+ def _expand_idea_macros(path):
104
+ """展开 IDEA 路径宏(jdk.table.xml 的 homePath 常写成 $USER_HOME$/... 形式)。
105
+
106
+ 实测:IDEA 2026.2 写入的是 `$USER_HOME$/.jdks/temurin-1.8.0_504`;不展开则
107
+ os.path.isdir() 恒为 False → java_home 被静默写成空(真踩过)。
108
+ """
109
+ if not path:
110
+ return path
111
+ home = str(Path.home())
112
+ repl = {
113
+ "$USER_HOME$": home,
114
+ "$APPLICATION_HOME_DIR$": os.environ.get("APPDATA", ""),
115
+ "$PROJECT_DIR$": os.getcwd(),
116
+ }
117
+ for macro, value in repl.items():
118
+ path = path.replace(macro, value)
119
+ return os.path.normpath(os.path.expanduser(path))
120
+
121
+
103
122
  def _jdk_home_from_idea(jdk_name):
104
- """在 IDEA jdk.table.xml 里找 name → homePath。"""
123
+ """在 IDEA jdk.table.xml 里找 name → homePath(已展开 IDEA 宏)。"""
105
124
  if not jdk_name:
106
125
  return None
107
126
  for f in _idea_jdk_table_files():
@@ -113,7 +132,7 @@ def _jdk_home_from_idea(jdk_name):
113
132
  nm = re.search(r'<name value="([^"]+)"', block)
114
133
  hp = re.search(r'<homePath value="([^"]+)"', block)
115
134
  if nm and hp and nm.group(1) == jdk_name:
116
- return hp.group(1)
135
+ return _expand_idea_macros(hp.group(1))
117
136
  return None
118
137
 
119
138