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
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
|
|
2
2
|
import { join, basename } from 'path';
|
|
3
|
+
import { proveUnchanged, RECORD_KINDS, RECORDS_KEY, isRealDirectory } from '../core/install-records.js';
|
|
4
|
+
import { stripUdsHookBlock } from '../utils/git-hooks.js';
|
|
3
5
|
import {
|
|
4
6
|
collectHookConfigs, standardsSourceDir, hooksSourceDir,
|
|
5
7
|
CODEX_HOOK_SCRIPT, GEMINI_HOOK_SCRIPT, AGY_HOOK_SCRIPT,
|
|
@@ -88,7 +90,7 @@ function applyHooksMap(config, nextMap) {
|
|
|
88
90
|
* difference is which file and which key they look at.
|
|
89
91
|
*/
|
|
90
92
|
function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile }) {
|
|
91
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
93
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
92
94
|
if (!existsSync(configPath)) return result; // nothing installed here — nothing to report
|
|
93
95
|
|
|
94
96
|
let config;
|
|
@@ -106,18 +108,21 @@ function uninstallHookConfigFile({ configPath, label, dryRun, deleteEmptyFile })
|
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
111
|
+
const updatedConfig = applyHooksMap(config, nextMap);
|
|
112
|
+
const willDeleteFile = deleteEmptyFile && Object.keys(updatedConfig).length === 0;
|
|
113
|
+
// Decided BEFORE the dry-run return: the preview must say the file goes away,
|
|
114
|
+
// and the folder-cleanup step needs to know the folder will be empty.
|
|
109
115
|
if (dryRun) {
|
|
110
|
-
result.removed.push(entryLabel);
|
|
116
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
117
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
111
118
|
return result;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
const updatedConfig = applyHooksMap(config, nextMap);
|
|
115
|
-
const isNowEmpty = Object.keys(updatedConfig).length === 0;
|
|
116
|
-
|
|
117
121
|
try {
|
|
118
|
-
if (
|
|
122
|
+
if (willDeleteFile) {
|
|
119
123
|
unlinkSync(configPath);
|
|
120
124
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
125
|
+
result.deletedPaths.push(label);
|
|
121
126
|
} else {
|
|
122
127
|
writeFileSync(configPath, JSON.stringify(updatedConfig, null, 2) + '\n');
|
|
123
128
|
result.removed.push(entryLabel);
|
|
@@ -186,7 +191,7 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
186
191
|
const configPath = join(projectPath, '.agents', 'hooks.json');
|
|
187
192
|
const label = '.agents/hooks.json';
|
|
188
193
|
const dryRun = options.dryRun || false;
|
|
189
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
194
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
190
195
|
if (!existsSync(configPath)) return result;
|
|
191
196
|
|
|
192
197
|
let config;
|
|
@@ -242,14 +247,17 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
242
247
|
}
|
|
243
248
|
|
|
244
249
|
const entryLabel = `${label} (${removedCount} UDS hook ${removedCount === 1 ? 'entry' : 'entries'})`;
|
|
250
|
+
const willDeleteFile = Object.keys(next).length === 0;
|
|
245
251
|
if (dryRun) {
|
|
246
|
-
result.removed.push(entryLabel);
|
|
252
|
+
result.removed.push(willDeleteFile ? `${entryLabel}, file removed — created by UDS, now empty` : entryLabel);
|
|
253
|
+
if (willDeleteFile) result.deletedPaths.push(label);
|
|
247
254
|
return result;
|
|
248
255
|
}
|
|
249
256
|
try {
|
|
250
|
-
if (
|
|
257
|
+
if (willDeleteFile) {
|
|
251
258
|
unlinkSync(configPath);
|
|
252
259
|
result.removed.push(`${entryLabel}, file removed — created by UDS, now empty`);
|
|
260
|
+
result.deletedPaths.push(label);
|
|
253
261
|
} else {
|
|
254
262
|
writeFileSync(configPath, JSON.stringify(next, null, 2) + '\n');
|
|
255
263
|
result.removed.push(entryLabel);
|
|
@@ -260,18 +268,113 @@ export function uninstallAgyHooks(projectPath, options = {}) {
|
|
|
260
268
|
return result;
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
// Proof of authorship — "only delete what UDS can prove it wrote, unchanged".
|
|
273
|
+
//
|
|
274
|
+
// A record in `manifest.installedArtifacts` (core/install-records.js) says UDS
|
|
275
|
+
// wrote a file; a matching hash says nobody has changed it since. Both are
|
|
276
|
+
// required to delete a whole file. Anything else is KEPT, and the reason is
|
|
277
|
+
// printed, because "not provably ours" is the honest state of a file from an
|
|
278
|
+
// older UDS (no record) or one an adopter has edited (hash differs) — and
|
|
279
|
+
// guessing "probably ours" is how an adopter's own hook script gets deleted.
|
|
280
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
/** Relative paths (forward slashes) of every file under `dir`, or [] if it is not there. */
|
|
283
|
+
function listFiles(dir, rel = '') {
|
|
284
|
+
if (!dir || !existsSync(dir)) return [];
|
|
285
|
+
const out = [];
|
|
286
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
287
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
288
|
+
if (e.isDirectory()) out.push(...listFiles(join(dir, e.name), r));
|
|
289
|
+
else out.push(r);
|
|
290
|
+
}
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Remove the hook scripts UDS copied into `scripts/hooks/`.
|
|
296
|
+
*
|
|
297
|
+
* `scripts/hooks/` is a directory UDS scaffolds inside the adopter's project, so
|
|
298
|
+
* an adopter's own scripts can sit beside ours (a test in hook-uninstaller.test.js
|
|
299
|
+
* places one there and it must survive). Only files that have an install record
|
|
300
|
+
* AND still match it are deleted.
|
|
301
|
+
*/
|
|
302
|
+
export function uninstallHookScripts(projectPath, manifest, { dryRun = false, blockedBy = null } = {}) {
|
|
303
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
304
|
+
const files = manifest?.[RECORDS_KEY]?.files || {};
|
|
305
|
+
const recorded = Object.entries(files)
|
|
306
|
+
.filter(([, rec]) => rec && rec.kind === RECORD_KINDS.HOOK_SCRIPT)
|
|
307
|
+
.map(([rel]) => rel)
|
|
308
|
+
.sort();
|
|
309
|
+
|
|
310
|
+
for (const rel of recorded) {
|
|
311
|
+
const abs = join(projectPath, rel);
|
|
312
|
+
if (!existsSync(abs)) continue; // already gone — nothing to remove, nothing to report
|
|
313
|
+
if (blockedBy) {
|
|
314
|
+
result.skipped.push(`${rel} (kept: ${blockedBy})`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const proof = proveUnchanged(manifest, projectPath, rel);
|
|
318
|
+
if (proof.state !== 'proven') {
|
|
319
|
+
result.skipped.push(`${rel} (kept: ${proof.why})`);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
if (!dryRun) unlinkSync(abs);
|
|
324
|
+
result.removed.push(`${rel} (deleted — installed by UDS, ${proof.why})`);
|
|
325
|
+
result.deletedPaths.push(rel);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
result.errors.push(`${rel} — ${error.message}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Files that carry the name of a script UDS ships but have no record. Say so once,
|
|
332
|
+
// so a leftover `scripts/hooks/` is an explained decision and not a silent gap.
|
|
333
|
+
const recordedSet = new Set(recorded);
|
|
334
|
+
const shipped = listFiles(hooksSourceDir());
|
|
335
|
+
const unrecorded = shipped
|
|
336
|
+
.map((f) => `scripts/hooks/${f}`)
|
|
337
|
+
.filter((rel) => existsSync(join(projectPath, rel)) && !recordedSet.has(rel));
|
|
338
|
+
if (unrecorded.length > 0) {
|
|
339
|
+
result.skipped.push(
|
|
340
|
+
`scripts/hooks/ (kept: ${unrecorded.length} file${unrecorded.length === 1 ? '' : 's'} carry the name of a UDS hook script, ` +
|
|
341
|
+
'but the manifest has no record that UDS wrote them — installed by an older UDS, or by hand. ' +
|
|
342
|
+
'Delete them yourself if you no longer want them)'
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Line-level fallback, for the one-line form older UDS wrote and for a block whose
|
|
349
|
+
// end marker an adopter removed. The current block is removed whole, by its markers
|
|
350
|
+
// (stripUdsHookBlock) — its inner lines are not individually matchable.
|
|
351
|
+
const UDS_PRECOMMIT_LINE = /uds\s+check|universal-dev-standards\s+check|checkin-standards|^#\s*UDS Standard Check\s*$/;
|
|
352
|
+
const NATIVE_UDS_LINE = /uds\s+check|checkin-standards|UDS pre-commit hook/;
|
|
353
|
+
|
|
354
|
+
/** True when nothing is left but a shebang and blank lines. */
|
|
355
|
+
function nothingButShebang(lines) {
|
|
356
|
+
return lines.every((l) => !l.trim() || /^#!/.test(l.trim()));
|
|
357
|
+
}
|
|
358
|
+
|
|
263
359
|
/**
|
|
264
360
|
* Remove UDS-related lines from .husky/pre-commit, the native
|
|
265
361
|
* .git/hooks/pre-commit fallback, and the enforcement-hook entries UDS wrote
|
|
266
362
|
* into .claude/settings.json, .codex/hooks.json, .gemini/settings.json and
|
|
267
|
-
* .agents/hooks.json
|
|
363
|
+
* .agents/hooks.json; and the hook scripts copied into scripts/hooks/.
|
|
364
|
+
*
|
|
365
|
+
* Whole-file deletion (a hook file, a script) needs proof — see proveUnchanged.
|
|
366
|
+
* A hook file that is not provably wholly UDS's still has its UDS lines removed
|
|
367
|
+
* (each is a line UDS wrote, and the rest of the file is untouched) and the
|
|
368
|
+
* remainder is reported as kept, with the reason.
|
|
369
|
+
*
|
|
268
370
|
* @param {string} projectPath - Project root path
|
|
269
|
-
* @param {Object} options - { dryRun: boolean }
|
|
270
|
-
*
|
|
371
|
+
* @param {Object} options - { dryRun: boolean, manifest: object|null }
|
|
372
|
+
* `manifest` must be read BEFORE `.standards/` is removed; it carries the install records.
|
|
373
|
+
* @returns {Object} { removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }
|
|
271
374
|
*/
|
|
272
375
|
export function uninstallHook(projectPath, options = {}) {
|
|
273
|
-
const { dryRun = false } = options;
|
|
274
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
376
|
+
const { dryRun = false, manifest = null } = options;
|
|
377
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
275
378
|
const hookPath = join(projectPath, '.husky', 'pre-commit');
|
|
276
379
|
|
|
277
380
|
if (!existsSync(hookPath)) {
|
|
@@ -279,12 +382,23 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
279
382
|
} else {
|
|
280
383
|
try {
|
|
281
384
|
const content = readFileSync(hookPath, 'utf-8');
|
|
385
|
+
const proof = proveUnchanged(manifest, projectPath, '.husky/pre-commit');
|
|
282
386
|
const lines = content.split('\n');
|
|
283
|
-
const
|
|
284
|
-
|
|
387
|
+
const filteredLines = stripUdsHookBlock(content).content.split('\n')
|
|
388
|
+
.filter(line => !UDS_PRECOMMIT_LINE.test(line));
|
|
285
389
|
|
|
286
|
-
if (
|
|
390
|
+
if (proof.state === 'proven') {
|
|
391
|
+
// UDS created this file and nobody has touched it: nothing of the adopter's is in it.
|
|
392
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
393
|
+
result.removed.push(`.husky/pre-commit (deleted — created by UDS, ${proof.why})`);
|
|
394
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
395
|
+
} else if (filteredLines.length === lines.length) {
|
|
287
396
|
result.skipped.push('.husky/pre-commit (no UDS lines found)');
|
|
397
|
+
} else if (nothingButShebang(filteredLines)) {
|
|
398
|
+
// Only UDS's lines (and a shebang) were in it — removing them leaves nothing worth keeping.
|
|
399
|
+
if (!dryRun) unlinkSync(hookPath);
|
|
400
|
+
result.removed.push('.husky/pre-commit (UDS check lines; file removed — nothing else was in it)');
|
|
401
|
+
result.deletedPaths.push('.husky/pre-commit');
|
|
288
402
|
} else if (dryRun) {
|
|
289
403
|
result.removed.push('.husky/pre-commit (UDS check lines)');
|
|
290
404
|
} else {
|
|
@@ -301,25 +415,30 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
301
415
|
if (existsSync(nativeHookPath)) {
|
|
302
416
|
try {
|
|
303
417
|
const content = readFileSync(nativeHookPath, 'utf-8');
|
|
304
|
-
const
|
|
418
|
+
const proof = proveUnchanged(manifest, projectPath, '.git/hooks/pre-commit');
|
|
305
419
|
|
|
306
|
-
if (
|
|
420
|
+
if (proof.state === 'proven') {
|
|
421
|
+
// The whole script is what `uds init` wrote — including the parts no line
|
|
422
|
+
// pattern would ever match (its "Auto-generated by uds init" header, the
|
|
423
|
+
// linter fallbacks, the closing echo). Deleting only the matching lines
|
|
424
|
+
// used to leave that body behind, still executable, still claiming
|
|
425
|
+
// "Pre-commit checks passed".
|
|
426
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
427
|
+
result.removed.push(`.git/hooks/pre-commit (UDS native hook, file removed — ${proof.why})`);
|
|
428
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
429
|
+
} else if (!NATIVE_UDS_LINE.test(content)) {
|
|
307
430
|
result.skipped.push('.git/hooks/pre-commit (no UDS lines found)');
|
|
308
|
-
} else if (dryRun) {
|
|
309
|
-
result.removed.push('.git/hooks/pre-commit (UDS native hook)');
|
|
310
431
|
} else {
|
|
311
|
-
// Remove only UDS-related lines, keep other hook content
|
|
312
432
|
const lines = content.split('\n');
|
|
313
|
-
const filtered = lines.filter(line => !
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const nonEmpty = filtered.filter(l => l.trim() && l.trim() !== '#!/bin/sh');
|
|
317
|
-
if (nonEmpty.length === 0) {
|
|
318
|
-
unlinkSync(nativeHookPath);
|
|
433
|
+
const filtered = lines.filter(line => !NATIVE_UDS_LINE.test(line));
|
|
434
|
+
if (nothingButShebang(filtered)) {
|
|
435
|
+
if (!dryRun) unlinkSync(nativeHookPath);
|
|
319
436
|
result.removed.push('.git/hooks/pre-commit (UDS native hook, file removed)');
|
|
437
|
+
result.deletedPaths.push('.git/hooks/pre-commit');
|
|
320
438
|
} else {
|
|
321
|
-
writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
439
|
+
if (!dryRun) writeFileSync(nativeHookPath, filtered.join('\n'), 'utf-8');
|
|
322
440
|
result.removed.push('.git/hooks/pre-commit (UDS lines removed)');
|
|
441
|
+
result.skipped.push(`.git/hooks/pre-commit (kept: the rest of the script — ${proof.why})`);
|
|
323
442
|
}
|
|
324
443
|
}
|
|
325
444
|
} catch (error) {
|
|
@@ -329,16 +448,81 @@ export function uninstallHook(projectPath, options = {}) {
|
|
|
329
448
|
|
|
330
449
|
// Enforcement hooks written by installHooks()/installCodexHooks()/installGeminiHooks()
|
|
331
450
|
// — the gap this function used to have entirely (see module doc comment above).
|
|
332
|
-
|
|
451
|
+
const configResults = [
|
|
333
452
|
uninstallClaudeCodeHooks(projectPath, { dryRun }),
|
|
334
453
|
uninstallCodexHooks(projectPath, { dryRun }),
|
|
335
454
|
uninstallGeminiHooks(projectPath, { dryRun }),
|
|
336
455
|
uninstallAgyHooks(projectPath, { dryRun }),
|
|
337
|
-
]
|
|
456
|
+
];
|
|
457
|
+
for (const sub of configResults) {
|
|
338
458
|
result.removed.push(...sub.removed);
|
|
339
459
|
result.skipped.push(...sub.skipped);
|
|
340
460
|
result.errors.push(...sub.errors);
|
|
461
|
+
result.deletedPaths.push(...sub.deletedPaths);
|
|
341
462
|
}
|
|
342
463
|
|
|
464
|
+
// The scripts those entries ran. If a config file could not be cleaned (it still
|
|
465
|
+
// names a script), deleting the script would turn a working hook into a failing one.
|
|
466
|
+
const configFailed = configResults.some((r) => r.errors.length > 0);
|
|
467
|
+
const scripts = uninstallHookScripts(projectPath, manifest, {
|
|
468
|
+
dryRun,
|
|
469
|
+
blockedBy: configFailed ? 'a hook config file could not be cleaned (see the error above) and still runs this script' : null
|
|
470
|
+
});
|
|
471
|
+
result.removed.push(...scripts.removed);
|
|
472
|
+
result.skipped.push(...scripts.skipped);
|
|
473
|
+
result.errors.push(...scripts.errors);
|
|
474
|
+
result.deletedPaths.push(...scripts.deletedPaths);
|
|
475
|
+
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Remove the folders UDS had to create for its files (`.codex/`, `scripts/hooks/`, ...),
|
|
481
|
+
* once they are empty.
|
|
482
|
+
*
|
|
483
|
+
* A folder is removed only if (a) the manifest recorded that UDS created it and
|
|
484
|
+
* (b) nothing is left in it. An empty `.codex/` that the adopter made themselves is
|
|
485
|
+
* indistinguishable by looking; the record is the only thing that tells them apart.
|
|
486
|
+
* A folder that still holds anything — `.agents/rules/style.md` — is kept and
|
|
487
|
+
* reported, so the adopter sees why it stayed.
|
|
488
|
+
*
|
|
489
|
+
* In a dry run nothing has been deleted yet, so `plannedDeletions` (relative paths
|
|
490
|
+
* the run WOULD delete) stands in for the missing deletions when judging "empty".
|
|
491
|
+
*
|
|
492
|
+
* @param {string} projectPath
|
|
493
|
+
* @param {Object|null} manifest - read before `.standards/` was removed
|
|
494
|
+
* @param {{ dryRun?: boolean, plannedDeletions?: string[] }} [options]
|
|
495
|
+
* @returns {{ removed: string[], skipped: string[], errors: string[], deletedPaths: string[] }}
|
|
496
|
+
*/
|
|
497
|
+
export function pruneCreatedDirs(projectPath, manifest, { dryRun = false, plannedDeletions = [] } = {}) {
|
|
498
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
499
|
+
const dirs = manifest?.[RECORDS_KEY]?.createdDirs || [];
|
|
500
|
+
const gone = new Set(plannedDeletions.map((p) => p.replace(/\\/g, '/')));
|
|
501
|
+
// Deepest first: `scripts/hooks` must go before `scripts` can be seen as empty.
|
|
502
|
+
const ordered = [...new Set(dirs)]
|
|
503
|
+
.filter((d) => d && !d.startsWith('..') && !d.startsWith('/'))
|
|
504
|
+
.sort((a, b) => b.split('/').length - a.split('/').length);
|
|
505
|
+
|
|
506
|
+
for (const rel of ordered) {
|
|
507
|
+
const abs = join(projectPath, rel);
|
|
508
|
+
if (!existsSync(abs)) continue;
|
|
509
|
+
if (!isRealDirectory(abs)) {
|
|
510
|
+
result.skipped.push(`${rel}/ (kept: not a plain directory)`);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const left = readdirSync(abs).filter((name) => !gone.has(`${rel}/${name}`));
|
|
515
|
+
if (left.length > 0) {
|
|
516
|
+
result.skipped.push(`${rel}/ (kept: created by UDS but not empty — still holds ${left.slice(0, 3).join(', ')}${left.length > 3 ? ', ...' : ''}, which UDS did not write)`);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (!dryRun) rmdirSync(abs);
|
|
520
|
+
gone.add(rel);
|
|
521
|
+
result.removed.push(`${rel}/ (empty folder created by UDS, removed)`);
|
|
522
|
+
result.deletedPaths.push(rel);
|
|
523
|
+
} catch (error) {
|
|
524
|
+
result.errors.push(`${rel}/ — ${error.message}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
343
527
|
return result;
|
|
344
528
|
}
|
|
@@ -3,6 +3,7 @@ import { join } from 'path';
|
|
|
3
3
|
import { extractMarkedContent } from '../utils/integration-generator.js';
|
|
4
4
|
import { SUPPORTED_AI_TOOLS } from '../core/constants.js';
|
|
5
5
|
import { resolveIntegrationFile } from '../core/constants.js';
|
|
6
|
+
import { proveUnchanged } from '../core/install-records.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Get the format for a given integration file name
|
|
@@ -29,7 +30,7 @@ function getFormatForFile(fileName) {
|
|
|
29
30
|
*/
|
|
30
31
|
export async function uninstallIntegrations(projectPath, manifest, options = {}) {
|
|
31
32
|
const { dryRun = false, interactive = false, promptFn = null } = options;
|
|
32
|
-
const result = { removed: [], skipped: [], errors: [] };
|
|
33
|
+
const result = { removed: [], skipped: [], errors: [], deletedPaths: [] };
|
|
33
34
|
|
|
34
35
|
const integrations = manifest?.integrations || [];
|
|
35
36
|
if (integrations.length === 0) {
|
|
@@ -64,11 +65,27 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
64
65
|
const userContent = (parts.before.trim() + parts.after.trim()).trim();
|
|
65
66
|
const hasUserContent = userContent.length > 0;
|
|
66
67
|
|
|
68
|
+
// Text outside the UDS block is not automatically the adopter's. A file UDS
|
|
69
|
+
// creates from nothing carries generated text there — AGENTS.md's
|
|
70
|
+
// "> Auto-generated by ..." header and sections, CLAUDE.md's leading guidance —
|
|
71
|
+
// and treating all of it as "user content" left an orphaned file pointing at
|
|
72
|
+
// the `.standards/` this very command removes. It is only "not the adopter's"
|
|
73
|
+
// if UDS recorded a hash of it when it created the file and the text still
|
|
74
|
+
// matches; without that proof it stays, and the reason is printed.
|
|
75
|
+
const proof = hasUserContent ? proveUnchanged(manifest, projectPath, fileName) : null;
|
|
76
|
+
const outsideIsUdsOwn = hasUserContent && proof.state === 'proven';
|
|
77
|
+
const keptNote = () => `${fileName} (kept: the text outside the UDS block — ${proof.why})`;
|
|
78
|
+
|
|
67
79
|
if (dryRun) {
|
|
68
|
-
if (hasUserContent) {
|
|
69
|
-
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
70
|
-
} else {
|
|
80
|
+
if (!hasUserContent) {
|
|
71
81
|
result.removed.push(`${fileName} (delete file)`);
|
|
82
|
+
result.deletedPaths.push(fileName);
|
|
83
|
+
} else if (outsideIsUdsOwn) {
|
|
84
|
+
result.removed.push(`${fileName} (delete file — everything outside the UDS block was generated by UDS)`);
|
|
85
|
+
result.deletedPaths.push(fileName);
|
|
86
|
+
} else {
|
|
87
|
+
result.removed.push(`${fileName} (remove UDS block, keep user content)`);
|
|
88
|
+
result.skipped.push(keptNote());
|
|
72
89
|
}
|
|
73
90
|
continue;
|
|
74
91
|
}
|
|
@@ -77,26 +94,39 @@ export async function uninstallIntegrations(projectPath, manifest, options = {})
|
|
|
77
94
|
// 100% UDS-generated → delete entire file
|
|
78
95
|
unlinkSync(filePath);
|
|
79
96
|
result.removed.push(`${fileName} (deleted)`);
|
|
97
|
+
result.deletedPaths.push(fileName);
|
|
98
|
+
} else if (outsideIsUdsOwn) {
|
|
99
|
+
// UDS created this file and its non-block text is still exactly what UDS
|
|
100
|
+
// wrote: there is no adopter content to preserve, so no question to ask.
|
|
101
|
+
unlinkSync(filePath);
|
|
102
|
+
result.removed.push(`${fileName} (deleted — everything outside the UDS block was generated by UDS and is unchanged)`);
|
|
103
|
+
result.deletedPaths.push(fileName);
|
|
80
104
|
} else if (interactive && promptFn) {
|
|
81
105
|
// Ask user what to do
|
|
82
106
|
const action = await promptFn(fileName, hasUserContent);
|
|
83
107
|
if (action === 'delete-file') {
|
|
84
108
|
unlinkSync(filePath);
|
|
85
109
|
result.removed.push(`${fileName} (deleted)`);
|
|
110
|
+
result.deletedPaths.push(fileName);
|
|
86
111
|
} else if (action === 'remove-block') {
|
|
87
112
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
88
113
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
89
114
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
115
|
+
result.skipped.push(keptNote());
|
|
90
116
|
} else {
|
|
91
117
|
result.skipped.push(`${fileName} (user skipped)`);
|
|
92
118
|
}
|
|
93
119
|
} else {
|
|
94
|
-
// Non-interactive (--yes): remove UDS block only, preserve
|
|
120
|
+
// Non-interactive (--yes): remove UDS block only, preserve everything else
|
|
95
121
|
const cleaned = (parts.before + parts.after).trim() + '\n';
|
|
96
122
|
writeFileSync(filePath, cleaned, 'utf-8');
|
|
97
123
|
result.removed.push(`${fileName} (UDS block removed)`);
|
|
124
|
+
result.skipped.push(keptNote());
|
|
98
125
|
}
|
|
99
126
|
} catch (error) {
|
|
127
|
+
// A closed prompt is the user stopping the run, not a failure of this file:
|
|
128
|
+
// let the command report it (and exit non-zero) instead of retrying the next file.
|
|
129
|
+
if (error?.name === 'ExitPromptError') throw error;
|
|
100
130
|
result.errors.push(`${fileName} — ${error.message}`);
|
|
101
131
|
}
|
|
102
132
|
}
|
package/src/utils/detector.js
CHANGED
|
@@ -111,6 +111,50 @@ export function detectFramework(projectPath) {
|
|
|
111
111
|
return detected;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Files and directories under `.agents/` that Antigravity (agy) owns.
|
|
116
|
+
*
|
|
117
|
+
* 🔴 Detection used to be `.agents/AGENTS.md` alone (2026-09-08). That file is one
|
|
118
|
+
* of the things agy reads but a project can use agy for a long time without ever
|
|
119
|
+
* creating it, so `uds init --with-hooks` in such a project never wired the hook
|
|
120
|
+
* (found 2026-09-29 installing 6.14.0-beta.1 into a fresh project).
|
|
121
|
+
*
|
|
122
|
+
* The markers below come from the agy 1.2.12 binary (`strings`: the literal path
|
|
123
|
+
* templates `.agents/rules/`, `.agents/workflows/`, `.agents/plugins/`,
|
|
124
|
+
* `.agents/hooks.json`, `.agents/skills.json`, `.agents/agents/`) and from
|
|
125
|
+
* antigravity.google/docs/hooks (project hooks live at `.agents/hooks.json`;
|
|
126
|
+
* "Rules" and "Workflows" are documented Antigravity concepts). Chosen: the ones
|
|
127
|
+
* documented or carried by the tool itself whose NAME is agy's — not the generic
|
|
128
|
+
* `agents/` or `skills.json`.
|
|
129
|
+
*
|
|
130
|
+
* ⚠️ `.agents/skills/` is NOT a marker. Codex reads project skills from the same
|
|
131
|
+
* `.agents/skills/` (measured 2026-09-08: only that arm made Codex see the
|
|
132
|
+
* skills), so a directory that both tools share cannot say which one is in use.
|
|
133
|
+
* A repo with root AGENTS.md plus `.agents/skills/` is Codex and must stay Codex.
|
|
134
|
+
*
|
|
135
|
+
* ⚠️ `.agents/hooks.json` is also the file `uds` itself writes for agy. Detecting
|
|
136
|
+
* on it is self-referential: after an install it proves the install happened, not
|
|
137
|
+
* that the adopter uses agy. It is kept because a hooks.json that someone else
|
|
138
|
+
* (the adopter, another tool) put there is real evidence, and because dropping it
|
|
139
|
+
* would make a re-run of the installer stop seeing the project it just wired.
|
|
140
|
+
*
|
|
141
|
+
* Not used: `~/.gemini/projects.json` lists the projects agy has opened. It is the
|
|
142
|
+
* tool's own registry and a strong signal, but it lives in the user's home, is
|
|
143
|
+
* machine-local, and would make the same repository detect differently on two
|
|
144
|
+
* machines. Detection stays a function of the project directory.
|
|
145
|
+
*/
|
|
146
|
+
export const ANTIGRAVITY_MARKERS = ['AGENTS.md', 'hooks.json', 'rules', 'workflows', 'plugins'];
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} projectPath
|
|
150
|
+
* @returns {boolean} true when `.agents/` carries something Antigravity owns
|
|
151
|
+
*/
|
|
152
|
+
export function detectAntigravity(projectPath) {
|
|
153
|
+
const dir = join(projectPath, '.agents');
|
|
154
|
+
if (!existsSync(dir)) return false;
|
|
155
|
+
return ANTIGRAVITY_MARKERS.some((m) => existsSync(join(dir, m)));
|
|
156
|
+
}
|
|
157
|
+
|
|
114
158
|
/**
|
|
115
159
|
* Detect AI tools configured in the project
|
|
116
160
|
* @param {string} projectPath - Path to the project
|
|
@@ -129,7 +173,8 @@ export function detectAITools(projectPath) {
|
|
|
129
173
|
// Antigravity never read INSTRUCTIONS.md.
|
|
130
174
|
// Measured 2026-09-08 with two positive controls in the same run: tokens planted in `AGENTS.md` and `.agents/AGENTS.md` both came back with correct attribution; the one in INSTRUCTIONS.md did not.
|
|
131
175
|
// `.agents/AGENTS.md` is used rather than the repo root so it does not collide with Codex/OpenCode, which both target root AGENTS.md.
|
|
132
|
-
|
|
176
|
+
// See detectAntigravity() for the wider marker set and for what is deliberately NOT one.
|
|
177
|
+
antigravity: detectAntigravity(projectPath),
|
|
133
178
|
// 🔴 Roo Code had a full entry in the path table (`.roo/skills/`, tier "complete"
|
|
134
179
|
// in REGISTRY.json) and NO line here, so `uds init` could never install for it —
|
|
135
180
|
// however correct those paths were. Found by `check:install-paths`, which walks
|