motif-design 0.1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +91 -0
  3. package/bin/install.js +724 -0
  4. package/core/references/context-engine.md +190 -0
  5. package/core/references/design-inputs.md +421 -0
  6. package/core/references/runtime-adapters.md +180 -0
  7. package/core/references/state-machine.md +124 -0
  8. package/core/references/verticals/ecommerce.md +251 -0
  9. package/core/references/verticals/fintech.md +226 -0
  10. package/core/references/verticals/health.md +235 -0
  11. package/core/references/verticals/saas.md +248 -0
  12. package/core/templates/STATE-TEMPLATE.md +28 -0
  13. package/core/templates/SUMMARY-TEMPLATE.md +21 -0
  14. package/core/templates/VERTICAL-TEMPLATE.md +144 -0
  15. package/core/templates/token-showcase-template.html +946 -0
  16. package/core/workflows/compose-screen.md +163 -0
  17. package/core/workflows/evolve.md +64 -0
  18. package/core/workflows/fix.md +64 -0
  19. package/core/workflows/generate-system.md +336 -0
  20. package/core/workflows/quick.md +23 -0
  21. package/core/workflows/research.md +233 -0
  22. package/core/workflows/review.md +126 -0
  23. package/package.json +26 -0
  24. package/runtimes/claude-code/CLAUDE-MD-SNIPPET.md +34 -0
  25. package/runtimes/claude-code/agents/motif-design-reviewer.md +207 -0
  26. package/runtimes/claude-code/agents/motif-fix-agent.md +119 -0
  27. package/runtimes/claude-code/agents/motif-researcher.md +100 -0
  28. package/runtimes/claude-code/agents/motif-screen-composer.md +157 -0
  29. package/runtimes/claude-code/agents/motif-system-architect.md +120 -0
  30. package/runtimes/claude-code/commands/motif/compose.md +7 -0
  31. package/runtimes/claude-code/commands/motif/evolve.md +6 -0
  32. package/runtimes/claude-code/commands/motif/fix.md +7 -0
  33. package/runtimes/claude-code/commands/motif/help.md +29 -0
  34. package/runtimes/claude-code/commands/motif/init.md +229 -0
  35. package/runtimes/claude-code/commands/motif/progress.md +11 -0
  36. package/runtimes/claude-code/commands/motif/quick.md +7 -0
  37. package/runtimes/claude-code/commands/motif/research.md +4 -0
  38. package/runtimes/claude-code/commands/motif/review.md +7 -0
  39. package/runtimes/claude-code/commands/motif/system.md +4 -0
  40. package/runtimes/claude-code/hooks/motif-aria-check.js +164 -0
  41. package/runtimes/claude-code/hooks/motif-context-monitor.js +40 -0
  42. package/runtimes/claude-code/hooks/motif-font-check.js +192 -0
  43. package/runtimes/claude-code/hooks/motif-token-check.js +221 -0
  44. package/runtimes/cursor/README.md +24 -0
  45. package/runtimes/gemini/README.md +13 -0
  46. package/runtimes/opencode/README.md +28 -0
  47. package/scripts/contrast-checker.js +114 -0
  48. package/scripts/token-counter.js +107 -0
package/bin/install.js ADDED
@@ -0,0 +1,724 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { parseArgs, styleText } = require('node:util');
7
+ const { createHash } = require('node:crypto');
8
+
9
+ // ─── Stage 1: Parse CLI flags ──────────────────────────────────
10
+
11
+ function parseFlags() {
12
+ const { values } = parseArgs({
13
+ args: process.argv.slice(2),
14
+ options: {
15
+ runtime: { type: 'string', short: 'r' },
16
+ force: { type: 'boolean', short: 'f', default: false },
17
+ 'dry-run': { type: 'boolean', default: false },
18
+ uninstall: { type: 'boolean', default: false },
19
+ help: { type: 'boolean', short: 'h', default: false },
20
+ },
21
+ strict: true,
22
+ });
23
+ return values;
24
+ }
25
+
26
+ function printHelp() {
27
+ console.log(`
28
+ ${styleText('bold', 'motif')} - Domain-intelligent design system for AI coding assistants
29
+
30
+ ${styleText('bold', 'USAGE')}
31
+ npx motif-design@latest [options]
32
+
33
+ ${styleText('bold', 'OPTIONS')}
34
+ -r, --runtime <name> Override runtime auto-detection (supported: claude-code)
35
+ -f, --force Overwrite all files without backup checks
36
+ --dry-run Print what would happen without writing any files
37
+ --uninstall Remove Motif installation
38
+ -h, --help Show this help message
39
+
40
+ ${styleText('bold', 'EXAMPLES')}
41
+ npx motif-design@latest Auto-detect runtime and install
42
+ npx motif-design@latest --runtime claude-code Explicit runtime selection
43
+ npx motif-design@latest --dry-run Preview installation without changes
44
+ npx motif-design@latest --force Overwrite all existing files
45
+ `);
46
+ }
47
+
48
+ // ─── Stage 2: Detect runtime ───────────────────────────────────
49
+
50
+ function detectRuntime(flags) {
51
+ const validRuntimes = ['claude-code'];
52
+
53
+ if (flags.runtime) {
54
+ if (!validRuntimes.includes(flags.runtime)) {
55
+ console.error(styleText('red', `Unknown runtime: ${flags.runtime}`));
56
+ console.error(`Supported runtimes: ${validRuntimes.join(', ')}`);
57
+ process.exit(1);
58
+ }
59
+ return flags.runtime;
60
+ }
61
+
62
+ const cwd = process.cwd();
63
+ if (fs.existsSync(path.join(cwd, '.claude'))) return 'claude-code';
64
+
65
+ console.error(styleText('red', 'Could not detect AI runtime.'));
66
+ console.error('No .claude/ directory found. Create it or specify --runtime claude-code');
67
+ process.exit(1);
68
+ }
69
+
70
+ // ─── Stage 3: Resolve source-to-target mapping ─────────────────
71
+
72
+ function resolveMapping(runtime) {
73
+ const pkgDir = path.dirname(__dirname);
74
+ const cwd = process.cwd();
75
+
76
+ if (runtime === 'claude-code') {
77
+ const copies = [
78
+ { src: path.join(pkgDir, 'core', 'references'), dest: path.join(cwd, '.claude', 'get-motif', 'references') },
79
+ { src: path.join(pkgDir, 'core', 'workflows'), dest: path.join(cwd, '.claude', 'get-motif', 'workflows') },
80
+ { src: path.join(pkgDir, 'core', 'templates'), dest: path.join(cwd, '.claude', 'get-motif', 'templates') },
81
+ { src: path.join(pkgDir, 'runtimes', 'claude-code', 'agents'), dest: path.join(cwd, '.claude', 'get-motif', 'agents') },
82
+ { src: path.join(pkgDir, 'runtimes', 'claude-code', 'commands', 'motif'), dest: path.join(cwd, '.claude', 'commands', 'motif') },
83
+ { src: path.join(pkgDir, 'runtimes', 'claude-code', 'hooks'), dest: path.join(cwd, '.claude', 'get-motif', 'hooks') },
84
+ { src: path.join(pkgDir, 'scripts'), dest: path.join(cwd, '.claude', 'get-motif', 'scripts') },
85
+ ];
86
+
87
+ return {
88
+ motifRoot: '.claude/get-motif',
89
+ copies,
90
+ snippet: path.join(pkgDir, 'runtimes', 'claude-code', 'CLAUDE-MD-SNIPPET.md'),
91
+ configTarget: path.join(cwd, 'CLAUDE.md'),
92
+ };
93
+ }
94
+ }
95
+
96
+ // ─── Stage 4: Copy files with {MOTIF_ROOT} resolution ──────────
97
+
98
+ function resolveContent(content, motifRoot) {
99
+ return content
100
+ .replaceAll('{MOTIF_ROOT}', motifRoot);
101
+ }
102
+
103
+ function shouldBackup(destPath, existingManifest) {
104
+ if (!fs.existsSync(destPath)) return false;
105
+ if (!existingManifest) return true; // No manifest = unknown state, back up to be safe
106
+
107
+ const relPath = path.relative(process.cwd(), destPath);
108
+ const entry = existingManifest.files[relPath];
109
+ if (!entry) return true; // File not in manifest = unknown, back up
110
+
111
+ const currentHash = hashFile(destPath);
112
+ // If current hash matches what we installed, user hasn't modified it -- safe to overwrite
113
+ if (currentHash === entry.hash) return false;
114
+ // User modified this file -- back up before overwriting
115
+ return true;
116
+ }
117
+
118
+ function walkAndCopy(srcDir, destDir, motifRoot, existingManifest, flags) {
119
+ const entries = fs.readdirSync(srcDir, { withFileTypes: true });
120
+ const cwd = process.cwd();
121
+ const results = { copied: 0, skipped: 0, backedUp: 0, errors: [] };
122
+
123
+ if (!flags['dry-run']) {
124
+ fs.mkdirSync(destDir, { recursive: true });
125
+ }
126
+
127
+ for (const entry of entries) {
128
+ if (entry.name === '.DS_Store' || entry.name.startsWith('.')) continue;
129
+
130
+ const srcPath = path.join(srcDir, entry.name);
131
+ const destPath = path.join(destDir, entry.name);
132
+
133
+ if (entry.isDirectory()) {
134
+ const subResult = walkAndCopy(srcPath, destPath, motifRoot, existingManifest, flags);
135
+ results.copied += subResult.copied;
136
+ results.skipped += subResult.skipped;
137
+ results.backedUp += subResult.backedUp;
138
+ results.errors.push(...subResult.errors);
139
+ continue;
140
+ }
141
+
142
+ // Validate target path is within project root
143
+ const resolved = path.resolve(destPath);
144
+ if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
145
+ console.error(styleText('red', `Path traversal detected: ${resolved}`));
146
+ process.exit(1);
147
+ }
148
+
149
+ const relPath = path.relative(cwd, destPath);
150
+
151
+ // Backup check for re-install (skip if --force)
152
+ if (!flags.force && shouldBackup(destPath, existingManifest)) {
153
+ if (flags['dry-run']) {
154
+ console.log(` Would back up: ${relPath}`);
155
+ } else {
156
+ const backupDir = path.join(cwd, '.motif-backup');
157
+ fs.mkdirSync(backupDir, { recursive: true });
158
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
159
+ const backupName = `${entry.name}.${timestamp}`;
160
+ fs.copyFileSync(destPath, path.join(backupDir, backupName));
161
+ console.log(` Backed up: ${relPath} -> .motif-backup/${backupName}`);
162
+ results.backedUp++;
163
+ }
164
+ }
165
+
166
+ if (flags['dry-run']) {
167
+ console.log(` Would copy: ${relPath}`);
168
+ results.skipped++;
169
+ continue;
170
+ }
171
+
172
+ try {
173
+ const ext = path.extname(srcPath).toLowerCase();
174
+ if (ext === '.md') {
175
+ const content = fs.readFileSync(srcPath, 'utf8');
176
+ const resolvedText = resolveContent(content, motifRoot);
177
+ fs.writeFileSync(destPath, resolvedText, 'utf8');
178
+ } else {
179
+ fs.copyFileSync(srcPath, destPath);
180
+ }
181
+ results.copied++;
182
+ } catch (err) {
183
+ results.errors.push(`Failed to copy ${relPath}: ${err.message}`);
184
+ }
185
+ }
186
+
187
+ return results;
188
+ }
189
+
190
+ function copyFiles(mapping, existingManifest, flags) {
191
+ const totals = { copied: 0, skipped: 0, backedUp: 0, errors: [] };
192
+
193
+ if (flags['dry-run']) {
194
+ console.log('');
195
+ console.log(styleText('bold', 'Dry run — files that would be copied:'));
196
+ console.log('');
197
+ }
198
+
199
+ for (const { src, dest } of mapping.copies) {
200
+ if (!fs.existsSync(src)) {
201
+ if (flags['dry-run']) {
202
+ console.log(` [skip] Source not found: ${path.relative(path.dirname(__dirname), src)}`);
203
+ }
204
+ continue;
205
+ }
206
+
207
+ const result = walkAndCopy(src, dest, mapping.motifRoot, existingManifest, flags);
208
+ totals.copied += result.copied;
209
+ totals.skipped += result.skipped;
210
+ totals.backedUp += result.backedUp;
211
+ totals.errors.push(...result.errors);
212
+ }
213
+
214
+ return totals;
215
+ }
216
+
217
+ // ─── Stage 5: Inject config into CLAUDE.md ─────────────────────
218
+
219
+ function escapeRegex(str) {
220
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
221
+ }
222
+
223
+ function injectConfig(mapping, flags) {
224
+ const START = '<!-- MOTIF-START -->';
225
+ const END = '<!-- MOTIF-END -->';
226
+ const cwd = process.cwd();
227
+
228
+ const snippetContent = fs.readFileSync(mapping.snippet, 'utf8');
229
+ const resolvedSnippet = resolveContent(snippetContent, mapping.motifRoot);
230
+ const block = `${START}\n${resolvedSnippet}\n${END}`;
231
+
232
+ // Determine config target path
233
+ let configPath = mapping.configTarget;
234
+ const altPath = path.join(cwd, '.claude', 'CLAUDE.md');
235
+
236
+ if (!fs.existsSync(configPath) && fs.existsSync(altPath)) {
237
+ configPath = altPath;
238
+ }
239
+
240
+ if (flags['dry-run']) {
241
+ if (fs.existsSync(configPath)) {
242
+ const content = fs.readFileSync(configPath, 'utf8');
243
+ if (content.includes(START) && content.includes(END)) {
244
+ console.log(` Would replace: Motif config in ${path.relative(cwd, configPath)}`);
245
+ return { action: 'replaced', path: configPath };
246
+ }
247
+ console.log(` Would append: Motif config to ${path.relative(cwd, configPath)}`);
248
+ return { action: 'appended', path: configPath };
249
+ }
250
+ console.log(` Would create: ${path.relative(cwd, configPath)}`);
251
+ return { action: 'created', path: configPath };
252
+ }
253
+
254
+ if (fs.existsSync(configPath)) {
255
+ let content = fs.readFileSync(configPath, 'utf8');
256
+
257
+ if (content.includes(START) && content.includes(END)) {
258
+ const regex = new RegExp(
259
+ `${escapeRegex(START)}[\\s\\S]*?${escapeRegex(END)}`,
260
+ 'g'
261
+ );
262
+ content = content.replace(regex, block);
263
+ fs.writeFileSync(configPath, content, 'utf8');
264
+ return { action: 'replaced', path: configPath };
265
+ }
266
+
267
+ content += '\n\n' + block + '\n';
268
+ fs.writeFileSync(configPath, content, 'utf8');
269
+ return { action: 'appended', path: configPath };
270
+ }
271
+
272
+ // Create new file
273
+ fs.writeFileSync(configPath, block + '\n', 'utf8');
274
+ return { action: 'created', path: configPath };
275
+ }
276
+
277
+ // ─── Stage 5b: Inject hook settings into .claude/settings.json ──
278
+
279
+ function injectHookSettings(mapping, flags) {
280
+ const cwd = process.cwd();
281
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
282
+ let settings = {};
283
+
284
+ // Load existing settings if present
285
+ if (fs.existsSync(settingsPath)) {
286
+ try {
287
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
288
+ } catch (_) {
289
+ // Corrupted settings -- start fresh for hooks section
290
+ settings = {};
291
+ }
292
+ }
293
+
294
+ if (flags['dry-run']) {
295
+ const hasMotifHooks = settings.hooks?.PostToolUse?.some(
296
+ g => g.matcher === 'Write|Edit' && g.hooks?.some(h => h.command?.includes('motif'))
297
+ );
298
+ if (hasMotifHooks) {
299
+ console.log(' Would update: Motif hooks in .claude/settings.json');
300
+ } else {
301
+ console.log(' Would add: Motif hooks to .claude/settings.json');
302
+ }
303
+ return;
304
+ }
305
+
306
+ // Ensure hooks structure exists
307
+ if (!settings.hooks) settings.hooks = {};
308
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
309
+
310
+ // Remove existing Motif matcher group (for idempotent re-install)
311
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
312
+ g => !(g.matcher === 'Write|Edit' && g.hooks?.some(h => h.command?.includes('motif')))
313
+ );
314
+
315
+ // Add Motif PostToolUse hooks
316
+ settings.hooks.PostToolUse.push({
317
+ matcher: 'Write|Edit',
318
+ hooks: [
319
+ { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR"/.claude/get-motif/hooks/motif-token-check.js' },
320
+ { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR"/.claude/get-motif/hooks/motif-font-check.js' },
321
+ { type: 'command', command: 'node "$CLAUDE_PROJECT_DIR"/.claude/get-motif/hooks/motif-aria-check.js' },
322
+ ],
323
+ });
324
+
325
+ // Add or update statusLine (Motif context monitor)
326
+ settings.statusLine = {
327
+ type: 'command',
328
+ command: 'node "$CLAUDE_PROJECT_DIR"/.claude/get-motif/hooks/motif-context-monitor.js',
329
+ };
330
+
331
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
332
+ }
333
+
334
+ // ─── Stage 6: Hash file helper ─────────────────────────────────
335
+
336
+ function hashFile(filePath) {
337
+ const content = fs.readFileSync(filePath);
338
+ return createHash('sha256').update(content).digest('hex');
339
+ }
340
+
341
+ // ─── Stage 7: Write manifest ───────────────────────────────────
342
+
343
+ function walkFiles(dir) {
344
+ const results = [];
345
+ if (!fs.existsSync(dir)) return results;
346
+
347
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
348
+ for (const entry of entries) {
349
+ if (entry.name === '.DS_Store' || entry.name.startsWith('.')) continue;
350
+ const fullPath = path.join(dir, entry.name);
351
+ if (entry.isDirectory()) {
352
+ results.push(...walkFiles(fullPath));
353
+ } else {
354
+ results.push(fullPath);
355
+ }
356
+ }
357
+ return results;
358
+ }
359
+
360
+ function writeManifest(mapping, copyResult, flags) {
361
+ if (flags['dry-run']) return;
362
+
363
+ const cwd = process.cwd();
364
+ const pkgDir = path.dirname(__dirname);
365
+ const manifest = {
366
+ version: '0.1.0',
367
+ runtime: 'claude-code',
368
+ installedAt: new Date().toISOString(),
369
+ files: {},
370
+ };
371
+
372
+ // Walk all installed destination directories
373
+ for (const { src, dest } of mapping.copies) {
374
+ if (!fs.existsSync(dest)) continue;
375
+ const files = walkFiles(dest);
376
+ for (const file of files) {
377
+ const relPath = path.relative(cwd, file);
378
+ // Derive source relative path
379
+ const relInDest = path.relative(dest, file);
380
+ const srcFile = path.join(src, relInDest);
381
+ manifest.files[relPath] = {
382
+ hash: hashFile(file),
383
+ source: path.relative(pkgDir, srcFile),
384
+ };
385
+ }
386
+ }
387
+
388
+ // Include config target if it exists
389
+ const configPath = mapping.configTarget;
390
+ const altPath = path.join(cwd, '.claude', 'CLAUDE.md');
391
+ const actualConfig = fs.existsSync(configPath) ? configPath : (fs.existsSync(altPath) ? altPath : null);
392
+ if (actualConfig) {
393
+ manifest.files[path.relative(cwd, actualConfig)] = {
394
+ hash: hashFile(actualConfig),
395
+ source: path.relative(pkgDir, mapping.snippet),
396
+ };
397
+ }
398
+
399
+ const manifestPath = path.join(cwd, '.motif-manifest.json');
400
+ fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
401
+ }
402
+
403
+ // ─── Stage 8: Post-install verification ────────────────────────
404
+
405
+ function verify(mapping) {
406
+ const cwd = process.cwd();
407
+ const errors = [];
408
+
409
+ // 1. Check expected directories exist
410
+ for (const { dest } of mapping.copies) {
411
+ // Only check directories whose source existed (skip scripts/, hooks/)
412
+ const srcExists = mapping.copies.find(c => c.dest === dest);
413
+ if (srcExists && fs.existsSync(srcExists.src) && !fs.existsSync(dest)) {
414
+ errors.push(`Missing directory: ${path.relative(cwd, dest)}`);
415
+ }
416
+ }
417
+
418
+ // 2. Check for unresolved {MOTIF_ROOT} in installed .md files
419
+ const dirsToCheck = [
420
+ path.join(cwd, '.claude', 'get-motif'),
421
+ path.join(cwd, '.claude', 'commands', 'motif'),
422
+ ];
423
+
424
+ for (const dir of dirsToCheck) {
425
+ if (!fs.existsSync(dir)) continue;
426
+ const files = walkFiles(dir);
427
+ for (const file of files) {
428
+ if (path.extname(file) !== '.md') continue;
429
+ const content = fs.readFileSync(file, 'utf8');
430
+ if (content.includes('{MOTIF_ROOT}')) {
431
+ errors.push(`Unresolved {MOTIF_ROOT} in: ${path.relative(cwd, file)}`);
432
+ }
433
+ }
434
+ }
435
+
436
+ // 3. Check CLAUDE.md has sentinel markers
437
+ const configPath = mapping.configTarget;
438
+ const altPath = path.join(cwd, '.claude', 'CLAUDE.md');
439
+ const actualConfig = fs.existsSync(configPath) ? configPath : (fs.existsSync(altPath) ? altPath : null);
440
+
441
+ if (actualConfig) {
442
+ const content = fs.readFileSync(actualConfig, 'utf8');
443
+ if (!content.includes('<!-- MOTIF-START -->') || !content.includes('<!-- MOTIF-END -->')) {
444
+ errors.push('CLAUDE.md missing Motif sentinel markers');
445
+ }
446
+ } else {
447
+ errors.push('CLAUDE.md not found after installation');
448
+ }
449
+
450
+ // 4. Check manifest was written
451
+ if (!fs.existsSync(path.join(cwd, '.motif-manifest.json'))) {
452
+ errors.push('Manifest file not written');
453
+ }
454
+
455
+ // 5. Check settings.json has hook configuration
456
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
457
+ if (fs.existsSync(settingsPath)) {
458
+ try {
459
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
460
+ if (!settings.hooks?.PostToolUse?.some(g => g.hooks?.some(h => h.command?.includes('motif')))) {
461
+ errors.push('settings.json missing Motif PostToolUse hooks');
462
+ }
463
+ if (!settings.statusLine?.command?.includes('motif')) {
464
+ errors.push('settings.json missing Motif statusLine');
465
+ }
466
+ } catch (_) {
467
+ errors.push('settings.json is not valid JSON');
468
+ }
469
+ } else {
470
+ errors.push('.claude/settings.json not found after installation');
471
+ }
472
+
473
+ return errors;
474
+ }
475
+
476
+ // ─── Stage 9: Print summary ───────────────────────────────────
477
+
478
+ function printSummary(copyResult, injectResult, verifyErrors) {
479
+ console.log('');
480
+ console.log(styleText('bold', 'Motif Installation Summary'));
481
+ console.log('\u2500'.repeat(40));
482
+ console.log(` Files copied: ${copyResult.copied}`);
483
+ console.log(` Files backed up: ${copyResult.backedUp}`);
484
+ console.log(` Files skipped: ${copyResult.skipped}`);
485
+ console.log(` Config: ${injectResult.action} (${path.relative(process.cwd(), injectResult.path)})`);
486
+
487
+ if (copyResult.errors.length > 0) {
488
+ console.log('');
489
+ console.log(styleText('red', `File errors (${copyResult.errors.length}):`));
490
+ for (const err of copyResult.errors) {
491
+ console.log(styleText('red', ` - ${err}`));
492
+ }
493
+ }
494
+
495
+ if (verifyErrors.length === 0) {
496
+ console.log('');
497
+ console.log(styleText('green', 'Installation verified successfully.'));
498
+ console.log('');
499
+ console.log('Get started:');
500
+ console.log(` ${styleText('cyan', '/motif:init')} Initialize a design project`);
501
+ console.log(` ${styleText('cyan', '/motif:help')} See all commands`);
502
+ } else {
503
+ console.log('');
504
+ console.log(styleText('red', `Verification failed (${verifyErrors.length} errors):`));
505
+ for (const err of verifyErrors) {
506
+ console.log(styleText('red', ` - ${err}`));
507
+ }
508
+ process.exit(1);
509
+ }
510
+
511
+ console.log('');
512
+ }
513
+
514
+ // ─── Uninstall ──────────────────────────────────────────────────
515
+
516
+ function cleanEmptyDirs(dir) {
517
+ if (!fs.existsSync(dir)) return;
518
+
519
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
520
+ for (const entry of entries) {
521
+ if (entry.isDirectory()) {
522
+ cleanEmptyDirs(path.join(dir, entry.name));
523
+ }
524
+ }
525
+
526
+ // Re-read after recursive cleanup (children may have been removed)
527
+ const remaining = fs.readdirSync(dir);
528
+ if (remaining.length === 0) {
529
+ fs.rmdirSync(dir);
530
+ }
531
+ }
532
+
533
+ function removeHookSettings(flags) {
534
+ const cwd = process.cwd();
535
+ const settingsPath = path.join(cwd, '.claude', 'settings.json');
536
+
537
+ if (!fs.existsSync(settingsPath)) return;
538
+
539
+ let settings;
540
+ try {
541
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
542
+ } catch (_) {
543
+ return; // Can't parse, leave it alone
544
+ }
545
+
546
+ if (flags['dry-run']) {
547
+ console.log(' Would remove: Motif hooks from .claude/settings.json');
548
+ return;
549
+ }
550
+
551
+ // Remove Motif PostToolUse matcher group
552
+ if (settings.hooks?.PostToolUse) {
553
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
554
+ g => !(g.matcher === 'Write|Edit' && g.hooks?.some(h => h.command?.includes('motif')))
555
+ );
556
+ // Clean up empty arrays
557
+ if (settings.hooks.PostToolUse.length === 0) delete settings.hooks.PostToolUse;
558
+ if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
559
+ }
560
+
561
+ // Remove Motif statusLine (only if it's the Motif one)
562
+ if (settings.statusLine?.command?.includes('motif')) {
563
+ delete settings.statusLine;
564
+ }
565
+
566
+ // If settings is now empty, delete the file
567
+ if (Object.keys(settings).length === 0) {
568
+ fs.unlinkSync(settingsPath);
569
+ } else {
570
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
571
+ }
572
+ }
573
+
574
+ function removeConfigSnippet() {
575
+ const cwd = process.cwd();
576
+ const START = '<!-- MOTIF-START -->';
577
+ const END = '<!-- MOTIF-END -->';
578
+
579
+ // Check both possible locations
580
+ const candidates = [
581
+ path.join(cwd, 'CLAUDE.md'),
582
+ path.join(cwd, '.claude', 'CLAUDE.md'),
583
+ ];
584
+
585
+ for (const configPath of candidates) {
586
+ if (!fs.existsSync(configPath)) continue;
587
+
588
+ let content = fs.readFileSync(configPath, 'utf8');
589
+ if (!content.includes(START) || !content.includes(END)) continue;
590
+
591
+ const regex = new RegExp(
592
+ `${escapeRegex(START)}[\\s\\S]*?${escapeRegex(END)}`,
593
+ 'g'
594
+ );
595
+ content = content.replace(regex, '');
596
+
597
+ // Clean up extra blank lines (3+ consecutive newlines -> 2)
598
+ content = content.replace(/\n{3,}/g, '\n\n');
599
+ content = content.trim();
600
+
601
+ if (content.length === 0) {
602
+ fs.unlinkSync(configPath);
603
+ } else {
604
+ fs.writeFileSync(configPath, content + '\n', 'utf8');
605
+ }
606
+ }
607
+ }
608
+
609
+ function uninstall(flags) {
610
+ const cwd = process.cwd();
611
+ const manifestPath = path.join(cwd, '.motif-manifest.json');
612
+
613
+ if (!fs.existsSync(manifestPath)) {
614
+ console.error(styleText('red', 'No Motif installation found (missing .motif-manifest.json)'));
615
+ process.exit(1);
616
+ }
617
+
618
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
619
+ let removedCount = 0;
620
+
621
+ // 1. Remove installed files (skip CLAUDE.md -- handled separately via sentinel removal)
622
+ const claudeMdPaths = ['CLAUDE.md', path.join('.claude', 'CLAUDE.md')];
623
+
624
+ for (const filePath of Object.keys(manifest.files)) {
625
+ // Skip CLAUDE.md -- sentinel block removal handles it in step 3
626
+ if (claudeMdPaths.includes(filePath)) continue;
627
+
628
+ const fullPath = path.join(cwd, filePath);
629
+
630
+ // Validate path is within project root
631
+ const resolved = path.resolve(fullPath);
632
+ if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) {
633
+ console.error(styleText('red', `Path traversal detected: ${resolved}`));
634
+ continue;
635
+ }
636
+
637
+ if (!fs.existsSync(fullPath)) continue;
638
+
639
+ if (flags['dry-run']) {
640
+ console.log(` Would remove: ${filePath}`);
641
+ removedCount++;
642
+ continue;
643
+ }
644
+
645
+ fs.unlinkSync(fullPath);
646
+ removedCount++;
647
+ }
648
+
649
+ if (flags['dry-run']) {
650
+ console.log(` Would remove: Motif hooks from .claude/settings.json`);
651
+ console.log(` Would remove: CLAUDE.md sentinel block`);
652
+ console.log(` Would remove: .motif-manifest.json`);
653
+ const backupDir = path.join(cwd, '.motif-backup');
654
+ if (fs.existsSync(backupDir)) {
655
+ console.log(` Would remove: .motif-backup/`);
656
+ }
657
+ console.log('');
658
+ console.log(`Dry run complete. Would remove ${removedCount} files.`);
659
+ process.exit(0);
660
+ }
661
+
662
+ // 2. Clean up empty directories
663
+ const getMotifDir = path.join(cwd, '.claude', 'get-motif');
664
+ const commandsMotifDir = path.join(cwd, '.claude', 'commands', 'motif');
665
+ cleanEmptyDirs(getMotifDir);
666
+ cleanEmptyDirs(commandsMotifDir);
667
+
668
+ // 2.5 Remove hook settings from settings.json
669
+ removeHookSettings(flags);
670
+
671
+ // 3. Remove CLAUDE.md sentinel block
672
+ removeConfigSnippet();
673
+
674
+ // 4. Remove manifest
675
+ fs.unlinkSync(manifestPath);
676
+
677
+ // 5. Remove .motif-backup/ if it exists
678
+ const backupDir = path.join(cwd, '.motif-backup');
679
+ if (fs.existsSync(backupDir)) {
680
+ fs.rmSync(backupDir, { recursive: true });
681
+ }
682
+
683
+ console.log(styleText('green', `Motif uninstalled successfully. Removed ${removedCount} files.`));
684
+ }
685
+
686
+ // ─── Main ──────────────────────────────────────────────────────
687
+
688
+ const flags = parseFlags();
689
+
690
+ if (flags.help) {
691
+ printHelp();
692
+ process.exit(0);
693
+ }
694
+
695
+ if (flags.uninstall) {
696
+ uninstall(flags);
697
+ process.exit(0);
698
+ }
699
+
700
+ const runtime = detectRuntime(flags);
701
+ const mapping = resolveMapping(runtime);
702
+
703
+ // Load existing manifest for upgrade tracking (re-install detection)
704
+ const manifestPath = path.join(process.cwd(), '.motif-manifest.json');
705
+ let existingManifest = null;
706
+ if (fs.existsSync(manifestPath)) {
707
+ try {
708
+ existingManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
709
+ } catch (_) {
710
+ // Corrupted manifest -- treat as fresh install (will back up all existing files)
711
+ existingManifest = null;
712
+ }
713
+ }
714
+
715
+ const copyResult = copyFiles(mapping, existingManifest, flags);
716
+ const injectResult = injectConfig(mapping, flags);
717
+ injectHookSettings(mapping, flags);
718
+
719
+ if (!flags['dry-run']) {
720
+ writeManifest(mapping, copyResult, flags);
721
+ }
722
+
723
+ const verifyErrors = flags['dry-run'] ? [] : verify(mapping);
724
+ printSummary(copyResult, injectResult, verifyErrors);