Skip to content

feat(cli): agent-mode support via once() and batch()#1287

Draft
posva wants to merge 1 commit intonuxt:mainfrom
posva:feat/clack-agent-mode
Draft

feat(cli): agent-mode support via once() and batch()#1287
posva wants to merge 1 commit intonuxt:mainfrom
posva:feat/clack-agent-mode

Conversation

@posva
Copy link
Copy Markdown
Member

@posva posva commented Apr 21, 2026

Summary

Alias @clack/prompts to @posva/clack-prompts so that interactive commands keep working under AI-agent harnesses (Claude Code, Cursor, Aider) that re-run the script to answer each prompt through a JSON session file.

  • Wrap non-idempotent side effects with once(id, fn) so they run exactly once across replays:
    • Template download + slug rename, nightly-version setup, install + git init, nuxt module add install + nuxt.config update.
    • The target-dir existsSync check — otherwise the second replay would see the folder the first run just created and diverge into the "directory already exists" branch.
  • Collapse independent prompt pairs into batch({...}) so agents answer them in one round-trip:
    • init — package-manager select + git-init confirm.
    • upgrade — nightly-channel select + dedupe/force/skip method select (only when both would fire).
  • Stable ids on every prompt so clack can resolve answers deterministically across replays. Both batched and single-prompt fallbacks share the same ids so the same question maps to the same session entry regardless of which branch runs.
  • selectModulesAutocomplete gains an optional id so the two call sites (init, module add) disambiguate, and no longer bails on !hasTTY when running under isAgent() — agents don't need a TTY.

Human UX is unchanged: once runs transparently in interactive mode, and batch displays its items sequentially just like group.

Test plan

  • pnpm test:types — clean
  • pnpm lint — clean
  • pnpm build — all three buildable packages compile
  • Interactive smoke: nuxi init, nuxi upgrade, nuxi module add each walk through prompts identically to today
  • Agent smoke: run each command under an agent harness, verify that wrapped once steps execute once and batched prompts emit together

@posva posva requested a review from danielroe as a code owner April 21, 2026 15:32
@danielroe
Copy link
Copy Markdown
Member

did you see #1264 and do you have an opinion?

I imagine this is a test PR to evaluate how it works, before opening a PR/merging into clack/prompts?

Alias @clack/prompts to @posva/clack-prompts so interactive commands
work under AI-agent harnesses (Claude Code, Cursor, Aider) that replay
the script to answer each prompt via a JSON session file.

- Wrap non-idempotent side effects with once(id, fn) so they run a
  single time across replays: template download + slug rename, nightly
  version setup, install + git init, module install + nuxt.config
  update, and the target-dir existence check (otherwise the second
  replay would see the folder created by the first run).
- Collapse independent prompt pairs into batch({...}) so agents answer
  them in one round-trip: package-manager + git-init in init,
  nightly-channel + upgrade-method in upgrade.
- Give every prompt a stable id across batch and non-batch branches;
  thread an optional id through selectModulesAutocomplete so its two
  call sites disambiguate.
- Drop the !hasTTY early-exit in selectModulesAutocomplete when
  running under isAgent() - agents handle the question via the
  session file without a TTY.
@posva posva force-pushed the feat/clack-agent-mode branch from 38466d5 to ae143a3 Compare April 21, 2026 15:34
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 21, 2026

  • nuxt-cli-playground

    npm i https://pkg.pr.new/create-nuxt@1287
    
    npm i https://pkg.pr.new/nuxi@1287
    
    npm i https://pkg.pr.new/@nuxt/cli@1287
    

commit: ae143a3

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Apr 21, 2026

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing posva:feat/clack-agent-mode (ae143a3) with main (c74dde6)

Open in CodSpeed

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Warning

Rate limit exceeded

@posva has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 52 minutes and 0 seconds before requesting another review.

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 52 minutes and 0 seconds.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4b8a0c11-1880-43d6-a2d3-1d059d1cb609

📥 Commits

Reviewing files that changed from the base of the PR and between 38466d5 and ae143a3.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • knip.json
  • packages/nuxi/package.json
  • packages/nuxi/src/commands/init.ts
  • packages/nuxi/src/commands/module/_autocomplete.ts
  • packages/nuxi/src/commands/module/add.ts
  • packages/nuxi/src/commands/upgrade.ts
  • packages/nuxt-cli/package.json
📝 Walkthrough

Walkthrough

The pull request migrates from @clack/prompts to @posva/clack-prompts across the Nuxi CLI packages and refactors multiple command implementations to use advanced prompting utilities. Package dependencies are aliased to the new package, and command files are refactored to use batch() for grouped prompts and once() to cache and replay operations. Interactive prompts throughout various commands receive explicit id fields for stable identity, and control flow is reorganized to support deterministic behavior in agent environments. The selectModulesAutocomplete function is extended to accept optional id values and to allow interactive module selection in agent environments without TTY.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding agent-mode support via once() and batch() prompting utilities to the CLI.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, detailing the agent-mode support implementation, wrapped side effects, batched prompts, and stable IDs.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/nuxi/src/commands/init.ts (1)

196-242: ⚠️ Potential issue | 🟡 Minor

Stale templateDownloadPath reference in init:download-template when user chose "different" directory.

At line 233 the 'different' branch reassigns templateDownloadPath but not dir. Inside the once('init:download-template', …) block (lines 260-278) the slug-rename logic reads dir.length > 0 and later basename(templateDownloadPath) — the basename is correct (it uses the updated path), but the guard if (dir.length > 0) can now be wrong: if the user originally ran nuxi init with no positional and then picked a "different" directory at the prompt, dir is still '' and the slug rename is skipped even though a real target was chosen. This is pre-existing behavior that the once-wrapping preserves, but since you're already touching this block it would be a good opportunity to fix.

🐛 Suggested fix
         case 'different': {
           const result = await text({
             id: 'init:different-dir',
             message: 'Please specify a different directory:',
           })

           if (isCancel(result)) {
             cancel('Operation cancelled.')
             process.exit(1)
           }

+          dir = result
           templateDownloadPath = resolve(cwd, result)
           break
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/init.ts` around lines 196 - 242, The slug-rename
guard uses the stale variable dir instead of the possibly-updated
templateDownloadPath, so update the logic inside the
once('init:download-template', ...) handler to derive the directory name from
templateDownloadPath (e.g., use basename(templateDownloadPath) or resolve a
localDir variable from templateDownloadPath) rather than checking dir.length;
adjust any references to dir in the slug-rename block to use the new derived
value so the rename runs correctly when the user selects "different" and
templateDownloadPath was reassigned in the 'different' branch.
🧹 Nitpick comments (5)
packages/nuxi/src/commands/init.ts (2)

293-330: Nightly set-nightly once block: process.exit(1) inside cached function.

Calling process.exit(1) from inside the once(…) callback means the process tears down before once() ever returns — so nothing is cached for the error path (fine) but it also means the error messaging is the only signal to the agent. Consider throwing instead and letting the outer flow handle it consistently with the downloadTemplate block at lines 284-290, which throw err under DEBUG. Keeps cancellation/cleanup semantics uniform across the init pipeline.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/init.ts` around lines 293 - 330, Replace the
direct process.exit(1) calls inside the once('init:set-nightly', ...) callback
with throwing an Error so the outer init flow can handle cleanup and caching
consistently; specifically, in the init:set-nightly handler (references:
once('init:set-nightly', ...), nightlySpinner, nightlyChannelTag,
nightlyChannelVersion) change the two branches that currently call
process.exit(1) to nightlySpinner.error(...) then throw new Error(...) (or
rethrow the caught error) and ensure callers that wrap this once handler
(consistent with the downloadTemplate error handling) will catch and handle the
thrown error.

344-344: Normalize the gitInit string/boolean handling to match the install pattern.

Line 401 handles the same citty-returns-string-'false' quirk with the more readable ctx.args.install === false || (ctx.args.install as unknown) === 'false'. The gitInit line squashes both branches into a single ternary with 'false' as unknown which reads oddly and, if ctx.args.gitInit is ever the string 'true', will propagate the string into a boolean | undefined variable. Aligning the two paths makes the intent clearer.

♻️ Proposed alignment
-    let gitInit: boolean | undefined = ctx.args.gitInit === 'false' as unknown ? false : ctx.args.gitInit
+    const gitInitArg = ctx.args.gitInit as boolean | string | undefined
+    let gitInit: boolean | undefined
+      = gitInitArg === false || gitInitArg === 'false'
+        ? false
+        : gitInitArg === true || gitInitArg === 'true'
+          ? true
+          : undefined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/init.ts` at line 344, The gitInit assignment
incorrectly mixes ternary and string propagation; change it to mirror the
install handling: compute gitInit from ctx.args.gitInit using the same
boolean/string normalization like "let gitInit: boolean | undefined =
ctx.args.gitInit === false || (ctx.args.gitInit as unknown) === 'false' ? false
: (ctx.args.gitInit as boolean | undefined)"; update the assignment to use
ctx.args.gitInit, the gitInit variable, and the same comparison pattern used for
ctx.args.install so the string 'false' is normalized to boolean false and other
values remain properly typed.
packages/nuxi/src/commands/module/add.ts (1)

221-252: Minor: updateConfig(...).catch(...) inside once() returns the wrong value.

await updateConfig({...}).catch(err => { logger.error(...) }) resolves with undefined on failure, so the block always falls through to return null — good. But because errors are swallowed here, a failed nuxt.config write is cached as a "success" by once(). On replay the agent won't see this again. That's fine for the "config file exists, partially updated" case, but consider logging enough detail (e.g., include error.stack behind DEBUG) so an operator can diagnose from the CLI output alone since the failure won't recur.

💡 Optional: surface more detail in debug mode
       }).catch((error) => {
         logger.error(`Failed to update ${colors.cyan('nuxt.config')}: ${error.message}`)
+        if (process.env.DEBUG) {
+          logger.error(error.stack)
+        }
         logger.error(`Please manually add ${colors.cyan(modules.map(module => module.pkgName).join(', '))} to the ${colors.cyan('modules')} in ${colors.cyan('nuxt.config.ts')}`)
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/module/add.ts` around lines 221 - 252, The catch
on updateConfig inside once('module-add:update-config') currently only logs
error.message and swallows the error; update the catch to also log detailed
diagnostics (e.g., error.stack) using a debug-level logger call so operators can
diagnose failures even though the error is not rethrown—specifically, inside the
.catch((error) => { ... }) block for updateConfig({...}) add logger.debug/error
with error.stack (or logger.debug({ err: error })) in addition to the existing
logger.error calls while keeping the return behavior unchanged; reference the
once('module-add:update-config') wrapper, the updateConfig call, and the
logger.error/logger.debug calls to locate the change.
packages/nuxi/src/commands/upgrade.ts (1)

213-268: once('upgrade:tasks', …) wraps the full install sequence — confirm replay intent.

Wrapping all three tasks(...) stages inside a single once() means on replay the agent will skip the entire install/dedupe/cleanup sequence as a unit. That's correct for idempotency (no duplicate installs), but if any sub-task failed silently on first run, the replay won't retry any of them — users have to remove the cache manually. Given the mixed scope (install + dedupe + cleanup), consider whether a finer-grained split (one once per logical step) would give better recovery semantics. Not a blocker.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxi/src/commands/upgrade.ts` around lines 213 - 268, The current
once('upgrade:tasks', ...) wraps the entire tasks([...]) sequence (including
addDependency, dedupeDependencies, cleanupNuxtDirs) so a replay will skip all
sub-steps if any previously succeeded; split the single once into finer-grained
once calls for each logical step to allow partial replays: wrap the install step
(the task with addDependency) in its own once key (e.g., 'upgrade:install'),
wrap the force/dedupe steps (calls to dedupeDependencies and recreate lockfile)
in their own once keys like 'upgrade:dedupe'/'upgrade:recreate-lockfile'
depending on method, and wrap the cleanup step (cleanupNuxtDirs and buildDir
resolution) in a separate once like 'upgrade:cleanup'; preserve spinner() usage
and existing task titles but move the respective task entries into their
corresponding once wrappers so each can be retried independently on replay.
packages/nuxt-cli/package.json (1)

45-45: Downstream consumers will inherit the aliased fork.

Since @nuxt/cli is a published package, this alias flows into every user's node_modules tree. The fork (@posva/clack-prompts) is actively maintained but distinct from the official @clack/prompts library. Worth making sure the fork is publicly documented (README note / release notes) so downstream projects aren't surprised to find @posva/clack-prompts@^1.2.2 being pulled in under @clack/prompts. Also consider:

  • Pinning to an exact version (1.2.2) instead of ^1.2.2 to avoid silently picking up unreviewed upstream changes from the fork, until the fork reaches a stable/maintained state.
  • Tracking upstream @clack/prompts for when once/batch land there, so you can revert the alias.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxt-cli/package.json` at line 45, The package.json currently
aliases "@clack/prompts" to "npm:`@posva/clack-prompts`@^1.2.2" which will
propagate the fork to downstream consumers; update this by pinning the alias to
an exact version (e.g., change ^1.2.2 to 1.2.2) in the packages/nuxt-cli
package.json, add a brief note in the repository README or release notes
documenting that the forked package "@posva/clack-prompts" is being used under
the "@clack/prompts" name, and add a short TODO in the repo (or an issue/task)
to track the official "@clack/prompts" upstream for the desired features
(once/batch) so the alias can be reverted when appropriate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/nuxi/src/commands/init.ts`:
- Around line 196-242: The slug-rename guard uses the stale variable dir instead
of the possibly-updated templateDownloadPath, so update the logic inside the
once('init:download-template', ...) handler to derive the directory name from
templateDownloadPath (e.g., use basename(templateDownloadPath) or resolve a
localDir variable from templateDownloadPath) rather than checking dir.length;
adjust any references to dir in the slug-rename block to use the new derived
value so the rename runs correctly when the user selects "different" and
templateDownloadPath was reassigned in the 'different' branch.

---

Nitpick comments:
In `@packages/nuxi/src/commands/init.ts`:
- Around line 293-330: Replace the direct process.exit(1) calls inside the
once('init:set-nightly', ...) callback with throwing an Error so the outer init
flow can handle cleanup and caching consistently; specifically, in the
init:set-nightly handler (references: once('init:set-nightly', ...),
nightlySpinner, nightlyChannelTag, nightlyChannelVersion) change the two
branches that currently call process.exit(1) to nightlySpinner.error(...) then
throw new Error(...) (or rethrow the caught error) and ensure callers that wrap
this once handler (consistent with the downloadTemplate error handling) will
catch and handle the thrown error.
- Line 344: The gitInit assignment incorrectly mixes ternary and string
propagation; change it to mirror the install handling: compute gitInit from
ctx.args.gitInit using the same boolean/string normalization like "let gitInit:
boolean | undefined = ctx.args.gitInit === false || (ctx.args.gitInit as
unknown) === 'false' ? false : (ctx.args.gitInit as boolean | undefined)";
update the assignment to use ctx.args.gitInit, the gitInit variable, and the
same comparison pattern used for ctx.args.install so the string 'false' is
normalized to boolean false and other values remain properly typed.

In `@packages/nuxi/src/commands/module/add.ts`:
- Around line 221-252: The catch on updateConfig inside
once('module-add:update-config') currently only logs error.message and swallows
the error; update the catch to also log detailed diagnostics (e.g., error.stack)
using a debug-level logger call so operators can diagnose failures even though
the error is not rethrown—specifically, inside the .catch((error) => { ... })
block for updateConfig({...}) add logger.debug/error with error.stack (or
logger.debug({ err: error })) in addition to the existing logger.error calls
while keeping the return behavior unchanged; reference the
once('module-add:update-config') wrapper, the updateConfig call, and the
logger.error/logger.debug calls to locate the change.

In `@packages/nuxi/src/commands/upgrade.ts`:
- Around line 213-268: The current once('upgrade:tasks', ...) wraps the entire
tasks([...]) sequence (including addDependency, dedupeDependencies,
cleanupNuxtDirs) so a replay will skip all sub-steps if any previously
succeeded; split the single once into finer-grained once calls for each logical
step to allow partial replays: wrap the install step (the task with
addDependency) in its own once key (e.g., 'upgrade:install'), wrap the
force/dedupe steps (calls to dedupeDependencies and recreate lockfile) in their
own once keys like 'upgrade:dedupe'/'upgrade:recreate-lockfile' depending on
method, and wrap the cleanup step (cleanupNuxtDirs and buildDir resolution) in a
separate once like 'upgrade:cleanup'; preserve spinner() usage and existing task
titles but move the respective task entries into their corresponding once
wrappers so each can be retried independently on replay.

In `@packages/nuxt-cli/package.json`:
- Line 45: The package.json currently aliases "@clack/prompts" to
"npm:`@posva/clack-prompts`@^1.2.2" which will propagate the fork to downstream
consumers; update this by pinning the alias to an exact version (e.g., change
^1.2.2 to 1.2.2) in the packages/nuxt-cli package.json, add a brief note in the
repository README or release notes documenting that the forked package
"@posva/clack-prompts" is being used under the "@clack/prompts" name, and add a
short TODO in the repo (or an issue/task) to track the official "@clack/prompts"
upstream for the desired features (once/batch) so the alias can be reverted when
appropriate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 65b453de-72ac-496a-bb6c-b7d2731e813b

📥 Commits

Reviewing files that changed from the base of the PR and between c74dde6 and 38466d5.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (7)
  • knip.json
  • packages/nuxi/package.json
  • packages/nuxi/src/commands/init.ts
  • packages/nuxi/src/commands/module/_autocomplete.ts
  • packages/nuxi/src/commands/module/add.ts
  • packages/nuxi/src/commands/upgrade.ts
  • packages/nuxt-cli/package.json

Comment on lines +538 to +541
await once('init:add-modules', async () => {
await runCommand(addModuleCommand, args)
return null
})
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

can be simplified to () => runCommand()

@posva
Copy link
Copy Markdown
Member Author

posva commented Apr 21, 2026

Yes, I saw #1264 . This is different: it's an experiment about bringing the interactivity of the CLI to agents so they can make choices based on instructions.

It's not meant to be merged

Oh, and I think #1264 is great and that one should def be shipped. Interactivety in CLIs should, at the very least fallback to showing the help when run by an agent, that would be so much better. Some times this is not possible: e.g. the dev server prompting the user to install a package (this is done in nuxt dev for example when using @nuxt/content it asks to install sqlite but it should just log it and not use an interactive prompt)

@danielroe danielroe marked this pull request as draft April 21, 2026 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants