feat(agent): typed UI-hint protocol on needs_input + projects list command#299
feat(agent): typed UI-hint protocol on needs_input + projects list command#299
Conversation
🧙 Wizard CIRun the Wizard CI and test your changes against wizard-workbench example apps by replying with a GitHub comment using one of the following commands: Test all apps:
Test all apps in a directory:
Test an individual app:
Show more apps
Results will be posted here when complete. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed:
--agentflag silently ignored inprojects listhandler- Added the missing
agent: argv.agent as boolean | undefinedparameter to theresolveModecall in theprojects listhandler, matching the pattern used by theapplycommand.
- Added the missing
- ✅ Fixed: New type identifiers use "Project" instead of "App"
- Renamed
ProjectChoicetoAppChoice,ProjectsListResulttoAppListResult,ProjectsListInputtoAppListInput, andrunProjectsListtorunAppListacross agent-ops.ts, bin.ts, and the test file.
- Renamed
Or push these changes by commenting:
@cursor push f19d9b9142
Preview (f19d9b9142)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -2400,16 +2400,15 @@
const { jsonOutput } = resolveMode({
json: argv.json as boolean | undefined,
human: argv.human as boolean | undefined,
+ agent: argv.agent as boolean | undefined,
requireExplicitWrites: true,
isTTY: Boolean(process.stdout.isTTY),
});
try {
- const { runProjectsList } = await import(
- './src/lib/agent-ops.js'
- );
+ const { runAppList } = await import('./src/lib/agent-ops.js');
const offset = (argv.offset as number | undefined) ?? 0;
const limit = (argv.limit as number | undefined) ?? 25;
- const result = await runProjectsList({
+ const result = await runAppList({
query: argv.query,
limit,
offset,
diff --git a/src/lib/__tests__/projects-list.test.ts b/src/lib/__tests__/projects-list.test.ts
--- a/src/lib/__tests__/projects-list.test.ts
+++ b/src/lib/__tests__/projects-list.test.ts
@@ -16,7 +16,7 @@
};
});
-import { runProjectsList } from '../agent-ops.js';
+import { runAppList } from '../agent-ops.js';
import { getStoredUser, getStoredToken } from '../../utils/ampli-settings.js';
import { fetchAmplitudeUser } from '../api.js';
@@ -89,7 +89,7 @@
],
};
-describe('runProjectsList', () => {
+describe('runAppList', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedGetUser.mockReturnValue({
@@ -109,7 +109,7 @@
});
it('returns one choice per environment with an apiKey', async () => {
- const result = await runProjectsList();
+ const result = await runAppList();
expect(result.warning).toBeUndefined();
expect(result.total).toBe(4); // 2 + 1 (filtered) + 1 = 4
expect(result.returned).toBe(4);
@@ -122,7 +122,7 @@
});
it('builds breadcrumb description and per-choice resumeFlags', async () => {
- const result = await runProjectsList();
+ const result = await runAppList();
const sample = result.choices.find((c) => c.appId === '769610');
expect(sample).toMatchObject({
label: 'Amplitude / Growth / Production',
@@ -137,20 +137,20 @@
});
it('filters by query case-insensitively across all label fields', async () => {
- const r = await runProjectsList({ query: 'GROWTH' });
+ const r = await runAppList({ query: 'GROWTH' });
expect(r.total).toBe(2);
expect(r.choices.every((c) => c.label.includes('Growth'))).toBe(true);
});
it('matches against app id', async () => {
- const r = await runProjectsList({ query: '999001' });
+ const r = await runAppList({ query: '999001' });
expect(r.total).toBe(1);
expect(r.choices[0].appId).toBe('999001');
});
it('paginates with limit + offset, deterministically ordered', async () => {
- const page1 = await runProjectsList({ limit: 2, offset: 0 });
- const page2 = await runProjectsList({ limit: 2, offset: 2 });
+ const page1 = await runAppList({ limit: 2, offset: 0 });
+ const page2 = await runAppList({ limit: 2, offset: 2 });
expect(page1.returned).toBe(2);
expect(page2.returned).toBe(2);
// No overlap, full coverage
@@ -159,15 +159,15 @@
});
it('clamps limit to [1, 200]', async () => {
- const r1 = await runProjectsList({ limit: -10 });
+ const r1 = await runAppList({ limit: -10 });
expect(r1.returned).toBeLessThanOrEqual(1);
- const r2 = await runProjectsList({ limit: 9999 });
+ const r2 = await runAppList({ limit: 9999 });
expect(r2.returned).toBe(4); // total fixture size
});
it('returns warning when not logged in (no user)', async () => {
mockedGetUser.mockReturnValue(null);
- const r = await runProjectsList();
+ const r = await runAppList();
expect(r.warning).toMatch(/Not logged in/);
expect(r.choices).toEqual([]);
expect(mockedFetch).not.toHaveBeenCalled();
@@ -181,25 +181,25 @@
email: '',
zone: 'us',
});
- const r = await runProjectsList();
+ const r = await runAppList();
expect(r.warning).toMatch(/Not logged in/);
expect(mockedFetch).not.toHaveBeenCalled();
});
it('returns warning when no idToken is stored', async () => {
mockedGetToken.mockReturnValue(null);
- const r = await runProjectsList();
+ const r = await runAppList();
expect(r.warning).toMatch(/id_token/);
expect(mockedFetch).not.toHaveBeenCalled();
});
it('echoes back the lowercased query for paginator cursor reasoning', async () => {
- const r = await runProjectsList({ query: ' Growth ' });
+ const r = await runAppList({ query: ' Growth ' });
expect(r.query).toBe('growth');
});
it('orders by rank then label for deterministic pagination', async () => {
- const r = await runProjectsList();
+ const r = await runAppList();
// rank=1 entries first, alphabetized by label
expect(r.choices[0].rank).toBe(1);
const ranks = r.choices.map((c) => c.rank);
diff --git a/src/lib/agent-ops.ts b/src/lib/agent-ops.ts
--- a/src/lib/agent-ops.ts
+++ b/src/lib/agent-ops.ts
@@ -343,7 +343,7 @@
// ── projects list ───────────────────────────────────────────────────
-export interface ProjectChoice {
+export interface AppChoice {
/** Numeric Amplitude app ID — the canonical selector. */
appId: string;
/** Pre-built one-line label for picker rendering. */
@@ -360,13 +360,13 @@
resumeFlags: string[];
}
-export interface ProjectsListResult {
+export interface AppListResult {
/** All choices matching the query (pre-pagination). */
total: number;
/** Choices included in this response (after query + pagination). */
returned: number;
/** Choice page. */
- choices: ProjectChoice[];
+ choices: AppChoice[];
/** Original query string, echoed back for pagination cursor reasoning. */
query: string | null;
/**
@@ -376,7 +376,7 @@
warning?: string;
}
-export interface ProjectsListInput {
+export interface AppListInput {
/** Optional case-insensitive substring match across label fields. */
query?: string;
/** Page size. Defaults to 25, capped at 200. */
@@ -397,9 +397,9 @@
* warning instead of throwing when the user is logged out so the CLI can
* still emit a useful NDJSON envelope.
*/
-export async function runProjectsList(
- input: ProjectsListInput = {},
-): Promise<ProjectsListResult> {
+export async function runAppList(
+ input: AppListInput = {},
+): Promise<AppListResult> {
const limit = Math.max(1, Math.min(input.limit ?? 25, 200));
const offset = Math.max(0, input.offset ?? 0);
const query = input.query?.trim().toLowerCase() ?? null;
@@ -430,7 +430,7 @@
const userInfo = await fetchAmplitudeUser(stored.idToken, user.zone);
// Flatten orgs/workspaces/environments to one choice per (env with apiKey).
- const allChoices: ProjectChoice[] = [];
+ const allChoices: AppChoice[] = [];
for (const org of userInfo.orgs) {
for (const ws of org.workspaces) {
const envs = (ws.environments ?? [])You can send follow-ups to the cloud agent here.
| rank: number; | ||
| /** Per-choice resume flags: `['--app-id', appId]`. */ | ||
| resumeFlags: string[]; | ||
| } |
There was a problem hiding this comment.
New type identifiers use "Project" instead of "App"
Low Severity
Newly declared TypeScript type/function identifiers ProjectChoice, ProjectsListResult, ProjectsListInput, and runProjectsList reintroduce "project" for what the canonical terminology glossary calls "app" (the Amplitude ingestion unit renamed in PR #120). While the internal fields correctly use appId, the type names themselves use "Project" to describe individual app/environment entries.
Additional Locations (1)
Triggered by learned rule: Canonical terminology: "app" not "project" for the Amplitude ingestion unit
Reviewed by Cursor Bugbot for commit ea8a241. Configure here.
…mmand
The wizard can't render Claude/Codex/Cursor UI directly, but it can
strongly influence what the outer agent shows by emitting structured,
typed events with display hints. This PR turns the existing `needs_input`
event into a small "UI protocol over NDJSON" so outer agents can produce
a much better human-facing UX without us shipping a UI.
New on the `needs_input` payload:
ui.component — searchable_select | select | multiselect |
confirmation | secret_input | text_input
ui.priority — required | recommended | optional
ui.title — heading
ui.description — supporting context line
ui.searchPlaceholder
ui.emptyState
recommendedReason — why `recommended` was picked, surfaced as tooltip
pagination — { total, returned, query, nextCommand }
allowManualEntry — outer agent may collect free text instead
manualEntry — { flag, placeholder, pattern }
Per-choice additions:
description — second line under the label
metadata — { orgName, envName, region, … } for rich rendering
resumeFlags — per-choice argv (was top-level only)
`promptEnvironmentSelection` now ships:
- searchable_select widget with title + breadcrumb description
- rich metadata per choice (org/workspace/env/region/rank)
- recommendedReason explaining the auto-pick
- pagination signal pointing at `wizard projects list --agent --query`
- allowManualEntry + --app-id manualEntry flag for missing projects
`promptChoice` auto-picks the widget based on list size: ≥10 options →
searchable_select with placeholder; otherwise plain select. `promptConfirm`
emits the confirmation widget.
New command: `npx wizard projects list --agent [--query <q>] [--limit N]
[--offset N]`. Returns a paginated, search-filtered list of every (org,
workspace, env) tuple the user has access to that has an API key. Emits
the SAME `needs_input` envelope as the inline prompt so outer agents can
render the same picker for both — and chain pages via `nextCommand` for
500-row lists without dumping them all at once.
`runProjectsList` is a pure function in `agent-ops.ts` — no UI, no
process.exit — so future MCP-style transports can reuse it directly.
Tests: +15 (11 in projects-list.test.ts, 4 in agent-ui.test.ts).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ea8a241 to
b59d6f0
Compare
The `wizard projects list --agent` invocation parsed `--agent` from the global hidden flag but never passed it to `resolveMode`, so on a TTY the command would emit human output instead of the documented NDJSON envelope. Pass `agent: argv.agent` to match the pattern used by `apply`. Caught by Cursor Bugbot.
|
Re Bugbot findings on commit ea8a241:
|
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Pagination cursor uses unclamped limit causing skipped items
- Changed
nextOffsetcalculation fromoffset + limit(unclamped user input) tooffset + result.returned(actual items returned after clamping), ensuring the pagination cursor always advances exactly past the items in the current page.
- Changed
Or push these changes by commenting:
@cursor push b07bfe9759
Preview (b07bfe9759)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -137,7 +137,7 @@
// Dynamic import to avoid preloading wizard-session.ts as CJS, which
// prevents the TUI's ESM dynamic imports from resolving named exports.
const lazyRunWizard = async (
- ...args: Parameters<typeof import('./src/run')['runWizard']>
+ ...args: Parameters<(typeof import('./src/run'))['runWizard']>
) => {
const { runWizard } = await import('./src/run.js');
return runWizard(...args);
@@ -242,9 +242,8 @@
// the project API key.
const envAccessToken =
process.env.AMPLITUDE_TOKEN ?? process.env.AMPLITUDE_WIZARD_TOKEN;
- const { resolveCredentials, resolveEnvironmentSelection } = await import(
- './src/lib/credential-resolution.js'
- );
+ const { resolveCredentials, resolveEnvironmentSelection } =
+ await import('./src/lib/credential-resolution.js');
await resolveCredentials(session, {
requireOrgId: false,
org: options.org as string | undefined,
@@ -511,9 +510,8 @@
if (!session.signup || !session.signupEmail || !session.signupFullName) {
return;
}
- const { performSignupOrAuth, trackSignupAttempt } = await import(
- './src/utils/signup-or-auth.js'
- );
+ const { performSignupOrAuth, trackSignupAttempt } =
+ await import('./src/utils/signup-or-auth.js');
const { tryResolveZone } = await import('./src/lib/zone-resolution.js');
// Non-TUI modes have no RegionSelect screen to disambiguate — and the
@@ -1052,9 +1050,8 @@
// selected. Without this, a successful signup would get silently
// cleared and the browser would open anyway, defeating the point.
await runDirectSignupIfRequested(session, 'OAuth', async () => {
- const { resolveCredentials } = await import(
- './src/lib/credential-resolution.js'
- );
+ const { resolveCredentials } =
+ await import('./src/lib/credential-resolution.js');
await resolveCredentials(session, { requireOrgId: false });
});
@@ -1080,9 +1077,8 @@
// fires — placing this after startTUI but before registering the
// handler avoids the TDZ issue that would occur if we referenced
// a `const` binding from a later dynamic import.
- const { performGracefulExit } = await import(
- './src/lib/graceful-exit.js'
- );
+ const { performGracefulExit } =
+ await import('./src/lib/graceful-exit.js');
let sigintReceived = false;
process.on('SIGINT', () => {
if (sigintReceived) {
@@ -1102,9 +1098,8 @@
// If --api-key was provided, skip the OAuth/TUI auth flow entirely.
if (session.apiKey) {
- const { DEFAULT_HOST_URL } = await import(
- './src/lib/constants.js'
- );
+ const { DEFAULT_HOST_URL } =
+ await import('./src/lib/constants.js');
session.credentials = {
accessToken: session.apiKey,
projectApiKey: session.apiKey,
@@ -1117,9 +1112,8 @@
const { logToFile } = await import('./src/utils/debug.js');
// Check for crash-recovery checkpoint
- const { loadCheckpoint } = await import(
- './src/lib/session-checkpoint.js'
- );
+ const { loadCheckpoint } =
+ await import('./src/lib/session-checkpoint.js');
const checkpoint = await loadCheckpoint(session.installDir);
if (checkpoint) {
Object.assign(session, checkpoint);
@@ -1132,9 +1126,8 @@
// Resolve credentials using shared logic (token refresh,
// env auto-select, pendingOrgs population)
- const { resolveCredentials } = await import(
- './src/lib/credential-resolution.js'
- );
+ const { resolveCredentials } =
+ await import('./src/lib/credential-resolution.js');
await resolveCredentials(session);
// Resolve org/workspace display names so /whoami shows them.
@@ -1147,18 +1140,14 @@
// agent-mode users whose zone comes from storedUser, not an
// explicit flag.
if (session.credentials && session.selectedOrgId) {
- const { getStoredUser, getStoredToken } = await import(
- './src/utils/ampli-settings.js'
- );
- const { fetchAmplitudeUser, extractAppId } = await import(
- './src/lib/api.js'
- );
- const { resolveZone } = await import(
- './src/lib/zone-resolution.js'
- );
- const { DEFAULT_AMPLITUDE_ZONE } = await import(
- './src/lib/constants.js'
- );
+ const { getStoredUser, getStoredToken } =
+ await import('./src/utils/ampli-settings.js');
+ const { fetchAmplitudeUser, extractAppId } =
+ await import('./src/lib/api.js');
+ const { resolveZone } =
+ await import('./src/lib/zone-resolution.js');
+ const { DEFAULT_AMPLITUDE_ZONE } =
+ await import('./src/lib/constants.js');
const storedUser = getStoredUser();
const realUser =
storedUser && storedUser.id !== 'pending' ? storedUser : null;
@@ -1218,9 +1207,9 @@
changed = true;
// Fall back to the first workspace if the stored ID is stale.
const ws = session.selectedWorkspaceId
- ? org.workspaces.find(
+ ? (org.workspaces.find(
(w) => w.id === session.selectedWorkspaceId,
- ) ?? org.workspaces[0]
+ ) ?? org.workspaces[0])
: org.workspaces[0];
if (ws) {
session.selectedWorkspaceName = ws.name;
@@ -1261,9 +1250,8 @@
// Dynamic-import keeps the Claude Agent SDK out of bin.ts load.
try {
const fs = await import('fs');
- const { parseEventPlanContent } = await import(
- './src/lib/agent-interface.js'
- );
+ const { parseEventPlanContent } =
+ await import('./src/lib/agent-interface.js');
const evtPath = resolve(
session.installDir,
'.amplitude-events.json',
@@ -1281,9 +1269,8 @@
}
// Initialize Amplitude Experiment feature flags (non-blocking).
- const { initFeatureFlags } = await import(
- './src/lib/feature-flags.js'
- );
+ const { initFeatureFlags } =
+ await import('./src/lib/feature-flags.js');
await initFeatureFlags().catch(() => {
// Flag init failure is non-fatal — all flags default to off
});
@@ -1291,18 +1278,16 @@
// Apply SDK-level opt-out based on feature flags
analytics.applyOptOut();
- const { FRAMEWORK_REGISTRY } = await import(
- './src/lib/registry.js'
- );
+ const { FRAMEWORK_REGISTRY } =
+ await import('./src/lib/registry.js');
const { detectAllFrameworks } = await import('./src/run.js');
const installDir = session.installDir ?? process.cwd();
// Verbose startup diagnostics — always written to the log file;
// visible in the RunScreen "Logs" tab.
if (session.verbose || session.debug) {
- const { enableDebugLogs, logToFile } = await import(
- './src/utils/debug.js'
- );
+ const { enableDebugLogs, logToFile } =
+ await import('./src/utils/debug.js');
enableDebugLogs();
logToFile('[verbose] Amplitude Wizard starting');
logToFile(`[verbose] node : ${process.version}`);
@@ -1312,9 +1297,8 @@
logToFile(`[verbose] argv : ${process.argv.join(' ')}`);
}
- const { DETECTION_TIMEOUT_MS } = await import(
- './src/lib/constants.js'
- );
+ const { DETECTION_TIMEOUT_MS } =
+ await import('./src/lib/constants.js');
// ── OAuth + account setup ──────────────────────────────
// Runs concurrently with framework detection while AuthScreen shows.
@@ -1328,19 +1312,15 @@
if (tui.store.session.credentials !== null) return;
try {
- const { ampliConfigExists } = await import(
- './src/lib/ampli-config.js'
- );
- const { performAmplitudeAuth } = await import(
- './src/utils/oauth.js'
- );
+ const { ampliConfigExists } =
+ await import('./src/lib/ampli-config.js');
+ const { performAmplitudeAuth } =
+ await import('./src/utils/oauth.js');
const { fetchAmplitudeUser } = await import('./src/lib/api.js');
- const { DEFAULT_AMPLITUDE_ZONE } = await import(
- './src/lib/constants.js'
- );
- const { storeToken } = await import(
- './src/utils/ampli-settings.js'
- );
+ const { DEFAULT_AMPLITUDE_ZONE } =
+ await import('./src/lib/constants.js');
+ const { storeToken } =
+ await import('./src/utils/ampli-settings.js');
const forceFresh = !ampliConfigExists(installDir);
@@ -1365,9 +1345,8 @@
}
});
});
- const { resolveZone } = await import(
- './src/lib/zone-resolution.js'
- );
+ const { resolveZone } =
+ await import('./src/lib/zone-resolution.js');
const zone = resolveZone(
tui.store.session,
DEFAULT_AMPLITUDE_ZONE,
@@ -1396,14 +1375,12 @@
// but user data not yet available) from the normal
// expired-token case.
let signupTokensObtained = false;
- const { trackSignupAttempt } = await import(
- './src/utils/signup-or-auth.js'
- );
+ const { trackSignupAttempt } =
+ await import('./src/utils/signup-or-auth.js');
const s = tui.store.session;
if (s.signup && s.signupEmail && s.signupFullName) {
- const { performSignupOrAuth } = await import(
- './src/utils/signup-or-auth.js'
- );
+ const { performSignupOrAuth } =
+ await import('./src/utils/signup-or-auth.js');
try {
const signupResult = await performSignupOrAuth({
email: s.signupEmail,
@@ -1578,9 +1555,8 @@
// Feature discovery — same helper that CI/agent uses, so the
// package and integration lists never drift between modes.
- const { discoverFeatures } = await import(
- './src/lib/feature-discovery.js'
- );
+ const { discoverFeatures } =
+ await import('./src/lib/feature-discovery.js');
const runDiscovery = () => {
for (const f of discoverFeatures({
installDir,
@@ -1615,9 +1591,8 @@
// Session checkpointing — save at key transitions so crash
// recovery can skip already-completed steps.
- const { saveCheckpoint, clearCheckpoint } = await import(
- './src/lib/session-checkpoint.js'
- );
+ const { saveCheckpoint, clearCheckpoint } =
+ await import('./src/lib/session-checkpoint.js');
// After auth completes (most expensive step to repeat)
tui.store.onEnterScreen(Screen.DataSetup, () => {
saveCheckpoint(tui.store.session);
@@ -1666,9 +1641,8 @@
// Before calling the AI agent, do a quick static check to see if
// Amplitude is already installed in the project. If so, skip the
// agent entirely and advance directly to MCP setup.
- const { detectAmplitudeInProject } = await import(
- './src/lib/detect-amplitude.js'
- );
+ const { detectAmplitudeInProject } =
+ await import('./src/lib/detect-amplitude.js');
const localDetection = detectAmplitudeInProject(installDir);
if (localDetection.confidence !== 'none') {
@@ -1678,9 +1652,8 @@
localDetection.reason ?? 'unknown'
}) — prompting on MCP screen (continue vs run wizard)`,
);
- const { RunPhase, OutroKind } = await import(
- './src/lib/wizard-session.js'
- );
+ const { RunPhase, OutroKind } =
+ await import('./src/lib/wizard-session.js');
tui.store.setAmplitudePreDetected();
tui.store.setRunPhase(RunPhase.Completed);
const runWizardAnyway =
@@ -1759,9 +1732,8 @@
const zone = argv.region as 'us' | 'eu';
try {
- const { getStoredUser, getStoredToken } = await import(
- './src/utils/ampli-settings.js'
- );
+ const { getStoredUser, getStoredToken } =
+ await import('./src/utils/ampli-settings.js');
// If a valid cached session exists, display the stored user without
// re-fetching from the API (the cached idToken may be expired).
const cachedToken = getStoredToken(undefined, zone);
@@ -1816,13 +1788,11 @@
() => {},
(argv) => {
void (async () => {
- const { getStoredUser, clearStoredCredentials } = await import(
- './src/utils/ampli-settings.js'
- );
+ const { getStoredUser, clearStoredCredentials } =
+ await import('./src/utils/ampli-settings.js');
const { clearApiKey } = await import('./src/utils/api-key-store.js');
- const { clearCheckpoint } = await import(
- './src/lib/session-checkpoint.js'
- );
+ const { clearCheckpoint } =
+ await import('./src/lib/session-checkpoint.js');
const installDir =
(argv.installDir as string | undefined) ?? process.cwd();
const user = getStoredUser();
@@ -1849,9 +1819,8 @@
() => {},
(_argv) => {
void (async () => {
- const { getStoredUser, getStoredToken } = await import(
- './src/utils/ampli-settings.js'
- );
+ const { getStoredUser, getStoredToken } =
+ await import('./src/utils/ampli-settings.js');
const user = getStoredUser();
const token = getStoredToken();
if (user && token && user.id !== 'pending') {
@@ -1905,9 +1874,8 @@
return;
}
try {
- const { trackWizardFeedback } = await import(
- './src/utils/track-wizard-feedback.js'
- );
+ const { trackWizardFeedback } =
+ await import('./src/utils/track-wizard-feedback.js');
await trackWizardFeedback(message);
getUI().log.success('Thanks — your feedback was sent.');
process.exit(0);
@@ -2415,9 +2383,8 @@
isTTY: Boolean(process.stdout.isTTY),
});
try {
- const { runProjectsList } = await import(
- './src/lib/agent-ops.js'
- );
+ const { runProjectsList } =
+ await import('./src/lib/agent-ops.js');
const offset = (argv.offset as number | undefined) ?? 0;
const limit = (argv.limit as number | undefined) ?? 25;
const result = await runProjectsList({
@@ -2430,7 +2397,7 @@
// Emit a `needs_input`-shaped envelope so outer agents can
// render the same picker they would for the inline prompt.
const hasMore = offset + result.returned < result.total;
- const nextOffset = offset + limit;
+ const nextOffset = offset + result.returned;
process.stdout.write(
JSON.stringify({
v: 1,
@@ -2815,9 +2782,8 @@
void (async () => {
try {
const { startTUI } = await import('./src/ui/tui/start-tui.js');
- const { buildSession } = await import(
- './src/lib/wizard-session.js'
- );
+ const { buildSession } =
+ await import('./src/lib/wizard-session.js');
const { Flow } = await import('./src/ui/tui/router.js');
const tui = startTUI(WIZARD_VERSION, Flow.McpAdd);
@@ -2829,9 +2795,8 @@
} catch {
// TUI unavailable — fallback to logging
setUI(new LoggingUI());
- const { addMCPServerToClientsStep } = await import(
- './src/steps/add-mcp-server-to-clients/index.js'
- );
+ const { addMCPServerToClientsStep } =
+ await import('./src/steps/add-mcp-server-to-clients/index.js');
await addMCPServerToClientsStep({
local: options.local,
});
@@ -2857,9 +2822,8 @@
void (async () => {
try {
const { startTUI } = await import('./src/ui/tui/start-tui.js');
- const { buildSession } = await import(
- './src/lib/wizard-session.js'
- );
+ const { buildSession } =
+ await import('./src/lib/wizard-session.js');
const { Flow } = await import('./src/ui/tui/router.js');
const tui = startTUI(WIZARD_VERSION, Flow.McpRemove);
@@ -2871,9 +2835,8 @@
} catch {
// TUI unavailable — fallback to logging
setUI(new LoggingUI());
- const { removeMCPServerFromClientsStep } = await import(
- './src/steps/add-mcp-server-to-clients/index.js'
- );
+ const { removeMCPServerFromClientsStep } =
+ await import('./src/steps/add-mcp-server-to-clients/index.js');
await removeMCPServerFromClientsStep({
local: options.local,
});
@@ -2888,9 +2851,8 @@
() => {
void (async () => {
try {
- const { startAgentMcpServer } = await import(
- './src/lib/wizard-mcp-server.js'
- );
+ const { startAgentMcpServer } =
+ await import('./src/lib/wizard-mcp-server.js');
await startAgentMcpServer();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -2911,9 +2873,8 @@
() => {},
() => {
void (async () => {
- const { getAgentManifest } = await import(
- './src/lib/agent-manifest.js'
- );
+ const { getAgentManifest } =
+ await import('./src/lib/agent-manifest.js');
process.stdout.write(
JSON.stringify(getAgentManifest(), null, 2) + '\n',
);You can send follow-ups to the cloud agent here.
…t pagination cursor `runProjectsList` clamps `limit` to `[1, 200]` internally. The `projects list --agent` envelope was advancing `nextOffset` by the unclamped user input (e.g. `--limit 9999` would skip from offset 0 to 9999, dropping rows 200..9998). Use `result.returned` so the cursor always advances exactly past the items in the current page. Caught by Cursor Bugbot.
|
Re commit ee4086e: fixed the pagination cursor finding by advancing |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Pagination nextCommand is unusable search template, not executable command
- Removed the unusable nextCommand stub (with empty --query and no --offset/--limit) from promptEnvironmentSelection's pagination object, aligning with bin.ts semantics where nextCommand is only emitted when there are more pages to fetch.
Or push these changes by commenting:
@cursor push bedf65b3ef
Preview (bedf65b3ef)
diff --git a/src/ui/agent-ui.ts b/src/ui/agent-ui.ts
--- a/src/ui/agent-ui.ts
+++ b/src/ui/agent-ui.ts
@@ -930,21 +930,9 @@
responseSchema: {
appId: 'string (required, from choices[].value)',
},
- // Pagination is signalled even when all choices fit so outer agents can
- // surface the total. `nextCommand` lets them re-fetch a search-filtered
- // subset via `wizard projects list --agent --query <q>`.
pagination: {
total: choices.length,
returned: needsInputChoices.length,
- nextCommand: [
- 'npx',
- '@amplitude/wizard',
- 'projects',
- 'list',
- '--agent',
- '--query',
- '',
- ],
},
// Allow free-form `--app-id` entry when none of the listed choices fit
// (e.g. a brand-new project the cached fetch missed).You can send follow-ups to the cloud agent here.
…ination `promptEnvironmentSelection` returns every choice up front, so there is no next page to fetch. Emitting a `nextCommand` with empty `--query` and no `--offset`/`--limit` overloaded the field's semantics — outer agents would treat it as an executable cursor command. Drop it; the search affordance is already discoverable via `ui.searchPlaceholder` and `wizard projects list --agent --query <q>`. Caught by Cursor Bugbot.
|
Re commit 185f09f: fixed the third Bugbot finding by dropping the stub |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Misleading "Lowest rank" wording in user-facing recommendedReason
- Changed bin.ts recommendedReason from 'Lowest rank in the first matching workspace' to 'Highest-ranked environment in the first matching workspace' to match the correct semantics used in agent-ui.ts.
Or push these changes by commenting:
@cursor push dea2d3de5d
Preview (dea2d3de5d)
diff --git a/bin.ts b/bin.ts
--- a/bin.ts
+++ b/bin.ts
@@ -2479,7 +2479,7 @@
})),
recommended: result.choices[0]?.appId,
recommendedReason: result.choices[0]
- ? `Lowest rank in the first matching workspace (${result.choices[0].description}).`
+ ? `Highest-ranked environment in the first matching workspace (${result.choices[0].description}).`
: undefined,
responseSchema: {
appId: 'string (required, from choices[].value)',You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit a137b63. Configure here.
Mirror the agent-ui wording so outer agents and humans don't read 'lowest rank' as 'lowest priority'. Rank 1 is numerically lowest but semantically highest (Production at most orgs).
|
Re commit d21e473: aligned the |



Summary
The wizard can't render Claude/Codex/Cursor UI directly, but it can strongly influence what the outer agent shows by emitting structured, typed events with display hints. This PR turns the existing
needs_inputevent into a small "UI protocol over NDJSON" so outer agents can produce a much better human-facing UX without us shipping a UI.This is Gap 5 in the wizard sub-agent design rollout. Redoes the closed #257 against current
mainafter the original was rebase-orphaned by stack churn.What's new on
needs_inputOuter agents that respect
ui.componentproduce a true searchable picker; those that don't fall back to a plain numbered list — both work, the rich one is just dramatically better.What changed
src/lib/agent-events.ts— newUiHints,PaginationInfo,ManualEntryHinttypes; expandedNeedsInputChoicewithdescription,metadata, per-choiceresumeFlags; expandedNeedsInputDatawithui,recommendedReason,pagination,allowManualEntry,manualEntry.AgentUI.emitNeedsInputforwards all the new fields.AgentUI.promptEnvironmentSelectionnow ships richsearchable_selectUI with breadcrumb descriptions, recommendedReason explaining the auto-pick, pagination pointing atprojects list, and--app-idmanual entry.AgentUI.promptChoiceauto-selects the widget by list size (≥10 →searchable_select, elseselect).AgentUI.promptConfirmemits theconfirmationwidget.npx wizard projects list --agent [--query <q>] [--limit N] [--offset N]returns the sameneeds_input-shaped envelope so outer agents render the same picker as the inline prompt.runProjectsList(input)— pure function inagent-ops.ts(no UI, noprocess.exit) that future MCP-style transports can reuse directly. Returns warning instead of throwing when not logged in so the CLI can still emit a useful envelope.Smoke tests
Test plan
pnpm test— 1555 passed, 17 skipped (15 new tests: 11 in projects-list.test.ts, 4 in agent-ui.test.ts)pnpm tsc --noEmitcleanpnpm lintclean (one pre-existing warning unrelated to this PR)wizard projects list --json --query nopeemits the richneeds_input-shaped envelopewizard projects list --agentemits the rich picker;--query growthfilters correctly;--limit 5 --offset 5paginates with anextCommandcursorwizard --agentend-to-end showssearchable_selectenvelope on multi-env authOut of scope (follow-up)
paginationcursor — the TUI uses Ink + nanostores and doesn't read NDJSON, so no change needed thereui.component: 'secret_input'into manual API-key entry — small follow-up that surfaces the dev-only--api-keyflag as a structured promptmultiselectcomponent — reserved for future flows (not used today)projectscommand to its own file is a trivial follow-up.cc @amplitude/growth
🤖 Generated with Claude Code
Note
Medium Risk
Medium risk because it expands the agent-mode NDJSON wire schema and changes how environment/project selection prompts are emitted/consumed by external orchestrators, plus adds a new CLI surface that depends on cached auth tokens.
Overview
Adds a typed "UI protocol over NDJSON" to
needs_inputevents by extending the schema withuirendering hints, richer per-choice fields (description,metadata,resumeFlags),recommendedReason, pagination (pagination.nextCommand), and optional manual entry (manualEntry).AgentUInow forwards these fields and emits better-structured prompts (confirmation/choice widgets, searchable environment picker with deterministic recommendation and pagination metadata).Introduces
wizard projects list(search + pagination) that returns aneeds_input-shaped envelope for outer agents, backed by a new purerunProjectsListop that flattens accessible org/workspace/environment tuples (filtering to envs with API keys), supports case-insensitive query, clampslimit, and ensures deterministic ordering; includes new Vitest coverage for both the project listing and the newneeds_inputfields.Reviewed by Cursor Bugbot for commit d21e473. Bugbot is set up for automated code reviews on this repo. Configure here.