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.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/bin/install.js +724 -0
- package/core/references/context-engine.md +190 -0
- package/core/references/design-inputs.md +421 -0
- package/core/references/runtime-adapters.md +180 -0
- package/core/references/state-machine.md +124 -0
- package/core/references/verticals/ecommerce.md +251 -0
- package/core/references/verticals/fintech.md +226 -0
- package/core/references/verticals/health.md +235 -0
- package/core/references/verticals/saas.md +248 -0
- package/core/templates/STATE-TEMPLATE.md +28 -0
- package/core/templates/SUMMARY-TEMPLATE.md +21 -0
- package/core/templates/VERTICAL-TEMPLATE.md +144 -0
- package/core/templates/token-showcase-template.html +946 -0
- package/core/workflows/compose-screen.md +163 -0
- package/core/workflows/evolve.md +64 -0
- package/core/workflows/fix.md +64 -0
- package/core/workflows/generate-system.md +336 -0
- package/core/workflows/quick.md +23 -0
- package/core/workflows/research.md +233 -0
- package/core/workflows/review.md +126 -0
- package/package.json +26 -0
- package/runtimes/claude-code/CLAUDE-MD-SNIPPET.md +34 -0
- package/runtimes/claude-code/agents/motif-design-reviewer.md +207 -0
- package/runtimes/claude-code/agents/motif-fix-agent.md +119 -0
- package/runtimes/claude-code/agents/motif-researcher.md +100 -0
- package/runtimes/claude-code/agents/motif-screen-composer.md +157 -0
- package/runtimes/claude-code/agents/motif-system-architect.md +120 -0
- package/runtimes/claude-code/commands/motif/compose.md +7 -0
- package/runtimes/claude-code/commands/motif/evolve.md +6 -0
- package/runtimes/claude-code/commands/motif/fix.md +7 -0
- package/runtimes/claude-code/commands/motif/help.md +29 -0
- package/runtimes/claude-code/commands/motif/init.md +229 -0
- package/runtimes/claude-code/commands/motif/progress.md +11 -0
- package/runtimes/claude-code/commands/motif/quick.md +7 -0
- package/runtimes/claude-code/commands/motif/research.md +4 -0
- package/runtimes/claude-code/commands/motif/review.md +7 -0
- package/runtimes/claude-code/commands/motif/system.md +4 -0
- package/runtimes/claude-code/hooks/motif-aria-check.js +164 -0
- package/runtimes/claude-code/hooks/motif-context-monitor.js +40 -0
- package/runtimes/claude-code/hooks/motif-font-check.js +192 -0
- package/runtimes/claude-code/hooks/motif-token-check.js +221 -0
- package/runtimes/cursor/README.md +24 -0
- package/runtimes/gemini/README.md +13 -0
- package/runtimes/opencode/README.md +28 -0
- package/scripts/contrast-checker.js +114 -0
- 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);
|