@ahmednawaz/crank 0.1.9 → 0.2.2

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,20 +103,32 @@ 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)
106
+ ## Panel injection (Retune pattern)
107
107
 
108
- Crank’s panel is a **script overlay** (`/dev-loader.js` bookmarklet), **not** a Retune-style React component in the app tree.
108
+ **Preferred** CLI inject (no manual edits):
109
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 |
110
+ ```bash
111
+ npx @ahmednawaz/crank@latest setup
112
+ npx @ahmednawaz/crank inject
113
+ npx @ahmednawaz/crank start
114
+ ```
115
+
116
+ `inject` finds `main.tsx` / `createRoot` / `ReactDOM.render`, adds:
117
+
118
+ ```tsx
119
+ import { Crank } from '@ahmednawaz/crank/react'
120
+ // ...
121
+ <Crank />
122
+ ```
116
123
 
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`.
124
+ Same idea as Sentry/Clerk setup CLIs. See `.crank/react-mount.md` after setup.
118
125
 
119
- Keep companion up via Workflow: `npx @ahmednawaz/crank start`. See `.crank/injection.md` after setup.
126
+ | Stack | Integration |
127
+ |-------|-------------|
128
+ | **React** | `npx @ahmednawaz/crank inject` |
129
+ | **Next.js** | same inject, or `import { Crank } from '@ahmednawaz/crank/next'` |
130
+ | **Non-React Vite** | Optional `@ahmednawaz/crank/vite` plugin |
131
+ | **Fallback** | Companion bookmarklet |
120
132
 
121
133
  ---
122
134
 
package/README.md CHANGED
@@ -102,19 +102,17 @@ 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)
105
+ ### Panel injection (Retune-style)
106
106
 
107
- The panel is a **script overlay**, not a Retune-style React component.
107
+ ```bash
108
+ npx @ahmednawaz/crank@latest setup
109
+ npx @ahmednawaz/crank inject # patches main.tsx — no manual edits
110
+ npx @ahmednawaz/crank start
111
+ ```
108
112
 
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 |
113
+ `inject` installs the package and adds `import { Crank }` + `<Crank />` (Sentry/Clerk-style).
116
114
 
117
- Keep companion running (`npx @ahmednawaz/crank start`). See `.crank/injection.md` after setup.
115
+ Replit public URL: `window.__CRANK_URL__` or `<Crank companionUrl="https://3344-….replit.dev" />` or `VITE_CRANK_URL`.
118
116
 
119
117
  Useful variants:
120
118
 
package/bin/crank.js CHANGED
@@ -31,6 +31,7 @@ const {
31
31
  readRuntimeManifest
32
32
  } = require('../lib/urls');
33
33
  const { resolveProjectRoot, describeEnvironment } = require('../lib/resolve-project');
34
+ const { injectCrank } = require('../lib/wire-react');
34
35
 
35
36
  const LOG_FILE = env('LOG', '/tmp/crank-companion.log');
36
37
  const PID_FILE = env('PID_FILE', '/tmp/crank-companion.pid');
@@ -39,11 +40,12 @@ function printHelp() {
39
40
  process.stdout.write(`Usage: crank [command] [project-root]
40
41
 
41
42
  Commands:
42
- (default) Init + start everything (companion, MCP, dev server when present)
43
- setup Same as init Retune-style one-time config (alias for init)
44
- init Only write IDE / Vite / MCP setup into the project
45
- mcp Start stdio MCP server (used by npx crank mcp in IDE configs)
46
- start Start companion (assumes init already done)
43
+ (default) Init + start companion (and npm run dev when present)
44
+ setup One-time config (MCP, skill, package.json) alias for init
45
+ init Same as setup
46
+ inject Patch main.tsx / entry with import { Crank } + <Crank /> (Retune-style)
47
+ mcp Start stdio MCP server (used by IDE MCP configs)
48
+ start Start companion (assumes setup already done)
47
49
  stop Stop daemon companion + HTTP MCP listeners
48
50
  doctor Print detected environment + runtime URLs
49
51
  status Print whether companion /health responds
@@ -60,11 +62,16 @@ Flags:
60
62
  --http-mcp Force HTTP MCP (Replit auto-enables this)
61
63
  -h, --help Show help
62
64
 
65
+ First run (React / Retune-style):
66
+ npx @ahmednawaz/crank@latest setup
67
+ npx @ahmednawaz/crank inject
68
+ npx @ahmednawaz/crank start
69
+
63
70
  Examples:
64
- cd ~/code/my-app && npx crank setup && npx crank
65
- npx crank
66
- npx crank mcp
67
- npx crank stop
71
+ cd ~/code/my-app && npx @ahmednawaz/crank setup && npx @ahmednawaz/crank inject
72
+ npx @ahmednawaz/crank
73
+ npx @ahmednawaz/crank mcp
74
+ npx @ahmednawaz/crank stop
68
75
  `);
69
76
  }
70
77
 
@@ -113,7 +120,7 @@ function parseArgs(argv) {
113
120
  }
114
121
  let command = 'run';
115
122
  let projectRoot = process.cwd();
116
- const cmds = new Set(['init', 'setup', 'start', 'doctor', 'stop', 'status', 'mcp']);
123
+ const cmds = new Set(['init', 'setup', 'start', 'doctor', 'stop', 'status', 'mcp', 'inject']);
117
124
  if (positional[0] === 'setup') {
118
125
  command = 'init';
119
126
  } else if (cmds.has(positional[0])) {
@@ -617,6 +624,37 @@ async function main() {
617
624
  return;
618
625
  }
619
626
 
627
+ if (command === 'inject') {
628
+ if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
629
+ console.error(`Project root not found: ${projectRoot}`);
630
+ process.exit(1);
631
+ }
632
+ console.log(`[Crank] Injecting <Crank /> into ${projectRoot}`);
633
+ const result = injectCrank(projectRoot, { install: true });
634
+ if (result.installed) {
635
+ console.log(`[Crank] Installed package (${result.installCommand})`);
636
+ } else if (result.installError) {
637
+ console.warn(`[Crank] Package install warning: ${result.installError}`);
638
+ console.warn('[Crank] Try: pnpm add -D @ahmednawaz/crank');
639
+ }
640
+ if (result.reactWire?.wired) {
641
+ console.log(`[Crank] Patched ${result.reactWire.file}`);
642
+ console.log('[Crank] Added: import { Crank } from \'@ahmednawaz/crank/react\' + <Crank />');
643
+ console.log('[Crank] Next: npx @ahmednawaz/crank start');
644
+ process.exit(0);
645
+ }
646
+ if (result.reactWire?.reason === 'already-present') {
647
+ console.log(`[Crank] Already injected in ${result.reactWire.file}`);
648
+ process.exit(0);
649
+ }
650
+ console.error(
651
+ `[Crank] Could not inject (${result.reactWire?.reason || 'unknown'}).` +
652
+ (result.reactWire?.file ? ` Looked at ${result.reactWire.file}.` : '') +
653
+ ' Add manually:\n\n import { Crank } from \'@ahmednawaz/crank/react\'\n <Crank />\n'
654
+ );
655
+ process.exit(1);
656
+ }
657
+
620
658
  if (!fs.existsSync(projectRoot) || !fs.statSync(projectRoot).isDirectory()) {
621
659
  console.error(`Project root not found: ${projectRoot}`);
622
660
  process.exit(1);
@@ -1,8 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * Injected by Vite plugin (or bookmarklet fallback).
5
- * Loads the Crank panel from the local companion.
4
+ * Injected by <Crank /> (React) or the optional Vite plugin / bookmarklet.
5
+ * Loads the Crank panel from the companion that served this script.
6
6
  */
7
7
  (function () {
8
8
  if (window.__CRANK_LOADER__) {
package/lib/init.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
 
13
13
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
14
14
  const { resolveUrls } = require('./urls');
15
+ const { installCrankPackage } = require('./wire-react');
15
16
 
16
17
  function ensureGitignore(projectRoot) {
17
18
  const file = path.join(projectRoot, '.gitignore');
@@ -433,7 +434,7 @@ function mergeUserCursorMcp(projectRoot, results) {
433
434
  }
434
435
 
435
436
  /**
436
- * Retune-style: add crank to devDependencies so `npx crank` works with no path.
437
+ * Retune-style: add @ahmednawaz/crank to devDependencies (scoped works with pnpm).
437
438
  */
438
439
  function ensureCrankDependency(projectRoot, results) {
439
440
  const pkgPath = path.join(projectRoot, 'package.json');
@@ -448,20 +449,28 @@ function ensureCrankDependency(projectRoot, results) {
448
449
  const crankPkg = readJson(path.join(PACKAGE_ROOT, 'package.json'), {});
449
450
  pkg.devDependencies = pkg.devDependencies || {};
450
451
 
451
- let spec;
452
+ const scoped = '@ahmednawaz/crank';
453
+ let versionSpec;
452
454
  if (process.env.CRANK_NPM_SPEC) {
453
- spec = process.env.CRANK_NPM_SPEC;
455
+ versionSpec = process.env.CRANK_NPM_SPEC;
454
456
  } else {
455
- spec = `@ahmednawaz/crank@^${crankPkg.version || '0.1.0'}`;
457
+ versionSpec = `^${crankPkg.version || '0.2.0'}`;
456
458
  }
457
459
 
458
- if (pkg.devDependencies.crank === spec) {
460
+ const prev = pkg.devDependencies[scoped];
461
+ const hadLegacyAlias = Boolean(pkg.devDependencies.crank);
462
+ pkg.devDependencies[scoped] = versionSpec;
463
+ if (hadLegacyAlias) {
464
+ delete pkg.devDependencies.crank;
465
+ }
466
+
467
+ if (prev === versionSpec && !hadLegacyAlias) {
459
468
  return;
460
469
  }
461
- pkg.devDependencies.crank = spec;
470
+
462
471
  writeJson(pkgPath, pkg);
463
- results.wrote.push('package.json#devDependencies.crank');
464
- results.crankDependencyAdded = spec;
472
+ results.wrote.push(`package.json#devDependencies.${scoped}`);
473
+ results.crankDependencyAdded = `${scoped}@${versionSpec}`;
465
474
  }
466
475
 
467
476
  function writeConfig(projectRoot, env) {
@@ -519,9 +528,7 @@ function initProject(projectRoot, options = {}) {
519
528
 
520
529
  // MCP + skill (Retune-style: global IDE config, automatic skill sync)
521
530
  if (options.mcp !== false) {
522
- if (!env.isReplit) {
523
- ensureCrankDependency(projectRoot, results);
524
- }
531
+ ensureCrankDependency(projectRoot, results);
525
532
  configureMcpRetuneStyle(env, projectRoot, results, options);
526
533
  }
527
534
 
@@ -529,7 +536,11 @@ function initProject(projectRoot, options = {}) {
529
536
  installCrankSkill(projectRoot, results);
530
537
  }
531
538
 
532
- if (env.hasVite && options.vite !== false) {
539
+ // React apps: install dep + write out inject (Sentry/Clerk-style). Do not patch source here.
540
+ if (env.hasReact && options.react !== false) {
541
+ writeReactMountHint(projectRoot, env, results);
542
+ results.reactMount = true;
543
+ } else if (env.hasVite && options.vite !== false) {
533
544
  const viteResult = patchViteConfig(projectRoot);
534
545
  results.vite = viteResult;
535
546
  if (viteResult.patched) {
@@ -539,7 +550,6 @@ function initProject(projectRoot, options = {}) {
539
550
  results.wrote.push(path.relative(projectRoot, viteResult.plugin));
540
551
  }
541
552
  } else if (options.vite !== false) {
542
- // Still write stub so agents can wire the graceful load manually.
543
553
  const stub = writeVitePluginStub(projectRoot);
544
554
  results.vite = { patched: false, reason: 'no-vite-detected', plugin: stub };
545
555
  results.wrote.push(path.relative(projectRoot, stub));
@@ -553,7 +563,7 @@ function initProject(projectRoot, options = {}) {
553
563
  const pkg = readJson(pkgPath, {});
554
564
  pkg.scripts = pkg.scripts || {};
555
565
  if (!pkg.scripts.crank) {
556
- pkg.scripts.crank = 'crank';
566
+ pkg.scripts.crank = 'npx @ahmednawaz/crank';
557
567
  writeJson(pkgPath, pkg);
558
568
  results.wrote.push('package.json#scripts.crank');
559
569
  }
@@ -563,14 +573,9 @@ function initProject(projectRoot, options = {}) {
563
573
  writeReplitPlatformGuide(projectRoot, results);
564
574
  }
565
575
 
566
- if (results.crankDependencyAdded && options.install !== false && !env.isReplit) {
567
- try {
568
- const { execSync } = require('child_process');
569
- execSync('npm install', { cwd: projectRoot, stdio: 'inherit' });
570
- results.installed = true;
571
- } catch (err) {
572
- results.installError = err.message;
573
- }
576
+ if (options.install !== false && results.crankDependencyAdded) {
577
+ const install = installCrankPackage(projectRoot);
578
+ Object.assign(results, install);
574
579
  }
575
580
 
576
581
  return results;
@@ -699,76 +704,90 @@ function copyAgentsDoc(projectRoot, results) {
699
704
  }
700
705
  }
701
706
 
707
+ function writeReactMountHint(projectRoot, env, results) {
708
+ const hint = path.join(projectRoot, '.crank', 'react-mount.md');
709
+ fs.mkdirSync(path.dirname(hint), { recursive: true });
710
+ fs.writeFileSync(
711
+ hint,
712
+ [
713
+ '# Mount Crank (Retune-style)',
714
+ '',
715
+ 'Run (preferred — no manual edits):',
716
+ '',
717
+ '```bash',
718
+ 'npx @ahmednawaz/crank inject',
719
+ '```',
720
+ '',
721
+ 'That installs `@ahmednawaz/crank` and patches your entry file with:',
722
+ '',
723
+ '```tsx',
724
+ "import { Crank } from '@ahmednawaz/crank/react'",
725
+ '',
726
+ '<Crank />',
727
+ '```',
728
+ '',
729
+ 'Then start the companion:',
730
+ '',
731
+ '```bash',
732
+ 'npx @ahmednawaz/crank start',
733
+ '```',
734
+ '',
735
+ 'On Replit, set the public companion URL if the preview is not on localhost:',
736
+ '',
737
+ '```ts',
738
+ "window.__CRANK_URL__ = 'https://3344-<your-repl>.replit.dev'",
739
+ '// or: <Crank companionUrl="https://3344-….replit.dev" />',
740
+ '// or: VITE_CRANK_URL=https://3344-….replit.dev',
741
+ '```',
742
+ '',
743
+ 'No Vite plugin required.',
744
+ ''
745
+ ].join('\n')
746
+ );
747
+ results.wrote.push('.crank/react-mount.md');
748
+ results.reactMount = true;
749
+ }
750
+
702
751
  function writeInjectionGuide(projectRoot, env, results) {
703
752
  const guide = path.join(projectRoot, '.crank', 'injection.md');
704
753
  fs.mkdirSync(path.dirname(guide), { recursive: true });
705
754
  const lines = [
706
755
  '# Crank panel injection',
707
756
  '',
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)',
757
+ '## Preferred `npx @ahmednawaz/crank inject`',
712
758
  '',
713
- 'For Vite apps (React, Vue, Svelte, vanilla):',
759
+ 'Patches your entry file automatically (Sentry/Clerk-style). No manual edits.',
714
760
  '',
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
- '})',
761
+ '```bash',
762
+ 'npx @ahmednawaz/crank@latest setup',
763
+ 'npx @ahmednawaz/crank inject',
764
+ 'npx @ahmednawaz/crank start',
741
765
  '```',
742
766
  '',
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):',
767
+ 'Adds:',
749
768
  '',
750
769
  '```tsx',
751
- "import { CrankLoader } from '@ahmednawaz/crank/react'",
752
- '// client root only — injects the same /dev-loader.js script',
753
- '<CrankLoader />',
770
+ "import { Crank } from '@ahmednawaz/crank/react'",
771
+ '<Crank />',
754
772
  '```',
755
773
  '',
756
- 'Do **not** invent a fake in-tree `<Crank />` panel UI. `CrankLoader` / `Crank` only inject a script.',
774
+ 'URL resolution (first match):',
775
+ '1. `window.__CRANK_URL__`',
776
+ '2. `<Crank companionUrl="…" />` / `url` prop',
777
+ '3. `VITE_CRANK_URL` / `VITE_CRANK_COMPANION_URL`',
778
+ '4. `http://localhost:3344`',
779
+ '',
780
+ 'See `.crank/react-mount.md` when this project has React.',
757
781
  '',
758
782
  '## Next.js',
759
783
  '',
760
- '```tsx',
761
- "import { CrankScript } from '@ahmednawaz/crank/next'",
762
- '// app/layout.tsx or pages/_app',
763
- '<CrankScript />',
784
+ '```bash',
785
+ 'npx @ahmednawaz/crank inject',
764
786
  '```',
765
787
  '',
766
- '## Webpack',
788
+ '## Non-React Vite (Vue / Svelte / vanilla)',
767
789
  '',
768
- '```js',
769
- "const { crankWebpackPlugin } = require('@ahmednawaz/crank/webpack')",
770
- 'plugins: [new HtmlWebpackPlugin(...), crankWebpackPlugin()]',
771
- '```',
790
+ 'Optional Vite plugin: `import { crank } from "@ahmednawaz/crank/vite"` — only if you are not using React.',
772
791
  '',
773
792
  '## Bookmarklet fallback',
774
793
  '',
@@ -776,9 +795,9 @@ function writeInjectionGuide(projectRoot, env, results) {
776
795
  '',
777
796
  '## Detected in this project',
778
797
  '',
798
+ `- hasReact: ${Boolean(env.hasReact)}`,
779
799
  `- hasVite: ${Boolean(env.hasVite)}`,
780
800
  `- viteConfig: ${env.viteConfig || '(none)'}`,
781
- `- hasReact: ${Boolean(env.hasReact)}`,
782
801
  `- hasNext: ${Boolean(env.hasNext)}`,
783
802
  `- hasWebpack: ${Boolean(env.hasWebpack)}`,
784
803
  ''
@@ -796,15 +815,16 @@ function printSetupNextSteps(env, runtime, results) {
796
815
  if (results?.wrote?.some((w) => String(w).includes('skills'))) {
797
816
  console.log(' • Skill crank-apply-changes installed (auto-syncs on start)');
798
817
  }
799
- if (results?.vite?.patched) {
800
- console.log(' • Vite plugin patched (graceful load via .crank/vite-plugin.cjs)');
818
+ if (results?.reactMount || env.hasReact) {
819
+ console.log(' • Next: npx @ahmednawaz/crank inject # patches main.tsx with <Crank />');
820
+ } else if (results?.vite?.patched) {
821
+ console.log(' • Vite plugin patched (non-React stack)');
801
822
  } else if (env.hasVite) {
802
- console.log(' • Vite detected — see .crank/injection.md if the panel does not auto-load');
823
+ console.log(' • Vite detected — see .crank/injection.md');
803
824
  } 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)');
825
+ console.log(' • Next.js — run: npx @ahmednawaz/crank inject');
807
826
  }
827
+ console.log(' • Then: npx @ahmednawaz/crank start');
808
828
  console.log(' • Injection guide: .crank/injection.md');
809
829
  if (env.isReplit) {
810
830
  console.log(' • Workflow command: npx @ahmednawaz/crank start');
@@ -858,10 +878,10 @@ function writeReplitGuide(projectRoot, detectEnv, results, runtime) {
858
878
  '## Panel in preview',
859
879
  '',
860
880
  `- Companion: ${companionUrl}`,
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`',
881
+ '- **React (preferred):** `pnpm add -D @ahmednawaz/crank` then `<Crank />` from `@ahmednawaz/crank/react` (Retune pattern).',
882
+ '- Set `window.__CRANK_URL__` or `VITE_CRANK_URL` to the public companion URL on Replit.',
883
+ '- Keep companion Workflow running: `npx @ahmednawaz/crank start`.',
884
+ '- See `.crank/react-mount.md` and `.crank/injection.md`.',
865
885
  ''
866
886
  ].join('\n')
867
887
  );
@@ -881,6 +901,7 @@ module.exports = {
881
901
  writeVitePluginStub,
882
902
  patchViteConfig,
883
903
  writeInjectionGuide,
904
+ writeReactMountHint,
884
905
  patchReplitConfig,
885
906
  copyAgentsDoc
886
907
  };
@@ -0,0 +1,297 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Auto-wire Retune-style <Crank /> into the app entry file (Sentry/Clerk-style inject).
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { exists } = require('./detect');
10
+
11
+ const ENTRY_CANDIDATES = [
12
+ 'src/main.tsx',
13
+ 'src/main.jsx',
14
+ 'src/main.ts',
15
+ 'src/main.js',
16
+ 'src/index.tsx',
17
+ 'src/index.jsx',
18
+ 'src/index.ts',
19
+ 'src/index.js',
20
+ 'client/src/main.tsx',
21
+ 'client/src/main.jsx',
22
+ 'client/src/index.tsx',
23
+ 'app/main.tsx',
24
+ 'src/App.tsx',
25
+ 'src/App.jsx',
26
+ 'App.tsx',
27
+ 'App.jsx',
28
+ 'main.tsx',
29
+ 'main.jsx',
30
+ 'index.tsx',
31
+ 'index.jsx'
32
+ ];
33
+
34
+ const SKIP_DIRS = new Set([
35
+ 'node_modules',
36
+ '.git',
37
+ 'dist',
38
+ 'build',
39
+ '.next',
40
+ 'coverage',
41
+ '.turbo',
42
+ '.cache',
43
+ '.crank'
44
+ ]);
45
+
46
+ function findEntryByCreateRoot(projectRoot) {
47
+ const queue = [{ dir: path.resolve(projectRoot), depth: 0 }];
48
+ const hits = [];
49
+ while (queue.length) {
50
+ const { dir, depth } = queue.shift();
51
+ if (depth > 3) continue;
52
+ let entries;
53
+ try {
54
+ entries = fs.readdirSync(dir, { withFileTypes: true });
55
+ } catch {
56
+ continue;
57
+ }
58
+ for (const entry of entries) {
59
+ const full = path.join(dir, entry.name);
60
+ if (entry.isDirectory()) {
61
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
62
+ queue.push({ dir: full, depth: depth + 1 });
63
+ }
64
+ continue;
65
+ }
66
+ if (!/\.(tsx|jsx|ts|js)$/.test(entry.name)) continue;
67
+ let src;
68
+ try {
69
+ src = fs.readFileSync(full, 'utf8');
70
+ } catch {
71
+ continue;
72
+ }
73
+ if (
74
+ /createRoot\s*\(/.test(src) ||
75
+ /ReactDOM\.render\s*\(/.test(src) ||
76
+ /hydrateRoot\s*\(/.test(src)
77
+ ) {
78
+ hits.push(full);
79
+ }
80
+ }
81
+ }
82
+ // Prefer main/index over App
83
+ hits.sort((a, b) => {
84
+ const score = (p) => {
85
+ const base = path.basename(p).toLowerCase();
86
+ if (base.startsWith('main')) return 0;
87
+ if (base.startsWith('index')) return 1;
88
+ return 2;
89
+ };
90
+ return score(a) - score(b);
91
+ });
92
+ return hits[0] || null;
93
+ }
94
+
95
+ function findEntry(projectRoot) {
96
+ for (const rel of ENTRY_CANDIDATES) {
97
+ const full = path.join(projectRoot, rel);
98
+ if (exists(full)) {
99
+ return full;
100
+ }
101
+ }
102
+ return findEntryByCreateRoot(projectRoot);
103
+ }
104
+
105
+ function alreadyWired(source) {
106
+ return (
107
+ /@ahmednawaz\/crank\/react/.test(source) ||
108
+ /from ['"]@ahmednawaz\/crank['"]/.test(source) ||
109
+ /<Crank[\s/>]/.test(source)
110
+ );
111
+ }
112
+
113
+ function insertImport(source) {
114
+ const importLine = `import { Crank } from '@ahmednawaz/crank/react';`;
115
+ const matches = [...source.matchAll(/^import\s.+;?\s*$/gm)];
116
+ if (matches.length) {
117
+ const last = matches[matches.length - 1];
118
+ const idx = last.index + last[0].length;
119
+ return source.slice(0, idx) + `\n${importLine}` + source.slice(idx);
120
+ }
121
+ return `${importLine}\n${source}`;
122
+ }
123
+
124
+ function insertCrankJsx(source) {
125
+ if (/<Crank[\s/>]/.test(source)) {
126
+ return { source, patched: true };
127
+ }
128
+
129
+ if (/<StrictMode>/.test(source)) {
130
+ return {
131
+ source: source.replace(/(<StrictMode>\s*)/, '$1\n <Crank />\n '),
132
+ patched: true
133
+ };
134
+ }
135
+
136
+ if (/\.render\s*\(\s*<>/.test(source) || /\.render\s*\(\s*<React\.Fragment>/.test(source)) {
137
+ return {
138
+ source: source.replace(/(<>\s*|<React\.Fragment>\s*)/, '$1\n <Crank />\n '),
139
+ patched: true
140
+ };
141
+ }
142
+
143
+ // createRoot(...).render(<App />) or multi-line JSX root
144
+ if (/\.render\s*\(/.test(source)) {
145
+ // Single-line: .render(<App />)
146
+ let next = source.replace(
147
+ /\.render\s*\(\s*(<(?!>)[^]*?>)\s*\)/m,
148
+ '.render(\n <>\n $1\n <Crank />\n </>\n)'
149
+ );
150
+ if (next !== source && /<Crank[\s/>]/.test(next)) {
151
+ return { source: next, patched: true };
152
+ }
153
+
154
+ // Multi-line root starting with <Something
155
+ next = source.replace(
156
+ /(\.render\s*\(\s*\n\s*)(<[A-Za-z/])/,
157
+ '$1<>\n <Crank />\n $2'
158
+ );
159
+ if (next !== source) {
160
+ // close fragment before the render's closing paren — best effort
161
+ next = next.replace(
162
+ /(\.render\s*\([\s\S]*?)(\n\s*\))/,
163
+ (match, body, close) => {
164
+ if (body.includes('<Crank') && !body.includes('</>')) {
165
+ return `${body}\n </>${close}`;
166
+ }
167
+ return match;
168
+ }
169
+ );
170
+ if (/<Crank[\s/>]/.test(next)) {
171
+ return { source: next, patched: true };
172
+ }
173
+ }
174
+ }
175
+
176
+ if (/ReactDOM\.render\s*\(/.test(source)) {
177
+ const next = source.replace(
178
+ /ReactDOM\.render\s*\(\s*(<(?!>)[^]*?>)\s*,/,
179
+ 'ReactDOM.render(\n <>\n $1\n <Crank />\n </>,'
180
+ );
181
+ if (next !== source && /<Crank[\s/>]/.test(next)) {
182
+ return { source: next, patched: true };
183
+ }
184
+ }
185
+
186
+ if (/<App\b[^/]*?\/>/.test(source)) {
187
+ return {
188
+ source: source.replace(/(<App\b[^/]*?\/>)/, '<Crank />\n $1'),
189
+ patched: true
190
+ };
191
+ }
192
+ if (/<App\b[^>]*>/.test(source)) {
193
+ return {
194
+ source: source.replace(/(<App\b[^>]*>)/, '<Crank />\n $1'),
195
+ patched: true
196
+ };
197
+ }
198
+
199
+ return { source, patched: false };
200
+ }
201
+
202
+ /**
203
+ * Patch the React entry to mount <Crank />. Idempotent.
204
+ * @returns {{ wired: boolean, reason?: string, file?: string }}
205
+ */
206
+ function wireReactEntry(projectRoot) {
207
+ const entryPath = findEntry(projectRoot);
208
+ if (!entryPath) {
209
+ return { wired: false, reason: 'no-entry' };
210
+ }
211
+
212
+ let source = fs.readFileSync(entryPath, 'utf8');
213
+ if (alreadyWired(source)) {
214
+ return { wired: false, reason: 'already-present', file: entryPath };
215
+ }
216
+
217
+ source = insertImport(source);
218
+ const { source: next, patched } = insertCrankJsx(source);
219
+ source = next;
220
+
221
+ if (!patched) {
222
+ return { wired: false, reason: 'unrecognized-entry-shape', file: entryPath };
223
+ }
224
+
225
+ fs.writeFileSync(entryPath, source);
226
+ return { wired: true, file: entryPath };
227
+ }
228
+
229
+ function detectInstallCommand(projectRoot) {
230
+ if (
231
+ exists(path.join(projectRoot, 'pnpm-lock.yaml')) ||
232
+ exists(path.join(projectRoot, 'pnpm-workspace.yaml'))
233
+ ) {
234
+ return { cmd: 'pnpm', args: ['add', '-D', '@ahmednawaz/crank'] };
235
+ }
236
+ if (exists(path.join(projectRoot, 'yarn.lock'))) {
237
+ return { cmd: 'yarn', args: ['add', '-D', '@ahmednawaz/crank'] };
238
+ }
239
+ return { cmd: 'npm', args: ['install', '-D', '@ahmednawaz/crank'] };
240
+ }
241
+
242
+ function installCrankPackage(projectRoot) {
243
+ const { execFileSync } = require('child_process');
244
+ const { cmd, args } = detectInstallCommand(projectRoot);
245
+ const result = { installed: false, installCommand: `${cmd} ${args.join(' ')}` };
246
+ try {
247
+ execFileSync(cmd, args, { cwd: projectRoot, stdio: 'inherit', env: process.env });
248
+ result.installed = true;
249
+ return result;
250
+ } catch (err) {
251
+ result.installError = err.message;
252
+ }
253
+ if (cmd !== 'npm') {
254
+ try {
255
+ execFileSync('npm', ['install', '-D', '@ahmednawaz/crank'], {
256
+ cwd: projectRoot,
257
+ stdio: 'inherit',
258
+ env: process.env
259
+ });
260
+ result.installed = true;
261
+ result.installCommand = 'npm install -D @ahmednawaz/crank';
262
+ delete result.installError;
263
+ } catch (err2) {
264
+ result.installError = err2.message;
265
+ }
266
+ }
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * Full inject flow: ensure dep → install → patch entry.
272
+ */
273
+ function injectCrank(projectRoot, options = {}) {
274
+ const results = {
275
+ projectRoot,
276
+ wrote: []
277
+ };
278
+
279
+ if (options.install !== false) {
280
+ Object.assign(results, installCrankPackage(projectRoot));
281
+ }
282
+
283
+ const wire = wireReactEntry(projectRoot);
284
+ results.reactWire = wire;
285
+ if (wire.wired && wire.file) {
286
+ results.wrote.push(path.relative(projectRoot, wire.file));
287
+ }
288
+ return results;
289
+ }
290
+
291
+ module.exports = {
292
+ wireReactEntry,
293
+ installCrankPackage,
294
+ injectCrank,
295
+ findEntry,
296
+ detectInstallCommand
297
+ };
package/next.js CHANGED
@@ -1,63 +1,53 @@
1
1
  'use strict';
2
2
 
3
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
4
+ * CJS Next adapter — prefer ESM `next.mjs` via package exports.
13
5
  */
14
6
 
15
- const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
16
- const { CrankLoader } = require('./react');
7
+ const {
8
+ Crank: CrankEffect,
9
+ resolveCrankLoaderSrc,
10
+ resolveCrankCompanionUrl
11
+ } = require('./react.js');
17
12
 
18
13
  function getReact() {
19
14
  try {
20
15
  return require('react');
21
16
  } catch {
22
- throw new Error(
23
- '@ahmednawaz/crank/next requires react. Prefer @ahmednawaz/crank/vite for Vite apps.'
24
- );
17
+ throw new Error('@ahmednawaz/crank/next requires react');
25
18
  }
26
19
  }
27
20
 
28
- function getNextScript() {
21
+ function Crank(props) {
22
+ const React = getReact();
23
+ props = props || {};
24
+ const src = resolveCrankLoaderSrc(props);
25
+ const strategy = props.strategy || 'afterInteractive';
26
+
27
+ let Script = null;
29
28
  try {
30
- return require('next/script');
29
+ const mod = require('next/script');
30
+ Script = mod.default || mod;
31
31
  } catch {
32
- return null;
32
+ Script = null;
33
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
34
 
45
- if (NextScript) {
46
- const Script = NextScript.default || NextScript;
35
+ if (Script) {
47
36
  return React.createElement(Script, {
48
- src,
37
+ src: src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now(),
49
38
  strategy,
50
39
  'data-crank-loader': '1'
51
40
  });
52
41
  }
53
42
 
54
- // Fallback when next/script is unavailable (tests / non-Next bundlers).
55
- return React.createElement(CrankLoader, props || {});
43
+ return React.createElement(CrankEffect, props);
56
44
  }
57
45
 
58
46
  module.exports = {
59
- CrankScript,
60
- CrankLoader,
61
- Crank: CrankScript,
62
- resolveCompanionLoaderUrl
47
+ Crank,
48
+ CrankScript: Crank,
49
+ CrankLoader: Crank,
50
+ resolveCrankLoaderSrc,
51
+ resolveCrankCompanionUrl,
52
+ default: Crank
63
53
  };
package/next.mjs ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Next.js entry — same Retune-style `<Crank />` (client component).
3
+ *
4
+ * import { Crank } from '@ahmednawaz/crank/next'
5
+ * <Crank />
6
+ */
7
+
8
+ 'use client';
9
+
10
+ export {
11
+ Crank,
12
+ CrankLoader,
13
+ resolveCrankCompanionUrl,
14
+ resolveCrankLoaderSrc
15
+ } from './react.mjs';
16
+
17
+ export { Crank as CrankScript, Crank as default } from './react.mjs';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.1.9",
3
+ "version": "0.2.2",
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",
@@ -10,12 +10,28 @@
10
10
  "bin": {
11
11
  "crank": "bin/crank.js"
12
12
  },
13
- "main": "vite.js",
13
+ "main": "./react.js",
14
+ "module": "./react.mjs",
15
+ "types": "./react.d.ts",
14
16
  "exports": {
15
- ".": "./vite.js",
17
+ ".": {
18
+ "types": "./react.d.ts",
19
+ "import": "./react.mjs",
20
+ "require": "./react.js",
21
+ "default": "./react.mjs"
22
+ },
23
+ "./react": {
24
+ "types": "./react.d.ts",
25
+ "import": "./react.mjs",
26
+ "require": "./react.js",
27
+ "default": "./react.mjs"
28
+ },
29
+ "./next": {
30
+ "import": "./next.mjs",
31
+ "require": "./next.js",
32
+ "default": "./next.mjs"
33
+ },
16
34
  "./vite": "./vite.js",
17
- "./react": "./react.js",
18
- "./next": "./next.js",
19
35
  "./webpack": "./webpack.js",
20
36
  "./package.json": "./package.json"
21
37
  },
@@ -25,9 +41,15 @@
25
41
  "react": ">=17"
26
42
  },
27
43
  "peerDependenciesMeta": {
28
- "react": { "optional": true },
29
- "next": { "optional": true },
30
- "html-webpack-plugin": { "optional": true }
44
+ "react": {
45
+ "optional": true
46
+ },
47
+ "next": {
48
+ "optional": true
49
+ },
50
+ "html-webpack-plugin": {
51
+ "optional": true
52
+ }
31
53
  },
32
54
  "workspaces": [
33
55
  "companion",
@@ -71,7 +93,10 @@
71
93
  "panel",
72
94
  "vite.js",
73
95
  "react.js",
96
+ "react.mjs",
97
+ "react.d.ts",
74
98
  "next.js",
99
+ "next.mjs",
75
100
  "webpack.js",
76
101
  ".env.example",
77
102
  "AGENTS.md",
@@ -87,6 +112,7 @@
87
112
  "glean",
88
113
  "mcp",
89
114
  "copy",
90
- "ux-writing"
115
+ "ux-writing",
116
+ "react"
91
117
  ]
92
118
  }
package/react.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { ReactElement } from 'react';
2
+
3
+ export interface CrankProps {
4
+ /** Full loader script URL (overrides companionUrl). */
5
+ src?: string;
6
+ /** Companion base URL, e.g. https://3344-….replit.dev */
7
+ companionUrl?: string;
8
+ /** Alias for companionUrl. */
9
+ url?: string;
10
+ }
11
+
12
+ export declare function resolveCrankCompanionUrl(props?: CrankProps): string;
13
+ export declare function resolveCrankLoaderSrc(props?: CrankProps): string;
14
+
15
+ /**
16
+ * Retune-style mount — injects companion `/dev-loader.js` once. Renders null.
17
+ *
18
+ * import { Crank } from '@ahmednawaz/crank/react'
19
+ * <Crank />
20
+ *
21
+ * Optional: `window.__CRANK_URL__` or `VITE_CRANK_URL` for Replit public companion host.
22
+ * Keep companion running: `npx @ahmednawaz/crank start`
23
+ */
24
+ export declare function Crank(props?: CrankProps): ReactElement | null;
25
+ export declare const CrankLoader: typeof Crank;
26
+ export default Crank;
27
+
28
+ declare global {
29
+ interface Window {
30
+ __CRANK_URL__?: string;
31
+ __CRANK_LOADER__?: boolean;
32
+ }
33
+ }
34
+
35
+ export {};
package/react.js CHANGED
@@ -1,56 +1,61 @@
1
1
  'use strict';
2
2
 
3
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
4
+ * CJS build of @ahmednawaz/crank/react (browser-safe, no Node/fs).
5
+ * Vite / modern apps should import the ESM build via package exports "import" condition.
14
6
  */
15
7
 
16
- const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
17
-
18
8
  function getReact() {
19
9
  try {
20
- // eslint-disable-next-line import/no-extraneous-dependencies
21
10
  return require('react');
22
11
  } catch {
23
12
  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".'
13
+ '@ahmednawaz/crank/react requires react as a peer dependency (npm i react / pnpm add react).'
25
14
  );
26
15
  }
27
16
  }
28
17
 
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) {
18
+ function resolveCrankCompanionUrl(props) {
19
+ props = props || {};
20
+ if (typeof window !== 'undefined' && window.__CRANK_URL__) {
21
+ return String(window.__CRANK_URL__).replace(/\/$/, '');
22
+ }
23
+ if (props.companionUrl) {
24
+ return String(props.companionUrl).replace(/\/$/, '');
25
+ }
26
+ if (props.url) {
27
+ return String(props.url).replace(/\/$/, '');
28
+ }
29
+ return 'http://localhost:3344';
30
+ }
31
+
32
+ function resolveCrankLoaderSrc(props) {
33
+ props = props || {};
34
+ if (props.src) {
35
+ return String(props.src);
36
+ }
37
+ return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
38
+ }
39
+
40
+ function Crank(props) {
34
41
  const React = getReact();
35
- const src = resolveCompanionLoaderUrl(props || {});
42
+ props = props || {};
43
+ const src = resolveCrankLoaderSrc(props);
36
44
 
37
45
  React.useEffect(() => {
38
46
  if (typeof document === 'undefined') {
39
47
  return undefined;
40
48
  }
41
- if (window.__CRANK_LOADER__) {
49
+ if (document.querySelector('script[data-crank-loader="1"]')) {
42
50
  return undefined;
43
51
  }
44
- window.__CRANK_LOADER__ = true;
45
-
46
- const existing = document.querySelector('script[data-crank-loader="1"]');
47
- if (existing) {
52
+ if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
48
53
  return undefined;
49
54
  }
50
55
 
51
56
  const script = document.createElement('script');
52
- script.src = src;
53
- script.defer = true;
57
+ script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
58
+ script.async = true;
54
59
  script.dataset.crankLoader = '1';
55
60
  document.documentElement.appendChild(script);
56
61
  return undefined;
@@ -59,11 +64,10 @@ function CrankLoader(props) {
59
64
  return null;
60
65
  }
61
66
 
62
- /** Alias — still only a script injector, not an in-tree panel UI. */
63
- const Crank = CrankLoader;
64
-
65
67
  module.exports = {
66
- CrankLoader,
67
68
  Crank,
68
- resolveCompanionLoaderUrl
69
+ CrankLoader: Crank,
70
+ resolveCrankCompanionUrl,
71
+ resolveCrankLoaderSrc,
72
+ default: Crank
69
73
  };
package/react.mjs ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Retune-style React entry — browser-only, Vite-bundlable (no Node/fs).
3
+ *
4
+ * // main.tsx
5
+ * import { Crank } from '@ahmednawaz/crank/react'
6
+ * // or: import { Crank } from '@ahmednawaz/crank'
7
+ *
8
+ * <Crank />
9
+ *
10
+ * Mount near your app root. Injects companion `/dev-loader.js` once (same panel as the
11
+ * optional Vite plugin). Requires the companion process: `npx @ahmednawaz/crank start`
12
+ *
13
+ * peerDependency: react >= 17
14
+ */
15
+
16
+ import { useEffect } from 'react';
17
+
18
+ /**
19
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
20
+ * @returns {string}
21
+ */
22
+ export function resolveCrankCompanionUrl(props = {}) {
23
+ if (typeof window !== 'undefined' && window.__CRANK_URL__) {
24
+ return String(window.__CRANK_URL__).replace(/\/$/, '');
25
+ }
26
+ if (props.companionUrl) {
27
+ return String(props.companionUrl).replace(/\/$/, '');
28
+ }
29
+ if (props.url) {
30
+ return String(props.url).replace(/\/$/, '');
31
+ }
32
+ try {
33
+ const envUrl =
34
+ typeof import.meta !== 'undefined' &&
35
+ import.meta.env &&
36
+ (import.meta.env.VITE_CRANK_URL || import.meta.env.VITE_CRANK_COMPANION_URL);
37
+ if (envUrl) {
38
+ return String(envUrl).replace(/\/$/, '');
39
+ }
40
+ } catch {
41
+ // ignore
42
+ }
43
+ return 'http://localhost:3344';
44
+ }
45
+
46
+ /**
47
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
48
+ * @returns {string}
49
+ */
50
+ export function resolveCrankLoaderSrc(props = {}) {
51
+ if (props.src) {
52
+ return String(props.src);
53
+ }
54
+ return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
55
+ }
56
+
57
+ /**
58
+ * Retune-style one-liner. Renders null; injects the companion loader script on the client.
59
+ *
60
+ * Do not set window.__CRANK_LOADER__ here — `/dev-loader.js` owns that flag and loads the panel.
61
+ *
62
+ * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
63
+ */
64
+ export function Crank(props = {}) {
65
+ const src = resolveCrankLoaderSrc(props);
66
+
67
+ useEffect(() => {
68
+ if (typeof document === 'undefined') {
69
+ return undefined;
70
+ }
71
+ if (document.querySelector('script[data-crank-loader="1"]')) {
72
+ return undefined;
73
+ }
74
+ // Panel already live (bookmarklet / prior mount)
75
+ if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
76
+ return undefined;
77
+ }
78
+
79
+ const script = document.createElement('script');
80
+ script.src = src.includes('?') ? src : `${src}?t=${Date.now()}`;
81
+ script.async = true;
82
+ script.dataset.crankLoader = '1';
83
+ document.documentElement.appendChild(script);
84
+ return undefined;
85
+ }, [src]);
86
+
87
+ return null;
88
+ }
89
+
90
+ /** Explicit alias. */
91
+ export const CrankLoader = Crank;
92
+
93
+ export default Crank;
package/skill/SKILL.md CHANGED
@@ -33,7 +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
+ 5. **Panel:** `npx @ahmednawaz/crank inject` patches `main.tsx` with `<Crank />` (no manual edits). Then keep companion Workflow running with `npx @ahmednawaz/crank start`.
37
37
 
38
38
  ## When the user says "start Crank" or pastes `npx crank`
39
39