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 +21 -0
- package/README.md +46 -2
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +8 -0
- package/dist/cli.d.ts +34 -0
- package/dist/cli.js +185 -0
- package/dist/mcpConfig.d.ts +32 -0
- package/dist/mcpConfig.js +43 -0
- package/dist/project.d.ts +31 -0
- package/dist/project.js +72 -0
- package/dist/viteConfig.d.ts +26 -0
- package/dist/viteConfig.js +145 -0
- package/package.json +46 -4
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
|
-
#
|
|
1
|
+
# click-to-source-3d
|
|
2
2
|
|
|
3
|
-
|
|
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
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
|
+
};
|
package/dist/project.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|