Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,94 @@ npx grab@latest configure --mode hold --hold-duration 500
npx grab@latest configure
```

## Node API

`@react-grab/cli/api` exposes the same primitives that power the CLI, so you can build your own installer or wrap React Grab setup inside another tool. Importing it runs no code, unlike the CLI entry (`.`), which parses `argv` on import.

### `installReactGrab(options?)`

A high-level, non-interactive orchestrator. It detects the project, installs `react-grab` with the detected package manager, and applies the framework-specific development-only setup. It returns a structured result instead of printing or exiting.

```ts
import { installReactGrab } from "@react-grab/cli/api";

const result = await installReactGrab({ cwd: process.cwd() });

console.log(result.framework); // "next" | "vite" | "tanstack" | "webpack"
console.log(result.didInstallPackage); // whether react-grab was added to deps
console.log(result.didChangeFile); // whether an entry file was modified
console.log(result.transform.filePath); // the file that was (or would be) edited
```

| Option | Type | Description |
| ----------------------- | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `cwd` | `string` | Project directory (default: `process.cwd()`) |
| `framework` | `Framework` | Override framework detection |
| `nextRouterType` | `NextRouterType` | Override Next.js router detection (`app` / `pages`) |
| `packageManager` | `PackageManager` | Override package-manager detection |
| `skipPackageInstall` | `boolean` | Skip installing the `react-grab` npm package |
| `skipTransform` | `boolean` | Skip editing the framework entry file |
| `dryRun` | `boolean` | Compute the changes without installing or writing |
| `installPackageOptions` | `Omit<InstallPackageOptions, "cwd" \| "packageManager">` | Passed through to `installPackages` (e.g. `silent`, `isDev`); `cwd`/`packageManager` are controlled by the orchestrator |

Failures throw a `ReactGrabInstallError` whose `code` identifies the cause, with the original error preserved on `error.cause`:

- `unsupported-framework`: framework has no automatic setup (Remix, Astro, SvelteKit, Gatsby)
- `unknown-framework`: no supported framework detected
- `transform-failed`: entry file could not be located or edited
- `install-failed`: package manager failed to install `react-grab`
- `write-failed`: edited file could not be written

`installReactGrab` configures a single project at `cwd` and does not walk a monorepo. Point `cwd` at the app you want to set up, or call `findReactProjects` first to locate the apps in a workspace.

By default the call mutates your project: it runs the package manager and edits a framework entry file. Pass `dryRun: true` to compute the change set (returned on `result.transform`) without installing or writing.

### Low-level building blocks

If you want full control, compose the same functions the orchestrator uses:

```ts
import {
detectProject,
previewTransform,
applyTransform,
installPackages,
getPackagesToInstall,
installSkill,
} from "@react-grab/cli/api";

const project = await detectProject(process.cwd());
const transform = previewTransform(
project.projectRoot,
project.framework,
project.nextRouterType,
project.isReactGrabConfigured,
);
```

Install the package only when it's missing, then write the previewed edit. `previewTransform` sets `noChanges` when React Grab is already wired up, so guard on it before calling `applyTransform`, which writes `transform.newContent` to `transform.filePath`:

```ts
if (!project.hasReactGrab) {
await installPackages(getPackagesToInstall(), {
cwd: project.projectRoot,
packageManager: project.packageManager,
});
}

if (transform.success && transform.newContent && !transform.noChanges) {
applyTransform(transform);
}

await installSkill({ cwd: project.projectRoot });
```

The full export surface, each with its TypeScript types:

- Detection: `detectProject`, `detectFramework`, `detectPackageManager`, `detectNextRouterType`, `detectReactGrab`, `detectReactGrabConfigured`, `detectUnsupportedFramework`, `findReactProjects`
- Transforms: `previewTransform`, `previewOptionsTransform`, `previewCdnTransform`, `applyTransform`, `hasFrameworkEntryPoint`
- Installation: `installPackages`, `getPackagesToInstall`, `installSkill`, `removeSkill`

## Supported Frameworks

The CLI currently configures:
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"types": "./dist/cli.d.ts",
"import": "./dist/cli.js",
"require": "./dist/cli.cjs"
},
"./api": {
"types": "./dist/api.d.ts",
"import": "./dist/api.js",
"require": "./dist/api.cjs"
}
},
"scripts": {
Expand Down
40 changes: 40 additions & 0 deletions packages/cli/src/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export {
detectFramework,
detectNextRouterType,
detectPackageManager,
detectProject,
detectReactGrab,
detectReactGrabConfigured,
detectUnsupportedFramework,
findReactProjects,
} from "./utils/detect.js";
export type {
Framework,
NextRouterType,
PackageManager,
ProjectInfo,
UnsupportedFramework,
WorkspaceProject,
} from "./utils/detect.js";

export {
applyTransform,
hasFrameworkEntryPoint,
previewCdnTransform,
previewOptionsTransform,
previewTransform,
} from "./utils/transform.js";
export type { ReactGrabOptions, TransformResult } from "./utils/transform.js";

export { getPackagesToInstall, installPackages } from "./utils/install.js";
export type { InstallPackageOptions } from "./utils/install.js";

export { installReactGrab, ReactGrabInstallError } from "./utils/install-react-grab.js";
export type {
InstallReactGrabOptions,
InstallReactGrabResult,
ReactGrabInstallErrorCode,
} from "./utils/install-react-grab.js";

export { installSkill, removeSkill } from "./utils/install-skill.js";
export type { InstallSkillOptions } from "./utils/install-skill.js";
2 changes: 1 addition & 1 deletion packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { detectNonInteractive } from "../utils/is-non-interactive.js";
import { detectProject } from "../utils/detect.js";
import { handleError } from "../utils/handle-error.js";
import { highlighter } from "../utils/highlighter.js";
import { promptSkillInstall } from "../utils/install-skill.js";
import { promptSkillInstall } from "../utils/prompt-skill-install.js";
import { logger } from "../utils/logger.js";
import { spinner } from "../utils/spinner.js";

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import pc from "picocolors";
import { detectNonInteractive } from "../utils/is-non-interactive.js";
import { prompts } from "../utils/prompts.js";
import { applyTransformWithFeedback, installPackagesWithFeedback } from "../utils/cli-helpers.js";
import { promptSkillInstall } from "../utils/install-skill.js";
import { promptSkillInstall } from "../utils/prompt-skill-install.js";
import {
detectProject,
findReactProjects,
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/commands/remove.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Command } from "commander";
import pc from "picocolors";
import { handleError } from "../utils/handle-error.js";
import { highlighter } from "../utils/highlighter.js";
import { removeSkill } from "../utils/install-skill.js";
import { agentLabel, removeSkill } from "../utils/install-skill.js";
import { logger } from "../utils/logger.js";

const VERSION = process.env.VERSION ?? "0.0.1";
Expand All @@ -19,14 +19,17 @@ export const remove = new Command()

try {
logger.break();
const removedCount = await removeSkill({ cwd: resolve(opts.cwd), global: opts.global });
const removedAgents = await removeSkill({ cwd: resolve(opts.cwd), global: opts.global });
for (const agent of removedAgents) {
logger.log(` ${highlighter.success("\u2713")} ${agentLabel(agent)}`);
}

logger.break();
if (removedCount === 0) {
if (removedAgents.length === 0) {
logger.log("React Grab skill is not installed in any detected agent.");
} else {
logger.log(
`${highlighter.success("Removed")} the React Grab skill from ${removedCount} agent${removedCount === 1 ? "" : "s"}.`,
`${highlighter.success("Removed")} the React Grab skill from ${removedAgents.length} agent${removedAgents.length === 1 ? "" : "s"}.`,
);
}
logger.break();
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/utils/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export type Framework = "next" | "vite" | "tanstack" | "webpack" | "unknown";
export type NextRouterType = "app" | "pages" | "unknown";
export type UnsupportedFramework = "remix" | "astro" | "sveltekit" | "gatsby" | null;

interface ProjectInfo {
export interface ProjectInfo {
packageManager: PackageManager;
framework: Framework;
nextRouterType: NextRouterType;
Expand Down
140 changes: 140 additions & 0 deletions packages/cli/src/utils/install-react-grab.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { resolve } from "node:path";
import {
detectNextRouterType,
detectProject,
type Framework,
type NextRouterType,
type PackageManager,
} from "./detect.js";
import { getPackagesToInstall, installPackages, type InstallPackageOptions } from "./install.js";
import { applyTransform, previewTransform, type TransformResult } from "./transform.js";

export type ReactGrabInstallErrorCode =
| "unsupported-framework"
| "unknown-framework"
| "transform-failed"
| "install-failed"
| "write-failed";

export class ReactGrabInstallError extends Error {
readonly code: ReactGrabInstallErrorCode;

constructor(message: string, code: ReactGrabInstallErrorCode, options?: ErrorOptions) {
super(message, options);
this.name = "ReactGrabInstallError";
this.code = code;
}
}

export interface InstallReactGrabOptions {
cwd?: string;
framework?: Framework;
nextRouterType?: NextRouterType;
packageManager?: PackageManager;
skipPackageInstall?: boolean;
skipTransform?: boolean;
dryRun?: boolean;
installPackageOptions?: Omit<InstallPackageOptions, "cwd" | "packageManager">;
}

export interface InstallReactGrabResult {
projectRoot: string;
framework: Framework;
nextRouterType: NextRouterType;
packageManager: PackageManager;
alreadyConfigured: boolean;
didInstallPackage: boolean;
didChangeFile: boolean;
dryRun: boolean;
transform: TransformResult;
}

export const installReactGrab = async (
options: InstallReactGrabOptions = {},
): Promise<InstallReactGrabResult> => {
const cwd = resolve(options.cwd ?? process.cwd());
const project = await detectProject(cwd);

const framework = options.framework ?? project.framework;
const packageManager = options.packageManager ?? project.packageManager;

if (project.unsupportedFramework && !options.framework) {
throw new ReactGrabInstallError(
`${project.unsupportedFramework} is not supported by automatic setup.`,
"unsupported-framework",
);
}

if (framework === "unknown") {
throw new ReactGrabInstallError(
"Could not detect a supported framework. Pass `framework` explicitly to override detection.",
"unknown-framework",
);
}

// Detection only resolves the router type when the *detected* framework is
// Next.js, so derive it ourselves when the caller overrides to Next.
const nextRouterType =
options.nextRouterType ??
(framework === "next" && project.nextRouterType === "unknown"
? detectNextRouterType(project.projectRoot)
: project.nextRouterType);

const alreadyConfigured = project.isReactGrabConfigured;

const transform = previewTransform(
project.projectRoot,
framework,
nextRouterType,
alreadyConfigured,
);

if (!transform.success && !options.skipTransform) {
throw new ReactGrabInstallError(transform.message, "transform-failed");
}

const didInstallPackage = !options.skipPackageInstall && !options.dryRun && !project.hasReactGrab;

if (didInstallPackage) {
try {
await installPackages(getPackagesToInstall(), {
...options.installPackageOptions,
cwd: project.projectRoot,
packageManager,
});
} catch (error) {
throw new ReactGrabInstallError(
error instanceof Error ? error.message : "Failed to install the react-grab package.",
"install-failed",
{ cause: error },
);
}
}

// Mirrors applyTransform's own write guard (it does not require originalContent),
// so an empty source file still receives the injected setup.
const hasPendingFileChange = !transform.noChanges && Boolean(transform.newContent);
const didChangeFile = hasPendingFileChange && !options.skipTransform && !options.dryRun;

if (didChangeFile) {
const writeResult = applyTransform(transform);
if (!writeResult.success) {
throw new ReactGrabInstallError(
writeResult.error ?? `Failed to write to ${transform.filePath}`,
"write-failed",
);
}
}

return {
projectRoot: project.projectRoot,
framework,
nextRouterType,
packageManager,
alreadyConfigured,
didInstallPackage,
didChangeFile,
dryRun: Boolean(options.dryRun),
transform,
};
};
Loading
Loading