Skip to content
Open
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
12 changes: 12 additions & 0 deletions .claude/settings.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(npx vitest *)",
"Bash(npm test *)",
"Bash(git add:*)",
"Bash(git commit -m *)",
"Bash(git push:*)"
]
}
}
12 changes: 0 additions & 12 deletions .claude/settings.local.json

This file was deleted.

3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ pids
*.swp
*.swo

# Claude Code local settings (machine-specific permissions)
.claude/settings.local.json

# Nx cache
.nx/

Expand Down
27 changes: 27 additions & 0 deletions src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ program
.option("--verbose", "Enable verbose output for debugging")
.option("--info", "Show information about this CLI")
.option("--skip-checks", "Skip prerequisite environment checks")
.option(
"--cursor-scope <scope>",
"Where to install Cursor config: project or global (default: prompt)",
validateCursorScope,
)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you also add a --cursor-scope example to the Examples section of the help footer (the boxen block starting around line 66)? The flag is listed in Options but not demonstrated, which makes it easy to miss. Something like:

# Install Cursor config globally (for all projects on this machine)
npx @julianoczkowski/create-trimble-app@latest my-app -f react --cursor-scope global

.addHelpText("after", () => {
const helpContent = [
colors.brandBold("Quick Start"),
Expand Down Expand Up @@ -79,6 +84,9 @@ program
` ${colors.dim("#")} Skip prerequisite checks`,
" npx @julianoczkowski/create-trimble-app@latest my-app --skip-checks",
"",
` ${colors.dim("#")} Install Cursor config globally (for all projects on this machine)`,
" npx @julianoczkowski/create-trimble-app@latest my-app -f react --cursor-scope global",
"",
colors.brandBold("Frameworks"),
` ${colors.brand("react")} React + Vite + Modus 2.0 Components`,
` ${colors.brand("angular")} Angular + Modus 2.0 Web Components`,
Expand Down Expand Up @@ -118,6 +126,7 @@ program
verbose: options.verbose,
showInfo: options.info,
skipChecks: options.skipChecks,
installScope: options.cursorScope,
});
} catch (error) {
console.error(colors.error("Error:"), error.message);
Expand Down Expand Up @@ -146,6 +155,24 @@ function validateFramework(value) {
return lowercaseValue;
}

/**
* Validate cursor-scope option
* @param {string} value - Scope name
* @returns {string} - Validated scope name
*/
function validateCursorScope(value) {
const validScopes = ["project", "global"];
const lowercaseValue = value.toLowerCase();

if (!validScopes.includes(lowercaseValue)) {
throw new Error(
`Invalid cursor scope "${value}". Valid options: ${validScopes.join(", ")}`,
);
}

return lowercaseValue;
}

// Export for testing
export async function cli(args = process.argv) {
await program.parseAsync(args);
Expand Down
56 changes: 46 additions & 10 deletions src/scaffold.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,41 @@ export async function scaffold(options = {}) {
process.exit(1);
}

// 2. Installation Location Choice
// 2. Cursor Config Scope
let installScope = options.installScope;

if (!installScope) {
installScope = await p.select({
message: "Where should Cursor config (MCP, Rules, Skills) be installed?",
options: [
{
label: "Project (.cursor/ in project folder)",
value: "project",
hint: "Only active for this project",
},
{
label: "Global (~/.cursor/)",
value: "global",
hint: "Installs global MCP config (~/.cursor/mcp.json)",
},
],
});

if (p.isCancel(installScope)) {
p.cancel("Operation cancelled");
process.exit(0);
}
}

if (installScope === "global") {
p.log.warn(
"Global scope installs mcp.json and skills/ to ~/.cursor/.\n" +
" If you scaffold multiple frameworks globally, all their rule sets\n" +
" will be active on every project on this machine."
);
}

// 3. Installation Location Choice
let installInCurrentFolder = options.currentFolder;
let projectName = options.projectName;

Expand Down Expand Up @@ -103,7 +137,7 @@ export async function scaffold(options = {}) {
installInCurrentFolder = location === "current";
}

// 3. Project Name
// 4. Project Name
if (installInCurrentFolder) {
projectName = getCurrentFolderName();
p.log.info(`Using current folder: ${colors.brand(projectName)}`);
Expand Down Expand Up @@ -141,19 +175,22 @@ export async function scaffold(options = {}) {
p.log.message(
`Location: ${installInCurrentFolder ? "Current directory" : projectName}`,
);
p.log.message(
`Cursor config: ${installScope === "global" ? "~/.cursor/mcp.json (global)" : ".cursor/ (project)"}`,
);
p.outro("Preview complete");
process.exit(0);
}

// 4. Copy Template with spinner
// 5. Copy Template with spinner
const copySpinner = p.spinner();
copySpinner.start(`Creating ${config.name} project`);

try {
await copyTemplate(framework, projectPath);
await copyTemplate(framework, projectPath, { cursorScope: installScope });
copySpinner.stop(`${colors.success("\u2713")} Project created`);

// 5. Update package.json with project name
// 6. Update package.json with project name
try {
await updatePackageJson(projectPath, {
name: projectName,
Expand All @@ -170,7 +207,7 @@ export async function scaffold(options = {}) {
process.exit(1);
}

// 6. Install Dependencies (optional)
// 7. Install Dependencies
let install = options.install;

if (install === undefined) {
Expand Down Expand Up @@ -198,7 +235,7 @@ export async function scaffold(options = {}) {
}
}

// 7. Post-scaffold validation (only if deps were installed)
// 8. Post-scaffold validation
if (install) {
const validateSpinner = p.spinner();
validateSpinner.start("Validating template integrity");
Expand All @@ -221,9 +258,8 @@ export async function scaffold(options = {}) {
}
}

// 8. Success outro
// 9. Success outro
p.outro(colors.success("Done! Configuration updated."));

// 8. Detailed next steps
logger.nextSteps(projectName, config.name, install, installInCurrentFolder);
logger.nextSteps(projectName, config.name, install, installInCurrentFolder, installScope);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate // 9. comment (also on line 253). Cosmetic.

}
101 changes: 95 additions & 6 deletions src/utils/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,19 @@ const SKIP_FILES = new Set([
]);

/**
* Copy bundled template to target directory
* Copy bundled template to target directory.
* When cursorScope is "global", only mcp.json and skills/ from .cursor/ are
* written to ~/.cursor/ (or globalCursorPath) instead of the project directory.
* @param {string} templateName - Name of the template (react, angular, solidjs)
* @param {string} targetPath - Target directory path
* @param {{ cursorScope?: "project" | "global", globalCursorPath?: string }} options
* @returns {Promise<boolean>}
*/
export async function copyTemplate(templateName, targetPath) {
export async function copyTemplate(
templateName,
targetPath,
{ cursorScope = "project", globalCursorPath } = {}
) {
const fs = await import("fs/promises");
const path = await import("path");

Expand All @@ -50,16 +57,98 @@ export async function copyTemplate(templateName, targetPath) {
);
}

await copyDirectory(bundledPath, targetPath);
if (cursorScope === "global") {
// Global scope: template's .cursor/ is redirected to ~/.cursor/ below,
// so we don't also want it inside the project folder.
const projectSkipDirs = new Set([...SKIP_DIRECTORIES, ".cursor"]);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor – consider also skipping .cursor in the recursive pass if any template ever nests it (defensive; current templates don't). More importantly, a doc comment here clarifying why .cursor is excluded would help future readers β€” something like:

// Global scope: template's .cursor/ is redirected to ~/.cursor/ below,
// so we don't also want it inside the project folder.
const projectSkipDirs = new Set([...SKIP_DIRECTORIES, ".cursor"]);

await copyDirectory(bundledPath, targetPath, projectSkipDirs);

// Copy only mcp.json and skills/ to the global cursor directory
const { homedir } = await import("os");
const cursorSrc = path.join(bundledPath, ".cursor");
const target = globalCursorPath ?? path.join(homedir(), ".cursor");
await copyGlobalCursor(cursorSrc, target);
} else {
await copyDirectory(bundledPath, targetPath);
}

return true;
}

/**
* Copy only mcp.json (with backup) and skills/ (skip-if-exists) to global cursor dir.
* @param {string} src - Source .cursor/ directory from template
* @param {string} dest - Destination ~/.cursor/ directory
*/
async function copyGlobalCursor(src, dest) {
const fs = await import("fs/promises");
const path = await import("path");

await fs.mkdir(dest, { recursive: true });

// Copy mcp.json, backing up any existing file first
const srcMcp = path.join(src, "mcp.json");
const destMcp = path.join(dest, "mcp.json");
try {
await fs.access(srcMcp);
try {
await fs.access(destMcp);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
await fs.copyFile(destMcp, path.join(dest, `mcp.json.bak-${ts}`));
} catch {
// No existing mcp.json to back up
}
await fs.copyFile(srcMcp, destMcp);
} catch {
// No mcp.json in template
}

// Copy skills/ with skip-if-exists per file
const srcSkills = path.join(src, "skills");
const destSkills = path.join(dest, "skills");
try {
await fs.access(srcSkills);
await copyDirectorySkipExisting(srcSkills, destSkills);
} catch {
// No skills/ in template
}
}

/**
* Copy directory recursively, skipping files that already exist at the destination.
* @param {string} src - Source directory
* @param {string} dest - Destination directory
*/
async function copyDirectorySkipExisting(src, dest) {
const fs = await import("fs/promises");
const path = await import("path");

await fs.mkdir(dest, { recursive: true });
const entries = await fs.readdir(src, { withFileTypes: true });

for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectorySkipExisting(srcPath, destPath);
} else {
try {
await fs.access(destPath);
// File already exists β€” skip
} catch {
await fs.copyFile(srcPath, destPath);
}
}
}
}

/**
* Copy directory recursively
* @param {string} src - Source directory
* @param {string} dest - Destination directory
* @param {Set<string>} skipDirs - Directory names to skip (defaults to SKIP_DIRECTORIES)
*/
async function copyDirectory(src, dest) {
async function copyDirectory(src, dest, skipDirs = SKIP_DIRECTORIES) {
const fs = await import("fs/promises");
const path = await import("path");

Expand All @@ -69,7 +158,7 @@ async function copyDirectory(src, dest) {

for (const entry of entries) {
// Skip excluded directories (node_modules, dist, .angular, etc.)
if (entry.isDirectory() && SKIP_DIRECTORIES.has(entry.name)) {
if (entry.isDirectory() && skipDirs.has(entry.name)) {
continue;
}

Expand All @@ -86,7 +175,7 @@ async function copyDirectory(src, dest) {
const destPath = path.join(dest, destName);

if (entry.isDirectory()) {
await copyDirectory(srcPath, destPath);
await copyDirectory(srcPath, destPath, skipDirs);
} else {
await fs.copyFile(srcPath, destPath);
}
Expand Down
9 changes: 8 additions & 1 deletion src/utils/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const logger = {
framework,
shouldInstall,
isCurrentFolder = false,
installScope = "project",
) => {
// Match the width of the header box (logo width = 55)
const boxWidth = 55;
Expand All @@ -138,6 +139,11 @@ export const logger = {
? "npm run start"
: "npm run dev";

const cursorLocation =
installScope === "global"
? "~/.cursor/ (global β€” active for all projects)"
: ".cursor/ (project-level)";

// Build numbered steps with beginner-friendly descriptions
const steps = [];
let stepNum = 1;
Expand Down Expand Up @@ -174,7 +180,8 @@ export const logger = {
const content = [
colors.success("\u2713") + " " + colors.bold("Success!") + " Your app is ready.",
"",
colors.dim("Location: ") + colors.brand(projectPath),
colors.dim("Location: ") + colors.brand(projectPath),
colors.dim("Cursor config: ") + colors.brand(cursorLocation),
"",
colors.dim("Next steps:"),
...steps.map((s) => " " + s),
Expand Down
Loading