Skip to content

feat(kilo-vscode): legacy migration wizard#6556

Open
catrielmuller wants to merge 8 commits intomainfrom
catrielmuller/kilo-vscode-migration
Open

feat(kilo-vscode): legacy migration wizard#6556
catrielmuller wants to merge 8 commits intomainfrom
catrielmuller/kilo-vscode-migration

Conversation

@catrielmuller
Copy link
Contributor

@catrielmuller catrielmuller commented Mar 3, 2026

Context

The new Kilo Code VS Code extension (kilo-code NEW) coexists with the legacy v5.x extension. Users who installed the legacy extension have API keys, MCP server configurations, custom modes, and personal settings stored in VS Code SecretStorage and on disk. Without migration, switching to the new extension means losing all that configuration.

This PR adds a guided migration wizard that detects legacy data on first run and walks the user through selecting what to import into the new CLI-backed extension.

Implementation

Detection

On first run (after the CLI connects and the webview is ready), checkAndShowMigrationWizard() reads legacy data from:

  • SecretStorage (roo_cline_config_api_config) — provider profiles with API keys
  • Disk (<globalStorage>/settings/mcp_settings.json) — MCP server configs
  • Disk (<globalStorage>/settings/custom_modes.yaml) — custom agent modes
  • globalState — auto-approval settings, language, autocomplete prefs

If any data is found and the user hasn't been prompted before, the webview navigates to the migration view and receives the detected data.

Migration Wizard (4-step flow)

  1. Welcome — Summary of what was found; user can skip or proceed
  2. Select — Granular checkboxes per item (individual providers, MCP servers, custom modes, and permission groups — similar to how each provider gets its own row)
  3. Progress — Live per-item status as migration runs
  4. Complete — Summary with an optional "Remove legacy settings data" cleanup step

What gets migrated

Category Target
Provider API keys client.auth.set() per provider
Custom base URLs client.global.config.update({ provider: { id: { options: { baseURL } } } })
MCP servers client.global.config.update({ mcp: {...} })
Custom modes client.global.config.update({ agent: {...} }) as agent configs
Default model client.global.config.update({ model: "provider/modelId" })
Auto-approval (7 granular groups) client.global.config.update({ permission: {...} }) — per-tool permission config
UI language VS Code config kilo-code.new.language
Autocomplete settings VS Code config kilo-code.new.autocomplete.*

Auto-approval → Permission mapping

The legacy extension had a master toggle + 12 fine-grained alwaysAllow* booleans. These are mapped to the CLI's PermissionConfig:

  • alwaysAllowReadOnlyread/glob/grep/list: "allow"
  • alwaysAllowWriteedit: "allow"
  • alwaysAllowExecute + command lists → bash: { "<cmd>": "allow"/"deny", "*": fallback }
  • alwaysAllowMcpskill: "allow"
  • alwaysAllowModeSwitch / alwaysAllowSubtaskstask: "allow"
  • alwaysAllowFollowupQuestionsquestion: "allow"
  • alwaysAllowReadOnlyOutsideWorkspaceexternal_directory: "allow"
  • Master toggle on + no specific rules → permission: "allow" (global scalar)

Provider mapping

provider-mapping.ts maps 30+ legacy provider IDs to their new CLI equivalents, handling API key field names, base URL fields, and model ID fields per provider.

Custom modes YAML parser

The legacy extension stored custom modes as YAML (with some versions using JSON). A minimal hand-rolled parser handles the specific YAML shape (slug, name, roleDefinition, groups) without adding a runtime YAML dependency.

Reliability fixes (from code review)

  • checkAndShowMigrationWizard is guarded by a migrationCheckInFlight boolean to prevent concurrent invocations (race between webviewReady and sse-connected)
  • Only triggers after connectionState === "connected" so the wizard doesn't appear before providers/agents are loaded
  • handleStartLegacyMigration is wrapped in try/catch — any network error sends a legacyMigrationComplete with error status instead of leaving the progress screen stuck
  • Global allow (autoApprovalEnabled=true, no rules) uses the scalar "allow" form of PermissionConfig, not an object with a "*" key

"Re-run migration" entry point

AboutKiloCodeTab gets a "Migrate from legacy extension" button so users who skipped the initial prompt can re-run migration from Settings → About.

Screenshots

image image image image image image

How to Test

Prerequisites: Have the legacy Kilo Code v5.x extension installed with at least one configured provider, or manually populate globalState/SecretStorage with legacy data.

Auto-trigger (first run):

  1. Install the new extension for the first time (or clear kilo.legacyMigrationStatus from globalState)
  2. Open the Kilo Code sidebar
  3. Once the CLI connects, the migration wizard should open automatically

Manual trigger:

  1. Open Settings → About Kilo Code
  2. Click "Migrate from legacy extension"

Migration steps to verify:

  1. Welcome screen shows a correct count of detected providers, MCP servers, custom modes, and settings
  2. Select screen shows each provider as its own checkbox row; auto-approval shows separate rows per permission group (Command Rules, Read Permission, Write Permission, etc.)
  3. Progress screen updates in real-time as each item migrates
  4. After completion, the configured API keys appear in the provider list immediately
  5. The CLI config at ~/.config/kilo/kilo.jsonc contains the migrated permission block
  6. "Remove legacy settings data" clears the old SecretStorage entry and globalState keys

@catrielmuller catrielmuller requested a review from markijbema March 3, 2026 13:02
async function readLegacyProviderProfiles(context: vscode.ExtensionContext): Promise<LegacyProviderProfiles | null> {
const raw = await context.secrets.get(SECRET_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as Record<string, unknown>
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: JSON.parse without error handling — corrupted SecretStorage data will throw an unhandled exception

readLegacyProviderProfiles calls JSON.parse(raw) without a try/catch. If the legacy SecretStorage entry contains malformed JSON (e.g. partial write, encoding issue, or a different extension wrote to the same key), this will throw and bubble up through detectLegacyData, potentially crashing the auto-detection on every extension startup.

The same issue exists in readLegacyMcpSettings at line 617.

Both functions should wrap the JSON.parse call and return null on failure, consistent with how parseCustomModesYaml already handles JSON parse errors gracefully.

() => null,
)
if (!bytes) return null
const parsed = JSON.parse(Buffer.from(bytes).toString("utf8")) as Record<string, unknown>
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: Same JSON.parse without try/catch issue as readLegacyProviderProfiles above.

Corrupted or truncated mcp_settings.json on disk will throw an unhandled exception here. Should return null on parse failure.

// Global allow with no specific command rules — apply immediately using the scalar form.
// PermissionConfig is "allow" | "ask" | "deny" | { read?: ..., ... }, so a global allow
// must be the scalar string, not an object with a "*" key.
await client.global.config.update({ config: { permission: "allow" } })
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: Scalar permission: "allow" will be overwritten by subsequent object update

When autoApprovalEnabled=true and there are no command lists, this line sets permission to the scalar "allow" via config.update. However, if the user also selected other permission groups (read, write, MCP, etc.), lines 508-509 will call config.update({ permission: { read: "allow", edit: "allow", ... } }) — an object that overwrites the scalar "allow", effectively downgrading the global allow to only the specific permissions in the object.

Consider either:

  1. Skipping the scalar update here and instead setting all individual permission keys to "allow" in the permission object, or
  2. Short-circuiting: if global allow is set, skip the per-tool permission object update entirely and return early.

message: error instanceof Error ? error.message : String(error),
},
],
})
Copy link
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION]: cachedLegacyData is never cleared after migration completes or is skipped

The cachedLegacyData field may contain sensitive data (provider API keys from the legacy profiles). After handleStartLegacyMigration completes or handleSkipLegacyMigration is called, consider setting this.cachedLegacyData = null to avoid keeping API keys in memory longer than necessary.

@kilo-code-bot
Copy link
Contributor

kilo-code-bot bot commented Mar 3, 2026

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Fix these issues in Kilo Cloud

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
packages/kilo-vscode/src/legacy-migration/provider-mapping.ts 124 lmstudio mapping uses key: "lmStudioBaseUrl" — a URL stored as auth credential, not an API key
packages/kilo-vscode/src/KiloProvider.ts 2026 migrationCheckInFlight not reset in finally block — if detectLegacyData throws, flag stays true forever
packages/kilo-vscode/src/legacy-migration/migration-service.ts 327 OAuth auth for Kilo Gateway uses refresh: "" and expires: 0 — token may appear immediately expired
packages/opencode/src/server/server.ts 172 Instance.disposeAll() on every auth set/remove — migrating N providers triggers N full instance disposals

SUGGESTION

File Line Issue
packages/kilo-vscode/src/legacy-migration/migration-service.ts 648 description field uses customInstructions (can be very long) instead of mode.description or mode.whenToUse
Files Reviewed (14 files)
  • packages/kilo-vscode/package.json - 0 issues
  • packages/kilo-vscode/src/KiloProvider.ts - 1 issue
  • packages/kilo-vscode/src/extension.ts - 0 issues
  • packages/kilo-vscode/src/legacy-migration/index.ts - 0 issues
  • packages/kilo-vscode/src/legacy-migration/legacy-types.ts - 0 issues
  • packages/kilo-vscode/src/legacy-migration/migration-messages.ts - 0 issues
  • packages/kilo-vscode/src/legacy-migration/migration-service.ts - 2 issues
  • packages/kilo-vscode/src/legacy-migration/provider-mapping.ts - 1 issue
  • packages/kilo-vscode/webview-ui/src/App.tsx - 0 issues
  • packages/kilo-vscode/webview-ui/src/components/migration/MigrationWizard.tsx - 0 issues
  • packages/kilo-vscode/webview-ui/src/components/settings/AboutKiloCodeTab.tsx - 0 issues
  • packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx - 0 issues
  • packages/opencode/src/provider/auth.ts - 0 issues
  • packages/opencode/src/server/server.ts - 1 issue

@markijbema
Copy link
Contributor

are you sure the question functionality means the same? allow in the old extension means 'pick the first answer', is that the same to what it does in the new extension/cli? I think we might want to consider not migrating that if not.

}

// Follow-up question permissions
if (sel.questionPermission) {
Copy link
Contributor

Choose a reason for hiding this comment

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

as said in the other screen, are we sure that question permission means the same in the old and new extension?

if (settings.enableChatAutocomplete !== undefined) {
await config.update("enableChatAutocomplete", settings.enableChatAutocomplete, vscode.ConfigurationTarget.Global)
}
// Only migrate snooze if the timestamp is still in the future
Copy link
Contributor

Choose a reason for hiding this comment

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

please dont migrate snooze

return { item: "Autocomplete settings", category: "settings", status: "success" }
}

const SUPPORTED_LOCALES = new Set([
Copy link
Contributor

Choose a reason for hiding this comment

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

there is some mapping needed, as languages in the old extension have another format. Did you test this?

},
lmstudio: {
id: "lmstudio",
key: "lmStudioBaseUrl",
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: lmstudio mapping uses key: "lmStudioBaseUrl" — this is a URL, not an API key

LM Studio typically doesn't use API keys; it uses a base URL for local connections. Using the base URL as the key field means:

  1. migrateProvider will store the base URL as an auth credential via client.auth.set({ providerID: "lmstudio", auth: { type: "api", key: <base_url> } })
  2. Since urlField is also "lmStudioBaseUrl", the same value gets stored again in config.provider.lmstudio.options.apiKey (line 337 of migration-service.ts)

This provider likely needs special handling — either skip auth entirely and only migrate the base URL + model, or use a sentinel/empty key.

const status = MigrationService.getMigrationStatus(this.extensionContext)
if (status) return // already prompted (skipped or completed)

this.migrationCheckInFlight = true
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: migrationCheckInFlight is not reset if detectLegacyData throws

If detectLegacyData rejects (e.g. SecretStorage read fails), migrationCheckInFlight stays true forever because line 2028 is never reached. This permanently blocks the migration wizard from appearing.

Wrap in try/finally:

this.migrationCheckInFlight = true
try {
  const data = await MigrationService.detectLegacyData(this.extensionContext)
  // ... rest of logic
} finally {
  this.migrationCheckInFlight = false
}

: undefined

const auth = organizationId
? { type: "oauth" as const, access: apiKey, refresh: "", expires: 0, accountId: organizationId }
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: OAuth auth for Kilo Gateway uses refresh: "" and expires: 0

The expires: 0 means the token appears immediately expired. If the CLI checks token expiry and attempts a refresh using the empty refresh token, this will fail. Verify that the CLI's auth layer treats expires: 0 as "no expiry" rather than "expired at epoch". If it interprets this as expired, the migrated Kilo Gateway credentials will be unusable.

Consider using a far-future timestamp (e.g. Date.now() + 365 * 24 * 60 * 60 * 1000) or a sentinel value that the CLI recognizes as "never expires".

const prompt = [mode.roleDefinition, mode.customInstructions].filter(Boolean).join("\n\n")
return {
mode: "primary",
description: mode.customInstructions ?? mode.roleDefinition?.slice(0, 120),
Copy link
Contributor

Choose a reason for hiding this comment

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

[SUGGESTION]: description uses customInstructions which can be arbitrarily long

The LegacyCustomMode type has dedicated description and whenToUse fields that are more semantically appropriate for a short description. Using customInstructions (which is typically a long block of instructions) as the description may produce poor UX in the agent list.

Consider:

description: mode.description ?? mode.whenToUse ?? mode.roleDefinition?.slice(0, 120),

await Auth.set(providerID, info)
// kilocode_change start - invalidate provider/model cache after auth change
ModelCache.clear(providerID)
void Instance.disposeAll().catch(() => undefined)
Copy link
Contributor

Choose a reason for hiding this comment

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

[WARNING]: Instance.disposeAll() on every auth set/remove will cause N full instance disposals during migration

The migration service calls client.auth.set() in a loop for each selected provider (migration-service.ts line 330). Each call hits this endpoint, triggering Instance.disposeAll(). Migrating 5+ providers means 5+ rapid disposal cycles.

While disposeAll has a dedup guard for concurrent calls, sequential calls after each disposal completes will each trigger a full disposal. Consider:

  • Adding a batch auth endpoint that accepts multiple provider credentials
  • Or debouncing the disposeAll() call (e.g. with a short timer)
  • Or having the migration service call a single "refresh" after all providers are migrated

Copy link
Contributor

@markijbema markijbema left a comment

Choose a reason for hiding this comment

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

I'm not a 100% sure of all mappings, and i feel like we need good testing here to make sure it works like expected, because people do this once, and expect it to work

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants