mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
203
src/commands/migrate/skill-selection-prompt.ts
Normal file
203
src/commands/migrate/skill-selection-prompt.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user