@ahmednawaz/crank 0.1.8 → 0.1.9

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/AGENTS.md CHANGED
@@ -103,6 +103,23 @@ See **[REPLIT.md](./REPLIT.md)** and `.crank/replit.md` (URLs after first run).
103
103
 
104
104
  ---
105
105
 
106
+ ## Panel injection (Vite vs React)
107
+
108
+ Crank’s panel is a **script overlay** (`/dev-loader.js` → bookmarklet), **not** a Retune-style React component in the app tree.
109
+
110
+ | Stack | What to do |
111
+ |-------|------------|
112
+ | **Vite** (React/Vue/Svelte/vanilla) | **Prefer** Vite plugin. `setup` writes `.crank/vite-plugin.cjs` and patches `vite.config` with Vizpatch-style graceful load (works when `pnpm add` fails — uses `packageRoot` from `.crank/config.json`). |
113
+ | **Next.js** | Optional: `import { CrankScript } from '@ahmednawaz/crank/next'` |
114
+ | **React without Vite** | Optional: `import { CrankLoader } from '@ahmednawaz/crank/react'` — injects the same script only |
115
+ | **Any** | Bookmarklet from companion URL |
116
+
117
+ **Agents must not** invent a fake in-tree `<Crank />` panel UI. If `hasVite: false` but a `vite.config.*` exists nearby, re-run setup from the app root or check `environment.viteConfig` from `doctor`.
118
+
119
+ Keep companion up via Workflow: `npx @ahmednawaz/crank start`. See `.crank/injection.md` after setup.
120
+
121
+ ---
122
+
106
123
  ## MCP tools (agent pull workflow)
107
124
 
108
125
  When the user clicks **Agent** on a copy variant or says *“Apply the pending Crank change”*:
package/README.md CHANGED
@@ -102,6 +102,20 @@ No external sibling tool required. Crank ships:
102
102
 
103
103
  Optional: `CRANK_ASSET_ROOT` overrides where packaged UI assets are loaded from (defaults to this install).
104
104
 
105
+ ### Panel injection (Vite first)
106
+
107
+ The panel is a **script overlay**, not a Retune-style React component.
108
+
109
+ | Stack | Integration |
110
+ |-------|-------------|
111
+ | **Vite** | `setup` patches `vite.config` with Vizpatch-style graceful load via `.crank/vite-plugin.cjs` (resolves from `packageRoot` / npx cache when `pnpm add` fails) |
112
+ | **Next.js** | `import { CrankScript } from '@ahmednawaz/crank/next'` |
113
+ | **React (no Vite)** | `import { CrankLoader } from '@ahmednawaz/crank/react'` — script injector only |
114
+ | **Webpack** | `crankWebpackPlugin()` from `@ahmednawaz/crank/webpack` |
115
+ | **Fallback** | Companion bookmarklet |
116
+
117
+ Keep companion running (`npx @ahmednawaz/crank start`). See `.crank/injection.md` after setup.
118
+
105
119
  Useful variants:
106
120
 
107
121
  ```bash
package/lib/detect.js CHANGED
@@ -4,6 +4,41 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const os = require('os');
6
6
 
7
+ const VITE_CONFIG_NAMES = [
8
+ 'vite.config.ts',
9
+ 'vite.config.js',
10
+ 'vite.config.mjs',
11
+ 'vite.config.cjs',
12
+ 'vite.config.mts',
13
+ 'vite.config.cts'
14
+ ];
15
+
16
+ const APP_DIR_CANDIDATES = [
17
+ '.',
18
+ 'app',
19
+ 'client',
20
+ 'web',
21
+ 'frontend',
22
+ 'ui',
23
+ 'src',
24
+ 'packages/app',
25
+ 'packages/web',
26
+ 'apps/web',
27
+ 'apps/frontend'
28
+ ];
29
+
30
+ const SKIP_DIRS = new Set([
31
+ 'node_modules',
32
+ '.git',
33
+ 'dist',
34
+ 'build',
35
+ '.next',
36
+ 'coverage',
37
+ '.turbo',
38
+ '.cache',
39
+ '.crank'
40
+ ]);
41
+
7
42
  function exists(p) {
8
43
  try {
9
44
  return fs.existsSync(p);
@@ -25,21 +60,97 @@ function writeJson(file, data) {
25
60
  fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
26
61
  }
27
62
 
63
+ function findViteConfigInDir(dir) {
64
+ for (const name of VITE_CONFIG_NAMES) {
65
+ const full = path.join(dir, name);
66
+ if (exists(full)) {
67
+ return full;
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Find vite.config.* at project root, common app subdirs, then shallow BFS.
75
+ */
76
+ function findViteConfig(projectRoot) {
77
+ const root = path.resolve(projectRoot);
78
+ for (const sub of APP_DIR_CANDIDATES) {
79
+ const dir = sub === '.' ? root : path.join(root, sub);
80
+ const hit = findViteConfigInDir(dir);
81
+ if (hit) {
82
+ return hit;
83
+ }
84
+ }
85
+
86
+ // Shallow walk (depth 2) for monorepos / Replit layouts
87
+ const queue = [{ dir: root, depth: 0 }];
88
+ while (queue.length) {
89
+ const { dir, depth } = queue.shift();
90
+ if (depth > 0) {
91
+ const hit = findViteConfigInDir(dir);
92
+ if (hit) {
93
+ return hit;
94
+ }
95
+ }
96
+ if (depth >= 2) {
97
+ continue;
98
+ }
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(dir, { withFileTypes: true });
102
+ } catch {
103
+ continue;
104
+ }
105
+ for (const entry of entries) {
106
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
107
+ continue;
108
+ }
109
+ queue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
110
+ }
111
+ }
112
+ return null;
113
+ }
114
+
115
+ function packageHasDep(pkg, name) {
116
+ if (!pkg || typeof pkg !== 'object') {
117
+ return false;
118
+ }
119
+ return Boolean(
120
+ pkg.dependencies?.[name] ||
121
+ pkg.devDependencies?.[name] ||
122
+ pkg.peerDependencies?.[name]
123
+ );
124
+ }
125
+
28
126
  function detectEnvironment(projectRoot) {
29
127
  const env = process.env;
30
128
  const isReplit = Boolean(
31
129
  env.REPL_ID || env.REPL_SLUG || env.REPLIT_DEV_DOMAIN || env.REPLIT_DB_URL
32
130
  );
33
- const hasVite =
34
- exists(path.join(projectRoot, 'vite.config.ts')) ||
35
- exists(path.join(projectRoot, 'vite.config.js')) ||
36
- exists(path.join(projectRoot, 'vite.config.mjs')) ||
37
- exists(path.join(projectRoot, 'vite.config.cjs'));
38
131
  const hasPackageJson = exists(path.join(projectRoot, 'package.json'));
39
- const hasCursor = exists(path.join(projectRoot, '.cursor')) ||
40
- exists(path.join(os.homedir(), '.cursor'));
41
- const hasVsCode = exists(path.join(projectRoot, '.vscode')) ||
42
- exists(path.join(projectRoot, '.vscode'.replace('s', 's')));
132
+ const packageJson = hasPackageJson
133
+ ? readJson(path.join(projectRoot, 'package.json'), {})
134
+ : null;
135
+ const viteConfig = findViteConfig(projectRoot);
136
+ const hasVite = Boolean(viteConfig) || packageHasDep(packageJson, 'vite');
137
+ const hasReact =
138
+ packageHasDep(packageJson, 'react') ||
139
+ exists(path.join(projectRoot, 'next.config.js')) ||
140
+ exists(path.join(projectRoot, 'next.config.mjs')) ||
141
+ exists(path.join(projectRoot, 'next.config.ts'));
142
+ const hasNext =
143
+ packageHasDep(packageJson, 'next') ||
144
+ exists(path.join(projectRoot, 'next.config.js')) ||
145
+ exists(path.join(projectRoot, 'next.config.mjs')) ||
146
+ exists(path.join(projectRoot, 'next.config.ts'));
147
+ const hasWebpack =
148
+ packageHasDep(packageJson, 'webpack') ||
149
+ exists(path.join(projectRoot, 'webpack.config.js')) ||
150
+ exists(path.join(projectRoot, 'webpack.config.ts'));
151
+
152
+ const hasCursor =
153
+ exists(path.join(projectRoot, '.cursor')) || exists(path.join(os.homedir(), '.cursor'));
43
154
  const claudeConfig = path.join(
44
155
  os.homedir(),
45
156
  'Library',
@@ -52,37 +163,26 @@ function detectEnvironment(projectRoot) {
52
163
  return {
53
164
  isReplit,
54
165
  hasVite,
166
+ viteConfig,
167
+ hasReact,
168
+ hasNext,
169
+ hasWebpack,
55
170
  hasPackageJson,
56
171
  hasCursor,
57
172
  hasVsCode: exists(path.join(projectRoot, '.vscode')),
58
173
  hasClaudeDesktop,
59
174
  claudeConfig,
60
175
  replitDomain: env.REPLIT_DEV_DOMAIN || null,
61
- packageJson: hasPackageJson
62
- ? readJson(path.join(projectRoot, 'package.json'), {})
63
- : null
176
+ packageJson
64
177
  };
65
178
  }
66
179
 
67
- function findViteConfig(projectRoot) {
68
- for (const name of [
69
- 'vite.config.ts',
70
- 'vite.config.js',
71
- 'vite.config.mjs',
72
- 'vite.config.cjs'
73
- ]) {
74
- const full = path.join(projectRoot, name);
75
- if (exists(full)) {
76
- return full;
77
- }
78
- }
79
- return null;
80
- }
81
-
82
180
  module.exports = {
83
181
  exists,
84
182
  readJson,
85
183
  writeJson,
86
184
  detectEnvironment,
87
- findViteConfig
185
+ findViteConfig,
186
+ VITE_CONFIG_NAMES,
187
+ APP_DIR_CANDIDATES
88
188
  };
package/lib/init.js CHANGED
@@ -94,59 +94,214 @@ function mergeVsCodeMcp(file, projectRoot) {
94
94
  return file;
95
95
  }
96
96
 
97
+ function writeVitePluginStub(projectRoot) {
98
+ const dir = path.join(projectRoot, '.crank');
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const stubPath = path.join(dir, 'vite-plugin.cjs');
101
+ const stub = `'use strict';
102
+
103
+ /**
104
+ * Crank Vite plugin loader — Vizpatch-style graceful resolve.
105
+ * Generated by \`npx @ahmednawaz/crank setup\`. Does not throw if Crank is missing.
106
+ *
107
+ * Resolves from (in order):
108
+ * 1. .crank/config.json packageRoot (npx cache / local checkout)
109
+ * 2. node_modules/@ahmednawaz/crank or node_modules/crank
110
+ * 3. require('@ahmednawaz/crank/vite')
111
+ */
112
+ const fs = require('fs');
113
+ const path = require('path');
114
+
115
+ function load() {
116
+ const candidates = [];
117
+ try {
118
+ const configPath = path.join(__dirname, 'config.json');
119
+ if (fs.existsSync(configPath)) {
120
+ const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
121
+ if (cfg && cfg.packageRoot) {
122
+ candidates.push(path.join(cfg.packageRoot, 'vite.js'));
123
+ }
124
+ }
125
+ } catch {
126
+ // ignore
127
+ }
128
+
129
+ const cwd = process.cwd();
130
+ candidates.push(
131
+ path.join(cwd, 'node_modules', '@ahmednawaz', 'crank', 'vite.js'),
132
+ path.join(cwd, 'node_modules', 'crank', 'vite.js'),
133
+ path.join(__dirname, '..', 'node_modules', '@ahmednawaz', 'crank', 'vite.js'),
134
+ path.join(__dirname, '..', 'node_modules', 'crank', 'vite.js')
135
+ );
136
+
137
+ for (const file of candidates) {
138
+ try {
139
+ if (file && fs.existsSync(file)) {
140
+ return require(file);
141
+ }
142
+ } catch {
143
+ // try next
144
+ }
145
+ }
146
+
147
+ try {
148
+ return require('@ahmednawaz/crank/vite');
149
+ } catch {
150
+ // ignore
151
+ }
152
+ try {
153
+ return require('@ahmednawaz/crank');
154
+ } catch {
155
+ // ignore
156
+ }
157
+ try {
158
+ return require('crank/vite');
159
+ } catch {
160
+ // ignore
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function noopPlugin() {
166
+ return {
167
+ name: 'crank-dev-loader-noop',
168
+ apply: 'serve',
169
+ transformIndexHtml(html) {
170
+ return html;
171
+ }
172
+ };
173
+ }
174
+
175
+ const mod = load();
176
+ const crankFn =
177
+ mod && typeof mod.crank === 'function' ? mod.crank : noopPlugin;
178
+
179
+ module.exports = {
180
+ crank: crankFn,
181
+ crankDevLoader: crankFn,
182
+ resolveCompanionLoaderUrl: mod && mod.resolveCompanionLoaderUrl
183
+ };
184
+ `;
185
+ fs.writeFileSync(stubPath, stub);
186
+ return stubPath;
187
+ }
188
+
97
189
  function patchViteConfig(projectRoot) {
98
190
  const configPath = findViteConfig(projectRoot);
191
+ const stubPath = writeVitePluginStub(projectRoot);
192
+
99
193
  if (!configPath) {
100
- return { patched: false, reason: 'no-vite-config' };
194
+ return { patched: false, reason: 'no-vite-config', plugin: stubPath };
101
195
  }
196
+
102
197
  let source = fs.readFileSync(configPath, 'utf8');
103
- if (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source)) {
104
- return { patched: false, reason: 'already-present', file: configPath };
198
+ if (
199
+ /vite-plugin\.cjs/.test(source) ||
200
+ (/(\bcrank)\s*\(/.test(source) && /plugins/.test(source))
201
+ ) {
202
+ return { patched: false, reason: 'already-present', file: configPath, plugin: stubPath };
105
203
  }
106
204
 
107
- // Copy plugin into the project so Vite can resolve a relative import everywhere.
108
- const localPluginDir = path.join(projectRoot, '.crank');
109
- fs.mkdirSync(localPluginDir, { recursive: true });
110
- const localPlugin = path.join(localPluginDir, 'vite-plugin.cjs');
111
- fs.copyFileSync(path.join(PACKAGE_ROOT, 'vite.js'), localPlugin);
205
+ let relStub = path.relative(path.dirname(configPath), stubPath).replace(/\\/g, '/');
206
+ if (!relStub.startsWith('.')) {
207
+ relStub = `./${relStub}`;
208
+ }
112
209
 
113
- const rel = './.crank/vite-plugin.cjs';
114
210
  const isEsm =
115
211
  configPath.endsWith('.mjs') ||
116
- (/^import\s/m.test(source) && !configPath.endsWith('.cjs'));
117
- const importLine = isEsm
118
- ? `import { crank } from '${rel}';\n`
119
- : `const { crank } = require('${rel}');\n`;
120
-
121
- if (!/vite-plugin\.cjs|from ['"]crank/.test(source)) {
122
- source = importLine + source;
123
- }
124
-
125
- if (/plugins\s*:\s*\[\s*\]/.test(source)) {
126
- source = source.replace(/plugins\s*:\s*\[\s*\]/, 'plugins: [crank()]');
127
- } else if (/plugins\s*:\s*\[/.test(source)) {
128
- source = source.replace(/plugins\s*:\s*\[/, 'plugins: [crank(), ');
129
- } else if (/defineConfig\s*\(\s*\{/.test(source)) {
130
- source = source.replace(
131
- /defineConfig\s*\(\s*\{/,
132
- 'defineConfig({\n plugins: [crank()],'
133
- );
134
- } else if (/export\s+default\s+\{/.test(source)) {
135
- source = source.replace(
136
- /export\s+default\s+\{/,
137
- 'export default {\n plugins: [crank()],'
138
- );
139
- } else if (/module\.exports\s*=\s*\{/.test(source)) {
140
- source = source.replace(
141
- /module\.exports\s*=\s*\{/,
142
- 'module.exports = {\n plugins: [crank()],'
143
- );
212
+ configPath.endsWith('.mts') ||
213
+ (/^import\s/m.test(source) && !configPath.endsWith('.cjs') && !configPath.endsWith('.cts'));
214
+
215
+ if (isEsm) {
216
+ const isTs = /\.tsx?$|\.mts$/.test(configPath);
217
+ const crankDecl = isTs
218
+ ? 'let crank: null | (() => unknown) = null'
219
+ : 'let crank = null';
220
+ const gracefulBlock = `import { createRequire } from 'node:module'
221
+ import { existsSync } from 'node:fs'
222
+ import { fileURLToPath } from 'node:url'
223
+
224
+ // Crank Vite plugin — graceful load (same pattern as Vizpatch); skip if unavailable
225
+ ${crankDecl}
226
+ try {
227
+ const _crankPluginPath = fileURLToPath(new URL('${relStub}', import.meta.url))
228
+ if (existsSync(_crankPluginPath)) {
229
+ const require = createRequire(import.meta.url)
230
+ crank = require(_crankPluginPath).crank
231
+ }
232
+ } catch {}
233
+
234
+ `;
235
+ if (!/_crankPluginPath/.test(source)) {
236
+ source = gracefulBlock + source;
237
+ }
238
+
239
+ if (/plugins\s*:\s*\[\s*\]/.test(source)) {
240
+ source = source.replace(
241
+ /plugins\s*:\s*\[\s*\]/,
242
+ 'plugins: [...(crank ? [crank()] : [])]'
243
+ );
244
+ } else if (/plugins\s*:\s*\[/.test(source)) {
245
+ source = source.replace(
246
+ /plugins\s*:\s*\[/,
247
+ 'plugins: [...(crank ? [crank()] : []), '
248
+ );
249
+ } else if (/defineConfig\s*\(\s*\{/.test(source)) {
250
+ source = source.replace(
251
+ /defineConfig\s*\(\s*\{/,
252
+ 'defineConfig({\n plugins: [...(crank ? [crank()] : [])],'
253
+ );
254
+ } else if (/export\s+default\s+\{/.test(source)) {
255
+ source = source.replace(
256
+ /export\s+default\s+\{/,
257
+ 'export default {\n plugins: [...(crank ? [crank()] : [])],'
258
+ );
259
+ } else {
260
+ return {
261
+ patched: false,
262
+ reason: 'unrecognized-vite-shape',
263
+ file: configPath,
264
+ plugin: stubPath
265
+ };
266
+ }
144
267
  } else {
145
- return { patched: false, reason: 'unrecognized-vite-shape', file: configPath };
268
+ const gracefulBlock = `// Crank Vite plugin graceful load (same pattern as Vizpatch)
269
+ let crank = null;
270
+ try {
271
+ crank = require('${relStub}').crank;
272
+ } catch {}
273
+
274
+ `;
275
+ if (!/vite-plugin\.cjs/.test(source)) {
276
+ source = gracefulBlock + source;
277
+ }
278
+ if (/plugins\s*:\s*\[\s*\]/.test(source)) {
279
+ source = source.replace(
280
+ /plugins\s*:\s*\[\s*\]/,
281
+ 'plugins: [...(crank ? [crank()] : [])]'
282
+ );
283
+ } else if (/plugins\s*:\s*\[/.test(source)) {
284
+ source = source.replace(
285
+ /plugins\s*:\s*\[/,
286
+ 'plugins: [...(crank ? [crank()] : []), '
287
+ );
288
+ } else if (/module\.exports\s*=\s*\{/.test(source)) {
289
+ source = source.replace(
290
+ /module\.exports\s*=\s*\{/,
291
+ 'module.exports = {\n plugins: [...(crank ? [crank()] : [])],'
292
+ );
293
+ } else {
294
+ return {
295
+ patched: false,
296
+ reason: 'unrecognized-vite-shape',
297
+ file: configPath,
298
+ plugin: stubPath
299
+ };
300
+ }
146
301
  }
147
302
 
148
303
  fs.writeFileSync(configPath, source);
149
- return { patched: true, file: configPath, plugin: localPlugin };
304
+ return { patched: true, file: configPath, plugin: stubPath };
150
305
  }
151
306
 
152
307
  function writeProjectEnvExample(projectRoot) {
@@ -327,7 +482,11 @@ function writeConfig(projectRoot, env) {
327
482
  createdAt: new Date().toISOString(),
328
483
  environment: {
329
484
  isReplit: env.isReplit,
330
- hasVite: env.hasVite
485
+ hasVite: env.hasVite,
486
+ viteConfig: env.viteConfig || null,
487
+ hasReact: env.hasReact,
488
+ hasNext: env.hasNext,
489
+ hasWebpack: env.hasWebpack
331
490
  }
332
491
  };
333
492
  writeJson(configPath, config);
@@ -376,8 +535,18 @@ function initProject(projectRoot, options = {}) {
376
535
  if (viteResult.patched) {
377
536
  results.wrote.push(path.relative(projectRoot, viteResult.file));
378
537
  }
538
+ if (viteResult.plugin) {
539
+ results.wrote.push(path.relative(projectRoot, viteResult.plugin));
540
+ }
541
+ } else if (options.vite !== false) {
542
+ // Still write stub so agents can wire the graceful load manually.
543
+ const stub = writeVitePluginStub(projectRoot);
544
+ results.vite = { patched: false, reason: 'no-vite-detected', plugin: stub };
545
+ results.wrote.push(path.relative(projectRoot, stub));
379
546
  }
380
547
 
548
+ writeInjectionGuide(projectRoot, env, results);
549
+
381
550
  // package.json script convenience
382
551
  if (env.hasPackageJson && options.scripts !== false) {
383
552
  const pkgPath = path.join(projectRoot, 'package.json');
@@ -530,6 +699,94 @@ function copyAgentsDoc(projectRoot, results) {
530
699
  }
531
700
  }
532
701
 
702
+ function writeInjectionGuide(projectRoot, env, results) {
703
+ const guide = path.join(projectRoot, '.crank', 'injection.md');
704
+ fs.mkdirSync(path.dirname(guide), { recursive: true });
705
+ const lines = [
706
+ '# Crank panel injection',
707
+ '',
708
+ 'Crank’s panel is a **script overlay** (companion `/dev-loader.js` → bookmarklet).',
709
+ 'It is **not** a Retune-style React UI component living in your component tree.',
710
+ '',
711
+ '## Prefer Vite plugin (default)',
712
+ '',
713
+ 'For Vite apps (React, Vue, Svelte, vanilla):',
714
+ '',
715
+ '1. Run `npx @ahmednawaz/crank setup` (writes `.crank/vite-plugin.cjs` + patches `vite.config`).',
716
+ '2. Keep a **Workflow** running: `npx @ahmednawaz/crank start` (companion on **3344**).',
717
+ '3. Dev server loads the panel automatically via the Vite plugin.',
718
+ '',
719
+ 'Graceful load (Vizpatch-style) — works even when `pnpm add @ahmednawaz/crank` fails:',
720
+ '',
721
+ '```ts',
722
+ "import { createRequire } from 'node:module'",
723
+ "import { existsSync } from 'node:fs'",
724
+ "import { fileURLToPath } from 'node:url'",
725
+ '',
726
+ 'let crank = null',
727
+ 'try {',
728
+ " const pluginPath = fileURLToPath(new URL('./.crank/vite-plugin.cjs', import.meta.url))",
729
+ ' if (existsSync(pluginPath)) {',
730
+ ' const require = createRequire(import.meta.url)',
731
+ ' crank = require(pluginPath).crank',
732
+ ' }',
733
+ '} catch {}',
734
+ '',
735
+ 'export default defineConfig({',
736
+ ' plugins: [',
737
+ ' ...(crank ? [crank()] : []),',
738
+ ' // ...your plugins',
739
+ ' ],',
740
+ '})',
741
+ '```',
742
+ '',
743
+ '`.crank/vite-plugin.cjs` resolves Crank from `config.json` → `packageRoot` (npx cache),',
744
+ 'then `node_modules`, and noops if missing.',
745
+ '',
746
+ '## React adapter (non-Vite React only)',
747
+ '',
748
+ 'Use only when there is **no** Vite config (CRA / custom webpack React):',
749
+ '',
750
+ '```tsx',
751
+ "import { CrankLoader } from '@ahmednawaz/crank/react'",
752
+ '// client root only — injects the same /dev-loader.js script',
753
+ '<CrankLoader />',
754
+ '```',
755
+ '',
756
+ 'Do **not** invent a fake in-tree `<Crank />` panel UI. `CrankLoader` / `Crank` only inject a script.',
757
+ '',
758
+ '## Next.js',
759
+ '',
760
+ '```tsx',
761
+ "import { CrankScript } from '@ahmednawaz/crank/next'",
762
+ '// app/layout.tsx or pages/_app',
763
+ '<CrankScript />',
764
+ '```',
765
+ '',
766
+ '## Webpack',
767
+ '',
768
+ '```js',
769
+ "const { crankWebpackPlugin } = require('@ahmednawaz/crank/webpack')",
770
+ 'plugins: [new HtmlWebpackPlugin(...), crankWebpackPlugin()]',
771
+ '```',
772
+ '',
773
+ '## Bookmarklet fallback',
774
+ '',
775
+ 'Open the companion URL → drag the bookmarklet onto any preview page.',
776
+ '',
777
+ '## Detected in this project',
778
+ '',
779
+ `- hasVite: ${Boolean(env.hasVite)}`,
780
+ `- viteConfig: ${env.viteConfig || '(none)'}`,
781
+ `- hasReact: ${Boolean(env.hasReact)}`,
782
+ `- hasNext: ${Boolean(env.hasNext)}`,
783
+ `- hasWebpack: ${Boolean(env.hasWebpack)}`,
784
+ ''
785
+ ];
786
+ fs.writeFileSync(guide, lines.join('\n'));
787
+ results.wrote.push('.crank/injection.md');
788
+ }
789
+
533
790
  function printSetupNextSteps(env, runtime, results) {
534
791
  console.log('');
535
792
  console.log(' Setup complete (Retune-style):');
@@ -539,6 +796,16 @@ function printSetupNextSteps(env, runtime, results) {
539
796
  if (results?.wrote?.some((w) => String(w).includes('skills'))) {
540
797
  console.log(' • Skill crank-apply-changes installed (auto-syncs on start)');
541
798
  }
799
+ if (results?.vite?.patched) {
800
+ console.log(' • Vite plugin patched (graceful load via .crank/vite-plugin.cjs)');
801
+ } else if (env.hasVite) {
802
+ console.log(' • Vite detected — see .crank/injection.md if the panel does not auto-load');
803
+ } else if (env.hasNext) {
804
+ console.log(' • Next.js — add <CrankScript /> from @ahmednawaz/crank/next (see .crank/injection.md)');
805
+ } else if (env.hasReact) {
806
+ console.log(' • React without Vite — add <CrankLoader /> from @ahmednawaz/crank/react (see .crank/injection.md)');
807
+ }
808
+ console.log(' • Injection guide: .crank/injection.md');
542
809
  if (env.isReplit) {
543
810
  console.log(' • Workflow command: npx @ahmednawaz/crank start');
544
811
  console.log(' • Add CRANK_TEAM_TOKEN in Secrets (not in the command)');
@@ -591,8 +858,10 @@ function writeReplitGuide(projectRoot, detectEnv, results, runtime) {
591
858
  '## Panel in preview',
592
859
  '',
593
860
  `- Companion: ${companionUrl}`,
594
- '- Vite apps: panel auto-loads in the webview.',
595
- '- Other stacks: open the companion URL and use the bookmarklet on your preview tab.',
861
+ '- **Vite:** panel auto-loads via `.crank/vite-plugin.cjs` (graceful load). Keep companion Workflow running.',
862
+ '- **React/Next without Vite:** see `.crank/injection.md` (`CrankLoader` / `CrankScript`).',
863
+ '- **Fallback:** open the companion URL and use the bookmarklet on your preview tab.',
864
+ '- Full guide: `.crank/injection.md`',
596
865
  ''
597
866
  ].join('\n')
598
867
  );
@@ -608,5 +877,10 @@ module.exports = {
608
877
  configureMcpRetuneStyle,
609
878
  printSetupNextSteps,
610
879
  writeReplitGuide,
611
- patchReplitConfig
880
+ writeReplitPlatformGuide,
881
+ writeVitePluginStub,
882
+ patchViteConfig,
883
+ writeInjectionGuide,
884
+ patchReplitConfig,
885
+ copyAgentsDoc
612
886
  };
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared companion loader URL for Vite / React / Next / webpack adapters.
5
+ * All paths inject the same /dev-loader.js → bookmarklet panel (not a React UI tree).
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { env } = require('./env');
11
+ const { readRuntimeManifest } = require('./urls');
12
+
13
+ function findProjectRoot(startDir) {
14
+ let dir = path.resolve(startDir || process.cwd());
15
+ for (let i = 0; i < 8; i++) {
16
+ if (fs.existsSync(path.join(dir, '.crank', 'runtime.json'))) {
17
+ return dir;
18
+ }
19
+ if (fs.existsSync(path.join(dir, 'package.json'))) {
20
+ return dir;
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) break;
24
+ dir = parent;
25
+ }
26
+ return path.resolve(startDir || process.cwd());
27
+ }
28
+
29
+ function resolveCompanionLoaderUrl(options = {}) {
30
+ if (options.src) {
31
+ return String(options.src);
32
+ }
33
+
34
+ const projectRoot = options.projectRoot || findProjectRoot(process.cwd());
35
+ const runtime = readRuntimeManifest(projectRoot);
36
+ if (runtime?.companion?.devLoader) {
37
+ return runtime.companion.devLoader;
38
+ }
39
+
40
+ const publicUrl = (
41
+ options.companionUrl ||
42
+ env('PUBLIC_URL', '') ||
43
+ ''
44
+ ).replace(/\/$/, '');
45
+
46
+ const port = String(options.port || env('PORT', '3344')).replace(/[^0-9]/g, '') || '3344';
47
+ const host = options.host || env('HOST', '127.0.0.1');
48
+ const base = publicUrl || `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
49
+ return `${base}/dev-loader.js`;
50
+ }
51
+
52
+ module.exports = {
53
+ findProjectRoot,
54
+ resolveCompanionLoaderUrl
55
+ };
package/next.js ADDED
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional Next.js adapter — injects Crank via next/script (App Router or Pages).
5
+ *
6
+ * Prefer Vite plugin for Vite apps. Use this for Next.js when there is no vite.config.
7
+ *
8
+ * // app/layout.tsx (or a client layout section)
9
+ * import { CrankScript } from '@ahmednawaz/crank/next'
10
+ * <CrankScript />
11
+ *
12
+ * peerDependencies: react, next
13
+ */
14
+
15
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
16
+ const { CrankLoader } = require('./react');
17
+
18
+ function getReact() {
19
+ try {
20
+ return require('react');
21
+ } catch {
22
+ throw new Error(
23
+ '@ahmednawaz/crank/next requires react. Prefer @ahmednawaz/crank/vite for Vite apps.'
24
+ );
25
+ }
26
+ }
27
+
28
+ function getNextScript() {
29
+ try {
30
+ return require('next/script');
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * @param {{ src?: string, companionUrl?: string, strategy?: string }} props
38
+ */
39
+ function CrankScript(props) {
40
+ const React = getReact();
41
+ const NextScript = getNextScript();
42
+ const src = resolveCompanionLoaderUrl(props || {});
43
+ const strategy = (props && props.strategy) || 'afterInteractive';
44
+
45
+ if (NextScript) {
46
+ const Script = NextScript.default || NextScript;
47
+ return React.createElement(Script, {
48
+ src,
49
+ strategy,
50
+ 'data-crank-loader': '1'
51
+ });
52
+ }
53
+
54
+ // Fallback when next/script is unavailable (tests / non-Next bundlers).
55
+ return React.createElement(CrankLoader, props || {});
56
+ }
57
+
58
+ module.exports = {
59
+ CrankScript,
60
+ CrankLoader,
61
+ Crank: CrankScript,
62
+ resolveCompanionLoaderUrl
63
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Internal Motive UX copy iteration: select UI text → Glean variants → MCP apply. Cursor, Replit, Claude.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -14,8 +14,21 @@
14
14
  "exports": {
15
15
  ".": "./vite.js",
16
16
  "./vite": "./vite.js",
17
+ "./react": "./react.js",
18
+ "./next": "./next.js",
19
+ "./webpack": "./webpack.js",
17
20
  "./package.json": "./package.json"
18
21
  },
22
+ "peerDependencies": {
23
+ "html-webpack-plugin": ">=5",
24
+ "next": ">=13",
25
+ "react": ">=17"
26
+ },
27
+ "peerDependenciesMeta": {
28
+ "react": { "optional": true },
29
+ "next": { "optional": true },
30
+ "html-webpack-plugin": { "optional": true }
31
+ },
19
32
  "workspaces": [
20
33
  "companion",
21
34
  "mcp-server"
@@ -57,6 +70,9 @@
57
70
  "skill",
58
71
  "panel",
59
72
  "vite.js",
73
+ "react.js",
74
+ "next.js",
75
+ "webpack.js",
60
76
  ".env.example",
61
77
  "AGENTS.md",
62
78
  "REPLIT.md",
package/react.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional React adapter for non-Vite React apps (CRA, custom webpack, etc.).
5
+ *
6
+ * This is NOT a Retune-style UI component. It only injects the same /dev-loader.js
7
+ * script the Vite plugin uses. Prefer `@ahmednawaz/crank/vite` when the app uses Vite.
8
+ *
9
+ * import { CrankLoader } from '@ahmednawaz/crank/react'
10
+ * // in App / root layout (client-only):
11
+ * <CrankLoader />
12
+ *
13
+ * peerDependency: react >= 17
14
+ */
15
+
16
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
17
+
18
+ function getReact() {
19
+ try {
20
+ // eslint-disable-next-line import/no-extraneous-dependencies
21
+ return require('react');
22
+ } catch {
23
+ throw new Error(
24
+ '@ahmednawaz/crank/react requires react as a peer dependency. Prefer the Vite plugin when possible: import { crank } from "@ahmednawaz/crank/vite".'
25
+ );
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Injects Crank's companion loader once on the client. Renders null.
31
+ * @param {{ src?: string, companionUrl?: string, port?: string|number, host?: string }} props
32
+ */
33
+ function CrankLoader(props) {
34
+ const React = getReact();
35
+ const src = resolveCompanionLoaderUrl(props || {});
36
+
37
+ React.useEffect(() => {
38
+ if (typeof document === 'undefined') {
39
+ return undefined;
40
+ }
41
+ if (window.__CRANK_LOADER__) {
42
+ return undefined;
43
+ }
44
+ window.__CRANK_LOADER__ = true;
45
+
46
+ const existing = document.querySelector('script[data-crank-loader="1"]');
47
+ if (existing) {
48
+ return undefined;
49
+ }
50
+
51
+ const script = document.createElement('script');
52
+ script.src = src;
53
+ script.defer = true;
54
+ script.dataset.crankLoader = '1';
55
+ document.documentElement.appendChild(script);
56
+ return undefined;
57
+ }, [src]);
58
+
59
+ return null;
60
+ }
61
+
62
+ /** Alias — still only a script injector, not an in-tree panel UI. */
63
+ const Crank = CrankLoader;
64
+
65
+ module.exports = {
66
+ CrankLoader,
67
+ Crank,
68
+ resolveCompanionLoaderUrl
69
+ };
package/skill/SKILL.md CHANGED
@@ -33,6 +33,7 @@ When the user asks to start Crank:
33
33
  ```
34
34
  `start` skips setup/init — companion on **3344** (+ HTTP MCP on **3345** on Replit). Add `CRANK_TEAM_TOKEN` in Secrets first.
35
35
  4. Call **`crank_get_environment`** after the companion is up.
36
+ 5. **Panel injection:** Vite apps use the Vite plugin (graceful `.crank/vite-plugin.cjs`) — not a React component. See **AGENTS.md** / `.crank/injection.md`. Keep companion running in a Workflow.
36
37
 
37
38
  ## When the user says "start Crank" or pastes `npx crank`
38
39
 
package/vite.js CHANGED
@@ -1,53 +1,19 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
5
- const { env } = require('./lib/env');
6
- const { readRuntimeManifest } = require('./lib/urls');
7
-
8
3
  /**
9
4
  * Vite / Rolldown-vite / Vitest-compatible plugin.
10
- * Injects Crank into the app during `vite` / `npm run dev` — no bookmarklet needed.
5
+ * Injects Crank into the app during `vite` / `npm run dev` — no React component needed.
11
6
  *
12
7
  * // vite.config.ts
13
- * import { crank } from 'crank/vite'
8
+ * import { crank } from '@ahmednawaz/crank/vite'
9
+ * // or: import { crank } from 'crank/vite'
14
10
  * export default { plugins: [crank()] }
11
+ *
12
+ * Prefer this for Vite apps (React, Vue, Svelte, vanilla). The panel is a script overlay,
13
+ * not a component in your React tree.
15
14
  */
16
15
 
17
- function findProjectRoot(startDir) {
18
- let dir = path.resolve(startDir || process.cwd());
19
- for (let i = 0; i < 8; i++) {
20
- if (fs.existsSync(path.join(dir, '.crank', 'runtime.json'))) {
21
- return dir;
22
- }
23
- if (fs.existsSync(path.join(dir, 'package.json'))) {
24
- return dir;
25
- }
26
- const parent = path.dirname(dir);
27
- if (parent === dir) break;
28
- dir = parent;
29
- }
30
- return path.resolve(startDir || process.cwd());
31
- }
32
-
33
- function resolveCompanionLoaderUrl(options = {}) {
34
- const projectRoot = options.projectRoot || findProjectRoot(process.cwd());
35
- const runtime = readRuntimeManifest(projectRoot);
36
- if (runtime?.companion?.devLoader) {
37
- return runtime.companion.devLoader;
38
- }
39
-
40
- const publicUrl = (
41
- options.companionUrl ||
42
- env('PUBLIC_URL', '') ||
43
- ''
44
- ).replace(/\/$/, '');
45
-
46
- const port = String(options.port || env('PORT', '3344')).replace(/[^0-9]/g, '') || '3344';
47
- const host = options.host || env('HOST', '127.0.0.1');
48
- const base = publicUrl || `http://${host === '0.0.0.0' ? '127.0.0.1' : host}:${port}`;
49
- return `${base}/dev-loader.js`;
50
- }
16
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
51
17
 
52
18
  function crank(options = {}) {
53
19
  const src = resolveCompanionLoaderUrl(options);
package/webpack.js ADDED
@@ -0,0 +1,58 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Optional webpack adapter — injects /dev-loader.js via html-webpack-plugin.
5
+ *
6
+ * // webpack.config.js
7
+ * const { crankWebpackPlugin } = require('@ahmednawaz/crank/webpack')
8
+ * plugins: [new HtmlWebpackPlugin(...), crankWebpackPlugin()]
9
+ *
10
+ * Prefer @ahmednawaz/crank/vite when the project uses Vite.
11
+ */
12
+
13
+ const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
14
+
15
+ function crankWebpackPlugin(options = {}) {
16
+ const src = resolveCompanionLoaderUrl(options);
17
+
18
+ return {
19
+ apply(compiler) {
20
+ compiler.hooks.compilation.tap('CrankWebpackPlugin', (compilation) => {
21
+ let hooks;
22
+ try {
23
+ // eslint-disable-next-line import/no-extraneous-dependencies
24
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
25
+ hooks = HtmlWebpackPlugin.getHooks(compilation);
26
+ } catch {
27
+ compilation.errors.push(
28
+ new Error(
29
+ '@ahmednawaz/crank/webpack requires html-webpack-plugin. Prefer the Vite plugin when possible.'
30
+ )
31
+ );
32
+ return;
33
+ }
34
+
35
+ hooks.alterAssetTagGroups.tap('CrankWebpackPlugin', (data) => {
36
+ const already = (data.headTags || []).some(
37
+ (t) =>
38
+ t.tagName === 'script' &&
39
+ String(t.attributes?.src || '').includes('dev-loader.js')
40
+ );
41
+ if (!already) {
42
+ data.headTags.push({
43
+ tagName: 'script',
44
+ voidTag: false,
45
+ attributes: { src, defer: true, 'data-crank-loader': '1' }
46
+ });
47
+ }
48
+ return data;
49
+ });
50
+ });
51
+ }
52
+ };
53
+ }
54
+
55
+ module.exports = {
56
+ crankWebpackPlugin,
57
+ resolveCompanionLoaderUrl
58
+ };