diff --git a/.changeset/uninit-command.md b/.changeset/uninit-command.md
new file mode 100644
index 0000000..58d97e4
--- /dev/null
+++ b/.changeset/uninit-command.md
@@ -0,0 +1,11 @@
+---
+"agent-react-devtools": minor
+---
+
+Add `uninit` command to reverse framework configuration
+
+`agent-react-devtools uninit` removes the changes made by `init` — restoring your config files to their original state.
+
+- Supports all frameworks: Vite, Next.js (Pages Router and App Router), CRA
+- `--dry-run` flag previews what would be removed without writing any files
+- Safe to run on projects not configured by `init` (no-op)
diff --git a/README.md b/README.md
index 521c89c..e98c7ae 100644
--- a/README.md
+++ b/README.md
@@ -178,6 +178,12 @@ npx agent-react-devtools init
This detects your framework (Vite, Next.js, CRA) and patches the appropriate config file.
+To undo these changes:
+
+```sh
+npx agent-react-devtools uninit
+```
+
### One-line import
Add a single import as the first line of your entry point (e.g. `src/main.tsx`):
diff --git a/packages/agent-react-devtools/src/__tests__/init.test.ts b/packages/agent-react-devtools/src/__tests__/init.test.ts
index b13b967..b961750 100644
--- a/packages/agent-react-devtools/src/__tests__/init.test.ts
+++ b/packages/agent-react-devtools/src/__tests__/init.test.ts
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
-import { detectFramework, runInit } from '../init.js';
+import { detectFramework, runInit, runUninit } from '../init.js';
function makeTempDir(): string {
return mkdtempSync(join(tmpdir(), 'ard-test-'));
@@ -200,3 +200,142 @@ describe('runInit', () => {
expect(content).toBe(original);
});
});
+
+describe('runUninit', () => {
+ let dir: string;
+
+ beforeEach(() => {
+ dir = makeTempDir();
+ });
+
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('removes Vite plugin import and usage', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ devDependencies: { '@vitejs/plugin-react': '^4.0.0' } }),
+ );
+ writeFileSync(
+ join(dir, 'vite.config.ts'),
+ `import { defineConfig } from 'vite';\nimport react from '@vitejs/plugin-react';\n\nexport default defineConfig({\n plugins: [react()],\n});\n`,
+ );
+
+ await runInit(dir, false);
+ const afterInit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8');
+ expect(afterInit).toContain('agent-react-devtools');
+
+ await runUninit(dir, false);
+ const afterUninit = readFileSync(join(dir, 'vite.config.ts'), 'utf-8');
+ expect(afterUninit).not.toContain('agent-react-devtools');
+ expect(afterUninit).not.toContain('reactDevtools()');
+ expect(afterUninit).toContain("import react from '@vitejs/plugin-react'");
+ });
+
+ it('removes CRA import', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
+ );
+ mkdirSync(join(dir, 'src'));
+ const original = `import React from 'react';\nimport ReactDOM from 'react-dom/client';\n`;
+ writeFileSync(join(dir, 'src/index.tsx'), original);
+
+ await runInit(dir, false);
+ const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterInit).toContain('agent-react-devtools');
+
+ await runUninit(dir, false);
+ const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterUninit).not.toContain('agent-react-devtools');
+ expect(afterUninit).toContain("import React from 'react'");
+ });
+
+ it('removes Next.js App Router wrapper and import', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { next: '^14.0.0' } }),
+ );
+ mkdirSync(join(dir, 'app'));
+ writeFileSync(
+ join(dir, 'app/layout.tsx'),
+ `export default function Layout({ children }) {\n return
{children};\n}\n`,
+ );
+
+ await runInit(dir, false);
+ expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(true);
+
+ await runUninit(dir, false);
+ expect(existsSync(join(dir, 'app/devtools.ts'))).toBe(false);
+ const layout = readFileSync(join(dir, 'app/layout.tsx'), 'utf-8');
+ expect(layout).not.toContain('devtools');
+ });
+
+ it('removes Next.js Pages Router import', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { next: '^14.0.0' } }),
+ );
+ mkdirSync(join(dir, 'pages'));
+ const original = `export default function App({ Component, pageProps }) {\n return ;\n}\n`;
+ writeFileSync(join(dir, 'pages/_app.tsx'), original);
+
+ await runInit(dir, false);
+ await runUninit(dir, false);
+
+ const content = readFileSync(join(dir, 'pages/_app.tsx'), 'utf-8');
+ expect(content).not.toContain('agent-react-devtools');
+ });
+
+ it('dry-run does not modify files', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
+ );
+ mkdirSync(join(dir, 'src'));
+ writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`);
+
+ await runInit(dir, false);
+ const afterInit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+
+ await runUninit(dir, true);
+ const afterDryRun = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterDryRun).toBe(afterInit);
+ });
+
+ it('is a no-op when not configured', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
+ );
+ mkdirSync(join(dir, 'src'));
+ const original = `import React from 'react';\n`;
+ writeFileSync(join(dir, 'src/index.tsx'), original);
+
+ await runUninit(dir, false);
+ const content = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(content).toBe(original);
+ });
+
+ it('init -> uninit -> init roundtrip works', async () => {
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify({ dependencies: { 'react-scripts': '^5.0.0' } }),
+ );
+ mkdirSync(join(dir, 'src'));
+ writeFileSync(join(dir, 'src/index.tsx'), `import React from 'react';\n`);
+
+ await runInit(dir, false);
+ const afterInit1 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterInit1).toContain('agent-react-devtools');
+
+ await runUninit(dir, false);
+ const afterUninit = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterUninit).not.toContain('agent-react-devtools');
+
+ await runInit(dir, false);
+ const afterInit2 = readFileSync(join(dir, 'src/index.tsx'), 'utf-8');
+ expect(afterInit2).toContain('agent-react-devtools');
+ });
+});
diff --git a/packages/agent-react-devtools/src/cli.ts b/packages/agent-react-devtools/src/cli.ts
index fdc5210..f8acb9c 100644
--- a/packages/agent-react-devtools/src/cli.ts
+++ b/packages/agent-react-devtools/src/cli.ts
@@ -29,6 +29,7 @@ function usage(): string {
Setup:
init [--dry-run] Auto-configure your React app
+ uninit [--dry-run] Remove configuration added by init
Daemon:
start [--port 8097] Start daemon
@@ -121,13 +122,19 @@ async function main(): Promise {
const cmd1 = command[1];
try {
- // ── Init ──
+ // ── Init / Uninit ──
if (cmd0 === 'init') {
const { runInit } = await import('./init.js');
await runInit(process.cwd(), flags['dry-run'] === true);
return;
}
+ if (cmd0 === 'uninit') {
+ const { runUninit } = await import('./init.js');
+ await runUninit(process.cwd(), flags['dry-run'] === true);
+ return;
+ }
+
// ── Profile diff (no daemon needed) ──
if (cmd0 === 'profile' && cmd1 === 'diff') {
const fileA = command[2];
diff --git a/packages/agent-react-devtools/src/init.ts b/packages/agent-react-devtools/src/init.ts
index 49e134c..c961346 100644
--- a/packages/agent-react-devtools/src/init.ts
+++ b/packages/agent-react-devtools/src/init.ts
@@ -1,4 +1,4 @@
-import { readFileSync, writeFileSync, existsSync } from 'node:fs';
+import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'node:fs';
import { join, dirname } from 'node:path';
type Framework = 'vite' | 'nextjs' | 'cra' | 'react-native' | 'unknown';
@@ -40,6 +40,21 @@ function prependImport(filePath: string, importLine: string, dryRun: boolean): s
return filePath;
}
+function removeImport(filePath: string, importLine: string, dryRun: boolean): string | null {
+ const content = readFileSync(filePath, 'utf-8');
+ if (!content.includes(importLine)) {
+ return null; // not configured
+ }
+ const newContent = content
+ .split('\n')
+ .filter((line) => line !== importLine)
+ .join('\n');
+ if (!dryRun) {
+ writeFileSync(filePath, newContent, 'utf-8');
+ }
+ return filePath;
+}
+
function patchViteConfig(cwd: string, dryRun: boolean): string[] {
const configPath = findFile(
cwd,
@@ -177,6 +192,158 @@ function patchCRA(cwd: string, dryRun: boolean): string[] {
return result ? [result] : [];
}
+function unpatchViteConfig(cwd: string, dryRun: boolean): string[] {
+ const configPath = findFile(
+ cwd,
+ 'vite.config.ts',
+ 'vite.config.js',
+ 'vite.config.mts',
+ 'vite.config.mjs',
+ );
+ if (!configPath) return [];
+
+ const content = readFileSync(configPath, 'utf-8');
+ if (!content.includes('agent-react-devtools')) return [];
+
+ let newContent = content
+ .split('\n')
+ .filter((line) => line !== "import { reactDevtools } from 'agent-react-devtools/vite';")
+ .join('\n');
+
+ // Remove reactDevtools() call from plugins array (with optional trailing comma)
+ newContent = newContent.replace(/\s*reactDevtools\(\),?/g, '');
+
+ if (!dryRun) {
+ writeFileSync(configPath, newContent, 'utf-8');
+ }
+ return [configPath];
+}
+
+function unpatchNextJs(cwd: string, dryRun: boolean): string[] {
+ const modified: string[] = [];
+
+ // Remove the devtools.ts wrapper file if it exists and is ours
+ const layoutPath = findFile(
+ cwd,
+ 'app/layout.tsx',
+ 'app/layout.jsx',
+ 'app/layout.js',
+ 'src/app/layout.tsx',
+ 'src/app/layout.jsx',
+ 'src/app/layout.js',
+ );
+
+ if (layoutPath) {
+ const devtoolsPath = join(dirname(layoutPath), 'devtools.ts');
+ let devtoolsIsOurs = false;
+ if (existsSync(devtoolsPath)) {
+ const content = readFileSync(devtoolsPath, 'utf-8');
+ if (content.includes('agent-react-devtools')) {
+ devtoolsIsOurs = true;
+ if (!dryRun) {
+ unlinkSync(devtoolsPath);
+ }
+ modified.push(devtoolsPath);
+ }
+ }
+
+ // Only remove the layout import if we confirmed devtools.ts was created by us,
+ // to avoid corrupting a pre-existing import './devtools' that we don't own.
+ if (devtoolsIsOurs) {
+ const layoutContent = readFileSync(layoutPath, 'utf-8');
+ const newContent = layoutContent
+ .split('\n')
+ .filter((line) => line !== "import './devtools';")
+ .join('\n');
+ if (newContent !== layoutContent) {
+ if (!dryRun) {
+ writeFileSync(layoutPath, newContent, 'utf-8');
+ }
+ modified.push(layoutPath);
+ }
+ }
+ }
+
+ // Also check Pages Router
+ const pagesEntry = findFile(
+ cwd,
+ 'pages/_app.tsx',
+ 'pages/_app.jsx',
+ 'pages/_app.js',
+ 'src/pages/_app.tsx',
+ 'src/pages/_app.jsx',
+ 'src/pages/_app.js',
+ );
+ if (pagesEntry) {
+ const result = removeImport(pagesEntry, "import 'agent-react-devtools/connect';", dryRun);
+ if (result) modified.push(result);
+ }
+
+ return modified;
+}
+
+function unpatchCRA(cwd: string, dryRun: boolean): string[] {
+ const entryPath = findFile(
+ cwd,
+ 'src/index.tsx',
+ 'src/index.jsx',
+ 'src/index.js',
+ );
+ if (!entryPath) return [];
+
+ const result = removeImport(entryPath, "import 'agent-react-devtools/connect';", dryRun);
+ return result ? [result] : [];
+}
+
+export async function runUninit(
+ cwd: string,
+ dryRun: boolean,
+): Promise {
+ const framework = detectFramework(cwd);
+
+ console.log(`Detected framework: ${framework}`);
+
+ if (framework === 'unknown') {
+ console.log('\nCould not detect framework. Manual removal required:');
+ console.log(" Remove any `import 'agent-react-devtools/connect'` lines");
+ return;
+ }
+
+ if (framework === 'react-native') {
+ console.log('\nReact Native detected - no code changes were made by init.');
+ return;
+ }
+
+ let modified: string[] = [];
+
+ if (dryRun) {
+ console.log('\n[dry-run] Would modify:');
+ }
+
+ switch (framework) {
+ case 'vite':
+ modified = unpatchViteConfig(cwd, dryRun);
+ break;
+ case 'nextjs':
+ modified = unpatchNextJs(cwd, dryRun);
+ break;
+ case 'cra':
+ modified = unpatchCRA(cwd, dryRun);
+ break;
+ }
+
+ if (modified.length === 0) {
+ console.log(' No changes needed (not configured or already removed)');
+ return;
+ }
+
+ for (const f of modified) {
+ console.log(` ${dryRun ? '[dry-run] ' : ''}Reverted: ${f}`);
+ }
+
+ console.log('\nDone! agent-react-devtools configuration has been removed.');
+}
+
export async function runInit(
cwd: string,
dryRun: boolean,