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
9 changes: 9 additions & 0 deletions .changeset/create-dot-in-place.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@agent-native/core": patch
---

`create .` now scaffolds into the current directory and takes the project name
from the folder's basename, matching `create-react-app` / `npm init`. Previously
`.` was rejected as an invalid name. The current directory must be empty apart
from benign VCS/editor files (`.git`, `.gitignore`, `LICENSE`, `README.md`, …)
so an existing project is never merged over.
35 changes: 35 additions & 0 deletions packages/core/src/cli/create.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,4 +236,39 @@ describe("createApp", { timeout: 30000 }, () => {
process.exit = origExit;
expect(exited).toBe(true);
});

it("scaffolds into the current directory for `create .`", async () => {
const dir = path.join(tmpDir, "my-inplace-app");
fs.mkdirSync(dir);
process.chdir(dir);
await createApp(".", { template: "blank" });
// No subfolder — files land directly in the current directory.
expect(fs.existsSync(path.join(dir, "my-inplace-app"))).toBe(false);
const pkg = JSON.parse(
fs.readFileSync(path.join(dir, "package.json"), "utf-8"),
);
expect(pkg.name).toBe("my-inplace-app");
});

it("refuses `create .` when the current directory is not empty", async () => {
const dir = path.join(tmpDir, "occupied-app");
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, "existing.txt"), "keep me");
process.chdir(dir);
let exited = false;
const origExit = process.exit.bind(process);
// @ts-ignore
process.exit = () => {
exited = true;
throw new Error("process.exit called");
};
try {
await createApp(".", { template: "blank" });
} catch {
// expected
}
process.exit = origExit;
expect(exited).toBe(true);
expect(fs.existsSync(path.join(dir, "package.json"))).toBe(false);
});
});
69 changes: 59 additions & 10 deletions packages/core/src/cli/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ const FIRST_PARTY_TARBALL_SYMLINK_EXCLUDES = [
"*/.claude/skills",
];
const localPackageTarballs = new Map<string, string>();
/** VCS/editor files that don't count as "not empty" for an in-place scaffold. */
const IN_PLACE_ALLOWLIST = new Set([
".git",
".gitignore",
".gitattributes",
".DS_Store",
".idea",
".vscode",
"LICENSE",
"README.md",
"Thumbs.db",
]);

/**
* Tagged error for input that fails CLI-level validation (repo names, app
Expand Down Expand Up @@ -100,6 +112,11 @@ export interface CreateAppOptions {
* unconditional workspace scaffold.
*/
forceWorkspace?: boolean;
/**
* Internal: scaffold into the current directory instead of a new subfolder.
* Set when the name argument is `.`/`./` (see `createApp`).
*/
inPlace?: boolean;
}

/**
Expand All @@ -117,6 +134,13 @@ export async function createApp(
): Promise<void> {
const clack = await import("@clack/prompts");

// `create .` (or `./`) means "scaffold into the current folder" — derive the
// project name from the folder's basename, like create-react-app / npm init.
if (name === "." || name === "./") {
name = path.basename(process.cwd());
opts = { ...opts, inPlace: true };
}

// Reject an invalid provided name before any interactive prompt so bad input
// fails fast instead of blocking on the start-shape picker below.
if (name !== undefined) {
Expand Down Expand Up @@ -250,6 +274,39 @@ function assertValidProjectName(
process.exit(1);
}
}
/**
* Resolve where a scaffold writes and guard the target. A named project writes
* to a new sibling subfolder that must not already exist; `create .` writes
* into the current directory, which must be empty apart from benign VCS/editor
* files so we never merge over existing work (copyDir merges silently).
*/
function resolveScaffoldTarget(
name: string,
inPlace: boolean | undefined,
clack: typeof import("@clack/prompts"),
): string {
if (inPlace) {
const targetDir = process.cwd();
const conflicting = fs
.readdirSync(targetDir)
.filter((entry) => !IN_PLACE_ALLOWLIST.has(entry));
if (conflicting.length > 0) {
const shown = conflicting.slice(0, 3).join(", ");
const more = conflicting.length > 3 ? ", …" : "";
clack.cancel(
`Current directory is not empty (${shown}${more}). Scaffold into an empty folder, or run \`create <name>\` to make a new one.`,
);
process.exit(1);
}
return targetDir;
}
const targetDir = path.resolve(process.cwd(), name);
if (fs.existsSync(targetDir)) {
clack.cancel(`Directory "${name}" already exists.`);
process.exit(1);
}
return targetDir;
}

/* ─────────────────────────────────────────────────────────────────────────
* Workspace creation (new default)
Expand Down Expand Up @@ -293,11 +350,7 @@ async function createWorkspaceInteractive(
});
const templates = ["dispatch", ...optionalPicks];

const targetDir = path.resolve(process.cwd(), name);
if (fs.existsSync(targetDir)) {
clack.cancel(`Directory "${name}" already exists.`);
process.exit(1);
}
const targetDir = resolveScaffoldTarget(name, opts?.inPlace, clack);

const s = clack.spinner();
s.start(`Scaffolding workspace "${name}"...`);
Expand Down Expand Up @@ -697,11 +750,7 @@ async function createStandaloneApp(

name = await promptNameIfMissing(name, clack, "app", "my-app");

const targetDir = path.resolve(process.cwd(), name);
if (fs.existsSync(targetDir)) {
clack.cancel(`Directory "${name}" already exists.`);
process.exit(1);
}
const targetDir = resolveScaffoldTarget(name, opts?.inPlace, clack);

// Standalone is single-select — pick one template.
let template =
Expand Down
Loading