click-to-source-3d 0.0.0-stage → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Click-to-Source 3D
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # click-to-source-3d
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sets up [Click-to-Source 3D](https://github.com/pun1th01/click-to-source-3d)
4
+ in a Vite + React Three Fiber project with one command.
5
+
6
+ npx click-to-source-3d init
7
+
8
+ Run it in your app's folder, the one with `package.json` and `vite.config`.
9
+ It needs Node 20.19+, Vite 6–8, React Three Fiber 9 and three.js 0.170+.
10
+ `pnpm dlx click-to-source-3d init` and `bunx click-to-source-3d init` work
11
+ too.
12
+
13
+ Then start your app as usual, press **Alt+Shift+C** (or the button in the
14
+ corner of the canvas), and click any object to see the line that created it
15
+ and edit its values.
16
+
17
+ ## What it does
18
+
19
+ 1. Installs `@click-to-source-3d/vite-plugin` as a dev dependency, with the
20
+ package manager your project uses (npm, pnpm, yarn or bun, from its
21
+ lockfile).
22
+ 2. Adds `clickToSource()` to the front of `plugins` in your `vite.config`,
23
+ changing nothing else in the file. If the config is written in a way it
24
+ cannot edit safely, it prints the two lines to add instead.
25
+ 3. Asks whether to set up the tools for AI coding assistants. On yes, it also
26
+ installs `@click-to-source-3d/mcp` and registers it in `.mcp.json` at your
27
+ repository root, which is Claude Code's config file. For Cursor, VS Code
28
+ and others, see the
29
+ [MCP package](https://www.npmjs.com/package/@click-to-source-3d/mcp).
30
+
31
+ It stops before changing anything if the project does not use Vite, or does
32
+ not use React Three Fiber. Running it again changes nothing.
33
+
34
+ ## Options
35
+
36
+ --mcp set up the AI-assistant tools without asking
37
+ --no-mcp do not ask about them
38
+ -y, --yes accept the defaults without asking
39
+ --force set up a project that does not use React Three Fiber
40
+ -h, --help show help
41
+
42
+ It is never added to your project: `npx` downloads it, runs it, and that is
43
+ all.
44
+
45
+ ## License
46
+
47
+ MIT
package/dist/bin.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/bin.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // The executable. Kept apart from cli.ts so tests can import that without
3
+ // running anything, and so nothing depends on comparing a module URL with
4
+ // argv[1] — which an npm bin symlink makes differ on macOS and Linux.
5
+ import { main } from "./cli.js";
6
+ main(process.argv.slice(2)).then((code) => {
7
+ process.exitCode = code;
8
+ });
package/dist/cli.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ export declare const VERSION: string;
2
+ /** Everything init does to the outside world, so a test can stand in for it. */
3
+ export type Io = {
4
+ cwd: string;
5
+ log: (line: string) => void;
6
+ error: (line: string) => void;
7
+ /** Runs a command to completion and returns its exit code. */
8
+ run: (command: string, args: string[], cwd: string) => number;
9
+ ask: (question: string) => Promise<string>;
10
+ interactive: boolean;
11
+ platform: NodeJS.Platform;
12
+ };
13
+ export type Flags = {
14
+ /** null: ask, when there is someone to ask. */
15
+ mcp: boolean | null;
16
+ yes: boolean;
17
+ force: boolean;
18
+ help: boolean;
19
+ version: boolean;
20
+ /** Package specs to install instead of the published ones — for testing unreleased builds. */
21
+ pluginSpec?: string;
22
+ mcpSpec?: string;
23
+ };
24
+ export declare function parseArgs(argv: string[]): {
25
+ command: string | null;
26
+ flags: Flags;
27
+ unknown: string[];
28
+ };
29
+ /**
30
+ * Installs the plugin, adds it to vite.config, and — if asked — registers the
31
+ * MCP server. Returns the process exit code.
32
+ */
33
+ export declare function init(flags: Flags, io: Io): Promise<number>;
34
+ export declare function main(argv: string[], io?: Io): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,185 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { MCP_PACKAGE, mcpServerEntry, mergeMcpConfig } from "./mcpConfig.js";
7
+ import { installCommand, readProject } from "./project.js";
8
+ import { MANUAL_SNIPPET, PLUGIN_PACKAGE, patchViteConfig } from "./viteConfig.js";
9
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
10
+ const MCP_SETUP_URL = "https://github.com/pun1th01/click-to-source-3d/tree/main/packages/mcp#install";
11
+ export function parseArgs(argv) {
12
+ const flags = { mcp: null, yes: false, force: false, help: false, version: false };
13
+ const unknown = [];
14
+ let command = null;
15
+ for (const arg of argv) {
16
+ if (arg === "--mcp")
17
+ flags.mcp = true;
18
+ else if (arg === "--no-mcp")
19
+ flags.mcp = false;
20
+ else if (arg === "--yes" || arg === "-y")
21
+ flags.yes = true;
22
+ else if (arg === "--force")
23
+ flags.force = true;
24
+ else if (arg === "--help" || arg === "-h")
25
+ flags.help = true;
26
+ else if (arg === "--version" || arg === "-v")
27
+ flags.version = true;
28
+ else if (arg.startsWith("--plugin-spec="))
29
+ flags.pluginSpec = arg.slice("--plugin-spec=".length);
30
+ else if (arg.startsWith("--mcp-spec="))
31
+ flags.mcpSpec = arg.slice("--mcp-spec=".length);
32
+ else if (!arg.startsWith("-") && command === null)
33
+ command = arg;
34
+ else
35
+ unknown.push(arg);
36
+ }
37
+ return { command, flags, unknown };
38
+ }
39
+ const HELP = `click-to-source-3d ${VERSION}
40
+
41
+ Sets up Click-to-Source 3D in a Vite + React Three Fiber project.
42
+
43
+ Usage
44
+ npx click-to-source-3d init [options]
45
+
46
+ Options
47
+ --mcp Also set up the MCP server for AI coding assistants
48
+ --no-mcp Do not ask about the MCP server
49
+ -y, --yes Accept the defaults without asking
50
+ --force Set up a project that does not use React Three Fiber
51
+ -h, --help Show this help
52
+ `;
53
+ function devCommand(manager) {
54
+ return manager === "npm" ? "npm run dev" : `${manager} dev`;
55
+ }
56
+ /**
57
+ * Installs the plugin, adds it to vite.config, and — if asked — registers the
58
+ * MCP server. Returns the process exit code.
59
+ */
60
+ export async function init(flags, io) {
61
+ const project = readProject(io.cwd);
62
+ if (!project) {
63
+ io.error(`No package.json in ${io.cwd}. Run this in your app's folder.`);
64
+ return 1;
65
+ }
66
+ if (!project.hasVite || !project.viteConfig) {
67
+ io.error(`${project.name} does not use Vite (no vite.config found). Click-to-Source 3D ` +
68
+ "works with Vite for now; webpack and Next.js are on the roadmap.");
69
+ return 1;
70
+ }
71
+ if (!project.hasR3F && !flags.force) {
72
+ io.error(`${project.name} does not use React Three Fiber. Plain three.js support arrives ` +
73
+ "in 0.2.0. Run with --force to set it up anyway.");
74
+ return 1;
75
+ }
76
+ const configName = path.basename(project.viteConfig);
77
+ io.log(`Setting up Click-to-Source 3D in ${project.name}\n`);
78
+ let mcp = flags.mcp;
79
+ if (mcp === null) {
80
+ mcp =
81
+ io.interactive && !flags.yes
82
+ ? /^y/i.test((await io.ask("Set up AI-agent tools (MCP)? (y/N) ")).trim())
83
+ : false;
84
+ }
85
+ const specs = [flags.pluginSpec ?? `${PLUGIN_PACKAGE}@^${VERSION}`];
86
+ if (mcp) {
87
+ specs.push(flags.mcpSpec ?? `${MCP_PACKAGE}@^${VERSION}`);
88
+ }
89
+ const { command, args } = installCommand(project.packageManager, specs);
90
+ io.log(`Installing with ${project.packageManager}: ${specs.join(" ")}`);
91
+ if (io.run(command, args, project.dir) !== 0) {
92
+ io.error(`\nThe install failed. Nothing else was changed. You can run it yourself:\n ${command} ${args.join(" ")}`);
93
+ return 1;
94
+ }
95
+ io.log(`✔ Installed ${PLUGIN_PACKAGE}${mcp ? ` and ${MCP_PACKAGE}` : ""}`);
96
+ let manual = false;
97
+ const patch = patchViteConfig(readFileSync(project.viteConfig, "utf8"));
98
+ if (patch.status === "patched") {
99
+ writeFileSync(project.viteConfig, patch.code);
100
+ io.log(`✔ Added clickToSource() to ${configName}`);
101
+ }
102
+ else if (patch.status === "already") {
103
+ io.log(`✔ ${configName} already uses clickToSource()`);
104
+ }
105
+ else {
106
+ manual = true;
107
+ io.log(`! Could not edit ${configName} automatically: ${patch.reason}.\n` +
108
+ ` Add these lines to it:\n\n${MANUAL_SNIPPET.replace(/^/gm, " ")}\n`);
109
+ }
110
+ if (mcp) {
111
+ const file = path.join(project.workspaceRoot, ".mcp.json");
112
+ const merged = mergeMcpConfig(existsSync(file) ? readFileSync(file, "utf8") : null, io.platform);
113
+ const where = path.relative(io.cwd, file) || ".mcp.json";
114
+ if (merged.status === "added") {
115
+ writeFileSync(file, merged.text);
116
+ io.log(`✔ Registered the MCP server in ${where}`);
117
+ io.log(` Claude Code reads it. For Cursor, VS Code and others, see ${MCP_SETUP_URL}`);
118
+ }
119
+ else if (merged.status === "already") {
120
+ io.log(`✔ ${where} already registers the MCP server`);
121
+ }
122
+ else {
123
+ manual = true;
124
+ io.log(`! Could not edit ${where} (${merged.reason}). Add this under "mcpServers":\n\n` +
125
+ ` "click-to-source": ${JSON.stringify(mcpServerEntry(io.platform))}\n`);
126
+ }
127
+ }
128
+ io.log(`\n${manual ? "Almost done" : "Done"}. Start your app with \`${devCommand(project.packageManager)}\`, ` +
129
+ "then press Alt+Shift+C (or the button in the corner of the canvas) and click any object.");
130
+ return 0;
131
+ }
132
+ /**
133
+ * Quoting for cmd.exe. Package managers are .cmd scripts on Windows and have
134
+ * to run through the shell, where `^` is the escape character: unquoted,
135
+ * `pkg@^0.1.5` installs exactly 0.1.5.
136
+ */
137
+ function quoteForCmd(arg) {
138
+ return /[\s^&|<>()%!"]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg;
139
+ }
140
+ function realIo() {
141
+ const windows = process.platform === "win32";
142
+ return {
143
+ cwd: process.cwd(),
144
+ log: (line) => console.log(line),
145
+ error: (line) => console.error(line),
146
+ // On Windows the command line is built here, quoted, and handed to the
147
+ // shell whole: passing an args array alongside shell: true is deprecated
148
+ // in Node 24, because Node would only concatenate it, unescaped.
149
+ run: (command, args, cwd) => (windows
150
+ ? spawnSync([command, ...args.map(quoteForCmd)].join(" "), { cwd, stdio: "inherit", shell: true })
151
+ : spawnSync(command, args, { cwd, stdio: "inherit" })).status ?? 1,
152
+ ask: async (question) => {
153
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
154
+ try {
155
+ return await readline.question(question);
156
+ }
157
+ finally {
158
+ readline.close();
159
+ }
160
+ },
161
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
162
+ platform: process.platform,
163
+ };
164
+ }
165
+ export async function main(argv, io = realIo()) {
166
+ const { command, flags, unknown } = parseArgs(argv);
167
+ if (flags.version) {
168
+ io.log(VERSION);
169
+ return 0;
170
+ }
171
+ if (flags.help) {
172
+ io.log(HELP);
173
+ return 0;
174
+ }
175
+ if (unknown.length > 0) {
176
+ io.error(`Unknown option ${unknown[0]}.\n\n${HELP}`);
177
+ return 1;
178
+ }
179
+ // `npx click-to-source-3d` on its own does the one thing it is for.
180
+ if (command !== null && command !== "init") {
181
+ io.error(`Unknown command "${command}".\n\n${HELP}`);
182
+ return 1;
183
+ }
184
+ return init(flags, io);
185
+ }
@@ -0,0 +1,32 @@
1
+ export declare const MCP_SERVER_NAME = "click-to-source";
2
+ export declare const MCP_PACKAGE = "@click-to-source-3d/mcp";
3
+ /**
4
+ * The `.mcp.json` entry that starts the MCP server.
5
+ *
6
+ * `npx -y <package>` runs the copy installed in the project when there is
7
+ * one, and fetches it otherwise, so the entry works from the repository root
8
+ * of a monorepo whose app is further down. On Windows, MCP clients spawn
9
+ * commands without a shell, and npx is a .cmd script there, so it has to go
10
+ * through `cmd /c`.
11
+ */
12
+ export declare function mcpServerEntry(platform?: NodeJS.Platform): {
13
+ command: string;
14
+ args: string[];
15
+ };
16
+ export type McpMergeResult = {
17
+ status: "added";
18
+ text: string;
19
+ } | {
20
+ status: "already";
21
+ } | {
22
+ status: "invalid";
23
+ reason: string;
24
+ };
25
+ /**
26
+ * Adds the server to an existing `.mcp.json`, or starts one.
27
+ *
28
+ * An existing entry of the same name is left as it is: the developer may
29
+ * have changed it. A file that is not valid JSON is never rewritten; the
30
+ * caller says what to add by hand instead.
31
+ */
32
+ export declare function mergeMcpConfig(existing: string | null, platform?: NodeJS.Platform): McpMergeResult;
@@ -0,0 +1,43 @@
1
+ export const MCP_SERVER_NAME = "click-to-source";
2
+ export const MCP_PACKAGE = "@click-to-source-3d/mcp";
3
+ /**
4
+ * The `.mcp.json` entry that starts the MCP server.
5
+ *
6
+ * `npx -y <package>` runs the copy installed in the project when there is
7
+ * one, and fetches it otherwise, so the entry works from the repository root
8
+ * of a monorepo whose app is further down. On Windows, MCP clients spawn
9
+ * commands without a shell, and npx is a .cmd script there, so it has to go
10
+ * through `cmd /c`.
11
+ */
12
+ export function mcpServerEntry(platform = process.platform) {
13
+ const run = ["npx", "-y", MCP_PACKAGE];
14
+ return platform === "win32"
15
+ ? { command: "cmd", args: ["/c", ...run] }
16
+ : { command: run[0], args: run.slice(1) };
17
+ }
18
+ /**
19
+ * Adds the server to an existing `.mcp.json`, or starts one.
20
+ *
21
+ * An existing entry of the same name is left as it is: the developer may
22
+ * have changed it. A file that is not valid JSON is never rewritten; the
23
+ * caller says what to add by hand instead.
24
+ */
25
+ export function mergeMcpConfig(existing, platform = process.platform) {
26
+ let config = {};
27
+ if (existing !== null && existing.trim() !== "") {
28
+ try {
29
+ config = JSON.parse(existing);
30
+ }
31
+ catch (error) {
32
+ return { status: "invalid", reason: error.message };
33
+ }
34
+ if (typeof config !== "object" || config === null || Array.isArray(config)) {
35
+ return { status: "invalid", reason: "it is not a JSON object" };
36
+ }
37
+ }
38
+ if (config.mcpServers && MCP_SERVER_NAME in config.mcpServers) {
39
+ return { status: "already" };
40
+ }
41
+ config.mcpServers = { ...(config.mcpServers ?? {}), [MCP_SERVER_NAME]: mcpServerEntry(platform) };
42
+ return { status: "added", text: `${JSON.stringify(config, null, 2)}\n` };
43
+ }
@@ -0,0 +1,31 @@
1
+ export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
2
+ export type Project = {
3
+ /** Directory holding the package.json init runs against. */
4
+ dir: string;
5
+ name: string;
6
+ packageManager: PackageManager;
7
+ /** Absolute path of the Vite config, if there is one. */
8
+ viteConfig: string | null;
9
+ hasVite: boolean;
10
+ hasR3F: boolean;
11
+ /**
12
+ * Where `.mcp.json` belongs: the repository root, since that is where an
13
+ * assistant is usually started — in a monorepo, above the app.
14
+ */
15
+ workspaceRoot: string;
16
+ };
17
+ /**
18
+ * The package manager a project uses: the nearest lockfile, looking up from
19
+ * the project to the repository root, since a monorepo keeps one lockfile at
20
+ * the top. With no lockfile — a project not installed yet — the manager that
21
+ * launched init, from the user agent it sets.
22
+ */
23
+ export declare function detectPackageManager(dir: string, userAgent?: string): PackageManager;
24
+ /** The nearest directory above `dir` holding .git, or `dir` itself. */
25
+ export declare function findWorkspaceRoot(dir: string): string;
26
+ export declare function readProject(dir: string): Project | null;
27
+ /** The command that adds packages as dev dependencies. */
28
+ export declare function installCommand(manager: PackageManager, specs: string[]): {
29
+ command: string;
30
+ args: string[];
31
+ };
@@ -0,0 +1,72 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { VITE_CONFIG_NAMES } from "./viteConfig.js";
4
+ const LOCKFILES = [
5
+ ["pnpm-lock.yaml", "pnpm"],
6
+ ["yarn.lock", "yarn"],
7
+ ["bun.lock", "bun"],
8
+ ["bun.lockb", "bun"],
9
+ ["package-lock.json", "npm"],
10
+ ];
11
+ /**
12
+ * The package manager a project uses: the nearest lockfile, looking up from
13
+ * the project to the repository root, since a monorepo keeps one lockfile at
14
+ * the top. With no lockfile — a project not installed yet — the manager that
15
+ * launched init, from the user agent it sets.
16
+ */
17
+ export function detectPackageManager(dir, userAgent = process.env.npm_config_user_agent ?? "") {
18
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
19
+ for (const [file, manager] of LOCKFILES) {
20
+ if (existsSync(path.join(current, file))) {
21
+ return manager;
22
+ }
23
+ }
24
+ if (existsSync(path.join(current, ".git")) || path.dirname(current) === current) {
25
+ break;
26
+ }
27
+ }
28
+ const launcher = userAgent.split("/")[0];
29
+ return launcher === "pnpm" || launcher === "yarn" || launcher === "bun" ? launcher : "npm";
30
+ }
31
+ /** The nearest directory above `dir` holding .git, or `dir` itself. */
32
+ export function findWorkspaceRoot(dir) {
33
+ for (let current = path.resolve(dir);; current = path.dirname(current)) {
34
+ if (existsSync(path.join(current, ".git"))) {
35
+ return current;
36
+ }
37
+ if (path.dirname(current) === current) {
38
+ return path.resolve(dir);
39
+ }
40
+ }
41
+ }
42
+ export function readProject(dir) {
43
+ const manifest = path.join(dir, "package.json");
44
+ if (!existsSync(manifest)) {
45
+ return null;
46
+ }
47
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
48
+ const deps = { ...pkg.peerDependencies, ...pkg.dependencies, ...pkg.devDependencies };
49
+ const config = VITE_CONFIG_NAMES.map((name) => path.join(dir, name)).find((file) => existsSync(file));
50
+ return {
51
+ dir,
52
+ name: pkg.name ?? path.basename(dir),
53
+ packageManager: detectPackageManager(dir),
54
+ viteConfig: config ?? null,
55
+ hasVite: "vite" in deps || config !== undefined,
56
+ hasR3F: "@react-three/fiber" in deps,
57
+ workspaceRoot: findWorkspaceRoot(dir),
58
+ };
59
+ }
60
+ /** The command that adds packages as dev dependencies. */
61
+ export function installCommand(manager, specs) {
62
+ switch (manager) {
63
+ case "pnpm":
64
+ return { command: "pnpm", args: ["add", "-D", ...specs] };
65
+ case "yarn":
66
+ return { command: "yarn", args: ["add", "-D", ...specs] };
67
+ case "bun":
68
+ return { command: "bun", args: ["add", "-d", ...specs] };
69
+ default:
70
+ return { command: "npm", args: ["install", "-D", ...specs] };
71
+ }
72
+ }
@@ -0,0 +1,26 @@
1
+ export declare const PLUGIN_PACKAGE = "@click-to-source-3d/vite-plugin";
2
+ export declare const MANUAL_SNIPPET: string;
3
+ export type PatchResult = {
4
+ status: "patched";
5
+ code: string;
6
+ } | {
7
+ status: "already";
8
+ } | {
9
+ status: "unsupported";
10
+ reason: string;
11
+ };
12
+ /**
13
+ * Adds `clickToSource()` to the front of a Vite config's plugins.
14
+ *
15
+ * Edits only what it must — one import, one call — so the developer's own
16
+ * formatting survives untouched. Finds the config in the shapes Vite's own
17
+ * templates and docs use: `export default defineConfig({...})`, a plain
18
+ * object, a function returning one (arrow or with a body), and a config held
19
+ * in a variable and exported. Anything else is reported as unsupported
20
+ * rather than guessed at, and the caller prints the lines to paste.
21
+ *
22
+ * The result is checked, not assumed: a library that reported success here
23
+ * while adding only the import is why this exists.
24
+ */
25
+ export declare function patchViteConfig(code: string): PatchResult;
26
+ export declare const VITE_CONFIG_NAMES: string[];
@@ -0,0 +1,145 @@
1
+ import { parse } from "@babel/parser";
2
+ import MagicString from "magic-string";
3
+ export const PLUGIN_PACKAGE = "@click-to-source-3d/vite-plugin";
4
+ export const MANUAL_SNIPPET = [
5
+ `import { clickToSource } from "${PLUGIN_PACKAGE}";`,
6
+ "",
7
+ "// first in the plugins array:",
8
+ "plugins: [clickToSource(), react()],",
9
+ ].join("\n");
10
+ function keyName(property) {
11
+ const key = property.key;
12
+ if (!key || property.computed) {
13
+ return null;
14
+ }
15
+ if (key.type === "Identifier") {
16
+ return key.name;
17
+ }
18
+ if (key.type === "StringLiteral") {
19
+ return key.value;
20
+ }
21
+ return null;
22
+ }
23
+ /** The object a call such as defineConfig(...) is configured with, wherever it is written. */
24
+ function configObject(node) {
25
+ if (!node) {
26
+ return null;
27
+ }
28
+ switch (node.type) {
29
+ case "ObjectExpression":
30
+ return node;
31
+ case "CallExpression":
32
+ // defineConfig({...}) and defineConfig(() => ...)
33
+ return configObject(node.arguments[0]);
34
+ case "ArrowFunctionExpression":
35
+ case "FunctionExpression": {
36
+ const body = node.body;
37
+ if (body.type !== "BlockStatement") {
38
+ return configObject(body);
39
+ }
40
+ const ret = body.body.find((statement) => statement.type === "ReturnStatement");
41
+ return configObject(ret?.argument);
42
+ }
43
+ case "TSAsExpression":
44
+ case "TSSatisfiesExpression":
45
+ case "ParenthesizedExpression":
46
+ return configObject(node.expression);
47
+ default:
48
+ return null;
49
+ }
50
+ }
51
+ /**
52
+ * Adds `clickToSource()` to the front of a Vite config's plugins.
53
+ *
54
+ * Edits only what it must — one import, one call — so the developer's own
55
+ * formatting survives untouched. Finds the config in the shapes Vite's own
56
+ * templates and docs use: `export default defineConfig({...})`, a plain
57
+ * object, a function returning one (arrow or with a body), and a config held
58
+ * in a variable and exported. Anything else is reported as unsupported
59
+ * rather than guessed at, and the caller prints the lines to paste.
60
+ *
61
+ * The result is checked, not assumed: a library that reported success here
62
+ * while adding only the import is why this exists.
63
+ */
64
+ export function patchViteConfig(code) {
65
+ if (code.includes(PLUGIN_PACKAGE)) {
66
+ return { status: "already" };
67
+ }
68
+ let ast;
69
+ try {
70
+ ast = parse(code, {
71
+ sourceType: "module",
72
+ plugins: ["typescript", "jsx"],
73
+ }).program;
74
+ }
75
+ catch (error) {
76
+ return { status: "unsupported", reason: `could not parse it (${error.message})` };
77
+ }
78
+ const body = ast.body;
79
+ const exported = body.find((statement) => statement.type === "ExportDefaultDeclaration");
80
+ if (!exported) {
81
+ return { status: "unsupported", reason: "it has no `export default`" };
82
+ }
83
+ let declaration = exported.declaration;
84
+ // `const config = defineConfig({...}); export default config`
85
+ if (declaration.type === "Identifier") {
86
+ for (const statement of body) {
87
+ if (statement.type !== "VariableDeclaration") {
88
+ continue;
89
+ }
90
+ for (const declarator of statement.declarations) {
91
+ const id = declarator.id;
92
+ if (id.type === "Identifier" && id.name === declaration.name && declarator.init) {
93
+ declaration = declarator.init;
94
+ }
95
+ }
96
+ }
97
+ }
98
+ const config = configObject(declaration);
99
+ if (!config) {
100
+ return { status: "unsupported", reason: "its exported config is not an object Vite's templates write" };
101
+ }
102
+ const magic = new MagicString(code);
103
+ const properties = config.properties;
104
+ const plugins = properties.find((property) => property.type === "ObjectProperty" && keyName(property) === "plugins");
105
+ if (plugins) {
106
+ const value = plugins.value;
107
+ if (value.type !== "ArrayExpression") {
108
+ return { status: "unsupported", reason: "its `plugins` is not an array literal" };
109
+ }
110
+ const elements = value.elements;
111
+ magic.appendLeft(value.start + 1, elements.length > 0 ? "clickToSource(), " : "clickToSource()");
112
+ }
113
+ else {
114
+ magic.appendLeft(config.start + 1, "\n plugins: [clickToSource()],");
115
+ }
116
+ // The import goes after the last one already there, or first.
117
+ const imports = body.filter((statement) => statement.type === "ImportDeclaration");
118
+ const statement = `import { clickToSource } from "${PLUGIN_PACKAGE}";`;
119
+ if (imports.length > 0) {
120
+ magic.appendLeft(imports[imports.length - 1].end, `\n${statement}`);
121
+ }
122
+ else {
123
+ magic.prepend(`${statement}\n`);
124
+ }
125
+ const patched = magic.toString();
126
+ // The guarantee: the plugin is called inside the config's plugins.
127
+ try {
128
+ parse(patched, { sourceType: "module", plugins: ["typescript", "jsx"] });
129
+ }
130
+ catch {
131
+ return { status: "unsupported", reason: "the edit did not produce valid code" };
132
+ }
133
+ if (!/plugins\s*:\s*\[\s*clickToSource\(\)/.test(patched)) {
134
+ return { status: "unsupported", reason: "the plugin could not be placed in `plugins`" };
135
+ }
136
+ return { status: "patched", code: patched };
137
+ }
138
+ export const VITE_CONFIG_NAMES = [
139
+ "vite.config.ts",
140
+ "vite.config.mts",
141
+ "vite.config.js",
142
+ "vite.config.mjs",
143
+ "vite.config.cts",
144
+ "vite.config.cjs",
145
+ ];
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "click-to-source-3d",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.5",
4
+ "description": "Set up Click-to-Source 3D in a Vite + React Three Fiber project with one command",
5
+ "keywords": [
6
+ "threejs",
7
+ "react-three-fiber",
8
+ "r3f",
9
+ "vite",
10
+ "devtools",
11
+ "inspector"
12
+ ],
13
+ "license": "MIT",
14
+ "type": "module",
15
+ "bin": {
16
+ "click-to-source-3d": "dist/bin.js"
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/pun1th01/click-to-source-3d.git",
27
+ "directory": "packages/cli"
28
+ },
29
+ "engines": {
30
+ "node": ">=20.19.0"
31
+ },
32
+ "scripts": {
33
+ "clean": "node -e \"const f=require('fs');f.rmSync('dist',{recursive:true,force:true});f.rmSync('tsconfig.tsbuildinfo',{force:true})\"",
34
+ "build": "tsc -b",
35
+ "prepack": "npm run clean && tsc -b",
36
+ "prepublishOnly": "npm run test",
37
+ "pretest": "tsc -b",
38
+ "test": "vitest run"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.2.0",
42
+ "vitest": "^4.1.11"
43
+ },
44
+ "dependencies": {
45
+ "@babel/parser": "^7.29.9",
46
+ "magic-string": "^0.30.21"
47
+ }
48
+ }