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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12.x, 14.x, 15.x]
node: [18.x, 20.x, 22.x]
steps:
- name: Checkout code
uses: actions/checkout@v2
Expand Down
29 changes: 29 additions & 0 deletions packages/create/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pnpx @marko/create

## Options

- `--name`: The name of the new app (also accepted as the first positional argument).
- `--dir`: Provide a different directory to setup the project in (default to `pwd`).
- `--template`: The name of an example from [marko-js/examples](https://github.com/marko-js/examples/tree/master/examples).
- An example name
Expand All @@ -64,6 +65,34 @@ pnpx @marko/create
- ```bash
marko-create --installer pnpm
```
- `--yes` (`-y`): Skip the interactive prompts and accept the defaults.

## Non-interactive usage (CI & AI agents)

The interactive prompts require a human at a terminal. People running the
command in a normal terminal get the prompts exactly as before. When there is no
human to answer them, `@marko/create` skips the prompts and uses defaults
instead of hanging. Defaults are used when any of the following is true:

- `--yes` (`-y`) is passed.
- A known AI coding agent is detected (e.g. `CLAUDECODE`, `CURSOR_TRACE_ID`, or a
generic `AGENT`/`AI_AGENT` environment variable). This applies even when the
agent attaches a pseudo-TTY, so the command never hangs waiting for input.
- The `CI` environment variable is set.

In those cases the project name defaults to `my-app` (pass `--name`/the
positional argument to override) and the template defaults to the starter app
(pass `--template` to override).

If `stdin` is not a TTY but none of the above apply (for example, piped input),
the command exits with a clear error asking you to pass `--name`/`--template` or
`--yes`, rather than silently guessing. The command also exits with a non-zero
status code if project creation fails, so it can be used reliably in scripts.

```bash
# Fully non-interactive; safe to run in CI or from an agent.
npx @marko/create my-app --template basic --yes
```

# API

Expand Down
153 changes: 101 additions & 52 deletions packages/create/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ exports.parse = function parse(argv) {
description:
"Override the package manager used to install dependencies. By default will determine from create command and fallback to npm."
},
"--yes -y": {
type: "boolean",
description:
"Skip interactive prompts and accept defaults. Automatically enabled in non-interactive environments (CI, AI agents, piped input)."
},
"--version -v": {
type: "boolean",
descrption: `print ${details.name} version`
Expand Down Expand Up @@ -81,70 +86,111 @@ exports.parse = function parse(argv) {
return options;
};

// Environment variables set by common AI coding agents/assistants. When one is
// present we treat the session as agent-driven even if a (pseudo) TTY is
// attached, since there is no human available to answer a prompt. The list is
// intentionally small and can grow; agents can also just pass `--yes`.
function isAgent() {
return Boolean(
process.env.CLAUDECODE ||
process.env.CLAUDE_CODE ||
process.env.CURSOR_TRACE_ID ||
process.env.AI_AGENT ||
process.env.AGENT
);
}

exports.run = async function run(options = {}) {
const hasTTY = Boolean(process.stdin.isTTY);
// Accept defaults without prompting for agents, CI, and explicit `--yes`.
const useDefaults = Boolean(options.yes) || isAgent() || Boolean(process.env.CI);
// Only render enquirer prompts when a human can actually answer them. This
// keeps the interactive experience unchanged for people in a real terminal.
const canPrompt = hasTTY && !useDefaults;
const spinner = ora("Starting...").start();

try {
if (!options.name || !options.template) {
spinner.stop();
const examples = !options.template && getExamples();
const trimHints = choices =>
choices.map(choice => {
const size = 4 + choice.name.length + 1 + choice.hint.length;
if (size > process.stdout.columns) {
return {
...choice,
hint:
choice.hint.slice(0, -1 + process.stdout.columns - size) + "…"
};
}
return choice;
});

if (!options.name) {
const nameInput = new Input({
name: "name",
message: "Type your project name",
initial: "my-app"
});
options.name = await nameInput.run();
}

if (!options.template) {
const templateSelect = new Select({
name: "template",
message: "Choose a template",
hint: "Use ↑ and ↓. Return ⏎ to submit.",
choices: [
{
name: "Default starter app"
},
{
name: "Example from marko-js/examples"
if (useDefaults) {
if (!options.name) {
options.name = "my-app";
spinner.info(
chalk.yellow(
`No project name provided; using "${options.name}". Pass --name to override.`
)
);
}
// Leaving `options.template` undefined causes createProject to use the
// default starter example, which is the right non-interactive default.
spinner.start();
} else if (!canPrompt) {
// No TTY and no signal to auto-accept defaults: don't hang and don't
// silently guess. Fail with actionable guidance instead.
throw new Error(
"An interactive terminal is required to choose a project name and template.\n" +
"Pass --name and --template, or --yes to accept the defaults, when running non-interactively."
);
} else {
spinner.stop();
const examples = !options.template && getExamples();
const trimHints = choices =>
choices.map(choice => {
const size = 4 + choice.name.length + 1 + choice.hint.length;
if (size > process.stdout.columns) {
return {
...choice,
hint:
choice.hint.slice(0, -1 + process.stdout.columns - size) + "…"
};
}
]
});
return choice;
});

if ("Default starter app" !== (await templateSelect.run())) {
const choices = await examples;
const exampleSelect = new Select({
if (!options.name) {
const nameInput = new Input({
name: "name",
message: "Type your project name",
initial: "my-app"
});
options.name = await nameInput.run();
}

if (!options.template) {
const templateSelect = new Select({
name: "template",
message: "Choose an example",
choices: trimHints(choices)
message: "Choose a template",
hint: "Use ↑ and ↓. Return ⏎ to submit.",
choices: [
{
name: "Default starter app"
},
{
name: "Example from marko-js/examples"
}
]
});
const resizeListener = async () => {
const trimmed = trimHints(choices);
exampleSelect.choices.forEach((choice, i) => {
choice.hint = trimmed[i].hint;

if ("Default starter app" !== (await templateSelect.run())) {
const choices = await examples;
const exampleSelect = new Select({
name: "template",
message: "Choose an example",
choices: trimHints(choices)
});
exampleSelect.render();
};
process.stdout.on("resize", resizeListener);
options.template = await exampleSelect.run();
process.stdout.off("resize", resizeListener);
const resizeListener = async () => {
const trimmed = trimHints(choices);
exampleSelect.choices.forEach((choice, i) => {
choice.hint = trimmed[i].hint;
});
exampleSelect.render();
};
process.stdout.on("resize", resizeListener);
options.template = await exampleSelect.run();
process.stdout.off("resize", resizeListener);
}
}
spinner.start();
}
spinner.start();
}

const result = createProject(options);
Expand All @@ -168,6 +214,9 @@ exports.run = async function run(options = {}) {
} catch (err) {
spinner.fail(err.message + "\n");
console.error(err);
// Surface the failure to callers (CI, AI agents, scripts) via the exit code
// instead of exiting 0 on error.
process.exitCode = 1;
} finally {
clearTimeout(spinner.timeout);
}
Expand Down
15 changes: 15 additions & 0 deletions packages/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ npx @marko/migrate ./components/my-component.marko
- `--dry-run`: Runs the migration in memory only.
- `--safe`: Run all safe migrations ignoring any prompts.

## Non-interactive usage (CI & AI agents)

Some migrations ask for a decision via an interactive prompt, which requires a
human at a terminal. People running in a normal terminal get the prompts as
before. When there is no human to answer them — no TTY on `stdin`, the `CI`
environment variable, or a detected AI coding agent (e.g. `CLAUDECODE`,
`CURSOR_TRACE_ID`, or a generic `AGENT`/`AI_AGENT` variable) — the command
applies the automatic migrations and, rather than hanging when a prompt is
needed, exits with a clear error telling you to re-run with `--safe`. Use
`--safe` to apply only the automatic migrations with no prompts:

```bash
npx @marko/migrate ./src --safe
```

# API

## Installation
Expand Down
31 changes: 30 additions & 1 deletion packages/migrate/src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,36 @@ export function parse(argv) {
return options;
}

// Environment variables set by common AI coding agents/assistants. When one is
// present we treat the session as agent-driven even if a (pseudo) TTY is
// attached, since there is no human available to answer a prompt.
function isAgent() {
return Boolean(
process.env.CLAUDECODE ||
process.env.CLAUDE_CODE ||
process.env.CURSOR_TRACE_ID ||
process.env.AI_AGENT ||
process.env.AGENT
);
}

// Migrations that require a decision use enquirer prompts, which need an
// interactive terminal. When there is no human to answer (no stdin TTY, CI, or
// an AI agent), fail with clear guidance the first time a prompt is needed
// instead of hanging forever. Automatic migrations still run; only interactive
// ones require `--safe` (to skip them) or a real terminal.
const nonInteractivePrompt = () => {
throw new Error(
"This migration needs an interactive prompt, but no interactive terminal is available.\n" +
"Re-run with `--safe` to apply only the automatic migrations, or run in an interactive terminal."
);
};

export async function run(options) {
// Computed at call time (not import time) so it reflects the current stdin/CI
// state, mirroring packages/create/src/cli.js.
const interactive =
Boolean(process.stdin.isTTY) && !process.env.CI && !isAgent();
await markoMigrate({
syntax: "html",
maxLen: 80,
Expand All @@ -91,7 +120,7 @@ export async function run(options) {
ignore: ["/node_modules", ".*"],
dir: process.cwd(),
...options,
prompt,
prompt: interactive || options.safe ? prompt : nonInteractivePrompt,
onWriteFile(file, source) {
return fs.writeFile(file, source, "utf-8");
},
Expand Down
Loading