universal-dev-standards 6.14.0-beta.2 → 6.14.0-beta.4

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.
Files changed (53) hide show
  1. package/bundled/ai/standards/ai-response-navigation.ai.yaml +43 -3
  2. package/bundled/ai/standards/checkin-standards.ai.yaml +25 -6
  3. package/bundled/ai/standards/open-work-tracking.ai.yaml +4 -1
  4. package/bundled/ai/standards/pipeline-security-gates.ai.yaml +5 -1
  5. package/bundled/core/ai-response-navigation.md +128 -12
  6. package/bundled/core/open-work-tracking.md +1 -1
  7. package/bundled/extensions/frameworks/fat-free-patterns.md +937 -0
  8. package/bundled/extensions/languages/csharp-style.md +464 -0
  9. package/bundled/extensions/languages/php/fat-free-patterns.md +915 -0
  10. package/bundled/extensions/languages/php/php-style.md +693 -0
  11. package/bundled/extensions/languages/php-style.md +700 -0
  12. package/bundled/extensions/locales/zh-cn.md +717 -0
  13. package/bundled/extensions/locales/zh-tw.md +717 -0
  14. package/bundled/locales/COVERAGE.md +5 -4
  15. package/bundled/locales/zh-CN/CHANGELOG.md +44 -3
  16. package/bundled/locales/zh-CN/README.md +2 -2
  17. package/bundled/locales/zh-CN/SECURITY.md +1 -1
  18. package/bundled/locales/zh-CN/core/ai-response-navigation.md +110 -12
  19. package/bundled/locales/zh-CN/skills/README.md +1 -0
  20. package/bundled/locales/zh-CN/skills/comprehension-ladder/SKILL.md +289 -0
  21. package/bundled/locales/zh-CN/skills/comprehension-ladder/eval-cases.md +261 -0
  22. package/bundled/locales/zh-TW/CHANGELOG.md +44 -3
  23. package/bundled/locales/zh-TW/README.md +2 -2
  24. package/bundled/locales/zh-TW/SECURITY.md +1 -1
  25. package/bundled/locales/zh-TW/core/ai-response-navigation.md +110 -12
  26. package/bundled/locales/zh-TW/core/open-work-tracking.md +3 -3
  27. package/bundled/locales/zh-TW/skills/README.md +1 -0
  28. package/bundled/locales/zh-TW/skills/comprehension-ladder/SKILL.md +289 -0
  29. package/bundled/locales/zh-TW/skills/comprehension-ladder/eval-cases.md +261 -0
  30. package/bundled/skills/README.md +1 -0
  31. package/bundled/skills/comprehension-ladder/SKILL.md +283 -0
  32. package/bundled/skills/comprehension-ladder/eval-cases.md +255 -0
  33. package/package.json +2 -2
  34. package/src/commands/check.js +9 -0
  35. package/src/commands/init.js +100 -27
  36. package/src/commands/uninstall.js +144 -30
  37. package/src/commands/update.js +62 -3
  38. package/src/core/install-records.js +191 -0
  39. package/src/i18n/messages.js +39 -6
  40. package/src/installers/hooks-installer.js +61 -30
  41. package/src/installers/integration-installer.js +5 -1
  42. package/src/installers/standards-installer.js +16 -23
  43. package/src/reconciler/plan-executor.js +10 -11
  44. package/src/uninstallers/hook-uninstaller.js +219 -33
  45. package/src/uninstallers/integration-uninstaller.js +35 -5
  46. package/src/utils/copier.js +57 -0
  47. package/src/utils/git-hooks.js +139 -7
  48. package/src/utils/hasher.js +36 -0
  49. package/src/utils/integration-generator.js +16 -6
  50. package/src/utils/legacy-hook-migration.js +112 -0
  51. package/src/utils/locale.js +19 -0
  52. package/src/utils/open-work-tracking.mjs +124 -23
  53. package/standards-registry.json +21 -7
@@ -1,7 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { createSpinner } from '../utils/spinner.js';
3
- import { execSync } from 'child_process';
4
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'fs';
5
4
  import { join } from 'path';
6
5
  import {
7
6
  manifestExists as isInitialized,
@@ -22,13 +21,17 @@ import {
22
21
  getAgentConfig,
23
22
  getAgentDisplayName
24
23
  } from '../utils/github.js';
25
- import { displayLanguageToLocale } from '../utils/locale.js';
24
+ import { displayLanguageToLocale, normalizeRequestedLocale } from '../utils/locale.js';
25
+ import localeUtils from '../utils/locale.js';
26
+ const LOCALE_MAP_FOR_INIT = localeUtils.LOCALE_MAP;
26
27
  import { generateReleaseConfig, RELEASE_MODE_LABELS } from '../utils/release-config.js';
27
28
  import { guardAgainstSelfAdoption } from '../utils/detect-self-adoption.js';
28
29
  import { readInstallYaml } from '../utils/config-manager.js';
29
30
  import { resolveIntegrationTargetFile } from '../utils/integration-generator.js';
30
31
  import { withFileTransaction } from '../utils/transaction.js';
31
- import { wireGitHooksPath, getLocalHooksPathConfig, stripLegacyHuskyShLine, ensureShebang } from '../utils/git-hooks.js';
32
+ import { newRecorder, mkdirTracked, recordFile, persistRecorder, RECORD_KINDS } from '../core/install-records.js';
33
+ import { wireGitHooksPath, getLocalHooksPathConfig, stripLegacyHuskyShLine, ensureShebang, hookRunsUdsCheck, buildPreCommitBlock } from '../utils/git-hooks.js';
34
+ import { migrateLegacyHuskyHook } from '../utils/legacy-hook-migration.js';
32
35
 
33
36
  /**
34
37
  * Init command - initialize standards in current project
@@ -213,7 +216,11 @@ export async function initCommand(options) {
213
216
  skillHashes: skillsResults.skillHashes,
214
217
  commandHashes: skillsResults.commandHashes,
215
218
  integrationBlockHashes: integrationResults.integrationBlockHashes,
216
- manifestIntegrationConfigs: integrationResults.manifestIntegrationConfigs
219
+ manifestIntegrationConfigs: integrationResults.manifestIntegrationConfigs,
220
+ createdIntegrationFiles: [
221
+ ...(integrationResults.createdFiles ?? []),
222
+ ...(agentsMdResult.created ? [agentsMdResult.path] : [])
223
+ ]
217
224
  };
218
225
  },
219
226
  // A half-installed project must never be committed: if any installer
@@ -258,9 +265,13 @@ export async function initCommand(options) {
258
265
  }
259
266
 
260
267
  // 4.6. Install enforcement hooks (if --with-hooks)
268
+ // What this step writes is recorded in the manifest (installedArtifacts) so
269
+ // `uds uninstall` can remove exactly it — hook scripts, and the folders UDS had
270
+ // to create for them — and leave everything the adopter put there alone.
271
+ const installRecorder = newRecorder();
261
272
  if (config.withHooks) {
262
273
  const { installHooks } = await import('../installers/hooks-installer.js');
263
- const hookResult = installHooks(projectPath);
274
+ const hookResult = installHooks(projectPath, { recorder: installRecorder });
264
275
  if (hookResult.installed) {
265
276
  console.log(chalk.green(
266
277
  ` ✓ Enforcement hooks installed (${hookResult.scriptsCount} scripts, `
@@ -292,7 +303,7 @@ export async function initCommand(options) {
292
303
  const selectedTools = config.integrations || config.aiTools || [];
293
304
  if (selectedTools.includes('codex')) {
294
305
  const { installCodexHooks } = await import('../installers/hooks-installer.js');
295
- const codexResult = installCodexHooks(projectPath);
306
+ const codexResult = installCodexHooks(projectPath, { recorder: installRecorder });
296
307
  if (codexResult.installed) {
297
308
  console.log(chalk.green(' ✓ Codex Stop hook installed (turn-completion-integrity)'));
298
309
  // Measured 2026-09-28 (codex-cli 0.155.1): Codex skips a project hook
@@ -306,7 +317,7 @@ export async function initCommand(options) {
306
317
  }
307
318
  if (selectedTools.includes('gemini-cli')) {
308
319
  const { installGeminiHooks } = await import('../installers/hooks-installer.js');
309
- const geminiResult = installGeminiHooks(projectPath);
320
+ const geminiResult = installGeminiHooks(projectPath, { recorder: installRecorder });
310
321
  if (geminiResult.installed) {
311
322
  console.log(chalk.green(' ✓ Gemini CLI AfterAgent hook installed (turn-completion-integrity; Gemini CLI is legacy — see the standard)'));
312
323
  } else {
@@ -315,7 +326,7 @@ export async function initCommand(options) {
315
326
  }
316
327
  if (selectedTools.includes('antigravity')) {
317
328
  const { installAgyHooks } = await import('../installers/hooks-installer.js');
318
- const agyResult = installAgyHooks(projectPath);
329
+ const agyResult = installAgyHooks(projectPath, { recorder: installRecorder });
319
330
  if (agyResult.installed) {
320
331
  console.log(chalk.green(' ✓ Antigravity CLI Stop hook installed (turn-completion-integrity; .agents/hooks.json)'));
321
332
  // Verified 2026-09-29 (agy 1.2.12): single turn, no tool calls, `agy -p`.
@@ -328,6 +339,15 @@ export async function initCommand(options) {
328
339
  }
329
340
  }
330
341
 
342
+ // 4.7. Record the integration files UDS created from nothing, and the hook
343
+ // files/folders written above. Their text outside the UDS marker block (the
344
+ // `> Auto-generated by ...` header, the generated sections) is what makes
345
+ // "delete the whole file" provable at uninstall time.
346
+ for (const created of combinedResults.createdIntegrationFiles ?? []) {
347
+ recordFile(installRecorder, projectPath, created, RECORD_KINDS.INTEGRATION_FILE);
348
+ }
349
+ persistRecorder(projectPath, installRecorder);
350
+
331
351
  // 5. Setup Pre-commit Hook
332
352
  await setupHuskyHook(projectPath);
333
353
 
@@ -339,7 +359,7 @@ export async function initCommand(options) {
339
359
  * - Node.js projects: use husky
340
360
  * - Non-Node.js projects: write native .git/hooks/pre-commit
341
361
  */
342
- export async function setupHuskyHook(projectPath, { allowInTest = false } = {}) {
362
+ export async function setupHuskyHook(projectPath, { allowInTest = false, platform = process.platform } = {}) {
343
363
  // 2026-07-30:本函式在跑測試時改寫了 universal-dev-standards 自己
344
364
  // ——`npm install --save-dev husky`、改 package.json、建 .husky/pre-commit。
345
365
  //
@@ -359,6 +379,9 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
359
379
 
360
380
  const isNodeProject = existsSync(join(projectPath, 'package.json'));
361
381
 
382
+ // What this function creates, for `uds uninstall` (see core/install-records.js).
383
+ const recorder = newRecorder();
384
+
362
385
  if (isNodeProject) {
363
386
  console.log(chalk.cyan('Configuring Pre-commit Hook...'));
364
387
 
@@ -436,7 +459,7 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
436
459
  // 3. Ensure .husky directory exists
437
460
  if (!existsSync(huskyDir)) {
438
461
  try {
439
- mkdirSync(huskyDir, { recursive: true });
462
+ mkdirTracked(recorder, projectPath, huskyDir);
440
463
  } catch (e) {
441
464
  console.log(chalk.red(` ✗ Failed to create .husky directory: ${e.message}`));
442
465
  return;
@@ -445,8 +468,10 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
445
468
 
446
469
  // 4. Add pre-commit hook content
447
470
  const preCommitPath = join(huskyDir, 'pre-commit');
448
- const udsCmd = 'npx uds check';
449
471
 
472
+ // Only a hook file UDS creates from nothing can later be proven to be wholly
473
+ // its own; one that already existed is appended to and stays the adopter's.
474
+ const preCommitExisted = existsSync(preCommitPath);
450
475
  try {
451
476
  // husky v9's own templates carry no shebang, no `_/husky.sh` sourcing
452
477
  // (that is v8 syntax, deprecated in v9 and removed in v10). Verified
@@ -464,6 +489,18 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
464
489
  // — cheap, harmless on POSIX, and required on Windows. Existing files
465
490
  // are appended to, never rewritten — their contents are the adopter's,
466
491
  // not ours; ensureShebang only ever prepends a missing first line.
492
+ // A hook an older UDS wrote asks npx for the bare name `uds`, which on the
493
+ // npm registry is an unrelated package (see buildPreCommitBlock). Swap the
494
+ // line UDS itself wrote for the current block before anything else reads
495
+ // the file; a line the adopter edited is left alone and reported below.
496
+ const legacy = existsSync(preCommitPath) ? migrateLegacyHuskyHook(projectPath) : null;
497
+ if (legacy?.state === 'migrated') {
498
+ console.log(chalk.green(' ✓ Replaced the pre-commit 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'));
499
+ }
500
+ for (const k of legacy?.kept ?? []) {
501
+ console.log(chalk.yellow(` ⚠ .husky/pre-commit line ${k.line} ("${k.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" (with the CLI installed in this project or on PATH).`));
502
+ }
503
+
467
504
  let content = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : '';
468
505
 
469
506
  // A pre-existing file may still carry husky v8's `_/husky.sh` sourcing
@@ -479,10 +516,10 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
479
516
  const { content: destripped, removed: hadLegacyLine } = stripLegacyHuskyShLine(content);
480
517
  content = destripped;
481
518
 
482
- const needsAppend = !content.includes('uds check');
519
+ const needsAppend = !hookRunsUdsCheck(content);
483
520
  if (needsAppend) {
484
521
  const sep = content && !content.endsWith('\n') ? '\n' : '';
485
- content = `${content}${sep}\n# UDS Standard Check\n${udsCmd}\n`;
522
+ content = `${content}${sep}\n${buildPreCommitBlock()}`;
486
523
  }
487
524
 
488
525
  const { content: shebanged, added: addedShebang } = ensureShebang(content);
@@ -490,10 +527,22 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
490
527
 
491
528
  if (hadLegacyLine || needsAppend || addedShebang) {
492
529
  writeFileSync(preCommitPath, content, 'utf-8');
493
- try {
494
- execSync(`chmod +x ${preCommitPath}`);
495
- } catch {
496
- // Ignore chmod failures on systems that don't support it
530
+ if (!preCommitExisted) recordFile(recorder, projectPath, '.husky/pre-commit', RECORD_KINDS.GIT_HOOK);
531
+ // 🔴 Was `execSync(`chmod +x ...`)` inside try/catch. On Windows that
532
+ // spawned cmd.exe, which printed "'chmod' is not recognized as an
533
+ // internal or external command" straight to the user's terminal —
534
+ // execSync inherits the child's stderr by default, so the catch hid the
535
+ // failure from the code and left it loud for the human (adopter report,
536
+ // 6.14.0-beta.2, Windows 11). No shell is needed to change a mode, and
537
+ // Windows has no exec bit to set (git for Windows runs the hook through
538
+ // its own sh regardless), so it is skipped there.
539
+ if (platform !== 'win32') {
540
+ try {
541
+ chmodSync(preCommitPath, 0o755);
542
+ } catch {
543
+ // A filesystem that refuses the mode change (FAT, some mounts) is not
544
+ // a reason to fail the install; git reports a non-executable hook itself.
545
+ }
497
546
  }
498
547
  }
499
548
 
@@ -512,6 +561,8 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
512
561
  console.log(chalk.red(` ✗ Failed to configure pre-commit hook: ${e.message}`));
513
562
  }
514
563
 
564
+ persistRecorder(projectPath, recorder);
565
+
515
566
  // 5. Wire git so the hook ACTUALLY runs — this is the fix. Setting
516
567
  // `core.hooksPath` ourselves is what husky's own bootstrap does internally
517
568
  // (verified against husky ^9.1.7's source: `git config core.hooksPath
@@ -567,7 +618,7 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
567
618
 
568
619
  if (existsSync(hookPath)) {
569
620
  const existingContent = readFileSync(hookPath, 'utf-8');
570
- if (existingContent.includes('uds check')) {
621
+ if (hookRunsUdsCheck(existingContent)) {
571
622
  console.log(chalk.gray(' ✓ Pre-commit hook already configured'));
572
623
  } else {
573
624
  // Never clobber an adopter's own hook (this fix — it used to be
@@ -576,27 +627,42 @@ export async function setupHuskyHook(projectPath, { allowInTest = false } = {})
576
627
  console.log(chalk.gray(' UDS will not overwrite it. Add "uds check" to it yourself, or remove it and re-run `uds init`.'));
577
628
  }
578
629
  } else {
630
+ // Fail-closed (XSPEC-444 R1 class sweep). This script used to run the linters
631
+ // as `... 2>/dev/null || true` and `uds check 2>/dev/null || true`, then print
632
+ // "Pre-commit checks passed" — it could not block a commit for any reason.
633
+ // Now: a linter that is installed and fails blocks the commit; one that is not
634
+ // installed is skipped. The UDS check is the same block the husky path writes
635
+ // (buildPreCommitBlock): its exit status stops the commit, and a missing CLI
636
+ // says so and blocks rather than being skipped in silence.
579
637
  const hookContent = `#!/bin/sh
580
638
  # UDS pre-commit hook
581
639
  # Auto-generated by uds init
582
640
 
583
641
  echo "Running UDS pre-commit checks..."
584
642
 
585
- # Run lint if available
643
+ # Lint, when the language's linter is installed. A failing linter blocks the commit.
586
644
  if [ -f pyproject.toml ] || [ -f requirements.txt ]; then
587
- python -m ruff check . 2>/dev/null || true
645
+ if python -m ruff --version >/dev/null 2>&1; then
646
+ python -m ruff check . || exit $?
647
+ fi
588
648
  elif [ -f go.mod ]; then
589
- go vet ./... 2>/dev/null || true
649
+ if command -v go >/dev/null 2>&1; then
650
+ go vet ./... || exit $?
651
+ fi
590
652
  elif [ -f Cargo.toml ]; then
591
- cargo clippy 2>/dev/null || true
653
+ if command -v cargo >/dev/null 2>&1; then
654
+ cargo clippy || exit $?
655
+ fi
592
656
  fi
593
657
 
594
- # UDS Standard Check
595
- uds check 2>/dev/null || true
596
-
658
+ ${buildPreCommitBlock({ hookFile: '.git/hooks/pre-commit' })}
597
659
  echo "Pre-commit checks passed"
598
660
  `;
599
661
  writeFileSync(hookPath, hookContent, { mode: 0o755 });
662
+ // Created from nothing (the branch above is "file does not exist"), so the
663
+ // whole file is UDS's — record its hash for `uds uninstall`.
664
+ recordFile(recorder, projectPath, '.git/hooks/pre-commit', RECORD_KINDS.GIT_HOOK);
665
+ persistRecorder(projectPath, recorder);
600
666
  console.log(chalk.green(' ✓ Installed .git/hooks/pre-commit (native git hook)'));
601
667
  }
602
668
  } catch (e) {
@@ -614,9 +680,16 @@ function buildNonInteractiveConfig(options, detected, projectPath) {
614
680
  // CLI --locale > .uds/install.yaml locale: > UDS_LOCALE env > LANG > 'en'
615
681
  // detectLanguage() handles UDS_LOCALE + LANG fallback internally (P1-CLI-3).
616
682
  const installYaml = readInstallYaml(projectPath);
617
- const displayLanguage = options.locale
683
+ const requestedLanguage = options.locale
618
684
  || installYaml.locale
619
685
  || detectLanguage(null);
686
+ // Case-insensitive, and an unsupported value is said out loud instead of silently becoming English
687
+ // (dev-platform XSPEC-451 follow-up: `--locale zh-CN` used to install English with exit 0).
688
+ const { displayLanguage: normalizedLanguage, supported } = normalizeRequestedLocale(requestedLanguage);
689
+ const displayLanguage = supported ? normalizedLanguage : 'en';
690
+ if (!supported) {
691
+ console.log(chalk.yellow(`⚠ Unsupported locale "${requestedLanguage}" — installing in English. Supported: ${Object.keys(LOCALE_MAP_FOR_INIT).join(', ')}.`));
692
+ }
620
693
 
621
694
  // Determine AI tools
622
695
  const detectedAiTools = Object.keys(detected.aiTools).filter(k => detected.aiTools[k]);
@@ -3,7 +3,8 @@ import { select, checkbox, confirm } from '@inquirer/prompts';
3
3
  import { readManifest, manifestExists, writeManifest } from '../core/manifest.js';
4
4
  import { t } from '../i18n/messages.js';
5
5
  import { uninstallStandards } from '../uninstallers/standards-uninstaller.js';
6
- import { uninstallHook } from '../uninstallers/hook-uninstaller.js';
6
+ import { uninstallHook, pruneCreatedDirs } from '../uninstallers/hook-uninstaller.js';
7
+ import { forgetRecords } from '../core/install-records.js';
7
8
  import { uninstallIntegrations } from '../uninstallers/integration-uninstaller.js';
8
9
  import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
9
10
 
@@ -12,6 +13,35 @@ import { uninstallSkills } from '../uninstallers/skills-uninstaller.js';
12
13
  */
13
14
  const CATEGORIES = ['hooks', 'skills', 'integrations', 'standards'];
14
15
 
16
+ /**
17
+ * Exit codes. Before these existed every path out of `uninstall` was 0 — a run
18
+ * that removed nothing because the prompt died looked exactly like a completed
19
+ * uninstall to CI and to the adopter's own scripts.
20
+ */
21
+ const EXIT_NOT_INITIALIZED = 1; // nothing to uninstall / manifest unreadable
22
+ const EXIT_CANNOT_PROMPT = 2; // needed an answer, nobody could give one (same as `uds update`)
23
+ const EXIT_INTERRUPTED = 130; // the prompt was closed before answering (SIGINT convention)
24
+
25
+ /**
26
+ * Can anything answer a prompt? Decided BEFORE a prompt is drawn.
27
+ *
28
+ * `uds update` catches ExitPromptError instead (see confirmOrFail there) and
29
+ * explains why it avoids `isTTY`: a wrapped stdin can answer with isTTY unset.
30
+ * That reasoning is sound for a prompt that has already been drawn, but it leaves
31
+ * the visible symptom this command was reported for — the checkbox painted onto a
32
+ * pipe, then a stack trace. Here the question is asked first, and only stdin
33
+ * matters: prompts read from it. An answer that arrives anyway (a test double, a
34
+ * wrapper) is not blocked by ExitPromptError handling further down.
35
+ */
36
+ function canPrompt() {
37
+ return Boolean(process.stdin.isTTY);
38
+ }
39
+
40
+ /** `@inquirer/prompts` throws this when stdin closes or the user presses Ctrl+C. */
41
+ function isPromptClosed(err) {
42
+ return err?.name === 'ExitPromptError' || /force closed the prompt/i.test(err?.message || '');
43
+ }
44
+
15
45
  /**
16
46
  * Uninstall command - remove UDS standards, integrations, skills, and hooks
17
47
  * @param {Object} options - Command options
@@ -25,19 +55,25 @@ export async function uninstallCommand(options) {
25
55
  console.log(chalk.bold(msg.title));
26
56
  console.log(chalk.gray('─'.repeat(50)));
27
57
 
28
- // Check if UDS is initialized
58
+ // Check if UDS is initialized. A project with nothing to uninstall is not a
59
+ // successful uninstall: exit non-zero so a script can tell the two apart.
29
60
  if (!manifestExists(projectPath)) {
30
61
  console.log(chalk.yellow(common.notInitialized));
31
62
  console.log(chalk.gray(` ${common.runInit}`));
63
+ process.exitCode = EXIT_NOT_INITIALIZED;
32
64
  return;
33
65
  }
34
66
 
35
67
  const manifest = readManifest(projectPath);
36
68
  if (!manifest) {
37
69
  console.log(chalk.red(common.couldNotReadManifest));
70
+ process.exitCode = EXIT_NOT_INITIALIZED;
38
71
  return;
39
72
  }
40
73
 
74
+ const includeUserLevel = options.all || false;
75
+ const dryRun = options.dryRun || false;
76
+
41
77
  // Determine which categories to uninstall
42
78
  let selectedCategories;
43
79
  if (options.all) {
@@ -48,20 +84,34 @@ export async function uninstallCommand(options) {
48
84
  selectedCategories = ['skills'];
49
85
  } else if (options.integrationsOnly) {
50
86
  selectedCategories = ['integrations'];
51
- } else if (options.yes) {
52
- // --yes without specific flag → all categories
87
+ } else if (options.yes || dryRun) {
88
+ // --yes without a specific flag → all categories.
89
+ // --dry-run → all categories too, and WITHOUT a prompt: a dry run writes
90
+ // nothing, so there is nothing to ask before showing what a run would do —
91
+ // and its whole use is to be runnable unattended (CI, a pipe). It used to
92
+ // draw the category checkbox even then, and die on it.
53
93
  selectedCategories = [...CATEGORIES];
54
94
  } else {
55
95
  // Interactive: checkbox selection
56
- const categories = await checkbox({
57
- message: msg.selectCategories,
58
- choices: [
59
- { name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
60
- { name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
61
- { name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
62
- { name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
63
- ]
64
- });
96
+ if (!canPrompt()) {
97
+ refuseToPrompt(msg);
98
+ return;
99
+ }
100
+ let categories;
101
+ try {
102
+ categories = await checkbox({
103
+ message: msg.selectCategories,
104
+ choices: [
105
+ { name: `${msg.categoryHooks} (.husky/pre-commit, .claude/settings.json, .codex/hooks.json, .gemini/settings.json, .agents/hooks.json)`, value: 'hooks', checked: true },
106
+ { name: `${msg.categorySkills} (skills, commands)`, value: 'skills', checked: true },
107
+ { name: `${msg.categoryIntegrations} (CLAUDE.md, .cursorrules, ...)`, value: 'integrations', checked: true },
108
+ { name: `${msg.categoryStandards} (.standards/)`, value: 'standards', checked: true }
109
+ ]
110
+ });
111
+ } catch (err) {
112
+ if (reportClosedPrompt(err, msg)) return;
113
+ throw err;
114
+ }
65
115
 
66
116
  if (categories.length === 0) {
67
117
  console.log(chalk.yellow(msg.nothingSelected));
@@ -70,9 +120,6 @@ export async function uninstallCommand(options) {
70
120
  selectedCategories = categories;
71
121
  }
72
122
 
73
- const includeUserLevel = options.all || false;
74
- const dryRun = options.dryRun || false;
75
-
76
123
  // Gather preview: run all uninstallers in dry-run mode to build summary
77
124
  const preview = await gatherPreview(projectPath, manifest, selectedCategories, includeUserLevel);
78
125
 
@@ -87,10 +134,22 @@ export async function uninstallCommand(options) {
87
134
 
88
135
  // Confirm (unless --yes or --dry-run)
89
136
  if (!dryRun && !options.yes) {
90
- const confirmed = await confirm({
91
- message: msg.confirmUninstall,
92
- default: false
93
- });
137
+ // Not auto-confirmed when nobody can answer: an unattended shell must not get
138
+ // more permission to delete files than an interactive one is given.
139
+ if (!canPrompt()) {
140
+ refuseToPrompt(msg);
141
+ return;
142
+ }
143
+ let confirmed;
144
+ try {
145
+ confirmed = await confirm({
146
+ message: msg.confirmUninstall,
147
+ default: false
148
+ });
149
+ } catch (err) {
150
+ if (reportClosedPrompt(err, msg)) return;
151
+ throw err;
152
+ }
94
153
 
95
154
  if (!confirmed) {
96
155
  console.log(chalk.yellow(common.cancelled));
@@ -106,18 +165,52 @@ export async function uninstallCommand(options) {
106
165
 
107
166
  // Execute uninstallation in order: hooks → skills → integrations → standards
108
167
  console.log();
109
- const results = await executeUninstall(
110
- projectPath, manifest, selectedCategories,
111
- { includeUserLevel, interactive: !options.yes }
112
- );
168
+ let results;
169
+ try {
170
+ results = await executeUninstall(
171
+ projectPath, manifest, selectedCategories,
172
+ { includeUserLevel, interactive: !options.yes }
173
+ );
174
+ } catch (err) {
175
+ // A per-file question asked mid-run (which is why this cannot be checked up
176
+ // front) was closed. Earlier steps are already done and stay done.
177
+ if (!isPromptClosed(err)) throw err;
178
+ console.log();
179
+ console.log(chalk.red(msg.promptClosedMidRun));
180
+ console.log();
181
+ process.exitCode = EXIT_INTERRUPTED;
182
+ return;
183
+ }
113
184
 
114
185
  // Update or remove manifest
115
- updateManifestAfterUninstall(projectPath, manifest, selectedCategories);
186
+ updateManifestAfterUninstall(projectPath, manifest, selectedCategories, collectDeletedPaths(results));
116
187
 
117
188
  // Display results
118
189
  displayResults(results, msg);
119
190
  }
120
191
 
192
+ /** Say why a real run cannot proceed without --yes, and set the exit code. Nothing has been changed. */
193
+ function refuseToPrompt(msg) {
194
+ console.log();
195
+ console.log(chalk.red(msg.cannotPrompt));
196
+ console.log(chalk.gray(` ${msg.cannotPromptHint}`));
197
+ console.log();
198
+ process.exitCode = EXIT_CANNOT_PROMPT;
199
+ }
200
+
201
+ /**
202
+ * If `err` is "the prompt was closed before an answer", report it, set a non-zero
203
+ * exit code and return true. Any other error is the caller's to rethrow.
204
+ */
205
+ function reportClosedPrompt(err, msg) {
206
+ if (!isPromptClosed(err)) return false;
207
+ console.log();
208
+ console.log(chalk.red(msg.promptClosed));
209
+ console.log();
210
+ process.exitCode = EXIT_INTERRUPTED;
211
+ return true;
212
+ }
213
+
121
214
  /**
122
215
  * Gather preview of what will be removed (dry-run all uninstallers)
123
216
  */
@@ -125,7 +218,7 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
125
218
  const preview = {};
126
219
 
127
220
  if (categories.includes('hooks')) {
128
- preview.hooks = uninstallHook(projectPath, { dryRun: true });
221
+ preview.hooks = uninstallHook(projectPath, { dryRun: true, manifest });
129
222
  }
130
223
  if (categories.includes('skills')) {
131
224
  preview.skills = uninstallSkills(projectPath, manifest, { dryRun: true, includeUserLevel });
@@ -136,10 +229,23 @@ async function gatherPreview(projectPath, manifest, categories, includeUserLevel
136
229
  if (categories.includes('standards')) {
137
230
  preview.standards = uninstallStandards(projectPath, { dryRun: true });
138
231
  }
232
+ if (categories.includes('hooks')) {
233
+ // Nothing is deleted yet in a preview, so folders are judged against what the
234
+ // steps above WOULD delete.
235
+ preview.folders = pruneCreatedDirs(projectPath, manifest, {
236
+ dryRun: true,
237
+ plannedDeletions: collectDeletedPaths(preview)
238
+ });
239
+ }
139
240
 
140
241
  return preview;
141
242
  }
142
243
 
244
+ /** Every relative path the given step results deleted (or, for a preview, would delete). */
245
+ function collectDeletedPaths(results) {
246
+ return Object.values(results).flatMap((r) => r.deletedPaths || []);
247
+ }
248
+
143
249
  /**
144
250
  * Execute actual uninstallation
145
251
  */
@@ -148,7 +254,7 @@ async function executeUninstall(projectPath, manifest, categories, options) {
148
254
  const results = {};
149
255
 
150
256
  if (categories.includes('hooks')) {
151
- results.hooks = uninstallHook(projectPath);
257
+ results.hooks = uninstallHook(projectPath, { manifest });
152
258
  }
153
259
  if (categories.includes('skills')) {
154
260
  results.skills = uninstallSkills(projectPath, manifest, { includeUserLevel });
@@ -163,6 +269,10 @@ async function executeUninstall(projectPath, manifest, categories, options) {
163
269
  if (categories.includes('standards')) {
164
270
  results.standards = uninstallStandards(projectPath);
165
271
  }
272
+ if (categories.includes('hooks')) {
273
+ // After everything else, so a folder that only held UDS files is empty by now.
274
+ results.folders = pruneCreatedDirs(projectPath, manifest);
275
+ }
166
276
 
167
277
  return results;
168
278
  }
@@ -188,7 +298,7 @@ function createIntegrationPromptFn() {
188
298
  /**
189
299
  * Update or remove manifest after uninstall
190
300
  */
191
- function updateManifestAfterUninstall(projectPath, manifest, categories) {
301
+ function updateManifestAfterUninstall(projectPath, manifest, categories, deletedPaths = []) {
192
302
  const removedStandards = categories.includes('standards');
193
303
 
194
304
  if (removedStandards) {
@@ -196,8 +306,10 @@ function updateManifestAfterUninstall(projectPath, manifest, categories) {
196
306
  return;
197
307
  }
198
308
 
199
- // Partial uninstall: update manifest to reflect removed items
200
- const updated = { ...manifest };
309
+ // Partial uninstall: update manifest to reflect removed items. Install records
310
+ // for the paths just deleted go too — a record for a file that is gone would
311
+ // otherwise "prove" authorship of whatever a user later puts at that path.
312
+ const updated = forgetRecords({ ...manifest }, deletedPaths);
201
313
 
202
314
  if (categories.includes('skills')) {
203
315
  updated.skills = {
@@ -281,6 +393,8 @@ function displayResults(results, msg) {
281
393
  console.log(chalk.green(msg.uninstallSuccess));
282
394
  } else {
283
395
  console.log(chalk.yellow(msg.uninstallPartial));
396
+ // "Completed with errors" is not a success to whoever runs this from a script.
397
+ process.exitCode = 1;
284
398
  }
285
399
  console.log(chalk.gray(` ${msg.removed}: ${totalRemoved} ${msg.skippedLabel}: ${totalSkipped} ${msg.errorsLabel}: ${totalErrors}`));
286
400
  }