universal-dev-standards 6.14.0-beta.1 → 6.14.0-beta.3
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/bin/uds.js +37 -0
- package/bundled/ai/standards/open-work-tracking.ai.yaml +7 -2
- package/bundled/core/open-work-tracking.md +1 -1
- package/bundled/hooks/turn-completion/locales/en.mjs +54 -5
- package/bundled/hooks/turn-completion/locales/zh-TW.mjs +37 -6
- package/bundled/locales/zh-CN/CHANGELOG.md +38 -3
- package/bundled/locales/zh-CN/README.md +2 -2
- package/bundled/locales/zh-CN/SECURITY.md +1 -1
- package/bundled/locales/zh-CN/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-CN/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-CN/docs/FEATURE-REFERENCE.md +11 -4
- package/bundled/locales/zh-TW/CHANGELOG.md +38 -3
- package/bundled/locales/zh-TW/README.md +2 -2
- package/bundled/locales/zh-TW/SECURITY.md +1 -1
- package/bundled/locales/zh-TW/core/open-work-tracking.md +4 -4
- package/bundled/locales/zh-TW/docs/CHEATSHEET.md +2 -0
- package/bundled/locales/zh-TW/docs/CLI-INIT-OPTIONS.md +24 -0
- package/bundled/locales/zh-TW/docs/FEATURE-REFERENCE.md +11 -4
- package/package.json +1 -1
- package/src/commands/check.js +9 -0
- package/src/commands/init.js +73 -18
- package/src/commands/open-work.js +60 -0
- package/src/commands/uninstall.js +144 -30
- package/src/commands/update.js +149 -0
- package/src/core/install-records.js +191 -0
- package/src/i18n/messages.js +42 -6
- package/src/installers/hooks-installer.js +236 -27
- package/src/installers/integration-installer.js +5 -1
- package/src/uninstallers/hook-uninstaller.js +216 -32
- package/src/uninstallers/integration-uninstaller.js +35 -5
- package/src/utils/detector.js +46 -1
- package/src/utils/git-hooks.js +135 -7
- package/src/utils/hasher.js +36 -0
- package/src/utils/integration-generator.js +16 -6
- package/src/utils/legacy-hook-migration.js +112 -0
- package/src/utils/open-work-tracking.mjs +794 -0
- package/standards-registry.json +7 -7
package/src/i18n/messages.js
CHANGED
|
@@ -875,7 +875,7 @@ export const messages = {
|
|
|
875
875
|
// Pre-commit hook wiring (XSPEC: uds init writes a hook but never
|
|
876
876
|
// confirmed git would run it — this reports the gap, read-only)
|
|
877
877
|
hookNotWiredTitle: '⚠ [pre-commit] {file} was installed, but git will not run it.',
|
|
878
|
-
hookNotWiredOverride: ' git core.hooksPath is set to "{path}" — UDS will not override it. Confirm the hook there also runs `
|
|
878
|
+
hookNotWiredOverride: ' git core.hooksPath is set to "{path}" — UDS will not override it. Confirm the hook there also runs `universal-dev-standards check` (the installed CLI — not the short name "uds", which on the npm registry is an unrelated package), or switch it yourself: git config --local core.hooksPath .husky',
|
|
879
879
|
hookNotWiredUnwired: ' git core.hooksPath is not set, and {file} is not on git\'s default hook path.',
|
|
880
880
|
hookNotWiredFix: ' Fix (per-clone — not committed, teammates must repeat it): git config --local core.hooksPath .husky',
|
|
881
881
|
hookNotWiredFixNative: ' Fix: point core.hooksPath back to the default (git config --local --unset core.hooksPath), or add `uds check` under "{path}" instead.',
|
|
@@ -892,6 +892,8 @@ export const messages = {
|
|
|
892
892
|
// file or directory". Measured 2026-09-27 in CI (windows-latest).
|
|
893
893
|
hookMissingShebangTitle: '⚠ [pre-commit] {file} has no shebang line — git cannot run it on Windows.',
|
|
894
894
|
hookMissingShebangFix: ' Fix: add a `#!/bin/sh` shebang as the very first line of {file} (a husky-managed hook gets this automatically the next time `uds init` touches it), or re-run `uds init`.',
|
|
895
|
+
hookBareUdsTitle: '⚠ [pre-commit] {file} asks npm to run the bare name "uds", which on the npm registry is an unrelated package (not this project).',
|
|
896
|
+
hookBareUdsFix: ' Fix: run `uds update` — it replaces the line UDS itself wrote with one that only runs the installed UDS CLI. A line you wrote or edited yourself is left alone; change it to `universal-dev-standards check`.',
|
|
895
897
|
// Summary mode (--summary)
|
|
896
898
|
summary_mode: {
|
|
897
899
|
title: 'UDS Status Summary',
|
|
@@ -930,6 +932,7 @@ export const messages = {
|
|
|
930
932
|
title: 'Universal Development Standards - Initialize',
|
|
931
933
|
alreadyInitialized: '⚠ Standards already initialized in this project.',
|
|
932
934
|
useUpdateOrDelete: 'Use `uds update` to update, or delete .standards/ to reinitialize.',
|
|
935
|
+
useUpdateWithHooks: 'To add enforcement hooks to this project (including for tools UDS began supporting after you initialized), run `uds update --with-hooks`.',
|
|
933
936
|
// Detection
|
|
934
937
|
detectingProject: 'Detecting project characteristics...',
|
|
935
938
|
analysisComplete: 'Project analysis complete',
|
|
@@ -1037,6 +1040,11 @@ export const messages = {
|
|
|
1037
1040
|
currentVersion: 'Current version',
|
|
1038
1041
|
latestVersion: 'Latest version',
|
|
1039
1042
|
upToDate: '✓ Standards are up to date.',
|
|
1043
|
+
// Pre-commit hook migration (bare-name `uds` on the npm registry is not this project)
|
|
1044
|
+
hookMigrated: '✓ .husky/pre-commit: replaced the line an older UDS wrote (it asked npm to resolve the bare name "uds", which is not this project) with one that runs only the installed UDS CLI.',
|
|
1045
|
+
hookWouldMigrate: '+ .husky/pre-commit: would replace the line an older UDS wrote (it asks npm to resolve the bare name "uds", which is not this project) with one that runs only the installed UDS CLI.',
|
|
1046
|
+
hookLegacyKept: '⚠ .husky/pre-commit line {line} ("{text}") asks npm to resolve the bare name "uds", which on the npm registry is an unrelated package. UDS did not write this exact line, so it was left alone. Change it to "universal-dev-standards check" — it needs the UDS CLI installed in this project or on PATH.',
|
|
1047
|
+
hookMigrateFailed: '⚠ Could not rewrite .husky/pre-commit: {error}. The line that asks npm for the bare name "uds" is still there — change it to "universal-dev-standards check".',
|
|
1040
1048
|
newerVersion: '(You have a newer version than the registry: {version})',
|
|
1041
1049
|
updateAvailable: 'Update available: {current} → {latest}',
|
|
1042
1050
|
// Files
|
|
@@ -1263,7 +1271,11 @@ export const messages = {
|
|
|
1263
1271
|
uninstallPartial: '⚠ Uninstall completed with errors.',
|
|
1264
1272
|
removed: 'Removed',
|
|
1265
1273
|
skippedLabel: 'Skipped',
|
|
1266
|
-
errorsLabel: 'Errors'
|
|
1274
|
+
errorsLabel: 'Errors',
|
|
1275
|
+
cannotPrompt: 'Cannot ask for confirmation: nothing is attached to answer the prompt (non-interactive shell, CI, or a pipe).',
|
|
1276
|
+
cannotPromptHint: 'Nothing has been changed. Re-run with --yes to confirm up front, or with --dry-run to preview the changes.',
|
|
1277
|
+
promptClosed: 'The prompt was closed before an answer was given. Nothing has been changed.',
|
|
1278
|
+
promptClosedMidRun: 'A prompt was closed part-way through. Steps already completed stay done; run `uds uninstall` again to finish.'
|
|
1267
1279
|
}
|
|
1268
1280
|
}
|
|
1269
1281
|
},
|
|
@@ -2134,7 +2146,7 @@ export const messages = {
|
|
|
2134
2146
|
missingSkillsHint: '提示:執行 `uds update` 安裝缺少的 Skills/斜線命令',
|
|
2135
2147
|
// 提交前檢查是否真的會被 git 執行(uds init 寫了檢查檔,卻沒確認 git 會跑它——這裡只回報,不寫入)
|
|
2136
2148
|
hookNotWiredTitle: '⚠ [pre-commit] {file} 已安裝,但 git 實際不會執行它。',
|
|
2137
|
-
hookNotWiredOverride: ' git core.hooksPath 已設定為「{path}」——UDS 不會覆蓋它。請確認該路徑下的檔案也會執行 `
|
|
2149
|
+
hookNotWiredOverride: ' git core.hooksPath 已設定為「{path}」——UDS 不會覆蓋它。請確認該路徑下的檔案也會執行 `universal-dev-standards check`(要用已安裝的 CLI——不要用短名稱 uds,它在 npm registry 上是不相干的套件),或自行改用:git config --local core.hooksPath .husky',
|
|
2138
2150
|
hookNotWiredUnwired: ' git core.hooksPath 未設定,而 {file} 也不在 git 預設會讀取的路徑上。',
|
|
2139
2151
|
hookNotWiredFix: ' 修復方式(僅對此 clone 生效,不會進版控,其他人 clone 後要自己再跑一次):git config --local core.hooksPath .husky',
|
|
2140
2152
|
hookNotWiredFixNative: ' 修復方式:把 core.hooksPath 改回預設(git config --local --unset core.hooksPath),或改在「{path}」底下也加上 `uds check`。',
|
|
@@ -2150,6 +2162,8 @@ export const messages = {
|
|
|
2150
2162
|
// 2026-09-27 於 CI(windows-latest)實測。
|
|
2151
2163
|
hookMissingShebangTitle: '⚠ [pre-commit] {file} 沒有 shebang 行——在 Windows 上 git 無法執行它。',
|
|
2152
2164
|
hookMissingShebangFix: ' 修復方式:在 {file} 的第一行加上 shebang `#!/bin/sh`(由 husky 管理的 hook,下次 `uds init` 動到它時會自動處理),或重新執行 `uds init`。',
|
|
2165
|
+
hookBareUdsTitle: '⚠ [pre-commit] {file} 會請 npm 執行裸名稱 uds,而它在 npm registry 上是不相干的套件(不是本專案)。',
|
|
2166
|
+
hookBareUdsFix: ' 修復方式:執行 `uds update`——它會把 UDS 自己寫入的那一行換成只執行已安裝 UDS CLI 的寫法。你自己寫或改過的那一行不會被動,請自行改成 `universal-dev-standards check`。',
|
|
2153
2167
|
// Summary mode (--summary)
|
|
2154
2168
|
summary_mode: {
|
|
2155
2169
|
title: 'UDS 狀態摘要',
|
|
@@ -2188,6 +2202,7 @@ export const messages = {
|
|
|
2188
2202
|
title: '通用開發標準 - 初始化',
|
|
2189
2203
|
alreadyInitialized: '⚠ 此專案已初始化標準。',
|
|
2190
2204
|
useUpdateOrDelete: '使用 `uds update` 更新,或刪除 .standards/ 重新初始化。',
|
|
2205
|
+
useUpdateWithHooks: '要為這個專案補裝執行關卡(含 UDS 在你初始化之後才支援的工具),請執行 `uds update --with-hooks`。',
|
|
2191
2206
|
// Detection
|
|
2192
2207
|
detectingProject: '正在偵測專案特性...',
|
|
2193
2208
|
analysisComplete: '專案分析完成',
|
|
@@ -2292,6 +2307,11 @@ export const messages = {
|
|
|
2292
2307
|
currentVersion: '目前版本',
|
|
2293
2308
|
latestVersion: '最新版本',
|
|
2294
2309
|
upToDate: '✓ 標準已是最新版本。',
|
|
2310
|
+
// pre-commit hook 遷移(npm registry 上的裸名稱 uds 不是本專案)
|
|
2311
|
+
hookMigrated: '✓ .husky/pre-commit:已把舊版 UDS 寫入的那一行(它會請 npm 解析裸名稱 uds,而那不是本專案)換成只執行已安裝 UDS CLI 的寫法。',
|
|
2312
|
+
hookWouldMigrate: '+ .husky/pre-commit:將把舊版 UDS 寫入的那一行(它會請 npm 解析裸名稱 uds,而那不是本專案)換成只執行已安裝 UDS CLI 的寫法。',
|
|
2313
|
+
hookLegacyKept: '⚠ .husky/pre-commit 第 {line} 行(「{text}」)會請 npm 解析裸名稱 uds,而它在 npm registry 上是不相干的套件。這一行不是 UDS 寫入的原樣,所以沒有動它。請自行改成 universal-dev-standards check——需要在本專案或 PATH 上已安裝 UDS CLI。',
|
|
2314
|
+
hookMigrateFailed: '⚠ 無法改寫 .husky/pre-commit:{error}。請 npm 解析裸名稱 uds 的那一行仍在,請自行改成 universal-dev-standards check。',
|
|
2295
2315
|
newerVersion: '(你的版本比登錄庫更新:{version})',
|
|
2296
2316
|
updateAvailable: '有可用更新:{current} → {latest}',
|
|
2297
2317
|
// Files
|
|
@@ -2518,7 +2538,11 @@ export const messages = {
|
|
|
2518
2538
|
uninstallPartial: '⚠ 解除安裝完成但有錯誤。',
|
|
2519
2539
|
removed: '已移除',
|
|
2520
2540
|
skippedLabel: '已跳過',
|
|
2521
|
-
errorsLabel: '錯誤'
|
|
2541
|
+
errorsLabel: '錯誤',
|
|
2542
|
+
cannotPrompt: '無法詢問確認:目前沒有可回答提示的終端機(非互動 shell、CI 或管線)。',
|
|
2543
|
+
cannotPromptHint: '尚未變更任何檔案。請加上 --yes 事先確認,或加上 --dry-run 預覽變更。',
|
|
2544
|
+
promptClosed: '提示在回答前被關閉。尚未變更任何檔案。',
|
|
2545
|
+
promptClosedMidRun: '執行途中提示被關閉。已完成的步驟維持完成;請再執行一次 `uds uninstall` 完成其餘部分。'
|
|
2522
2546
|
}
|
|
2523
2547
|
}
|
|
2524
2548
|
},
|
|
@@ -3158,6 +3182,7 @@ export const messages = {
|
|
|
3158
3182
|
title: '通用开发标准 - 初始化',
|
|
3159
3183
|
alreadyInitialized: '⚠ 此项目已初始化标准。',
|
|
3160
3184
|
useUpdateOrDelete: '使用 `uds update` 更新,或删除 .standards/ 重新初始化。',
|
|
3185
|
+
useUpdateWithHooks: '要为这个项目补装执行关卡(含 UDS 在你初始化之后才支持的工具),请执行 `uds update --with-hooks`。',
|
|
3161
3186
|
// Detection
|
|
3162
3187
|
detectingProject: '正在检测项目特性...',
|
|
3163
3188
|
analysisComplete: '项目分析完成',
|
|
@@ -3404,7 +3429,7 @@ export const messages = {
|
|
|
3404
3429
|
missingSkillsHint: '提示:执行 `uds update` 安装缺少的 Skills/斜线命令',
|
|
3405
3430
|
// 提交前检查是否真的会被 git 执行(uds init 写了检查文件,却没确认 git 会跑它——这里只回报,不写入)
|
|
3406
3431
|
hookNotWiredTitle: '⚠ [pre-commit] {file} 已安装,但 git 实际不会执行它。',
|
|
3407
|
-
hookNotWiredOverride: ' git core.hooksPath 已设定为“{path}”——UDS 不会覆盖它。请确认该路径下的文件也会执行 `
|
|
3432
|
+
hookNotWiredOverride: ' git core.hooksPath 已设定为“{path}”——UDS 不会覆盖它。请确认该路径下的文件也会执行 `universal-dev-standards check`(要用已安装的 CLI——不要用短名称 uds,它在 npm registry 上是不相干的包),或自行改用:git config --local core.hooksPath .husky',
|
|
3408
3433
|
hookNotWiredUnwired: ' git core.hooksPath 未设定,而 {file} 也不在 git 默认会读取的路径上。',
|
|
3409
3434
|
hookNotWiredFix: ' 修复方式(仅对此 clone 生效,不会进版控,其他人 clone 后要自己再跑一次):git config --local core.hooksPath .husky',
|
|
3410
3435
|
hookNotWiredFixNative: ' 修复方式:把 core.hooksPath 改回默认(git config --local --unset core.hooksPath),或改在“{path}”下也加上 `uds check`。',
|
|
@@ -3416,6 +3441,8 @@ export const messages = {
|
|
|
3416
3441
|
// 2026-09-27 于 CI(windows-latest)实测。
|
|
3417
3442
|
hookMissingShebangTitle: '⚠ [pre-commit] {file} 没有 shebang 行——在 Windows 上 git 无法执行它。',
|
|
3418
3443
|
hookMissingShebangFix: ' 修复方式:在 {file} 的第一行加上 shebang `#!/bin/sh`(由 husky 管理的 hook,下次 `uds init` 动到它时会自动处理),或重新运行 `uds init`。',
|
|
3444
|
+
hookBareUdsTitle: '⚠ [pre-commit] {file} 会请 npm 执行裸名称 uds,而它在 npm registry 上是不相干的包(不是本项目)。',
|
|
3445
|
+
hookBareUdsFix: ' 修复方式:运行 `uds update`——它会把 UDS 自己写入的那一行换成只执行已安装 UDS CLI 的写法。你自己写或改过的那一行不会被动,请自行改成 `universal-dev-standards check`。',
|
|
3419
3446
|
// Summary mode (--summary)
|
|
3420
3447
|
summary_mode: {
|
|
3421
3448
|
title: 'UDS 状态摘要',
|
|
@@ -3471,6 +3498,11 @@ export const messages = {
|
|
|
3471
3498
|
currentVersion: '当前版本',
|
|
3472
3499
|
latestVersion: '最新版本',
|
|
3473
3500
|
upToDate: '✓ 标准是最新的。',
|
|
3501
|
+
// pre-commit hook 迁移(npm registry 上的裸名称 uds 不是本项目)
|
|
3502
|
+
hookMigrated: '✓ .husky/pre-commit:已把旧版 UDS 写入的那一行(它会请 npm 解析裸名称 uds,而那不是本项目)换成只执行已安装 UDS CLI 的写法。',
|
|
3503
|
+
hookWouldMigrate: '+ .husky/pre-commit:将把旧版 UDS 写入的那一行(它会请 npm 解析裸名称 uds,而那不是本项目)换成只执行已安装 UDS CLI 的写法。',
|
|
3504
|
+
hookLegacyKept: '⚠ .husky/pre-commit 第 {line} 行(“{text}”)会请 npm 解析裸名称 uds,而它在 npm registry 上是不相干的包。这一行不是 UDS 写入的原样,所以没有动它。请自行改成 universal-dev-standards check——需要在本项目或 PATH 上已安装 UDS CLI。',
|
|
3505
|
+
hookMigrateFailed: '⚠ 无法改写 .husky/pre-commit:{error}。请 npm 解析裸名称 uds 的那一行仍在,请自行改成 universal-dev-standards check。',
|
|
3474
3506
|
newerVersion: '(您有比注册表更新的版本:{version})',
|
|
3475
3507
|
updateAvailable: '有可用更新:{current} → {latest}',
|
|
3476
3508
|
// Files
|
|
@@ -3697,7 +3729,11 @@ export const messages = {
|
|
|
3697
3729
|
uninstallPartial: '⚠ 卸载完成但有错误。',
|
|
3698
3730
|
removed: '已移除',
|
|
3699
3731
|
skippedLabel: '已跳过',
|
|
3700
|
-
errorsLabel: '错误'
|
|
3732
|
+
errorsLabel: '错误',
|
|
3733
|
+
cannotPrompt: '无法询问确认:当前没有可回答提示的终端(非交互 shell、CI 或管道)。',
|
|
3734
|
+
cannotPromptHint: '尚未更改任何文件。请加上 --yes 提前确认,或加上 --dry-run 预览变更。',
|
|
3735
|
+
promptClosed: '提示在回答前被关闭。尚未更改任何文件。',
|
|
3736
|
+
promptClosedMidRun: '执行途中提示被关闭。已完成的步骤保持完成;请再运行一次 `uds uninstall` 完成其余部分。'
|
|
3701
3737
|
}
|
|
3702
3738
|
}
|
|
3703
3739
|
}
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
* @see core/turn-completion-integrity.md
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
-
import { existsSync, readFileSync, writeFileSync,
|
|
35
|
-
import { join, dirname, basename } from 'path';
|
|
34
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, readdirSync } from 'fs';
|
|
35
|
+
import { join, dirname, basename, relative } from 'path';
|
|
36
36
|
import { fileURLToPath } from 'url';
|
|
37
37
|
import { execFileSync } from 'child_process';
|
|
38
38
|
import { load as parseYaml } from 'js-yaml';
|
|
39
|
+
import { detectAITools } from '../utils/detector.js';
|
|
40
|
+
import { newRecorder, mkdirTracked, recordFile, RECORD_KINDS } from '../core/install-records.js';
|
|
39
41
|
|
|
40
42
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
41
43
|
|
|
@@ -171,7 +173,41 @@ function probeLanguageLimits(hooksDir, scripts) {
|
|
|
171
173
|
return out;
|
|
172
174
|
}
|
|
173
175
|
|
|
174
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Copy the shipped hook scripts (recursively — a hook may ship a directory beside
|
|
178
|
+
* it: locale packs, fixtures) into `<project>/scripts/hooks`, and RECORD every
|
|
179
|
+
* file this call actually wrote and every directory it had to create.
|
|
180
|
+
*
|
|
181
|
+
* `cpSync` did the copy before and returns nothing, which is why `uds uninstall`
|
|
182
|
+
* had no way to know which of the files under `scripts/hooks/` were UDS's — a
|
|
183
|
+
* directory an adopter's own hook scripts may share. A file that already exists
|
|
184
|
+
* and is byte-identical is not recorded (this call did not write it, so it
|
|
185
|
+
* proves nothing about who did); a file that exists, differs, and is not
|
|
186
|
+
* overwritten is left alone and not recorded.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} srcDir Shipped hooks directory
|
|
189
|
+
* @param {string} destDir Destination directory (absolute)
|
|
190
|
+
* @param {{ overwrite: boolean, recorder: object, projectPath: string }} ctx
|
|
191
|
+
*/
|
|
192
|
+
function copyTreeRecorded(srcDir, destDir, ctx) {
|
|
193
|
+
mkdirTracked(ctx.recorder, ctx.projectPath, destDir);
|
|
194
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
195
|
+
const src = join(srcDir, entry.name);
|
|
196
|
+
const dest = join(destDir, entry.name);
|
|
197
|
+
if (entry.isDirectory()) {
|
|
198
|
+
copyTreeRecorded(src, dest, ctx);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (existsSync(dest)) {
|
|
202
|
+
if (!ctx.overwrite) continue;
|
|
203
|
+
if (readFileSync(src).equals(readFileSync(dest))) continue;
|
|
204
|
+
}
|
|
205
|
+
copyFileSync(src, dest);
|
|
206
|
+
recordFile(ctx.recorder, ctx.projectPath, relative(ctx.projectPath, dest), RECORD_KINDS.HOOK_SCRIPT);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function installHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
|
|
175
211
|
const claudeDir = join(projectPath, '.claude');
|
|
176
212
|
const settingsPath = join(claudeDir, 'settings.json');
|
|
177
213
|
const hooksDir = join(projectPath, 'scripts', 'hooks');
|
|
@@ -186,11 +222,8 @@ export function installHooks(projectPath) {
|
|
|
186
222
|
return { installed: false, scriptsCount: 0, settingsPath, events, skipped };
|
|
187
223
|
}
|
|
188
224
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Recursive: a hook may ship a directory beside it (locale packs, fixtures).
|
|
193
|
-
cpSync(hookDir, hooksDir, { recursive: true });
|
|
225
|
+
mkdirTracked(recorder, projectPath, claudeDir);
|
|
226
|
+
copyTreeRecorded(hookDir, hooksDir, { overwrite: overwriteScripts, recorder, projectPath });
|
|
194
227
|
|
|
195
228
|
let settings = {};
|
|
196
229
|
if (existsSync(settingsPath)) {
|
|
@@ -216,6 +249,7 @@ export function installHooks(projectPath) {
|
|
|
216
249
|
events,
|
|
217
250
|
skipped,
|
|
218
251
|
languageLimits: probeLanguageLimits(hooksDir, scripts),
|
|
252
|
+
artifacts: recorder,
|
|
219
253
|
};
|
|
220
254
|
}
|
|
221
255
|
|
|
@@ -258,10 +292,13 @@ export const AGY_HOOK_COMMAND = `node ../scripts/hooks/${AGY_HOOK_SCRIPT}`;
|
|
|
258
292
|
// re-install repairs it and uninstall still removes it.
|
|
259
293
|
export const AGY_HOOK_COMMAND_LEGACY = `node scripts/hooks/${AGY_HOOK_SCRIPT}`;
|
|
260
294
|
|
|
261
|
-
/**
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Copy the shared hook scripts into the project, same as installHooks() does.
|
|
297
|
+
* `overwrite: false` keeps a script that already exists (an adopter may have
|
|
298
|
+
* edited it); it is what `uds update --with-hooks` uses unless `--force` is given.
|
|
299
|
+
*/
|
|
300
|
+
function copyHookScripts(hookDir, hooksDir, { overwrite = true, recorder, projectPath } = {}) {
|
|
301
|
+
copyTreeRecorded(hookDir, hooksDir, { overwrite, recorder, projectPath });
|
|
265
302
|
}
|
|
266
303
|
|
|
267
304
|
/**
|
|
@@ -278,7 +315,7 @@ function copyHookScripts(hookDir, hooksDir) {
|
|
|
278
315
|
* @param {string} projectPath
|
|
279
316
|
* @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
|
|
280
317
|
*/
|
|
281
|
-
export function installCodexHooks(projectPath) {
|
|
318
|
+
export function installCodexHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
|
|
282
319
|
const hooksJsonPath = join(projectPath, '.codex', 'hooks.json');
|
|
283
320
|
const hookDir = hooksSourceDir();
|
|
284
321
|
|
|
@@ -286,9 +323,8 @@ export function installCodexHooks(projectPath) {
|
|
|
286
323
|
return { installed: false, settingsPath: hooksJsonPath, reason: `hook script not found: ${CODEX_HOOK_SCRIPT}` };
|
|
287
324
|
}
|
|
288
325
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
|
|
326
|
+
mkdirTracked(recorder, projectPath, join(projectPath, '.codex'));
|
|
327
|
+
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
|
|
292
328
|
|
|
293
329
|
let config = {};
|
|
294
330
|
if (existsSync(hooksJsonPath)) {
|
|
@@ -305,7 +341,7 @@ export function installCodexHooks(projectPath) {
|
|
|
305
341
|
]);
|
|
306
342
|
|
|
307
343
|
writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
|
|
308
|
-
return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
|
|
344
|
+
return { installed: true, settingsPath: hooksJsonPath, event: 'Stop', artifacts: recorder };
|
|
309
345
|
}
|
|
310
346
|
|
|
311
347
|
/**
|
|
@@ -318,7 +354,7 @@ export function installCodexHooks(projectPath) {
|
|
|
318
354
|
* @param {string} projectPath
|
|
319
355
|
* @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
|
|
320
356
|
*/
|
|
321
|
-
export function installGeminiHooks(projectPath) {
|
|
357
|
+
export function installGeminiHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
|
|
322
358
|
const settingsPath = join(projectPath, '.gemini', 'settings.json');
|
|
323
359
|
const hookDir = hooksSourceDir();
|
|
324
360
|
|
|
@@ -326,9 +362,8 @@ export function installGeminiHooks(projectPath) {
|
|
|
326
362
|
return { installed: false, settingsPath, reason: `hook script not found: ${GEMINI_HOOK_SCRIPT}` };
|
|
327
363
|
}
|
|
328
364
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
|
|
365
|
+
mkdirTracked(recorder, projectPath, join(projectPath, '.gemini'));
|
|
366
|
+
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
|
|
332
367
|
|
|
333
368
|
let settings = {};
|
|
334
369
|
if (existsSync(settingsPath)) {
|
|
@@ -344,7 +379,7 @@ export function installGeminiHooks(projectPath) {
|
|
|
344
379
|
]);
|
|
345
380
|
|
|
346
381
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
347
|
-
return { installed: true, settingsPath, event: 'AfterAgent' };
|
|
382
|
+
return { installed: true, settingsPath, event: 'AfterAgent', artifacts: recorder };
|
|
348
383
|
}
|
|
349
384
|
|
|
350
385
|
/**
|
|
@@ -372,7 +407,7 @@ export function installGeminiHooks(projectPath) {
|
|
|
372
407
|
* @param {string} projectPath
|
|
373
408
|
* @returns {{ installed: boolean, settingsPath: string, event?: string, reason?: string }}
|
|
374
409
|
*/
|
|
375
|
-
export function installAgyHooks(projectPath) {
|
|
410
|
+
export function installAgyHooks(projectPath, { overwriteScripts = true, recorder = newRecorder() } = {}) {
|
|
376
411
|
const hooksJsonPath = join(projectPath, '.agents', 'hooks.json');
|
|
377
412
|
const hookDir = hooksSourceDir();
|
|
378
413
|
|
|
@@ -392,9 +427,8 @@ export function installAgyHooks(projectPath) {
|
|
|
392
427
|
}
|
|
393
428
|
}
|
|
394
429
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'));
|
|
430
|
+
mkdirTracked(recorder, projectPath, join(projectPath, '.agents'));
|
|
431
|
+
copyHookScripts(hookDir, join(projectPath, 'scripts', 'hooks'), { overwrite: overwriteScripts, recorder, projectPath });
|
|
398
432
|
|
|
399
433
|
const command = AGY_HOOK_COMMAND;
|
|
400
434
|
const entry = config[AGY_HOOK_NAME];
|
|
@@ -411,5 +445,180 @@ export function installAgyHooks(projectPath) {
|
|
|
411
445
|
config[AGY_HOOK_NAME] = { ...mine, Stop: stop };
|
|
412
446
|
|
|
413
447
|
writeFileSync(hooksJsonPath, JSON.stringify(config, null, 2) + '\n');
|
|
414
|
-
return { installed: true, settingsPath: hooksJsonPath, event: 'Stop' };
|
|
448
|
+
return { installed: true, settingsPath: hooksJsonPath, event: 'Stop', artifacts: recorder };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
452
|
+
// `uds update --with-hooks` — hooks for a project that is already initialized.
|
|
453
|
+
//
|
|
454
|
+
// 🔴 Found 2026-09-29 installing the 6.14.0-beta.1 package into a fresh project:
|
|
455
|
+
// hooks were only ever wired by `uds init --with-hooks`, and `uds init` refuses to
|
|
456
|
+
// run twice ("Standards already initialized"). `uds update` had no hook option.
|
|
457
|
+
// So an existing adopter could NEVER receive the hook for a tool UDS started
|
|
458
|
+
// supporting after they initialized — and could not repair a hook file that was
|
|
459
|
+
// never written. This is that door.
|
|
460
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
461
|
+
|
|
462
|
+
/** The tools that have a hook installer, in the order they are reported. */
|
|
463
|
+
export const HOOK_CAPABLE_TOOLS = ['claude-code', 'codex', 'gemini-cli', 'antigravity'];
|
|
464
|
+
|
|
465
|
+
/** detectAITools() keys are camelCase; manifests and flags use the kebab-case tool names. */
|
|
466
|
+
const DETECTED_KEY_TO_TOOL = { claudeCode: 'claude-code', geminiCli: 'gemini-cli' };
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Which tools to install hooks for.
|
|
470
|
+
*
|
|
471
|
+
* --ai-tool given → exactly those (validated); nothing is detected or guessed.
|
|
472
|
+
* otherwise → the manifest's tools ∪ what the project directory shows now.
|
|
473
|
+
*
|
|
474
|
+
* The union is the point: a project initialized before agy detection existed (or
|
|
475
|
+
* before the adopter started using agy) has no `antigravity` in its manifest, and
|
|
476
|
+
* detection alone finds it; a project whose marker files were deleted still has
|
|
477
|
+
* the tool in its manifest. Either source is enough, neither is required.
|
|
478
|
+
*
|
|
479
|
+
* @param {string} projectPath
|
|
480
|
+
* @param {object|null} manifest
|
|
481
|
+
* @param {{ aiTool?: string }} [options]
|
|
482
|
+
* @returns {{ tools: string[], explicit: boolean, unknown: string[], sources: Record<string,string[]> }}
|
|
483
|
+
*/
|
|
484
|
+
export function resolveHookTools(projectPath, manifest, options = {}) {
|
|
485
|
+
if (options.aiTool) {
|
|
486
|
+
const asked = String(options.aiTool).split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
487
|
+
const unknown = asked.filter((t) => !HOOK_CAPABLE_TOOLS.includes(t));
|
|
488
|
+
const tools = HOOK_CAPABLE_TOOLS.filter((t) => asked.includes(t));
|
|
489
|
+
return { tools, explicit: true, unknown, sources: Object.fromEntries(tools.map((t) => [t, ['--ai-tool']])) };
|
|
490
|
+
}
|
|
491
|
+
const sources = {};
|
|
492
|
+
const add = (tool, why) => {
|
|
493
|
+
if (!HOOK_CAPABLE_TOOLS.includes(tool)) return;
|
|
494
|
+
(sources[tool] ||= []).push(why);
|
|
495
|
+
};
|
|
496
|
+
for (const t of [...(manifest?.integrations ?? []), ...(manifest?.aiTools ?? [])]) add(t, 'manifest');
|
|
497
|
+
const detected = detectAITools(projectPath);
|
|
498
|
+
for (const [k, on] of Object.entries(detected)) if (on) add(DETECTED_KEY_TO_TOOL[k] ?? k, 'detected in the project');
|
|
499
|
+
for (const t of Object.keys(sources)) sources[t] = [...new Set(sources[t])];
|
|
500
|
+
return { tools: HOOK_CAPABLE_TOOLS.filter((t) => sources[t]), explicit: false, unknown: [], sources };
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function readJson(path) {
|
|
504
|
+
if (!existsSync(path)) return { state: 'absent' };
|
|
505
|
+
try {
|
|
506
|
+
const value = JSON.parse(readFileSync(path, 'utf-8'));
|
|
507
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return { state: 'invalid', why: 'is not a JSON object' };
|
|
508
|
+
return { state: 'ok', value };
|
|
509
|
+
} catch {
|
|
510
|
+
return { state: 'invalid', why: 'is not valid JSON' };
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const hasCommand = (entries, cmd) =>
|
|
515
|
+
Array.isArray(entries) && entries.some((e) => commandOf(e) === cmd);
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Is the hook for `tool` already in the project's config? Read-only.
|
|
519
|
+
*
|
|
520
|
+
* @returns {{ status: 'present'|'missing'|'stale'|'blocked', path: string, why?: string }}
|
|
521
|
+
* present every entry UDS would write is there — nothing to do
|
|
522
|
+
* missing none (or only part) of it is there
|
|
523
|
+
* stale agy's pre-fix command is there (does not resolve from agy's cwd)
|
|
524
|
+
* blocked the config file exists but cannot be merged safely (invalid JSON)
|
|
525
|
+
*/
|
|
526
|
+
export function hookStatus(projectPath, tool) {
|
|
527
|
+
if (tool === 'antigravity') {
|
|
528
|
+
const path = join(projectPath, '.agents', 'hooks.json');
|
|
529
|
+
const j = readJson(path);
|
|
530
|
+
if (j.state === 'absent') return { status: 'missing', path };
|
|
531
|
+
if (j.state === 'invalid') return { status: 'blocked', path, why: `.agents/hooks.json ${j.why}; left untouched` };
|
|
532
|
+
const stop = j.value[AGY_HOOK_NAME]?.Stop;
|
|
533
|
+
const cmds = Array.isArray(stop) ? stop.map((h) => h && h.command) : [];
|
|
534
|
+
if (cmds.includes(AGY_HOOK_COMMAND) && !cmds.includes(AGY_HOOK_COMMAND_LEGACY)) return { status: 'present', path };
|
|
535
|
+
return { status: cmds.includes(AGY_HOOK_COMMAND_LEGACY) ? 'stale' : 'missing', path };
|
|
536
|
+
}
|
|
537
|
+
if (tool === 'codex') {
|
|
538
|
+
const path = join(projectPath, '.codex', 'hooks.json');
|
|
539
|
+
const j = readJson(path);
|
|
540
|
+
if (j.state === 'absent') return { status: 'missing', path };
|
|
541
|
+
if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
|
|
542
|
+
return { status: hasCommand(j.value.hooks?.Stop, `node scripts/hooks/${CODEX_HOOK_SCRIPT}`) ? 'present' : 'missing', path };
|
|
543
|
+
}
|
|
544
|
+
if (tool === 'gemini-cli') {
|
|
545
|
+
const path = join(projectPath, '.gemini', 'settings.json');
|
|
546
|
+
const j = readJson(path);
|
|
547
|
+
if (j.state === 'absent') return { status: 'missing', path };
|
|
548
|
+
if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
|
|
549
|
+
return { status: hasCommand(j.value.hooks?.AfterAgent, `node scripts/hooks/${GEMINI_HOOK_SCRIPT}`) ? 'present' : 'missing', path };
|
|
550
|
+
}
|
|
551
|
+
if (tool === 'claude-code') {
|
|
552
|
+
const path = join(projectPath, '.claude', 'settings.json');
|
|
553
|
+
const { configs } = collectHookConfigs(standardsSourceDir(), hooksSourceDir());
|
|
554
|
+
if (Object.keys(configs).length === 0) return { status: 'blocked', path, why: 'no standard produced a usable hook' };
|
|
555
|
+
const j = readJson(path);
|
|
556
|
+
if (j.state === 'absent') return { status: 'missing', path };
|
|
557
|
+
if (j.state === 'invalid') return { status: 'missing', path, why: 'unreadable; it would be rewritten by the installer' };
|
|
558
|
+
for (const [event, entries] of Object.entries(configs)) {
|
|
559
|
+
const existing = j.value.hooks?.[event] ?? [];
|
|
560
|
+
if (mergeHookArray(existing, entries).length !== existing.length) return { status: 'missing', path };
|
|
561
|
+
}
|
|
562
|
+
return { status: 'present', path };
|
|
563
|
+
}
|
|
564
|
+
return { status: 'blocked', path: '', why: `no hook installer for ${tool}` };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Hook scripts UDS ships vs what the project already has: how many are new, identical, or edited. */
|
|
568
|
+
function scriptDiff(projectPath) {
|
|
569
|
+
const src = hooksSourceDir();
|
|
570
|
+
const out = { added: 0, identical: 0, kept: [] };
|
|
571
|
+
if (!src) return out;
|
|
572
|
+
const walk = (dir, rel = '') => {
|
|
573
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
574
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
575
|
+
if (e.isDirectory()) { walk(join(dir, e.name), r); continue; }
|
|
576
|
+
const dest = join(projectPath, 'scripts', 'hooks', r);
|
|
577
|
+
if (!existsSync(dest)) out.added++;
|
|
578
|
+
else if (readFileSync(dest).equals(readFileSync(join(dir, e.name)))) out.identical++;
|
|
579
|
+
else out.kept.push(r);
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
walk(src);
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const INSTALLERS = {
|
|
587
|
+
'claude-code': (p, o) => installHooks(p, o),
|
|
588
|
+
codex: (p, o) => installCodexHooks(p, o),
|
|
589
|
+
'gemini-cli': (p, o) => installGeminiHooks(p, o),
|
|
590
|
+
antigravity: (p, o) => installAgyHooks(p, o),
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Add the hooks that are missing to an already-initialized project.
|
|
595
|
+
*
|
|
596
|
+
* - A hook that is already there is not touched, and nothing is written for it.
|
|
597
|
+
* - The adopter's own hooks are never touched: every installer merges (Claude,
|
|
598
|
+
* Codex, Gemini) or writes only under the key UDS owns (agy).
|
|
599
|
+
* - A hook script the project already has and that differs from this version is
|
|
600
|
+
* KEPT unless `overwriteScripts` — it may have been edited. It is reported.
|
|
601
|
+
* - `plan: true` reads and reports, and writes nothing.
|
|
602
|
+
*
|
|
603
|
+
* @param {string} projectPath
|
|
604
|
+
* @param {string[]} tools
|
|
605
|
+
* @param {{ plan?: boolean, overwriteScripts?: boolean }} [opts]
|
|
606
|
+
* @returns {{ results: Array<{tool:string, outcome:'installed'|'unchanged'|'would-install'|'blocked'|'failed', path:string, why?:string, repaired?:boolean}>, scripts: {added:number, identical:number, kept:string[]}, artifacts: object }}
|
|
607
|
+
* `artifacts` is the recorder of what this call wrote (see core/install-records.js);
|
|
608
|
+
* the caller persists it into the manifest so `uds uninstall` can remove exactly that.
|
|
609
|
+
*/
|
|
610
|
+
export function installMissingHooks(projectPath, tools, { plan = false, overwriteScripts = false, recorder = newRecorder() } = {}) {
|
|
611
|
+
// Measured BEFORE anything is copied: afterwards every script would read "identical".
|
|
612
|
+
const scripts = scriptDiff(projectPath);
|
|
613
|
+
const results = [];
|
|
614
|
+
for (const tool of tools) {
|
|
615
|
+
const st = hookStatus(projectPath, tool);
|
|
616
|
+
if (st.status === 'present') { results.push({ tool, outcome: 'unchanged', path: st.path }); continue; }
|
|
617
|
+
if (st.status === 'blocked') { results.push({ tool, outcome: 'blocked', path: st.path, why: st.why }); continue; }
|
|
618
|
+
if (plan) { results.push({ tool, outcome: 'would-install', path: st.path, repaired: st.status === 'stale' }); continue; }
|
|
619
|
+
const r = INSTALLERS[tool](projectPath, { overwriteScripts, recorder });
|
|
620
|
+
if (r.installed) results.push({ tool, outcome: 'installed', path: r.settingsPath ?? st.path, repaired: st.status === 'stale' });
|
|
621
|
+
else results.push({ tool, outcome: 'failed', path: r.settingsPath ?? st.path, why: r.reason ?? 'no standard produced a usable hook' });
|
|
622
|
+
}
|
|
623
|
+
return { results, scripts, artifacts: recorder };
|
|
415
624
|
}
|
|
@@ -141,6 +141,10 @@ export async function installIntegrations(config, projectPath) {
|
|
|
141
141
|
if (result.success) {
|
|
142
142
|
results.integrations.push(result.path);
|
|
143
143
|
generatedFiles.add(targetFile);
|
|
144
|
+
// Files this run created from nothing (not merged into an existing file);
|
|
145
|
+
// init records them so uninstall can prove they are wholly UDS's. Added only
|
|
146
|
+
// when there is one, so an empty run keeps its original result shape.
|
|
147
|
+
if (result.created) (results.createdFiles ||= []).push(result.path);
|
|
144
148
|
|
|
145
149
|
// Capture integration block hash for tracking UDS content
|
|
146
150
|
if (result.blockHashInfo) {
|
|
@@ -310,7 +314,7 @@ export async function generateUniversalAgentsMd(config, integrationResults, proj
|
|
|
310
314
|
|
|
311
315
|
if (result.success) {
|
|
312
316
|
spinner.succeed(msg.generatedAgentsMd || 'Generated AGENTS.md (universal summary)');
|
|
313
|
-
return { path: result.path, error: null, blockHashInfo: result.blockHashInfo };
|
|
317
|
+
return { path: result.path, error: null, blockHashInfo: result.blockHashInfo, created: result.created };
|
|
314
318
|
} else {
|
|
315
319
|
spinner.warn(msg.couldNotGenerateAgentsMd || 'Could not generate AGENTS.md');
|
|
316
320
|
return { path: null, error: result.error };
|