feat: improve Codex skill migration selection (#77597)

* feat: improve Codex skill migration selection

* docs: add Codex migration changelog entry

* fix codex skill migration bulk toggles

* fix codex migration skip selection

* fix codex migration skip option order

* fix: handle codex migration shortcut toggles

* fix codex migration shortcut reconciliation

* fix: unblock Codex migration CI
This commit is contained in:
Kevin Lin
2026-05-05 16:41:26 -07:00
committed by GitHub
parent d9545babb5
commit 81349cdc2a
9 changed files with 672 additions and 19 deletions

View File

@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.
- Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc.
- CLI/migrate: add bulk on/off and skip controls to interactive Codex skill migration, leaving conflicting skill copies unchecked by default. (#77597) Thanks @kevinslin.
- OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc.
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc.
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.

View File

@@ -119,9 +119,10 @@ your personal Codex CLI state by default.
Running `openclaw migrate codex` in an interactive terminal previews the full
plan, then opens a checkbox selector for skill copy items before the final
apply confirmation. All skills start selected; uncheck any skill you do not want
copied into this agent. For scripted or exact runs, pass `--skill <name>` once
per skill, for example:
apply confirmation. Use `Toggle all on` or `Toggle all off` for bulk selection;
planned skills start checked, conflict skills start unchecked, and `Skip for now`
leaves skills unchanged without applying. For scripted or exact runs, pass
`--skill <name>` once per skill, for example:
```bash
openclaw migrate codex --dry-run --skill gog-vault77-google-workspace

View File

@@ -1674,6 +1674,7 @@
"@aws-sdk/client-bedrock-runtime": "3.1042.0",
"@aws-sdk/credential-provider-node": "3.972.39",
"@aws/bedrock-token-generator": "^1.1.0",
"@clack/core": "^1.3.0",
"@clack/prompts": "^1.3.0",
"@google/genai": "^1.51.0",
"@grammyjs/runner": "^2.0.3",

3
pnpm-lock.yaml generated
View File

@@ -64,6 +64,9 @@ importers:
'@aws/bedrock-token-generator':
specifier: ^1.1.0
version: 1.1.0
'@clack/core':
specifier: ^1.3.0
version: 1.3.0
'@clack/prompts':
specifier: ^1.3.0
version: 1.3.0

View File

@@ -34,7 +34,10 @@ vi.mock("../cli/prompt.js", () => ({
vi.mock("@clack/prompts", () => ({
cancel: mocks.clackCancel,
isCancel: mocks.clackIsCancel,
multiselect: mocks.multiselect,
}));
vi.mock("./migrate/skill-selection-prompt.js", () => ({
promptMigrationSkillSelectionValues: mocks.multiselect,
}));
vi.mock("../plugins/migration-provider-runtime.js", () => ({
@@ -47,6 +50,11 @@ vi.mock("./backup.js", () => ({
backupCreateCommand: mocks.backupCreateCommand,
}));
const {
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
} = await import("./migrate/selection.js");
const { migrateApplyCommand, migrateDefaultCommand } = await import("./migrate.js");
function plan(overrides: Partial<MigrationPlan> = {}): MigrationPlan {
@@ -215,10 +223,22 @@ describe("migrateApplyCommand", () => {
message: expect.stringContaining("Select Codex skills"),
initialValues: ["skill:alpha", "skill:beta"],
required: false,
options: expect.arrayContaining([
options: [
expect.objectContaining({
value: MIGRATION_SKILL_SELECTION_SKIP,
label: "Skip for now",
}),
expect.objectContaining({
value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
label: "Toggle all on",
}),
expect.objectContaining({
value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
label: "Toggle all off",
}),
expect.objectContaining({ value: "skill:alpha", label: "alpha" }),
expect.objectContaining({ value: "skill:beta", label: "beta" }),
]),
],
}),
);
expect(mocks.promptYesNo).toHaveBeenCalledWith("Apply this migration now?", false);
@@ -237,6 +257,155 @@ describe("migrateApplyCommand", () => {
);
});
it("leaves conflicting Codex skills unchecked by default", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
const planned = codexSkillPlan({
summary: {
total: 3,
planned: 2,
migrated: 0,
skipped: 0,
conflicts: 1,
errors: 0,
sensitive: 0,
},
items: [
{
id: "skill:alpha",
kind: "skill",
action: "copy",
status: "planned",
details: { skillName: "alpha" },
},
{
id: "skill:beta",
kind: "skill",
action: "copy",
status: "conflict",
reason: "target exists",
details: { skillName: "beta" },
},
{
id: "archive:config.toml",
kind: "archive",
action: "archive",
status: "planned",
},
],
});
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue(["skill:alpha"]);
mocks.promptYesNo.mockResolvedValue(false);
await migrateDefaultCommand(runtime, { provider: "codex" });
expect(mocks.multiselect).toHaveBeenCalledWith(
expect.objectContaining({
initialValues: ["skill:alpha"],
options: expect.arrayContaining([
expect.objectContaining({ value: "skill:beta", label: "beta" }),
]),
}),
);
expect(mocks.promptYesNo).toHaveBeenCalledWith("Apply this migration now?", false);
expect(mocks.provider.apply).not.toHaveBeenCalled();
});
it("skips interactive Codex skill migration when Skip for now is selected", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
const planned = codexSkillPlan();
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_SKIP]);
const result = await migrateDefaultCommand(runtime, { provider: "codex" });
expect(result).toBe(planned);
expect(mocks.promptYesNo).not.toHaveBeenCalled();
expect(mocks.backupCreateCommand).not.toHaveBeenCalled();
expect(mocks.provider.apply).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("Codex skill migration skipped for now.");
});
it("applies Toggle all off before interactive Codex migration apply", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
const planned = codexSkillPlan();
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]);
mocks.promptYesNo.mockResolvedValue(true);
mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({
...selectedPlan,
summary: { ...selectedPlan.summary, planned: 0, migrated: 1 },
items: selectedPlan.items.map((item) =>
item.status === "planned" ? { ...item, status: "migrated" as const } : item,
),
}));
await migrateDefaultCommand(runtime, { provider: "codex" });
const appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan;
expect(appliedPlan.summary).toMatchObject({ planned: 1, skipped: 2, conflicts: 0 });
expect(appliedPlan.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "skill:alpha",
status: "skipped",
reason: "not selected for migration",
}),
expect.objectContaining({
id: "skill:beta",
status: "skipped",
reason: "not selected for migration",
}),
expect.objectContaining({ id: "archive:config.toml", status: "planned" }),
]),
);
});
it("applies Toggle all on unless Toggle all off is also selected", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
const planned = codexSkillPlan();
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON]);
mocks.promptYesNo.mockResolvedValue(true);
mocks.provider.apply.mockImplementation(async (_ctx, selectedPlan: MigrationPlan) => ({
...selectedPlan,
summary: { ...selectedPlan.summary, planned: 0, migrated: 3 },
items: selectedPlan.items.map((item) =>
item.status === "planned" ? { ...item, status: "migrated" as const } : item,
),
}));
await migrateDefaultCommand(runtime, { provider: "codex" });
let appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan;
expect(appliedPlan.summary).toMatchObject({ planned: 3, skipped: 0, conflicts: 0 });
mocks.provider.plan.mockResolvedValue(planned);
mocks.multiselect.mockResolvedValue([
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
]);
mocks.promptYesNo.mockResolvedValue(true);
mocks.provider.apply.mockClear();
await migrateDefaultCommand(runtime, { provider: "codex" });
appliedPlan = mocks.provider.apply.mock.calls[0]?.[1] as MigrationPlan;
expect(appliedPlan.summary).toMatchObject({ planned: 1, skipped: 2, conflicts: 0 });
});
it("does not apply when interactive apply confirmation is declined", async () => {
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,

View File

@@ -1,4 +1,4 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import { cancel, isCancel } from "@clack/prompts";
import { promptYesNo } from "../cli/prompt.js";
import { getRuntimeConfig } from "../config/config.js";
import { redactMigrationPlan } from "../plugin-sdk/migration.js";
@@ -18,9 +18,15 @@ import {
applyMigrationSkillSelection,
formatMigrationSkillSelectionHint,
formatMigrationSkillSelectionLabel,
getDefaultMigrationSkillSelectionValues,
getMigrationSkillSelectionValue,
getSelectableMigrationSkillItems,
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
resolveInteractiveMigrationSkillSelection,
} from "./migrate/selection.js";
import { promptMigrationSkillSelectionValues } from "./migrate/skill-selection-prompt.js";
import type {
MigrateApplyOptions,
MigrateCommonOptions,
@@ -51,26 +57,48 @@ async function promptCodexMigrationSkillSelection(
if (skillItems.length === 0) {
return plan;
}
const selected = await multiselect<string>({
const selected = await promptMigrationSkillSelectionValues({
message: stylePromptMessage("Select Codex skills to migrate into this agent"),
options: skillItems.map((item) => {
const hint = formatMigrationSkillSelectionHint(item);
return {
value: getMigrationSkillSelectionValue(item),
label: formatMigrationSkillSelectionLabel(item),
hint: hint === undefined ? undefined : stylePromptHint(hint),
};
}),
initialValues: skillItems.map(getMigrationSkillSelectionValue),
options: [
{
value: MIGRATION_SKILL_SELECTION_SKIP,
label: "Skip for now",
},
{
value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
label: "Toggle all on",
},
{
value: MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
label: "Toggle all off",
},
...skillItems.map((item) => {
const hint = formatMigrationSkillSelectionHint(item);
return {
value: getMigrationSkillSelectionValue(item),
label: formatMigrationSkillSelectionLabel(item),
hint: hint === undefined ? undefined : stylePromptHint(hint),
};
}),
],
initialValues: getDefaultMigrationSkillSelectionValues(skillItems),
required: false,
selectableValues: skillItems.map(getMigrationSkillSelectionValue),
});
if (isCancel(selected)) {
cancel(stylePromptTitle("Migration cancelled.") ?? "Migration cancelled.");
runtime.log("Migration cancelled.");
return null;
}
const selectedPlan = applyMigrationSelectedSkillItemIds(plan, new Set(selected));
runtime.log(`Selected ${selected.length} of ${skillItems.length} Codex skills for migration.`);
const selection = resolveInteractiveMigrationSkillSelection(skillItems, selected ?? []);
if (selection.action === "skip") {
runtime.log("Codex skill migration skipped for now.");
return null;
}
const selectedPlan = applyMigrationSelectedSkillItemIds(plan, selection.selectedItemIds);
runtime.log(
`Selected ${selection.selectedItemIds.size} of ${skillItems.length} Codex skills for migration.`,
);
return selectedPlan;
}

View File

@@ -3,7 +3,14 @@ import type { MigrationItem, MigrationPlan } from "../../plugins/types.js";
import {
applyMigrationSelectedSkillItemIds,
applyMigrationSkillSelection,
getDefaultMigrationSkillSelectionValues,
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
MIGRATION_SKILL_NOT_SELECTED_REASON,
reconcileInteractiveMigrationShortcutValues,
reconcileInteractiveMigrationSkillToggleValues,
resolveInteractiveMigrationSkillSelection,
} from "./selection.js";
function skillItem(params: {
@@ -136,6 +143,151 @@ describe("applyMigrationSkillSelection", () => {
expect(selected.items.every((item) => item.status === "skipped")).toBe(true);
});
it("defaults interactive selection to planned skills only", () => {
expect(
getDefaultMigrationSkillSelectionValues([
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({
id: "skill:beta",
name: "beta",
status: "conflict",
reason: "target exists",
}),
]),
).toEqual(["skill:alpha"]);
});
it("resolves interactive special options with skip and toggle-off precedence", () => {
const items = [
skillItem({ id: "skill:alpha", name: "alpha" }),
skillItem({
id: "skill:beta",
name: "beta",
status: "conflict",
reason: "target exists",
}),
];
expect(
resolveInteractiveMigrationSkillSelection(items, [
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
]),
).toEqual({ action: "skip" });
expect(
resolveInteractiveMigrationSkillSelection(items, [
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
]),
).toEqual({ action: "select", selectedItemIds: new Set() });
expect(
resolveInteractiveMigrationSkillSelection(items, [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON]),
).toEqual({
action: "select",
selectedItemIds: new Set(["skill:alpha", "skill:beta"]),
});
expect(
resolveInteractiveMigrationSkillSelection(items, [
MIGRATION_SKILL_SELECTION_SKIP,
"skill:alpha",
]),
).toEqual({
action: "select",
selectedItemIds: new Set(["skill:alpha"]),
});
});
it("reconciles live interactive bulk toggle checkbox state", () => {
const selectable = ["skill:alpha", "skill:beta"];
expect(
reconcileInteractiveMigrationSkillToggleValues(
[MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON],
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
selectable,
),
).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, "skill:alpha", "skill:beta"]);
expect(
reconcileInteractiveMigrationSkillToggleValues(
[
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
"skill:alpha",
"skill:beta",
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
],
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
selectable,
),
).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]);
expect(
reconcileInteractiveMigrationSkillToggleValues(
[MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF, "skill:alpha"],
"skill:alpha",
selectable,
),
).toEqual(["skill:alpha"]);
expect(
reconcileInteractiveMigrationSkillToggleValues(
[
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
"skill:alpha",
"skill:beta",
MIGRATION_SKILL_SELECTION_SKIP,
],
MIGRATION_SKILL_SELECTION_SKIP,
selectable,
),
).toEqual([MIGRATION_SKILL_SELECTION_SKIP]);
expect(
reconcileInteractiveMigrationSkillToggleValues(
[MIGRATION_SKILL_SELECTION_SKIP, "skill:alpha"],
"skill:alpha",
selectable,
),
).toEqual(["skill:alpha"]);
expect(
reconcileInteractiveMigrationShortcutValues(
["skill:alpha", "skill:beta"],
[
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
"skill:alpha",
"skill:beta",
],
selectable,
"a",
),
).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]);
expect(
reconcileInteractiveMigrationShortcutValues(
[MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF],
[
MIGRATION_SKILL_SELECTION_SKIP,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF,
MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON,
],
selectable,
"i",
),
).toEqual([MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF]);
expect(
reconcileInteractiveMigrationShortcutValues(
[MIGRATION_SKILL_SELECTION_SKIP],
[MIGRATION_SKILL_SELECTION_SKIP, "skill:beta"],
selectable,
"i",
),
).toEqual(["skill:beta"]);
});
it("rejects unknown explicit skill selectors with available choices", () => {
expect(() =>
applyMigrationSkillSelection(

View File

@@ -3,6 +3,13 @@ import { markMigrationItemSkipped, summarizeMigrationItems } from "../../plugin-
import type { MigrationItem, MigrationPlan } from "../../plugins/types.js";
export const MIGRATION_SKILL_NOT_SELECTED_REASON = "not selected for migration";
export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON = "__openclaw_migrate_toggle_all_on__";
export const MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF = "__openclaw_migrate_toggle_all_off__";
export const MIGRATION_SKILL_SELECTION_SKIP = "__openclaw_migrate_skip_for_now__";
export type InteractiveMigrationSkillSelection =
| { action: "skip" }
| { action: "select"; selectedItemIds: Set<string> };
function normalizeSelectionRef(value: string): string {
return value.trim().toLowerCase();
@@ -112,6 +119,10 @@ export function getMigrationSkillSelectionValue(item: MigrationItem): string {
return item.id;
}
export function getDefaultMigrationSkillSelectionValues(items: readonly MigrationItem[]): string[] {
return items.filter((item) => item.status === "planned").map(getMigrationSkillSelectionValue);
}
export function formatMigrationSkillSelectionLabel(item: MigrationItem): string {
return readMigrationSkillName(item) ?? item.id.replace(/^skill:/u, "");
}
@@ -157,3 +168,87 @@ export function applyMigrationSkillSelection(
const selectedIds = resolveSelectedSkillItemIds(selectable, selectedSkillRefs);
return applyMigrationSelectedSkillItemIds(plan, selectedIds);
}
export function resolveInteractiveMigrationSkillSelection(
items: readonly MigrationItem[],
selectedValues: readonly string[],
): InteractiveMigrationSkillSelection {
const selectableIds = new Set(items.map(getMigrationSkillSelectionValue));
const selectedItemIds = new Set(selectedValues.filter((value) => selectableIds.has(value)));
if (selectedItemIds.size > 0) {
return { action: "select", selectedItemIds };
}
const selectedValueSet = new Set(selectedValues);
if (selectedValueSet.has(MIGRATION_SKILL_SELECTION_SKIP)) {
return { action: "skip" };
}
if (selectedValueSet.has(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF)) {
return { action: "select", selectedItemIds: new Set() };
}
if (selectedValueSet.has(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON)) {
return { action: "select", selectedItemIds: selectableIds };
}
return {
action: "select",
selectedItemIds,
};
}
export function reconcileInteractiveMigrationSkillToggleValues(
selectedValues: readonly string[],
activatedValue: string | undefined,
selectableValues: readonly string[],
): string[] {
if (activatedValue === MIGRATION_SKILL_SELECTION_SKIP) {
return selectedValues.includes(MIGRATION_SKILL_SELECTION_SKIP)
? [MIGRATION_SKILL_SELECTION_SKIP]
: [];
}
if (activatedValue === MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON) {
return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, ...selectableValues];
}
if (activatedValue === MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF) {
return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF];
}
if (activatedValue !== undefined && selectableValues.includes(activatedValue)) {
return selectedValues.filter(
(value) =>
value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON &&
value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF &&
value !== MIGRATION_SKILL_SELECTION_SKIP,
);
}
return selectedValues.filter(
(value) =>
value !== MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON ||
!selectedValues.includes(MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF),
);
}
export function reconcileInteractiveMigrationShortcutValues(
previousValues: readonly string[],
selectedValues: readonly string[],
selectableValues: readonly string[],
key: "a" | "i",
): string[] {
const previousSelectable = previousValues.filter((value) => selectableValues.includes(value));
if (
key === "a" &&
!previousValues.includes(MIGRATION_SKILL_SELECTION_SKIP) &&
previousSelectable.length === selectableValues.length
) {
return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF];
}
const selectedSelectable = selectedValues.filter((value) => selectableValues.includes(value));
if (selectedSelectable.length === selectableValues.length) {
return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_ON, ...selectableValues];
}
if (selectedSelectable.length === 0) {
return [MIGRATION_SKILL_SELECTION_TOGGLE_ALL_OFF];
}
return selectedSelectable;
}

View File

@@ -0,0 +1,203 @@
import { styleText } from "node:util";
import { MultiSelectPrompt, settings, wrapTextWithPrefix } from "@clack/core";
import {
limitOptions,
S_BAR,
S_BAR_END,
S_CHECKBOX_ACTIVE,
S_CHECKBOX_INACTIVE,
S_CHECKBOX_SELECTED,
symbol,
symbolBar,
} from "@clack/prompts";
import {
reconcileInteractiveMigrationShortcutValues,
reconcileInteractiveMigrationSkillToggleValues,
} from "./selection.js";
type MigrationSkillSelectionOption = {
value: string;
label?: string;
hint?: string;
disabled?: boolean;
};
export type MigrationSkillSelectionPromptOptions = {
message: string;
options: MigrationSkillSelectionOption[];
initialValues?: string[];
maxItems?: number;
required?: boolean;
cursorAt?: string;
input?: NodeJS.ReadStream;
output?: NodeJS.WriteStream;
signal?: AbortSignal;
withGuide?: boolean;
selectableValues: readonly string[];
};
function formatOption(
option: MigrationSkillSelectionOption,
state:
| "active"
| "active-selected"
| "cancelled"
| "disabled"
| "inactive"
| "selected"
| "submitted",
): string {
const label = option.label ?? option.value;
const withHint = option.hint ? `${label} ${styleText("dim", `(${option.hint})`)}` : label;
switch (state) {
case "active":
return `${styleText("cyan", S_CHECKBOX_ACTIVE)} ${withHint}`;
case "active-selected":
return `${styleText("green", S_CHECKBOX_SELECTED)} ${withHint}`;
case "cancelled":
return styleText(["strikethrough", "dim"], label);
case "disabled":
return `${styleText("gray", S_CHECKBOX_INACTIVE)} ${styleText(["strikethrough", "gray"], label)}${
option.hint ? ` ${styleText("dim", `(${option.hint})`)}` : ""
}`;
case "selected":
return `${styleText("green", S_CHECKBOX_SELECTED)} ${styleText("dim", withHint)}`;
case "submitted":
return styleText("dim", label);
case "inactive":
return `${styleText("dim", S_CHECKBOX_INACTIVE)} ${styleText("dim", withHint)}`;
}
return withHint;
}
export function promptMigrationSkillSelectionValues(
opts: MigrationSkillSelectionPromptOptions,
): Promise<string[] | symbol | undefined> {
const required = opts.required ?? true;
const prompt = new MultiSelectPrompt<MigrationSkillSelectionOption>({
options: opts.options,
signal: opts.signal,
input: opts.input,
output: opts.output,
initialValues: opts.initialValues,
required,
cursorAt: opts.cursorAt,
validate(value) {
if (required && (value === undefined || value.length === 0)) {
return "Please select at least one option.";
}
return undefined;
},
render() {
const withGuide = opts.withGuide ?? settings.withGuide;
const message = wrapTextWithPrefix(
opts.output,
opts.message,
withGuide ? `${symbolBar(this.state)} ` : "",
`${symbol(this.state)} `,
);
const header = `${withGuide ? `${styleText("gray", S_BAR)}\n` : ""}${message}\n`;
const value = this.value ?? [];
const optionState = (option: MigrationSkillSelectionOption, active: boolean) => {
if (option.disabled) {
return formatOption(option, "disabled");
}
const selected = value.includes(option.value);
if (active && selected) {
return formatOption(option, "active-selected");
}
if (selected) {
return formatOption(option, "selected");
}
return formatOption(option, active ? "active" : "inactive");
};
switch (this.state) {
case "submit": {
const selected = this.options
.filter((option) => value.includes(option.value))
.map((option) => formatOption(option, "submitted"))
.join(styleText("dim", ", "));
const label = selected || styleText("dim", "none");
return `${header}${wrapTextWithPrefix(opts.output, label, withGuide ? `${styleText("gray", S_BAR)} ` : "")}`;
}
case "cancel": {
const selected = this.options
.filter((option) => value.includes(option.value))
.map((option) => formatOption(option, "cancelled"))
.join(styleText("dim", ", "));
if (selected.trim() === "") {
return `${header}${styleText("gray", S_BAR)}`;
}
return `${header}${wrapTextWithPrefix(
opts.output,
selected,
withGuide ? `${styleText("gray", S_BAR)} ` : "",
)}${withGuide ? `\n${styleText("gray", S_BAR)}` : ""}`;
}
case "error": {
const prefix = withGuide ? `${styleText("yellow", S_BAR)} ` : "";
const body = limitOptions({
output: opts.output,
options: this.options,
cursor: this.cursor,
maxItems: opts.maxItems,
columnPadding: prefix.length,
rowPadding: header.split("\n").length + this.error.split("\n").length + 1,
style: optionState,
}).join(`\n${prefix}`);
const error = this.error
.split("\n")
.map((line, index) =>
index === 0
? `${withGuide ? `${styleText("yellow", S_BAR_END)} ` : ""}${styleText("yellow", line)}`
: ` ${line}`,
)
.join("\n");
return `${header}${prefix}${body}\n${error}\n`;
}
default: {
const prefix = withGuide ? `${styleText("cyan", S_BAR)} ` : "";
return `${header}${prefix}${limitOptions({
output: opts.output,
options: this.options,
cursor: this.cursor,
maxItems: opts.maxItems,
columnPadding: prefix.length,
rowPadding: header.split("\n").length + (withGuide ? 2 : 1),
style: optionState,
}).join(`\n${prefix}`)}\n${withGuide ? styleText("cyan", S_BAR_END) : ""}\n`;
}
}
},
});
let lastSelectedValues = [...(prompt.value ?? [])];
prompt.on("cursor", (key) => {
if (key !== "space") {
return;
}
const activatedValue = prompt.options[prompt.cursor]?.value;
prompt.value = reconcileInteractiveMigrationSkillToggleValues(
prompt.value ?? [],
activatedValue,
opts.selectableValues,
);
lastSelectedValues = [...(prompt.value ?? [])];
});
prompt.on("key", (key) => {
if (key !== "a" && key !== "i") {
return;
}
prompt.value = reconcileInteractiveMigrationShortcutValues(
lastSelectedValues,
prompt.value ?? [],
opts.selectableValues,
key,
);
lastSelectedValues = [...(prompt.value ?? [])];
});
return prompt.prompt();
}