Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/listage-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add scroll indicators ("↑ N more above" / "↓ N more below") to interactive list prompts when choices overflow the visible page. Add interactive environment picker to `clerk switch-env` when no argument is given.
2 changes: 2 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
},
"dependencies": {
"@commander-js/extra-typings": "^14.0.0",
"@inquirer/ansi": "^2.0.5",
"@inquirer/core": "^11.1.7",
"@inquirer/figures": "^2.0.5",
"@inquirer/prompts": "^8.4.1",
"@napi-rs/keyring": "^1.2.0",
"commander": "^14.0.3",
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-core/src/commands/api/interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect, describe, beforeEach, afterEach, spyOn, mock } from "bun:
import { mkdtemp, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { captureLog, promptsStubs, stubFetch } from "../../test/lib/stubs.ts";
import { captureLog, promptsStubs, listageStubs, stubFetch } from "../../test/lib/stubs.ts";

let _mode = "human";
mock.module("../../mode.ts", () => ({
Expand Down Expand Up @@ -63,6 +63,15 @@ mock.module("@inquirer/prompts", () => ({
confirm: async () => confirmResponses.shift(),
}));

mock.module("../../lib/prompts.ts", () => ({
confirm: async () => confirmResponses.shift(),
}));

mock.module("../../lib/listage.ts", () => ({
...listageStubs,
select: async () => selectResponses.shift(),
}));

describe("apiInteractive", () => {
let tempDir: string;
let errorSpy: ReturnType<typeof spyOn>;
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/api/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
* Interactive API request builder for `clerk api` (no args, human mode).
*/

import { select, input, confirm, editor } from "@inquirer/prompts";
import { input, editor } from "@inquirer/prompts";
import { select } from "../../lib/listage.ts";
import { confirm } from "../../lib/prompts.ts";
import { isHuman } from "../../mode.ts";
import { loadCatalog, endpointsByTag, type EndpointInfo } from "./catalog.ts";
import type { ApiOptions } from "./index.ts";
Expand Down
11 changes: 10 additions & 1 deletion packages/cli-core/src/commands/deploy/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog, promptsStubs } from "../../test/lib/stubs.ts";
import { captureLog, promptsStubs, listageStubs } from "../../test/lib/stubs.ts";

const mockIsAgent = mock();
let _modeOverride: string | undefined;
Expand Down Expand Up @@ -28,6 +28,15 @@ mock.module("@inquirer/prompts", () => ({
password: (...args: unknown[]) => mockPassword(...args),
}));

mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/listage.ts", () => ({
...listageStubs,
select: (...args: unknown[]) => mockSelect(...args),
}));

const { deploy } = await import("./index.ts");

describe("deploy", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/deploy/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { select, input, confirm, password } from "@inquirer/prompts";
import { input, password } from "@inquirer/prompts";
import { select } from "../../lib/listage.ts";
import { confirm } from "../../lib/prompts.ts";
import { isAgent } from "../../mode.ts";
import { dim, bold, cyan, green, blue } from "../../lib/color.ts";
import { printNextSteps, NEXT_STEPS } from "../../lib/next-steps.ts";
Expand Down
10 changes: 3 additions & 7 deletions packages/cli-core/src/commands/init/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { join } from "node:path";
import { search, confirm, input } from "@inquirer/prompts";
import { input } from "@inquirer/prompts";
import { confirm } from "../../lib/prompts.ts";
import { search, filterChoices } from "../../lib/listage.ts";
import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js";
import { log } from "../../lib/log.js";
import type { FrameworkInfo } from "../../lib/framework.js";
Expand Down Expand Up @@ -32,12 +34,6 @@ const PM_CHOICES: Array<{ name: string; value: PackageManager }> = [
{ name: "npm", value: "npm" },
];

function filterChoices<T extends { name: string }>(choices: T[], term: string | undefined): T[] {
if (!term) return choices;
const lower = term.toLowerCase();
return choices.filter((c) => c.name.toLowerCase().includes(lower));
}

async function pickFramework(frameworkOverride?: FrameworkInfo): Promise<BootstrapEntry> {
if (!frameworkOverride) {
const chosen = await search({
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/init/preview.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { confirm } from "@inquirer/prompts";
import { confirm } from "../../lib/prompts.ts";
import { cyan, dim, green, yellow } from "../../lib/color.js";
import { log } from "../../lib/log.js";
import type { FileAction, ScaffoldPlan } from "./frameworks/types.js";
Expand Down
10 changes: 10 additions & 0 deletions packages/cli-core/src/commands/link/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
autolinkStubs,
gitStubs,
promptsStubs,
listageStubs,
} from "../../test/lib/stubs.ts";
import { PlapiError } from "../../lib/errors.ts";

Expand Down Expand Up @@ -86,6 +87,15 @@ mock.module("@inquirer/prompts", () => ({
input: (...args: unknown[]) => mockInput(...args),
}));

mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/listage.ts", () => ({
...listageStubs,
search: (...args: unknown[]) => mockSearch(...args),
}));

mock.module("../../lib/spinner.ts", () => ({
intro: () => {},
outro: () => {},
Expand Down
4 changes: 3 additions & 1 deletion packages/cli-core/src/commands/link/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { basename } from "node:path";
import { search, confirm, input } from "@inquirer/prompts";
import { input } from "@inquirer/prompts";
import { search } from "../../lib/listage.ts";
import { confirm } from "../../lib/prompts.ts";
import { isAgent } from "../../mode.ts";
import { getToken } from "../../lib/credential-store.ts";
import { login } from "../auth/login.ts";
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/skill/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { dim } from "../../lib/color.js";
import { isHuman } from "../../mode.js";
import { log } from "../../lib/log.js";
import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.js";
import { select } from "../../lib/prompts.js";
import { select } from "../../lib/listage.js";
import {
type Runner,
detectAvailableRunners,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/commands/switch-env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ clerk switch-env production

## Behavior

- When called without an argument in interactive mode, shows an interactive picker listing available environments.
- When called without an argument in non-interactive mode (agent, piped stdin), prints the current environment and available options.
- Validates the environment name against the set of available profiles (injected at build time via `CLI_ENV_PROFILES`).
- Persists the selection in `~/.clerk/config.json` under the `environment` key.
- All subsequent commands use the selected environment's API endpoints and OAuth client.
Expand Down
49 changes: 47 additions & 2 deletions packages/cli-core/src/commands/switch-env/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
import { captureLog, configStubs, credentialStoreStubs } from "../../test/lib/stubs.ts";
import {
captureLog,
configStubs,
credentialStoreStubs,
listageStubs,
} from "../../test/lib/stubs.ts";

const mockSetEnvironment = mock();
const mockGetToken = mock();
const mockSelect = mock();
let mockCurrentEnv = "production";

mock.module("../../lib/config.ts", () => ({
Expand All @@ -26,36 +32,70 @@ mock.module("../../lib/environment.ts", () => ({
},
}));

let _modeOverride: string | undefined;
mock.module("../../mode.ts", () => ({
isAgent: () => _modeOverride === "agent",
isHuman: () => _modeOverride !== "agent",
setMode: (m: string) => {
_modeOverride = m;
},
getMode: () => _modeOverride ?? "human",
}));

mock.module("../../lib/listage.ts", () => ({
...listageStubs,
select: (...args: unknown[]) => mockSelect(...args),
}));

const { switchEnv } = await import("./index.ts");

describe("switch-env", () => {
let logSpy: ReturnType<typeof spyOn>;
let captured: ReturnType<typeof captureLog>;
const originalIsTTY = process.stdin.isTTY;

beforeEach(() => {
captured = captureLog();
process.stdin.isTTY = true;
});

afterEach(() => {
captured.teardown();
mockSetEnvironment.mockReset();
mockGetToken.mockReset();
mockSelect.mockReset();
mockCurrentEnv = "production";
_modeOverride = undefined;
logSpy?.mockRestore();
process.stdin.isTTY = originalIsTTY;
});

function runSwitchEnv(environment: string | undefined) {
return captured.run(() => switchEnv(environment));
}

test("prints current environment when no argument given", async () => {
test("prints current environment in non-interactive mode", async () => {
_modeOverride = "agent";
logSpy = spyOn(console, "log").mockImplementation(() => {});
await runSwitchEnv(undefined);

expect(captured.err).toContain("Current environment: production");
expect(captured.err).toContain("Available environments: production, staging");
});

test("shows interactive picker when no argument given in human mode", async () => {
mockSetEnvironment.mockResolvedValue(undefined);
mockGetToken.mockResolvedValue("some-token");
mockSelect.mockResolvedValue("staging");

logSpy = spyOn(console, "log").mockImplementation(() => {});
await runSwitchEnv(undefined);

expect(mockSelect).toHaveBeenCalledTimes(1);
expect(mockCurrentEnv).toBe("staging");
expect(captured.out).toContain("Switched from production to staging.");
});

test("switches to a valid environment", async () => {
mockSetEnvironment.mockResolvedValue(undefined);
mockGetToken.mockResolvedValue("some-token");
Expand All @@ -78,6 +118,11 @@ describe("switch-env", () => {
expect(captured.out).toContain("Already on production environment.");
});

test("throws when no TTY is available in human mode", async () => {
process.stdin.isTTY = false;
await expect(runSwitchEnv(undefined)).rejects.toThrow("No interactive terminal available");
});

test("throws on invalid environment", async () => {
await expect(runSwitchEnv("nonexistent")).rejects.toThrow(
'Unknown environment "nonexistent". Available environments: production, staging',
Expand Down
52 changes: 37 additions & 15 deletions packages/cli-core/src/commands/switch-env/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,40 +17,62 @@ import {
} from "../../lib/environment.ts";
import { CliError } from "../../lib/errors.ts";
import { log } from "../../lib/log.ts";
import { isHuman } from "../../mode.ts";
import { select } from "../../lib/listage.ts";

export async function switchEnv(environment: string | undefined): Promise<void> {
export async function switchEnv(environmentArg: string | undefined): Promise<void> {
const available = getAvailableEnvs();
const current = getCurrentEnvName();

// No argument: print current environment
if (!environment) {
const current = getCurrentEnvName();
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
return;
// No argument: show interactive picker (human) or print info (non-interactive)
let target = environmentArg;
if (!target) {
if (isHuman() && available.length > 1 && process.stdin.isTTY) {
target = await select<string>({
Comment thread
rafa-thayto marked this conversation as resolved.
message: "Switch to environment:",
choices: available.map((env) => ({
name: env === current ? `${env} (current)` : env,
value: env,
})),
default: current,
});
} else if (isHuman() && available.length > 1 && !process.stdin.isTTY) {
throw new CliError(
"No interactive terminal available — pass an environment name explicitly: `clerk switch-env <name>`",
);
} else if (available.length <= 1) {
log.info(`Current environment: ${current}`);
log.info("Only one environment configured — nothing to switch to.");
return;
} else {
log.info(`Current environment: ${current}`);
log.info(`Available environments: ${available.join(", ")}`);
return;
}
}

if (!isValidEnv(environment)) {
if (!isValidEnv(target)) {
throw new CliError(
`Unknown environment "${environment}". Available environments: ${available.join(", ")}`,
`Unknown environment "${target}". Available environments: ${available.join(", ")}`,
);
}

const previousEnv = getCurrentEnvName();

if (previousEnv === environment) {
log.data(`Already on ${environment} environment.`);
if (previousEnv === target) {
log.data(`Already on ${target} environment.`);
return;
}

// Update the in-memory state and persist
setCurrentEnv(environment);
await setEnvironment(environment);
setCurrentEnv(target);
await setEnvironment(target);

log.data(`Switched from ${previousEnv} to ${environment}.`);
log.data(`Switched from ${previousEnv} to ${target}.`);

// Check if there's a stored token for the target environment
const token = await getToken();
if (!token) {
log.data(`No credentials found for ${environment}. Run \`clerk auth login\` to authenticate.`);
log.data(`No credentials found for ${target}. Run \`clerk auth login\` to authenticate.`);
}
}
4 changes: 4 additions & 0 deletions packages/cli-core/src/commands/unlink/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ mock.module("@inquirer/prompts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

mock.module("../../lib/prompts.ts", () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));

const { unlink } = await import("./index.ts");

const mockProfile = {
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-core/src/commands/unlink/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { confirm } from "@inquirer/prompts";
import { confirm } from "../../lib/prompts.ts";
import { isAgent, isHuman } from "../../mode.ts";
import { resolveProfile, removeProfile } from "../../lib/config.ts";
import { getGitRepoRoot } from "../../lib/git.ts";
Expand Down
Loading
Loading