@3xhaust/oh-my-design 0.16.1

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 (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,145 @@
1
+ # SVG geometric patterns
2
+
3
+ An SVG geometric pattern — dots, lines, or a grid — embedded as a data URI and applied
4
+ as a repeating `background-image`. The pattern adds visual structure to backgrounds without
5
+ imagery or illustration: it reads as a surface property rather than a content element.
6
+ Unlike grain, which produces organic randomness, a geometric pattern produces deliberate
7
+ order — it implies a system, a grid, an underlying precision.
8
+
9
+ No external file is required. The SVG is a single small tile (16–40px square) that the
10
+ browser repeats across the element. The result is a background that scales to any
11
+ viewport size with no pixelation.
12
+
13
+ ## When it earns its place / When it does not
14
+
15
+ Condition: backgrounds that need visual structure without imagery — code editors, technical
16
+ product sites, engineering tools, documentation — where an organic texture would be
17
+ mismatched to the product's register. A dot grid on a developer tool reads as graph paper:
18
+ it signals precision and technical culture. A line pattern on an editorial layout can
19
+ read as a reference to print production. `core/theory/layout.md` closure principle:
20
+ a repeated grid element creates implied boundaries and structure that the eye reads as
21
+ organisational even before content is added.
22
+
23
+ Also appropriate as a hero background when the concept is systematic or ordered —
24
+ architecture, infrastructure, planning tools — where the grid pattern reinforces the
25
+ concept's governing metaphor.
26
+
27
+ Condition against: warm or organic brand concepts where a geometric pattern reads as
28
+ cold or over-engineered. Any context where the pattern's density competes with the text
29
+ density above it — a tight dot grid under dense body text creates visual noise. On dark
30
+ surfaces with low-contrast patterns, the pattern may not read at all; verify visibility.
31
+ And: a geometric pattern is not a substitute for a considered background decision —
32
+ it is a surface treatment on top of a colour choice. The colour must be chosen first.
33
+
34
+ ## Parameters
35
+
36
+ ```css
37
+ :root {
38
+ /* Pattern type: set by choosing the appropriate background-image value below.
39
+ Three canonical patterns: dot grid, line grid, isometric grid. */
40
+
41
+ /* Pattern colour: the foreground element of the pattern.
42
+ Should be a low-contrast step above the background — typically 8–15% opacity
43
+ of the text colour. More contrast than this makes the pattern dominant. */
44
+ --pattern-color: rgba(0, 0, 0, 0.08);
45
+ --pattern-bg: #fafafa; /* the background colour beneath the pattern */
46
+
47
+ /* Tile size: the repeat interval.
48
+ 20–32px: fine, dense pattern — reads as texture.
49
+ 40–60px: medium grid — reads as structural grid.
50
+ 80px+: coarse grid — reads as scaffolding, layout guide. */
51
+ --pattern-tile: 24px;
52
+
53
+ /* Element size within the tile: dot diameter, line stroke width. */
54
+ --pattern-dot-size: 1.5px;
55
+ --pattern-line-width: 0.5px;
56
+ }
57
+ ```
58
+
59
+ ## Implementation
60
+
61
+ **Dot grid pattern:**
62
+
63
+ ```css
64
+ .pattern-dots {
65
+ background-color: var(--pattern-bg);
66
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Ccircle cx='12' cy='12' r='1' fill='rgba(0%2C0%2C0%2C0.12)'/%3E%3C/svg%3E");
67
+ background-size: var(--pattern-tile) var(--pattern-tile);
68
+ background-repeat: repeat;
69
+ }
70
+ ```
71
+
72
+ **Line grid pattern (horizontal lines only):**
73
+
74
+ ```css
75
+ .pattern-lines {
76
+ background-color: var(--pattern-bg);
77
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24'%3E%3Cline x1='0' y1='23.5' x2='24' y2='23.5' stroke='rgba(0%2C0%2C0%2C0.08)' stroke-width='0.5'/%3E%3C/svg%3E");
78
+ background-size: var(--pattern-tile) var(--pattern-tile);
79
+ background-repeat: repeat;
80
+ }
81
+ ```
82
+
83
+ **Full grid pattern (horizontal + vertical lines):**
84
+
85
+ ```css
86
+ .pattern-grid {
87
+ background-color: var(--pattern-bg);
88
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Cpath d='M 32 0 L 0 0 0 32' fill='none' stroke='rgba(0%2C0%2C0%2C0.07)' stroke-width='0.5'/%3E%3C/svg%3E");
89
+ background-size: 32px 32px;
90
+ background-repeat: repeat;
91
+ }
92
+ ```
93
+
94
+ **Dark surface variants:** Replace `rgba(0,0,0,0.08)` with `rgba(255,255,255,0.06)` in the
95
+ SVG fill/stroke value. The percent-encoding changes accordingly:
96
+ - `rgba(255%2C255%2C255%2C0.06)` in the data URI.
97
+
98
+ ```html
99
+ <!-- Apply to any section or container: -->
100
+ <section class="pattern-dots">
101
+ <div class="section-inner">
102
+ <h2>Structured background.</h2>
103
+ </div>
104
+ </section>
105
+
106
+ <!-- Or as a decorative panel: -->
107
+ <div class="panel pattern-grid">
108
+ <!-- Content above the pattern grid -->
109
+ </div>
110
+ ```
111
+
112
+ ## Linter notes
113
+
114
+ - Geometric patterns at opacity that makes them dominant over the content above them are a
115
+ defect. The pattern must never compete with text or UI elements for visual attention.
116
+ Run `omd check` to verify contrast between any text and the pattern background —
117
+ the checker uses the `background-color` value and the overlaid SVG pattern's effective
118
+ luminance. If text contrast fails, reduce pattern opacity or increase the gap between
119
+ background and pattern colour values.
120
+
121
+ - A dot grid in the flagged SLOP-GRADIENT hue bands is not automatically a `SLOP-GRADIENT`
122
+ violation (the rule targets gradient fills, not patterns) — but a saturated coloured
123
+ dot grid reads as decoration pretending to be structure. Geometric patterns work as
124
+ neutral structure; adding hue to them converts them from structure to decoration and
125
+ they lose the technical signal that earns their place.
126
+
127
+ - The SVG tile `width` and `height` attributes must match the `background-size` value or
128
+ the tile will scale with display density, producing a visible seam at HiDPI. Set both
129
+ the SVG dimensions and `background-size` to the same pixel value in `--pattern-tile`.
130
+
131
+ ## Do not combine with
132
+
133
+ **gradient-mesh.md behind the same element** — a mesh gradient and a geometric pattern
134
+ applied to the same surface produce two background systems: one ambient and organic, one
135
+ ordered and geometric. They describe different things about the surface and the combination
136
+ reads as indecision rather than depth.
137
+
138
+ **noise-grain-texture.md at visible opacity** — grain and a geometric pattern both
139
+ operate on the background texture register. One is organic randomness; the other is
140
+ deliberate order. Combining them does not produce a richer surface — it obscures each
141
+ technique's signal.
142
+
143
+ **High-density body text sections** — a dot grid under dense text produces a visual
144
+ texture that competes with the letter forms. Reserve geometric patterns for sections
145
+ where content density is low and the pattern can read as structure rather than noise.
@@ -0,0 +1,39 @@
1
+ import { mkdirSync, appendFileSync, existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import type { Run, Violation } from '../types.ts';
4
+
5
+ const historyPath = (cwd: string): string => join(cwd, '.omd', 'history.jsonl');
6
+
7
+ /**
8
+ * Appends one run to the history log, creating `.omd/` if needed. A run with zero
9
+ * violations is still recorded — zero findings is the evidence that the user improved.
10
+ */
11
+ export function logRun(cwd: string, page: string, violations: Violation[], ts?: string): void {
12
+ const counts: Record<string, number> = {};
13
+ for (const v of violations) counts[v.id] = (counts[v.id] ?? 0) + 1;
14
+
15
+ const run: Run = { ts: ts ?? new Date().toISOString(), page, counts, total: violations.length };
16
+
17
+ mkdirSync(join(cwd, '.omd'), { recursive: true });
18
+ appendFileSync(historyPath(cwd), `${JSON.stringify(run)}\n`);
19
+ }
20
+
21
+ /**
22
+ * Reads every logged run in file order. Missing history -> []. A corrupt line is skipped
23
+ * rather than losing the whole log.
24
+ */
25
+ export function readHistory(cwd: string): Run[] {
26
+ const path = historyPath(cwd);
27
+ if (!existsSync(path)) return [];
28
+
29
+ const lines = readFileSync(path, 'utf8').split('\n').filter((line) => line.trim().length > 0);
30
+ const runs: Run[] = [];
31
+ for (const line of lines) {
32
+ try {
33
+ runs.push(JSON.parse(line) as Run);
34
+ } catch {
35
+ // corrupt line — skip, keep the rest of the log
36
+ }
37
+ }
38
+ return runs;
39
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import type { Host } from '../types.ts';
5
+
6
+ export interface Detected {
7
+ host: Host;
8
+ home: string;
9
+ version?: string;
10
+ }
11
+
12
+ export interface DetectOpts {
13
+ /** Override for `os.homedir()`, so detection is testable without touching the real home. */
14
+ homedir?: string;
15
+ }
16
+
17
+ /**
18
+ * Global, not per-project: a host is "present" when its config directory exists on disk,
19
+ * honouring the env vars each host itself uses to relocate that directory.
20
+ */
21
+ export function detectHosts(env: NodeJS.ProcessEnv = process.env, opts: DetectOpts = {}): Detected[] {
22
+ const home = opts.homedir ?? homedir();
23
+ const detected: Detected[] = [];
24
+
25
+ const claudeHome = env['CLAUDE_CONFIG_DIR'] || join(home, '.claude');
26
+ if (existsSync(claudeHome)) detected.push({ host: 'claude', home: claudeHome });
27
+
28
+ const codexHome = env['CODEX_HOME'] || join(home, '.codex');
29
+ if (existsSync(codexHome)) detected.push({ host: 'codex', home: codexHome });
30
+
31
+ return detected;
32
+ }
@@ -0,0 +1,321 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, cpSync, rmSync, readdirSync } from 'node:fs';
2
+ import { join, dirname, basename } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { parse as parseToml } from 'smol-toml';
6
+ import { build } from '../../adapters/build.ts';
7
+ import type { Detected } from './detect.ts';
8
+ import { unpatchSettings, patchSettings, unpatchHooks } from './patch-claude.ts';
9
+ import type { Settings } from './patch-claude.ts';
10
+ import { patchConfigToml, unpatchConfigToml } from './patch-codex.ts';
11
+ import type { Host } from '../types.ts';
12
+
13
+ // core/install/install.ts -> core/install -> core -> package root
14
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
15
+
16
+ const MARKETPLACE_URL = 'https://github.com/3x-haust/oh-my-design.git';
17
+ // Derived from what the build emitted, never hardcoded: a hardcoded list is how
18
+ // omd-sketch survived its own deletion, twice.
19
+ function shippedSkillNames(): string[] {
20
+ return readdirSafe(join(pkgRoot, 'dist', 'codex', 'skills'));
21
+ }
22
+
23
+ function pkgVersion(): string {
24
+ const pkg = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf8')) as { version: string };
25
+ return pkg.version;
26
+ }
27
+
28
+ function readdirSafe(dir: string): string[] {
29
+ try {
30
+ return readdirSync(dir);
31
+ } catch {
32
+ return [];
33
+ }
34
+ }
35
+
36
+ /** Every config we are about to patch gets backed up first, unconditionally. */
37
+ function backupFile(path: string, home: string): void {
38
+ if (!existsSync(path)) return;
39
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
40
+ const dest = join(home, 'backups', `${basename(path)}.${stamp}.bak`);
41
+ mkdirSync(dirname(dest), { recursive: true });
42
+ cpSync(path, dest);
43
+ }
44
+
45
+ // Every omd subcommand the skill invokes. A loop that raises a permission prompt ten times
46
+ // per iteration is a loop nobody finishes.
47
+ const OMD_ALLOW = [
48
+ 'Bash(omd check:*)', 'Bash(omd ir:*)', 'Bash(omd render:*)',
49
+ 'Bash(omd frame:*)', 'Bash(omd ref:*)', 'Bash(omd choose:*)',
50
+ 'Bash(omd decision:*)', 'Bash(omd taste:*)', 'Bash(omd coach:*)',
51
+ 'Bash(omd probe:*)', 'Bash(omd config:*)', 'Bash(omd craft:*)', 'Bash(omd copy:*)',
52
+ 'Bash(omd composition:*)',
53
+ 'Bash(omd text-slop:*)', 'Bash(omd visual-richness:*)', 'Bash(omd stack:*)',
54
+ 'Bash(omd source:*)', 'Bash(omd pack:*)', 'Bash(shasum:*)',
55
+ ];
56
+
57
+ // Pre-namespace skill directory names that predate the `omd-` prefix. Pruned on install so a
58
+ // legacy bare directory cannot shadow the `oh-my-design:` plugin.
59
+ const LEGACY_SKILLS = ['ultradesign', 'critique'];
60
+
61
+ /**
62
+ * Claude Code loads OMD as the marketplace plugin `oh-my-design@omd` (marketplace `omd` ->
63
+ * github `3x-haust/oh-my-design`). Everything resolves under the `oh-my-design:` namespace,
64
+ * so the installer registers the plugin in settings.json and prunes any bare-name `omd-*`
65
+ * skills/agents a previous direct install left behind — one surface, never a duplicate.
66
+ */
67
+ function installClaude(d: Detected, changes: string[]): void {
68
+ // Claude Code installs OMD as the marketplace plugin `oh-my-design@omd`, so every skill and
69
+ // agent resolves under the `oh-my-design:` namespace (e.g. `oh-my-design:ultradesign`). The
70
+ // older bare-name copy under ~/.claude/skills and ~/.claude/agents is pruned here so a stale
71
+ // `omd-*` surface can never shadow the plugin with a duplicate.
72
+ const skillsDest = join(d.home, 'skills');
73
+ for (const name of readdirSafe(skillsDest)) {
74
+ if (!name.startsWith('omd-') && !LEGACY_SKILLS.includes(name)) continue;
75
+ rmSync(join(skillsDest, name), { recursive: true, force: true });
76
+ changes.push(`claude: removed direct skill ${name}`);
77
+ }
78
+ const agentsDest = join(d.home, 'agents');
79
+ for (const file of readdirSafe(agentsDest)) {
80
+ if (!file.startsWith('omd-')) continue;
81
+ rmSync(join(agentsDest, file), { force: true });
82
+ changes.push(`claude: removed direct agent ${file}`);
83
+ }
84
+
85
+ const settingsPath = join(d.home, 'settings.json');
86
+ backupFile(settingsPath, d.home);
87
+ const current: Settings = existsSync(settingsPath)
88
+ ? (JSON.parse(readFileSync(settingsPath, 'utf8')) as Settings)
89
+ : {};
90
+ // Register the marketplace + enable the plugin (patchSettings) and clear the legacy gate.
91
+ const next = unpatchHooks(patchSettings(current, { marketplaceUrl: MARKETPLACE_URL, allow: OMD_ALLOW }));
92
+ mkdirSync(d.home, { recursive: true });
93
+ writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`);
94
+ changes.push(`claude: registered oh-my-design@omd plugin and patched ${settingsPath}`);
95
+ }
96
+
97
+ function uninstallClaude(d: Detected, changes: string[]): void {
98
+ const settingsPath = join(d.home, 'settings.json');
99
+ if (existsSync(settingsPath)) {
100
+ const current = JSON.parse(readFileSync(settingsPath, 'utf8')) as Settings;
101
+ const next = unpatchHooks(unpatchSettings(current));
102
+ writeFileSync(settingsPath, `${JSON.stringify(next, null, 2)}\n`);
103
+ changes.push(`claude: unpatched ${settingsPath}`);
104
+ }
105
+
106
+ // Remove any bare-name skill directory a previous direct install left, by prefix. The
107
+ // user's own skills (no omd- prefix) live beside them and are never touched.
108
+ const skillsDir = join(d.home, 'skills');
109
+ for (const name of readdirSafe(skillsDir)) {
110
+ if (!name.startsWith('omd-') && !LEGACY_SKILLS.includes(name)) continue;
111
+ rmSync(join(skillsDir, name), { recursive: true, force: true });
112
+ changes.push(`claude: removed ${join(skillsDir, name)}`);
113
+ }
114
+
115
+ for (const file of readdirSafe(join(d.home, 'agents'))) {
116
+ if (!file.startsWith('omd-')) continue;
117
+ rmSync(join(d.home, 'agents', file), { force: true });
118
+ changes.push(`claude: removed ${join(d.home, 'agents', file)}`);
119
+ }
120
+
121
+ // NEVER delete .omd/ — that is the user's own work.
122
+ const pluginRoot = join(d.home, 'plugins', 'omd');
123
+ if (existsSync(pluginRoot)) {
124
+ rmSync(pluginRoot, { recursive: true, force: true });
125
+ changes.push(`claude: removed ${pluginRoot}`);
126
+ }
127
+ }
128
+
129
+ function installCodex(d: Detected, version: string, changes: string[]): void {
130
+ const pluginDir = join(d.home, 'plugins', 'cache', 'omd', 'oh-my-design', version);
131
+ mkdirSync(pluginDir, { recursive: true });
132
+ cpSync(join(pkgRoot, 'dist', 'codex'), pluginDir, { recursive: true });
133
+ changes.push(`codex: installed plugin -> ${pluginDir}`);
134
+
135
+ const agentsDir = join(d.home, 'agents');
136
+ mkdirSync(agentsDir, { recursive: true });
137
+ const srcAgentsDir = join(pkgRoot, 'dist', 'codex', 'agents');
138
+ const agentNames: string[] = [];
139
+ for (const file of readdirSafe(srcAgentsDir)) {
140
+ if (!file.endsWith('.toml')) continue;
141
+ cpSync(join(srcAgentsDir, file), join(agentsDir, file));
142
+ agentNames.push(file.replace(/\.toml$/, ''));
143
+ }
144
+ changes.push(`codex: registered agents -> ${agentsDir} (${agentNames.join(', ') || 'none'})`);
145
+
146
+ const skillsSrc = join(pkgRoot, 'dist', 'codex', 'skills');
147
+ const skillsDest = join(d.home, 'skills');
148
+ if (existsSync(skillsSrc)) {
149
+ mkdirSync(skillsDest, { recursive: true });
150
+ cpSync(skillsSrc, skillsDest, { recursive: true });
151
+ changes.push(`codex: installed skills -> ${skillsDest}`);
152
+ }
153
+
154
+ const configPath = join(d.home, 'config.toml');
155
+ backupFile(configPath, d.home);
156
+ const currentText = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
157
+ const patched = patchConfigToml(currentText, { agents: agentNames });
158
+ mkdirSync(d.home, { recursive: true });
159
+ writeFileSync(configPath, patched);
160
+ changes.push(`codex: patched ${configPath}`);
161
+ }
162
+
163
+ function uninstallCodex(d: Detected, changes: string[]): void {
164
+ const configPath = join(d.home, 'config.toml');
165
+ if (existsSync(configPath)) {
166
+ writeFileSync(configPath, unpatchConfigToml(readFileSync(configPath, 'utf8')));
167
+ changes.push(`codex: unpatched ${configPath}`);
168
+ }
169
+
170
+ const pluginRoot = join(d.home, 'plugins', 'cache', 'omd');
171
+ if (existsSync(pluginRoot)) {
172
+ rmSync(pluginRoot, { recursive: true, force: true });
173
+ changes.push(`codex: removed ${pluginRoot}`);
174
+ }
175
+
176
+ const agentsDir = join(d.home, 'agents');
177
+ for (const file of readdirSafe(agentsDir)) {
178
+ if (!file.startsWith('omd-') || !file.endsWith('.toml')) continue;
179
+ rmSync(join(agentsDir, file), { force: true });
180
+ changes.push(`codex: removed ${join(agentsDir, file)}`);
181
+ }
182
+
183
+ // NEVER delete .omd/ anywhere — only our own skill copies live under ~/.codex/skills/.
184
+ const skillsDir = join(d.home, 'skills');
185
+ for (const name of shippedSkillNames()) {
186
+ const path = join(skillsDir, name);
187
+ if (!existsSync(path)) continue;
188
+ rmSync(path, { recursive: true, force: true });
189
+ changes.push(`codex: removed skill ${name}`);
190
+ }
191
+ }
192
+
193
+ export function install(hosts: Detected[]): string[] {
194
+ build();
195
+ const version = pkgVersion();
196
+ const changes: string[] = [];
197
+ for (const d of hosts) {
198
+ if (d.host === 'claude') installClaude(d, changes);
199
+ else installCodex(d, version, changes);
200
+ }
201
+ return changes;
202
+ }
203
+
204
+ export function uninstall(hosts: Detected[]): string[] {
205
+ const changes: string[] = [];
206
+ for (const d of hosts) {
207
+ if (d.host === 'claude') uninstallClaude(d, changes);
208
+ else uninstallCodex(d, changes);
209
+ }
210
+ return changes;
211
+ }
212
+
213
+ export interface DoctorCheck {
214
+ name: string;
215
+ ok: boolean;
216
+ detail?: string;
217
+ }
218
+
219
+ export interface DoctorResult {
220
+ host: Host;
221
+ ok: boolean;
222
+ checks: DoctorCheck[];
223
+ }
224
+
225
+ function check(name: string, ok: boolean, detail?: string): DoctorCheck {
226
+ return detail === undefined ? { name, ok } : { name, ok, detail };
227
+ }
228
+
229
+ function omdVersionRuns(): DoctorCheck {
230
+ try {
231
+ execFileSync(process.execPath, [join(pkgRoot, 'bin', 'omd.ts'), '--version'], { stdio: 'pipe' });
232
+ return check('omd --version runs', true);
233
+ } catch (err) {
234
+ return check('omd --version runs', false, err instanceof Error ? err.message : String(err));
235
+ }
236
+ }
237
+
238
+ function doctorClaude(d: Detected): DoctorCheck[] {
239
+ const checks: DoctorCheck[] = [];
240
+ const settingsPath = join(d.home, 'settings.json');
241
+ let settings: Settings | undefined;
242
+ try {
243
+ settings = existsSync(settingsPath) ? (JSON.parse(readFileSync(settingsPath, 'utf8')) as Settings) : {};
244
+ checks.push(check('settings.json parses', true));
245
+ } catch (err) {
246
+ checks.push(check('settings.json parses', false, err instanceof Error ? err.message : String(err)));
247
+ }
248
+
249
+ const legacyHookPresent = settings?.hooks?.PreToolUse?.some(
250
+ (entry) => entry.hooks.some((hook) => hook.command.includes('omd.ts')),
251
+ ) ?? false;
252
+ checks.push(check('legacy PreToolUse hook absent', settings !== undefined && !legacyHookPresent));
253
+
254
+ const pluginEnabled = settings?.enabledPlugins?.['oh-my-design@omd'] === true;
255
+ checks.push(check('oh-my-design@omd plugin enabled', pluginEnabled));
256
+
257
+ const marketplaces = settings?.extraKnownMarketplaces as Record<string, unknown> | undefined;
258
+ checks.push(check('omd marketplace registered', Boolean(marketplaces && marketplaces['omd'])));
259
+
260
+ const strayAgents = readdirSafe(join(d.home, 'agents')).filter((file) => file.startsWith('omd-') && file.endsWith('.md')).sort();
261
+ checks.push(check('no duplicate direct agents', strayAgents.length === 0, strayAgents.length ? `found ${strayAgents.join(', ')}` : undefined));
262
+
263
+ const straySkills = readdirSafe(join(d.home, 'skills')).filter((name) => name.startsWith('omd-') || LEGACY_SKILLS.includes(name)).sort();
264
+ checks.push(check('no duplicate direct skills', straySkills.length === 0, straySkills.length ? `found ${straySkills.join(', ')}` : undefined));
265
+
266
+ const copyPermission = settings?.permissions?.allow?.includes('Bash(omd copy:*)') ?? false;
267
+ checks.push(check('copy check permission present', copyPermission));
268
+ const compositionPermission = settings?.permissions?.allow?.includes('Bash(omd composition:*)') ?? false;
269
+ checks.push(check('composition check permission present', compositionPermission));
270
+ const shasumPermission = settings?.permissions?.allow?.includes('Bash(shasum:*)') ?? false;
271
+ checks.push(check('composition hash permission present', shasumPermission));
272
+ const sourcePermission = settings?.permissions?.allow?.includes('Bash(omd source:*)') ?? false;
273
+ checks.push(check('source seal permission present', sourcePermission));
274
+
275
+ return checks;
276
+ }
277
+
278
+ function doctorCodex(d: Detected): DoctorCheck[] {
279
+ const checks: DoctorCheck[] = [];
280
+ const configPath = join(d.home, 'config.toml');
281
+ let config: { agents?: Record<string, { config_file?: string }> } | undefined;
282
+ try {
283
+ config = existsSync(configPath)
284
+ ? parseToml(readFileSync(configPath, 'utf8')) as { agents?: Record<string, { config_file?: string }> }
285
+ : {};
286
+ checks.push(check('config.toml parses', true));
287
+ } catch (err) {
288
+ checks.push(check('config.toml parses', false, err instanceof Error ? err.message : String(err)));
289
+ }
290
+
291
+ const omdAgents = readdirSafe(join(d.home, 'agents')).filter((f) => f.startsWith('omd-') && f.endsWith('.toml'));
292
+ checks.push(check('agents registered', omdAgents.length > 0));
293
+ const typesetterRegistered = config?.agents?.['omd-typesetter']?.config_file === './agents/omd-typesetter.toml'
294
+ && existsSync(join(d.home, 'agents', 'omd-typesetter.toml'));
295
+ checks.push(check('typesetter agent registered', typesetterRegistered));
296
+ const composerRegistered = config?.agents?.['omd-composer']?.config_file === './agents/omd-composer.toml'
297
+ && existsSync(join(d.home, 'agents', 'omd-composer.toml'));
298
+ checks.push(check('composer agent registered', composerRegistered));
299
+
300
+ const skillsPresent = shippedSkillNames().some((name) => existsSync(join(d.home, 'skills', name)));
301
+ checks.push(check('skills present', skillsPresent));
302
+
303
+ // Not a pass/fail: we could not confirm Codex's trusted_hash key format against a real
304
+ // install, so this must read as unverified rather than claim success.
305
+ checks.push(check(
306
+ 'hook trust: unverified',
307
+ true,
308
+ 'trusted_hash key format (see core/install/patch-codex.ts) has not been confirmed against a real Codex session',
309
+ ));
310
+
311
+ return checks;
312
+ }
313
+
314
+ export function doctor(hosts: Detected[]): DoctorResult[] {
315
+ const versionCheck = omdVersionRuns();
316
+ return hosts.map((d) => {
317
+ const checks = d.host === 'claude' ? doctorClaude(d) : doctorCodex(d);
318
+ checks.push(versionCheck);
319
+ return { host: d.host, ok: checks.every((c) => c.ok), checks };
320
+ });
321
+ }
@@ -0,0 +1,109 @@
1
+ const ALLOW_PREFIX = 'Bash(omd ';
2
+ const PLUGIN_KEY = 'oh-my-design@omd';
3
+ // Identifies our hook among any others in PreToolUse: anything invoking omd.ts is ours.
4
+ const HOOK_MARKER = 'omd.ts';
5
+
6
+ export interface HookCommandEntry {
7
+ type: 'command';
8
+ command: string;
9
+ timeout: number;
10
+ }
11
+
12
+ export interface HookMatcherEntry {
13
+ matcher: string;
14
+ hooks: HookCommandEntry[];
15
+ }
16
+
17
+ /** What Claude Code's settings.json actually loads: event -> ordered matcher entries. */
18
+ export interface HooksBlock {
19
+ PreToolUse?: HookMatcherEntry[];
20
+ [event: string]: HookMatcherEntry[] | undefined;
21
+ }
22
+
23
+ export interface Settings {
24
+ permissions?: { allow?: string[]; [k: string]: unknown };
25
+ enabledPlugins?: Record<string, boolean>;
26
+ extraKnownMarketplaces?: Record<string, unknown>;
27
+ hooks?: HooksBlock;
28
+ [k: string]: unknown;
29
+ }
30
+
31
+ const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
32
+
33
+ export function patchSettings(settings: Settings, opts: { marketplaceUrl: string; allow?: string[] }): Settings {
34
+ const out = clone(settings ?? {});
35
+
36
+ out.extraKnownMarketplaces = {
37
+ ...out.extraKnownMarketplaces,
38
+ omd: { source: { source: 'git', url: opts.marketplaceUrl } },
39
+ };
40
+ out.enabledPlugins = { ...out.enabledPlugins, [PLUGIN_KEY]: true };
41
+
42
+ const existing = out.permissions?.allow ?? [];
43
+ const merged = [...existing];
44
+ for (const entry of opts.allow ?? []) if (!merged.includes(entry)) merged.push(entry);
45
+ out.permissions = { ...out.permissions, allow: merged };
46
+
47
+ return out;
48
+ }
49
+
50
+ export function unpatchSettings(settings: Settings): Settings {
51
+ const out = clone(settings ?? {});
52
+
53
+ if (out.extraKnownMarketplaces) {
54
+ delete out.extraKnownMarketplaces['omd'];
55
+ if (Object.keys(out.extraKnownMarketplaces).length === 0) delete out.extraKnownMarketplaces;
56
+ }
57
+ if (out.enabledPlugins) {
58
+ delete out.enabledPlugins[PLUGIN_KEY];
59
+ if (Object.keys(out.enabledPlugins).length === 0) delete out.enabledPlugins;
60
+ }
61
+ if (out.permissions) {
62
+ if (Array.isArray(out.permissions.allow)) {
63
+ out.permissions.allow = out.permissions.allow.filter((a) => !a.startsWith(ALLOW_PREFIX));
64
+ if (out.permissions.allow.length === 0) delete out.permissions.allow;
65
+ }
66
+ if (Object.keys(out.permissions).length === 0) delete out.permissions;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /**
72
+ * Grants the `omd` subcommands the skill needs, without claiming a marketplace. Every
73
+ * Bash call the model makes would otherwise raise a permission prompt, and a loop that
74
+ * prompts ten times per iteration is a loop nobody finishes.
75
+ */
76
+ export function patchAllow(settings: Settings, allow: string[]): Settings {
77
+ const out = clone(settings ?? {});
78
+
79
+ // We own the `Bash(omd ...)` namespace, so we replace it wholesale rather than appending.
80
+ // Appending leaves permissions for subcommands that no longer exist — an upgrade that
81
+ // removed `omd session` would otherwise grant it forever. Foreign entries keep their order.
82
+ const foreign = (out.permissions?.allow ?? []).filter((e) => !e.startsWith(ALLOW_PREFIX));
83
+ out.permissions = { ...out.permissions, allow: [...foreign, ...allow] };
84
+ return out;
85
+ }
86
+
87
+ /**
88
+ * Removes the PreToolUse gate that earlier versions installed, and only that: every other
89
+ * hook, on PreToolUse or any other event, is left exactly as it was.
90
+ *
91
+ * Nothing installs a hook any more. The gate made a human sign off on the reframing before
92
+ * anything could be built, which meant nothing could be rendered, which meant nothing could
93
+ * reveal that the reframing was wrong. It solved by signature a problem that is only ever
94
+ * solved by looking.
95
+ */
96
+ export function unpatchHooks(settings: Settings): Settings {
97
+ const out = clone(settings ?? {});
98
+ if (!out.hooks) return out;
99
+ const hooks: HooksBlock = { ...out.hooks };
100
+ const existing = hooks.PreToolUse;
101
+ if (existing) {
102
+ const filtered = existing.filter((entry) => !entry.hooks.some((h) => h.command.includes(HOOK_MARKER)));
103
+ if (filtered.length) hooks.PreToolUse = filtered;
104
+ else delete hooks.PreToolUse;
105
+ }
106
+ if (Object.keys(hooks).length === 0) delete out.hooks;
107
+ else out.hooks = hooks;
108
+ return out;
109
+ }