oh-my-harness 0.14.0 โ†’ 0.16.0

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 (36) hide show
  1. package/README.md +55 -0
  2. package/dist/cli/commands/diff.d.ts +17 -0
  3. package/dist/cli/commands/diff.js +88 -0
  4. package/dist/cli/commands/doctor.d.ts +8 -0
  5. package/dist/cli/commands/doctor.js +28 -2
  6. package/dist/cli/commands/sync.d.ts +7 -1
  7. package/dist/cli/commands/sync.js +46 -6
  8. package/dist/cli/commands/uninstall.d.ts +16 -0
  9. package/dist/cli/commands/uninstall.js +76 -0
  10. package/dist/cli/index.js +36 -3
  11. package/dist/core/drift.d.ts +36 -0
  12. package/dist/core/drift.js +69 -0
  13. package/dist/core/generator.d.ts +12 -0
  14. package/dist/core/generator.js +40 -5
  15. package/dist/core/harness-schema.d.ts +4 -4
  16. package/dist/core/managed-hooks.d.ts +6 -0
  17. package/dist/core/managed-hooks.js +74 -0
  18. package/dist/core/plan.d.ts +24 -0
  19. package/dist/core/plan.js +1 -0
  20. package/dist/core/uninstall.d.ts +41 -0
  21. package/dist/core/uninstall.js +291 -0
  22. package/dist/generators/codex-config.d.ts +9 -0
  23. package/dist/generators/codex-config.js +104 -6
  24. package/dist/generators/gitignore.d.ts +9 -0
  25. package/dist/generators/gitignore.js +14 -3
  26. package/dist/generators/hooks.d.ts +21 -0
  27. package/dist/generators/hooks.js +48 -24
  28. package/dist/generators/managed-md.d.ts +5 -0
  29. package/dist/generators/managed-md.js +9 -1
  30. package/dist/generators/pi-extension.d.ts +8 -0
  31. package/dist/generators/pi-extension.js +17 -8
  32. package/dist/generators/settings.d.ts +10 -0
  33. package/dist/generators/settings.js +71 -4
  34. package/dist/utils/version.d.ts +1 -0
  35. package/dist/utils/version.js +5 -0
  36. package/package.json +1 -1
package/README.md CHANGED
@@ -60,8 +60,30 @@ omh init "Android Kotlin app with Hilt, JUnit, Gradle"
60
60
  omh catalog list
61
61
  omh test # Dry-run verify your harness
62
62
  omh stats # TUI analytics dashboard
63
+ omh diff # Preview what `omh sync` would change
64
+ omh sync --check # Fail (exit 1) if generated files are out of date โ€” CI gate
63
65
  ```
64
66
 
67
+ ### ๐Ÿ”„ Keeping generated files in sync
68
+
69
+ `harness.yaml` is the source of truth, so the committed `CLAUDE.md`, hooks, and
70
+ runtime configs can drift if someone edits `harness.yaml` without re-running
71
+ `omh sync`. Three commands keep them honest:
72
+
73
+ | Command | Use |
74
+ |---------|-----|
75
+ | `omh sync --check` | CI gate โ€” exits non-zero (and lists the files) when generated output is stale, writes nothing |
76
+ | `omh diff` | Human preview of exactly what `omh sync` would change |
77
+ | `omh doctor --strict` | Health check that also fails on drift (plain `omh doctor` warns) |
78
+
79
+ ```yaml
80
+ # .github/workflows/ci.yml
81
+ - run: npx oh-my-harness sync --check # fails the build if the harness is out of date
82
+ ```
83
+
84
+ The hook manifest records the oh-my-harness version that generated it, so an
85
+ upgrade that changes output is surfaced as a "re-run `omh sync`" hint.
86
+
65
87
  ### ๐Ÿ“ What Gets Generated
66
88
 
67
89
  ```text
@@ -251,6 +273,9 @@ omh hook remove auto-pr # Remove a hook
251
273
 
252
274
  # ๐Ÿ”„ Sync & manage
253
275
  omh sync # Regenerate all files from harness.yaml
276
+ omh uninstall --dry-run # Preview generated-file cleanup
277
+ omh uninstall -y # Remove generated files, keep user content
278
+ omh uninstall -y --purge # Also remove harness.yaml
254
279
 
255
280
  # ๐Ÿฉบ Verify & monitor
256
281
  omh doctor # Health check
@@ -258,6 +283,35 @@ omh test # Dry-run verify all hooks
258
283
  omh stats # TUI analytics dashboard
259
284
  ```
260
285
 
286
+ ### ๐Ÿงน `omh uninstall` โ€” Safe generated-file cleanup
287
+
288
+ `omh uninstall` removes oh-my-harness generated artifacts while preserving user
289
+ content in merged files.
290
+
291
+ ```bash
292
+ omh uninstall --dry-run
293
+ omh uninstall -y
294
+ omh uninstall -y --purge
295
+ ```
296
+
297
+ Safety behavior:
298
+
299
+ - Prints the same uninstall plan for dry-run and real execution.
300
+ - Recommends backing up before execution; use `--skip-backup-warning` only for
301
+ automation that already handles backups.
302
+ - Keeps `harness.yaml` by default; `--purge` removes it.
303
+ - Preserves user content in `CLAUDE.md`, `AGENTS.md`, `.claude/settings.json`,
304
+ `.codex/hooks.json`, `.codex/config.toml`, and user Pi extensions.
305
+ - Removes only OMH-owned hook commands that point at this project's
306
+ `.omh/hooks` directory.
307
+ - Warns when `.codex/config.toml` feature flags (`hooks`/`goals`) are removed,
308
+ because manually-owned feature settings cannot be distinguished from OMH
309
+ generated settings.
310
+ - Warns that `.codex/config.toml` comments may be lost when TOML is rewritten.
311
+ - Uses backups for modified files and restores them on stop-on-error failures;
312
+ `--continue-on-error` records failures and keeps applying independent
313
+ operations.
314
+
261
315
  ### ๐Ÿฉบ `omh doctor`
262
316
 
263
317
  ```text
@@ -418,6 +472,7 @@ oh-my-harness/
418
472
  - [x] Unified `.omh/` layout โ€” single source of truth for hooks & state across runtimes
419
473
  - [x] Pi ([pi.dev](https://pi.dev)) emitter โ€” bridge extension (`.pi/extensions/omh-harness.ts`) reusing the same `.omh/hooks/*.sh`
420
474
  - [x] `ask` mode โ€” request approval before executing risky tools (Claude native prompt / Pi `ctx.ui.select`; Codex falls back to block)
475
+ - [x] `omh uninstall` โ€” remove generated artifacts while preserving user content
421
476
  - [ ] Community harness.yaml registry โ€” share and reuse configs
422
477
  - [ ] `omh modify "change X"` โ€” NL config editing
423
478
 
@@ -0,0 +1,17 @@
1
+ export interface DiffOptions {
2
+ projectDir?: string;
3
+ }
4
+ export interface DiffResult {
5
+ exitCode: number;
6
+ inSync?: boolean;
7
+ }
8
+ /**
9
+ * Minimal LCS line diff โ€” enough to show what `omh sync` would change without
10
+ * pulling in a diff dependency. Returns unified-style lines (" ", "-", "+").
11
+ */
12
+ export declare function unifiedDiff(oldStr: string, newStr: string): string[];
13
+ /**
14
+ * `omh diff`: human-readable preview of what `omh sync` would change, without
15
+ * writing anything.
16
+ */
17
+ export declare function diffCommand(options?: DiffOptions): Promise<DiffResult>;
@@ -0,0 +1,88 @@
1
+ import chalk from "chalk";
2
+ import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
3
+ /**
4
+ * Minimal LCS line diff โ€” enough to show what `omh sync` would change without
5
+ * pulling in a diff dependency. Returns unified-style lines (" ", "-", "+").
6
+ */
7
+ export function unifiedDiff(oldStr, newStr) {
8
+ const a = oldStr.split("\n");
9
+ const b = newStr.split("\n");
10
+ const n = a.length;
11
+ const m = b.length;
12
+ // LCS length table.
13
+ const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
14
+ for (let i = n - 1; i >= 0; i--) {
15
+ for (let j = m - 1; j >= 0; j--) {
16
+ lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
17
+ }
18
+ }
19
+ const out = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ while (i < n && j < m) {
23
+ if (a[i] === b[j]) {
24
+ out.push(` ${a[i]}`);
25
+ i++;
26
+ j++;
27
+ }
28
+ else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
29
+ out.push(`- ${a[i]}`);
30
+ i++;
31
+ }
32
+ else {
33
+ out.push(`+ ${b[j]}`);
34
+ j++;
35
+ }
36
+ }
37
+ while (i < n)
38
+ out.push(`- ${a[i++]}`);
39
+ while (j < m)
40
+ out.push(`+ ${b[j++]}`);
41
+ return out;
42
+ }
43
+ function printChange(change) {
44
+ const header = change.kind === "create" ? `+++ ${change.path} (new)` : `~~~ ${change.path}`;
45
+ console.log(chalk.bold(header));
46
+ const lines = unifiedDiff(change.current ?? "", change.planned);
47
+ for (const line of lines) {
48
+ if (line.startsWith("+"))
49
+ console.log(chalk.green(line));
50
+ else if (line.startsWith("-"))
51
+ console.log(chalk.red(line));
52
+ else
53
+ console.log(chalk.dim(line));
54
+ }
55
+ console.log("");
56
+ }
57
+ /**
58
+ * `omh diff`: human-readable preview of what `omh sync` would change, without
59
+ * writing anything.
60
+ */
61
+ export async function diffCommand(options = {}) {
62
+ const projectDir = options.projectDir ?? process.cwd();
63
+ let drift;
64
+ try {
65
+ drift = await computeDrift(projectDir);
66
+ }
67
+ catch (err) {
68
+ if (err instanceof HarnessNotFoundError) {
69
+ console.error(chalk.red(err.message));
70
+ console.error("Run `oh-my-harness init` to create one.");
71
+ }
72
+ else {
73
+ console.error(chalk.red(`omh diff failed: ${err.message}`));
74
+ }
75
+ return { exitCode: 1 };
76
+ }
77
+ if (drift.inSync) {
78
+ console.log(chalk.green("oh-my-harness: no changes โ€” generated files are up to date"));
79
+ return { exitCode: 0, inSync: true };
80
+ }
81
+ for (const change of drift.changed) {
82
+ printChange(change);
83
+ }
84
+ for (const stale of drift.wouldDelete) {
85
+ console.log(chalk.red(`--- ${stale} (would be removed)`));
86
+ }
87
+ return { exitCode: 0, inSync: false };
88
+ }
@@ -1,5 +1,7 @@
1
1
  export interface DoctorOptions {
2
2
  projectDir?: string;
3
+ /** When true, treat drift (out-of-sync generated files) as a health failure. */
4
+ strict?: boolean;
3
5
  }
4
6
  export interface DoctorResult {
5
7
  healthy: boolean;
@@ -13,6 +15,12 @@ export interface DoctorResult {
13
15
  piConfig: boolean;
14
16
  hooksExecutable: boolean;
15
17
  };
18
+ /**
19
+ * Whether generated files are up to date with harness.yaml. undefined when
20
+ * there is no harness.yaml (drift not evaluated). Drift is a non-fatal
21
+ * warning unless `strict` is set.
22
+ */
23
+ inSync?: boolean;
16
24
  messages: string[];
17
25
  }
18
26
  export declare function doctorCommand(options?: DoctorOptions): Promise<DoctorResult>;
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parse } from "smol-toml";
4
4
  import { OMH_HOOKS_DIR } from "../../utils/paths.js";
5
+ import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
5
6
  export async function doctorCommand(options = {}) {
6
7
  const projectDir = options.projectDir ?? process.cwd();
7
8
  const messages = [];
@@ -132,10 +133,35 @@ export async function doctorCommand(options = {}) {
132
133
  // No hooks dir โ€” acceptable if no hooks defined
133
134
  checks.hooksExecutable = true;
134
135
  }
135
- const healthy = Object.values(checks).every(Boolean);
136
+ // 8. Drift: are generated files up to date with harness.yaml? Only evaluated
137
+ // when a harness.yaml is present. Non-fatal by default (a warning), fatal
138
+ // under --strict so CI can gate on it.
139
+ let inSync;
140
+ try {
141
+ const drift = await computeDrift(projectDir);
142
+ inSync = drift.inSync;
143
+ if (!drift.inSync) {
144
+ messages.push(`${options.strict ? "FAIL" : "WARN"}: generated files are out of sync with harness.yaml โ€” run \`omh sync\`.`);
145
+ }
146
+ else if (drift.versionDrift) {
147
+ messages.push(`WARN: harness generated by oh-my-harness ${drift.versionDrift.manifestVersion}, ` +
148
+ `now running ${drift.versionDrift.currentVersion} โ€” run \`omh sync\` to refresh.`);
149
+ }
150
+ }
151
+ catch (err) {
152
+ if (!(err instanceof HarnessNotFoundError)) {
153
+ messages.push(`WARN: could not evaluate sync status: ${err.message}`);
154
+ }
155
+ // No harness.yaml โ†’ leave inSync undefined (drift not applicable).
156
+ }
157
+ const checksHealthy = Object.values(checks).every(Boolean);
158
+ const healthy = checksHealthy && (!options.strict || inSync !== false);
136
159
  const exitCode = healthy ? 0 : 1;
137
160
  if (healthy) {
138
161
  console.log("oh-my-harness: all checks passed");
162
+ for (const msg of messages) {
163
+ console.log(` ${msg}`);
164
+ }
139
165
  }
140
166
  else {
141
167
  console.log("oh-my-harness: some checks failed:");
@@ -143,5 +169,5 @@ export async function doctorCommand(options = {}) {
143
169
  console.log(` ${msg}`);
144
170
  }
145
171
  }
146
- return { healthy, exitCode, checks, messages };
172
+ return { healthy, exitCode, checks, inSync, messages };
147
173
  }
@@ -1,4 +1,10 @@
1
1
  export interface SyncOptions {
2
2
  projectDir?: string;
3
+ /** When true, report drift without writing and exit non-zero if out of date. */
4
+ check?: boolean;
3
5
  }
4
- export declare function syncCommand(options?: SyncOptions): Promise<void>;
6
+ export interface SyncResult {
7
+ exitCode: number;
8
+ inSync?: boolean;
9
+ }
10
+ export declare function syncCommand(options?: SyncOptions): Promise<SyncResult>;
@@ -5,10 +5,14 @@ import chalk from "chalk";
5
5
  import { HarnessConfigSchema } from "../../core/harness-schema.js";
6
6
  import { harnessToMergedConfigV2 } from "../../core/harness-converter-v2.js";
7
7
  import { generate } from "../../core/generator.js";
8
+ import { computeDrift, HarnessNotFoundError } from "../../core/drift.js";
8
9
  import { writeHarnessState } from "./init.js";
9
10
  export async function syncCommand(options = {}) {
10
11
  const projectDir = options.projectDir ?? process.cwd();
11
12
  const harnessYamlPath = path.join(projectDir, "harness.yaml");
13
+ if (options.check) {
14
+ return runCheck(projectDir);
15
+ }
12
16
  let raw;
13
17
  try {
14
18
  raw = await fs.readFile(harnessYamlPath, "utf-8");
@@ -22,8 +26,7 @@ export async function syncCommand(options = {}) {
22
26
  else {
23
27
  console.error(chalk.red(`Failed to read harness.yaml: ${error.message}`));
24
28
  }
25
- process.exit(1);
26
- return;
29
+ return { exitCode: 1 };
27
30
  }
28
31
  let parsed;
29
32
  try {
@@ -32,8 +35,7 @@ export async function syncCommand(options = {}) {
32
35
  catch (err) {
33
36
  const error = err;
34
37
  console.error(chalk.red(`Failed to parse harness.yaml: ${error.message}`));
35
- process.exit(1);
36
- return;
38
+ return { exitCode: 1 };
37
39
  }
38
40
  const result = HarnessConfigSchema.safeParse(parsed);
39
41
  if (!result.success) {
@@ -41,8 +43,7 @@ export async function syncCommand(options = {}) {
41
43
  for (const issue of result.error.issues) {
42
44
  console.error(` ${issue.path.join(".")}: ${issue.message}`);
43
45
  }
44
- process.exit(1);
45
- return;
46
+ return { exitCode: 1 };
46
47
  }
47
48
  const harness = result.data;
48
49
  const mergedV2 = await harnessToMergedConfigV2(harness, undefined, projectDir);
@@ -58,4 +59,43 @@ export async function syncCommand(options = {}) {
58
59
  for (const f of genResult.files) {
59
60
  console.log(` ${f}`);
60
61
  }
62
+ return { exitCode: 0 };
63
+ }
64
+ /**
65
+ * `omh sync --check`: report whether generated files are up to date with
66
+ * harness.yaml without writing anything. exitCode 0 = in sync, 1 = drift (or a
67
+ * harness.yaml problem). CI-friendly, like `prettier --check`.
68
+ */
69
+ async function runCheck(projectDir) {
70
+ let drift;
71
+ try {
72
+ drift = await computeDrift(projectDir);
73
+ }
74
+ catch (err) {
75
+ if (err instanceof HarnessNotFoundError) {
76
+ console.error(chalk.red(err.message));
77
+ console.error("Run `oh-my-harness init` to create one.");
78
+ }
79
+ else {
80
+ console.error(chalk.red(`sync --check failed: ${err.message}`));
81
+ }
82
+ return { exitCode: 1 };
83
+ }
84
+ if (drift.inSync) {
85
+ console.log(chalk.green("oh-my-harness: up to date"));
86
+ if (drift.versionDrift) {
87
+ console.log(chalk.yellow(` โš  generated by oh-my-harness ${drift.versionDrift.manifestVersion}, ` +
88
+ `now running ${drift.versionDrift.currentVersion} โ€” re-run \`omh sync\` to refresh.`));
89
+ }
90
+ return { exitCode: 0, inSync: true };
91
+ }
92
+ console.error(chalk.red("oh-my-harness: out of sync โ€” run `omh sync`"));
93
+ for (const change of drift.changed) {
94
+ const label = change.kind === "create" ? "would create" : "would update";
95
+ console.error(` ${label}: ${change.path}`);
96
+ }
97
+ for (const stale of drift.wouldDelete) {
98
+ console.error(` would delete: ${stale}`);
99
+ }
100
+ return { exitCode: 1, inSync: false };
61
101
  }
@@ -0,0 +1,16 @@
1
+ import { type UninstallPlan, type UninstallResult } from "../../core/uninstall.js";
2
+ export interface UninstallOptions {
3
+ projectDir?: string;
4
+ dryRun?: boolean;
5
+ yes?: boolean;
6
+ purge?: boolean;
7
+ skipBackupWarning?: boolean;
8
+ continueOnError?: boolean;
9
+ }
10
+ export interface UninstallCommandResult {
11
+ exitCode: number;
12
+ plan: UninstallPlan;
13
+ result?: UninstallResult;
14
+ }
15
+ export declare function renderUninstallPlan(plan: UninstallPlan, options?: UninstallOptions): string;
16
+ export declare function uninstallCommand(options?: UninstallOptions): Promise<UninstallCommandResult>;
@@ -0,0 +1,76 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { applyUninstallPlan, computeUninstall, } from "../../core/uninstall.js";
4
+ function visibleWarnings(plan, options) {
5
+ return plan.destructiveWarnings.filter((warning) => !(options.skipBackupWarning && warning.includes("๋ฐฑ์—… ํ›„ ์‹คํ–‰ ๊ถŒ์žฅ")));
6
+ }
7
+ export function renderUninstallPlan(plan, options = {}) {
8
+ const warnings = visibleWarnings(plan, options);
9
+ const lines = [
10
+ "oh-my-harness uninstall plan",
11
+ `- delete: ${plan.delete.length} files/directories`,
12
+ `- modify: ${plan.modify.length} files`,
13
+ `- keep: ${plan.keptHarnessYaml ? "harness.yaml" : "none"}`,
14
+ `- warnings: ${warnings.length}`,
15
+ ];
16
+ if (warnings.length > 0) {
17
+ lines.push("", "Safety:");
18
+ for (const warning of warnings)
19
+ lines.push(`- ${warning}`);
20
+ }
21
+ if (plan.modify.length > 0) {
22
+ lines.push("", "modify:");
23
+ for (const item of plan.modify)
24
+ lines.push(` ${item.path}`);
25
+ }
26
+ if (plan.delete.length > 0) {
27
+ lines.push("", "delete:");
28
+ for (const target of plan.delete)
29
+ lines.push(` ${target}`);
30
+ }
31
+ if (plan.removeDirs.length > 0) {
32
+ lines.push("", "remove empty dirs:");
33
+ for (const dir of plan.removeDirs)
34
+ lines.push(` ${dir}`);
35
+ }
36
+ return lines.join("\n");
37
+ }
38
+ function renderResult(result) {
39
+ const lines = [
40
+ "oh-my-harness uninstall result",
41
+ `modified: ${result.modified.length}`,
42
+ `deleted: ${result.deleted.length}`,
43
+ `removedDirs: ${result.removedDirs.length}`,
44
+ `restored: ${result.restored.length}`,
45
+ `failed: ${result.failed.length}`,
46
+ ];
47
+ for (const failure of result.failed) {
48
+ lines.push(` ${failure.op}: ${failure.path} โ€” ${failure.message}`);
49
+ }
50
+ return lines.join("\n");
51
+ }
52
+ async function confirmUninstall() {
53
+ const rl = createInterface({ input, output });
54
+ try {
55
+ const answer = await rl.question("Proceed with uninstall? Type 'yes' to continue: ");
56
+ return answer.trim().toLowerCase() === "yes";
57
+ }
58
+ finally {
59
+ rl.close();
60
+ }
61
+ }
62
+ export async function uninstallCommand(options = {}) {
63
+ const projectDir = options.projectDir ?? process.cwd();
64
+ const plan = await computeUninstall({ projectDir, purge: options.purge });
65
+ console.log(renderUninstallPlan(plan, options));
66
+ if (options.dryRun) {
67
+ return { exitCode: 0, plan };
68
+ }
69
+ if (!options.yes && !await confirmUninstall()) {
70
+ console.log("oh-my-harness: uninstall cancelled");
71
+ return { exitCode: 1, plan };
72
+ }
73
+ const result = await applyUninstallPlan(plan, { continueOnError: options.continueOnError });
74
+ console.log(renderResult(result));
75
+ return { exitCode: result.failed.length === 0 ? 0 : 1, plan, result };
76
+ }
package/dist/cli/index.js CHANGED
@@ -31,9 +31,22 @@ export function createCli() {
31
31
  program
32
32
  .command("doctor")
33
33
  .description("Validate harness configuration health")
34
- .action(async () => {
34
+ .option("-d, --project-dir <dir>", "Project directory")
35
+ .option("--strict", "Treat drift (out-of-sync generated files) as a failure")
36
+ .action(async (options) => {
35
37
  const { doctorCommand } = await import("./commands/doctor.js");
36
- const result = await doctorCommand();
38
+ const result = await doctorCommand(options);
39
+ if (result.exitCode !== 0) {
40
+ process.exitCode = result.exitCode;
41
+ }
42
+ });
43
+ program
44
+ .command("diff")
45
+ .description("Preview what `omh sync` would change, without writing")
46
+ .option("-d, --project-dir <dir>", "Project directory")
47
+ .action(async (options) => {
48
+ const { diffCommand } = await import("./commands/diff.js");
49
+ const result = await diffCommand(options);
37
50
  if (result.exitCode !== 0) {
38
51
  process.exitCode = result.exitCode;
39
52
  }
@@ -50,9 +63,29 @@ export function createCli() {
50
63
  .command("sync")
51
64
  .description("Regenerate files from harness.yaml")
52
65
  .option("-d, --project-dir <dir>", "Project directory")
66
+ .option("--check", "Report drift without writing; exit non-zero if out of date")
53
67
  .action(async (options) => {
54
68
  const { syncCommand } = await import("./commands/sync.js");
55
- await syncCommand(options);
69
+ const result = await syncCommand(options);
70
+ if (result.exitCode !== 0) {
71
+ process.exitCode = result.exitCode;
72
+ }
73
+ });
74
+ program
75
+ .command("uninstall")
76
+ .description("Safely remove oh-my-harness generated files while preserving user content")
77
+ .option("-d, --project-dir <dir>", "Project directory")
78
+ .option("--dry-run", "Print the uninstall plan without writing")
79
+ .option("-y, --yes", "Skip confirmation prompt")
80
+ .option("--purge", "Also delete harness.yaml")
81
+ .option("--skip-backup-warning", "Suppress the backup recommendation")
82
+ .option("--continue-on-error", "Keep applying independent operations after failures")
83
+ .action(async (options) => {
84
+ const { uninstallCommand } = await import("./commands/uninstall.js");
85
+ const result = await uninstallCommand(options);
86
+ if (result.exitCode !== 0) {
87
+ process.exitCode = result.exitCode;
88
+ }
56
89
  });
57
90
  program
58
91
  .command("stats")
@@ -0,0 +1,36 @@
1
+ export interface DriftChange {
2
+ path: string;
3
+ /** "create": file is missing on disk; "update": content differs. */
4
+ kind: "create" | "update";
5
+ /** Content sync would write. */
6
+ planned: string;
7
+ /** Current on-disk content, or null when the file is missing. */
8
+ current: string | null;
9
+ }
10
+ export interface DriftResult {
11
+ /** True when no files would change and nothing would be deleted. */
12
+ inSync: boolean;
13
+ changed: DriftChange[];
14
+ /** Absolute paths of stale files a sync would remove. */
15
+ wouldDelete: string[];
16
+ /**
17
+ * Set when the manifest records a different oh-my-harness version than the
18
+ * one running now (a cheap "re-sync recommended after upgrade" signal). null
19
+ * when versions match or no manifest exists.
20
+ */
21
+ versionDrift: {
22
+ manifestVersion: string | null;
23
+ currentVersion: string;
24
+ } | null;
25
+ }
26
+ /** Raised when computeDrift is asked to run on a project without a harness.yaml. */
27
+ export declare class HarnessNotFoundError extends Error {
28
+ readonly harnessPath: string;
29
+ constructor(harnessPath: string);
30
+ }
31
+ /**
32
+ * Compare what a sync would produce against the files currently on disk, without
33
+ * writing anything. Reads harness.yaml, builds the merged config, runs the
34
+ * generator in plan mode, and diffs each planned file against disk.
35
+ */
36
+ export declare function computeDrift(projectDir: string): Promise<DriftResult>;
@@ -0,0 +1,69 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import yaml from "js-yaml";
4
+ import { HarnessConfigSchema } from "./harness-schema.js";
5
+ import { harnessToMergedConfigV2 } from "./harness-converter-v2.js";
6
+ import { planGenerate } from "./generator.js";
7
+ import { OMH_MANIFEST } from "../utils/paths.js";
8
+ import { OMH_VERSION } from "../utils/version.js";
9
+ /** Raised when computeDrift is asked to run on a project without a harness.yaml. */
10
+ export class HarnessNotFoundError extends Error {
11
+ harnessPath;
12
+ constructor(harnessPath) {
13
+ super(`harness.yaml not found at ${harnessPath}`);
14
+ this.harnessPath = harnessPath;
15
+ this.name = "HarnessNotFoundError";
16
+ }
17
+ }
18
+ async function readFileOrNull(path) {
19
+ try {
20
+ return await readFile(path, "utf-8");
21
+ }
22
+ catch (err) {
23
+ if (err.code === "ENOENT")
24
+ return null;
25
+ throw err;
26
+ }
27
+ }
28
+ async function readManifestVersion(projectDir) {
29
+ const raw = await readFileOrNull(join(projectDir, OMH_MANIFEST));
30
+ if (raw === null)
31
+ return null;
32
+ try {
33
+ const manifest = JSON.parse(raw);
34
+ return typeof manifest.omhVersion === "string" ? manifest.omhVersion : null;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /**
41
+ * Compare what a sync would produce against the files currently on disk, without
42
+ * writing anything. Reads harness.yaml, builds the merged config, runs the
43
+ * generator in plan mode, and diffs each planned file against disk.
44
+ */
45
+ export async function computeDrift(projectDir) {
46
+ const harnessPath = join(projectDir, "harness.yaml");
47
+ const raw = await readFileOrNull(harnessPath);
48
+ if (raw === null)
49
+ throw new HarnessNotFoundError(harnessPath);
50
+ const parsed = HarnessConfigSchema.parse(yaml.load(raw));
51
+ const config = await harnessToMergedConfigV2(parsed, undefined, projectDir);
52
+ const plan = await planGenerate({ projectDir, config });
53
+ const changed = [];
54
+ for (const file of plan.files) {
55
+ const current = await readFileOrNull(file.path);
56
+ if (current === null) {
57
+ changed.push({ path: file.path, kind: "create", planned: file.content, current: null });
58
+ }
59
+ else if (current !== file.content) {
60
+ changed.push({ path: file.path, kind: "update", planned: file.content, current });
61
+ }
62
+ }
63
+ const manifestVersion = await readManifestVersion(projectDir);
64
+ const versionDrift = manifestVersion !== null && manifestVersion !== OMH_VERSION
65
+ ? { manifestVersion, currentVersion: OMH_VERSION }
66
+ : null;
67
+ const inSync = changed.length === 0 && plan.wouldDelete.length === 0;
68
+ return { inSync, changed, wouldDelete: plan.wouldDelete, versionDrift };
69
+ }
@@ -1,4 +1,5 @@
1
1
  import type { MergedConfig } from "./merged-config.js";
2
+ import type { GenerationPlan } from "./plan.js";
2
3
  export interface GenerateOptions {
3
4
  projectDir: string;
4
5
  config: MergedConfig;
@@ -7,3 +8,14 @@ export interface GenerateResult {
7
8
  files: string[];
8
9
  }
9
10
  export declare function generate(options: GenerateOptions): Promise<GenerateResult>;
11
+ /**
12
+ * Compute every file `generate()` would write โ€” and the stale files it would
13
+ * remove โ€” WITHOUT touching disk. Backs `omh sync --check`, `omh diff`, and the
14
+ * doctor drift warning. Uses the same compute functions as the write path, so
15
+ * the plan can never disagree with what a real sync produces.
16
+ *
17
+ * The bookkeeping files that embed timestamps (.omh/manifest.json and
18
+ * .claude/oh-my-harness.json) are intentionally excluded โ€” they change every
19
+ * run and are not part of the reproducible harness output.
20
+ */
21
+ export declare function planGenerate(options: GenerateOptions): Promise<GenerationPlan>;