From bbc8cd44f3e15b2fd1e7ddb31b06e7fe7b7af684 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 25 May 2026 20:33:02 +0800 Subject: [PATCH 1/3] feat(migration-legacy): migrate user skills from kimi-cli The first-launch migration previously left ~/.kimi/skills/ behind: the new scanner only reads ~/.kimi-code/skills/ and ~/.agents/skills/, so any custom skills authored against kimi-cli silently disappeared after the upgrade. Adds a skills step that copies top-level entries from ~/.kimi/skills/ into ~/.kimi-code/skills/ with skip-existing semantics, wires it into the existing run-migration pipeline and result screen, and surfaces the count alongside config/mcp/REPL-history. --- .../src/migration/migration-screen.ts | 1 + .../test/migration/migration-screen.test.ts | 1 + packages/migration-legacy/src/paths.ts | 2 + packages/migration-legacy/src/prompt.ts | 1 + .../migration-legacy/src/run-migration.ts | 8 ++ packages/migration-legacy/src/steps/skills.ts | 78 +++++++++++ packages/migration-legacy/src/types.ts | 2 + .../migration-legacy/test/integration.test.ts | 59 +++++++- packages/migration-legacy/test/prompt.test.ts | 2 + packages/migration-legacy/test/report.test.ts | 1 + .../test/steps/skills.test.ts | 129 ++++++++++++++++++ 11 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 packages/migration-legacy/src/steps/skills.ts create mode 100644 packages/migration-legacy/test/steps/skills.test.ts diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index 3f981492..d713912c 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -304,6 +304,7 @@ export class MigrationScreenComponent extends Container implements Focusable { if (sum.config.migratedHooks > 0) migratedKinds.push('hooks'); if (sum.mcp.mergedServers.length > 0) migratedKinds.push('MCP'); if (sum.userHistory.copied > 0) migratedKinds.push('REPL history'); + if (sum.skills.copied > 0) migratedKinds.push('skills'); if (migratedKinds.length > 0) { lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`)); } diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 90b34dca..7424f8c3 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -298,6 +298,7 @@ function makeReport( }, mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, userHistory: { copied: 12, skippedExisting: 0 }, + skills: { copied: 0, skippedExisting: 0 }, sessions: { scope: 'all', bucketsScanned: 0, diff --git a/packages/migration-legacy/src/paths.ts b/packages/migration-legacy/src/paths.ts index 37f785f8..0b4c8bb0 100644 --- a/packages/migration-legacy/src/paths.ts +++ b/packages/migration-legacy/src/paths.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; export const sourceCredentialsDir = (src: string): string => join(src, 'credentials'); export const sourceSessionsDir = (src: string): string => join(src, 'sessions'); export const sourceUserHistoryDir = (src: string): string => join(src, 'user-history'); +export const sourceSkillsDir = (src: string): string => join(src, 'skills'); export const sourceKimiJson = (src: string): string => join(src, 'kimi.json'); export const sourceConfigToml = (src: string): string => join(src, 'config.toml'); export const sourceMcpJson = (src: string): string => join(src, 'mcp.json'); @@ -14,6 +15,7 @@ export const migratedMarker = (src: string): string => join(src, '.migrated-to-k // Target (~/.kimi-code/) paths export const targetSessionsDir = (tgt: string): string => join(tgt, 'sessions'); export const targetUserHistoryDir = (tgt: string): string => join(tgt, 'user-history'); +export const targetSkillsDir = (tgt: string): string => join(tgt, 'skills'); export const targetConfigFile = (tgt: string): string => join(tgt, 'config.toml'); export const targetTuiFile = (tgt: string): string => join(tgt, 'tui.toml'); export const targetMcpFile = (tgt: string): string => join(tgt, 'mcp.json'); diff --git a/packages/migration-legacy/src/prompt.ts b/packages/migration-legacy/src/prompt.ts index 0b740bab..61932aed 100644 --- a/packages/migration-legacy/src/prompt.ts +++ b/packages/migration-legacy/src/prompt.ts @@ -42,6 +42,7 @@ export function resolveMigrationScope( config: true, mcp: true, userHistory: true, + skills: true, sessions: c2 === 'all-sessions', }, }; diff --git a/packages/migration-legacy/src/run-migration.ts b/packages/migration-legacy/src/run-migration.ts index 412ec3ff..11ee1892 100644 --- a/packages/migration-legacy/src/run-migration.ts +++ b/packages/migration-legacy/src/run-migration.ts @@ -7,6 +7,7 @@ import type { import { migrateConfigStep } from './steps/config.js'; import { migrateMcpStep } from './steps/mcp.js'; import { migrateUserHistoryStep } from './steps/user-history.js'; +import { migrateSkillsStep } from './steps/skills.js'; import { migrateSessionsStep } from './sessions/index.js'; import { writeReport } from './report.js'; import { writeMigrationErrorsLog } from './migration-errors-log.js'; @@ -64,6 +65,11 @@ export async function runMigration(input: RunMigrationInput): Promise { + const srcDir = sourceSkillsDir(input.sourceHome); + const tgtDir = targetSkillsDir(input.targetHome); + + let entries: string[]; + try { + entries = await readdir(srcDir); + } catch { + return { copied: 0, skippedExisting: 0 }; + } + + let copied = 0; + let skippedExisting = 0; + let targetDirReady = false; + for (const name of entries) { + const srcPath = join(srcDir, name); + const tgtPath = join(tgtDir, name); + + try { + await stat(srcPath); + } catch { + continue; + } + + if (existsSync(tgtPath)) { + skippedExisting++; + continue; + } + + // Defer creating the target root until we know there is something to put + // in it — touching it earlier would fail when ~/.kimi-code/skills is + // blocked by a file or has restrictive permissions, turning an empty + // source into a hard error. + if (!targetDirReady) { + await mkdir(tgtDir, { recursive: true, mode: 0o700 }); + targetDirReady = true; + } + + // Copy to a sibling temp path and rename into place so a crash mid-copy + // never leaves a half-populated skill directory that the next idempotent + // re-run would then `existsSync` and skip. + const tmpPath = `${tgtPath}.${process.pid}.tmp`; + try { + await cp(srcPath, tmpPath, { recursive: true, errorOnExist: false, force: true }); + await rename(tmpPath, tgtPath); + } catch (err) { + await rm(tmpPath, { recursive: true, force: true }).catch(() => {}); + throw err; + } + copied++; + } + + return { copied, skippedExisting }; +} diff --git a/packages/migration-legacy/src/types.ts b/packages/migration-legacy/src/types.ts index bee222ef..137d0b84 100644 --- a/packages/migration-legacy/src/types.ts +++ b/packages/migration-legacy/src/types.ts @@ -39,6 +39,7 @@ export interface MigrationScope { readonly config: boolean; readonly mcp: boolean; readonly userHistory: boolean; + readonly skills: boolean; readonly sessions: boolean; } @@ -98,6 +99,7 @@ export interface MigrationSummary { readonly wroteSiblingDueToConflict: boolean; }; readonly userHistory: { readonly copied: number; readonly skippedExisting: number }; + readonly skills: { readonly copied: number; readonly skippedExisting: number }; readonly sessions: SessionsSummary; } diff --git a/packages/migration-legacy/test/integration.test.ts b/packages/migration-legacy/test/integration.test.ts index 8e1f80af..81e84651 100644 --- a/packages/migration-legacy/test/integration.test.ts +++ b/packages/migration-legacy/test/integration.test.ts @@ -33,6 +33,7 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { config: true, mcp: true, userHistory: true, + skills: true, sessions: true, }, source: SOURCE_HOME, @@ -55,7 +56,7 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { const plan = await detectMigration({ sourcePath: SOURCE_HOME }); const report = await runMigration({ plan, - scope: { config: true, mcp: true, userHistory: true, sessions: true }, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: true }, source: SOURCE_HOME, target: tgt, }); @@ -74,6 +75,7 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { config: true, mcp: true, userHistory: true, + skills: true, sessions: false, }, source: SOURCE_HOME, @@ -83,6 +85,59 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { expect(report.summary.sessions.sessionsMigrated).toBe(0); }); + it('migrates user skills bundles end-to-end and surfaces them in the report', async () => { + // Drive the full pipeline against a synthetic source so the assertion + // tests integration (run-migration wiring + paths + summary plumbing), + // not just the step in isolation. + const src = await mkdtemp(join(tmpdir(), 'skills-e2e-src-')); + try { + await mkdir(join(src, 'skills', 'my-bundle'), { recursive: true }); + await writeFile( + join(src, 'skills', 'my-bundle', 'SKILL.md'), + '---\nname: my-bundle\ndescription: e2e\n---\n', + ); + await writeFile(join(src, 'skills', 'flat.md'), '---\nname: flat\ndescription: e2e\n---\n'); + + const plan = await detectMigration({ sourcePath: src }); + const report = await runMigration({ + plan, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: false }, + source: src, + target: tgt, + }); + + expect(report.summary.skills.copied).toBe(2); + expect(report.summary.skills.skippedExisting).toBe(0); + await expect( + readFile(join(tgt, 'skills', 'my-bundle', 'SKILL.md'), 'utf-8'), + ).resolves.toContain('my-bundle'); + await expect(readFile(join(tgt, 'skills', 'flat.md'), 'utf-8')).resolves.toContain('flat'); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + + it('skips skills migration when scope.skills is false', async () => { + const src = await mkdtemp(join(tmpdir(), 'skills-off-src-')); + try { + await mkdir(join(src, 'skills', 'mine'), { recursive: true }); + await writeFile(join(src, 'skills', 'mine', 'SKILL.md'), 'x'); + + const plan = await detectMigration({ sourcePath: src }); + const report = await runMigration({ + plan, + scope: { config: true, mcp: true, userHistory: true, skills: false, sessions: false }, + source: src, + target: tgt, + }); + + expect(report.summary.skills).toEqual({ copied: 0, skippedExisting: 0 }); + await expect(readFile(join(tgt, 'skills', 'mine', 'SKILL.md'))).rejects.toThrow(); + } finally { + await rm(src, { recursive: true, force: true }); + } + }); + it('does not copy OAuth credentials into the target', async () => { // OAuth refresh tokens rotate server-side: they are single-use and // single-owner. Copying a credential to a second install breaks login @@ -105,7 +160,7 @@ describe('runMigration (end-to-end on multi-workdir fixture)', () => { const plan = await detectMigration({ sourcePath: src }); const report = await runMigration({ plan, - scope: { config: true, mcp: true, userHistory: true, sessions: false }, + scope: { config: true, mcp: true, userHistory: true, skills: true, sessions: false }, source: src, target: tgt, }); diff --git a/packages/migration-legacy/test/prompt.test.ts b/packages/migration-legacy/test/prompt.test.ts index 827ff26b..943d135f 100644 --- a/packages/migration-legacy/test/prompt.test.ts +++ b/packages/migration-legacy/test/prompt.test.ts @@ -10,6 +10,7 @@ describe('resolveMigrationScope', () => { config: true, mcp: true, userHistory: true, + skills: true, sessions: false, }); }); @@ -21,6 +22,7 @@ describe('resolveMigrationScope', () => { config: true, mcp: true, userHistory: true, + skills: true, sessions: true, }); }); diff --git a/packages/migration-legacy/test/report.test.ts b/packages/migration-legacy/test/report.test.ts index bc8b2f19..d49605c4 100644 --- a/packages/migration-legacy/test/report.test.ts +++ b/packages/migration-legacy/test/report.test.ts @@ -37,6 +37,7 @@ describe('writeReport', () => { }, mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false }, userHistory: { copied: 0, skippedExisting: 0 }, + skills: { copied: 0, skippedExisting: 0 }, sessions: { scope: 'all', bucketsScanned: 0, diff --git a/packages/migration-legacy/test/steps/skills.test.ts b/packages/migration-legacy/test/steps/skills.test.ts new file mode 100644 index 00000000..9e885771 --- /dev/null +++ b/packages/migration-legacy/test/steps/skills.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, mkdir, writeFile, readFile, readdir, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { migrateSkillsStep } from '../../src/steps/skills.js'; + +let src: string; +let tgt: string; +beforeEach(async () => { + src = await mkdtemp(join(tmpdir(), 'skills-src-')); + tgt = await mkdtemp(join(tmpdir(), 'skills-tgt-')); +}); +afterEach(async () => { + await rm(src, { recursive: true, force: true }); + await rm(tgt, { recursive: true, force: true }); +}); + +describe('migrateSkillsStep', () => { + it('copies SKILL.md bundles and flat .md skills under ~/.kimi/skills/', async () => { + await mkdir(join(src, 'skills', 'my-skill'), { recursive: true }); + await writeFile( + join(src, 'skills', 'my-skill', 'SKILL.md'), + '---\nname: my-skill\ndescription: x\n---\nbody\n', + ); + await writeFile( + join(src, 'skills', 'flat-skill.md'), + '---\nname: flat-skill\ndescription: y\n---\nflat\n', + ); + + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + + expect(r.copied).toBe(2); + expect(r.skippedExisting).toBe(0); + expect(await readFile(join(tgt, 'skills', 'my-skill', 'SKILL.md'), 'utf-8')).toContain('body'); + expect(await readFile(join(tgt, 'skills', 'flat-skill.md'), 'utf-8')).toContain('flat'); + }); + + it('recursively copies bundle contents (references/scripts subdirs)', async () => { + await mkdir(join(src, 'skills', 'bundle', 'references'), { recursive: true }); + await mkdir(join(src, 'skills', 'bundle', 'scripts'), { recursive: true }); + await writeFile( + join(src, 'skills', 'bundle', 'SKILL.md'), + '---\nname: bundle\ndescription: z\n---\n', + ); + await writeFile(join(src, 'skills', 'bundle', 'references', 'ref.md'), 'ref-body'); + await writeFile(join(src, 'skills', 'bundle', 'scripts', 'run.sh'), '#!/bin/sh\necho hi'); + + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + + expect(r.copied).toBe(1); + expect(await readFile(join(tgt, 'skills', 'bundle', 'references', 'ref.md'), 'utf-8')).toBe( + 'ref-body', + ); + expect(await readFile(join(tgt, 'skills', 'bundle', 'scripts', 'run.sh'), 'utf-8')).toContain( + 'echo hi', + ); + }); + + it('skips entries whose name already exists in target (no overwrite)', async () => { + await mkdir(join(src, 'skills', 'shared'), { recursive: true }); + await writeFile(join(src, 'skills', 'shared', 'SKILL.md'), 'SRC'); + await writeFile(join(src, 'skills', 'flat.md'), 'SRC-FLAT'); + + await mkdir(join(tgt, 'skills', 'shared'), { recursive: true }); + await writeFile(join(tgt, 'skills', 'shared', 'SKILL.md'), 'TGT'); + await writeFile(join(tgt, 'skills', 'flat.md'), 'TGT-FLAT'); + + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + + expect(r.copied).toBe(0); + expect(r.skippedExisting).toBe(2); + expect(await readFile(join(tgt, 'skills', 'shared', 'SKILL.md'), 'utf-8')).toBe('TGT'); + expect(await readFile(join(tgt, 'skills', 'flat.md'), 'utf-8')).toBe('TGT-FLAT'); + }); + + it('mixes copied + skippedExisting in one run', async () => { + await mkdir(join(src, 'skills', 'already-there'), { recursive: true }); + await mkdir(join(src, 'skills', 'fresh'), { recursive: true }); + await writeFile(join(src, 'skills', 'already-there', 'SKILL.md'), 'SRC'); + await writeFile(join(src, 'skills', 'fresh', 'SKILL.md'), 'NEW'); + + await mkdir(join(tgt, 'skills', 'already-there'), { recursive: true }); + await writeFile(join(tgt, 'skills', 'already-there', 'SKILL.md'), 'TGT'); + + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + + expect(r.copied).toBe(1); + expect(r.skippedExisting).toBe(1); + expect(await readFile(join(tgt, 'skills', 'fresh', 'SKILL.md'), 'utf-8')).toBe('NEW'); + expect(await readFile(join(tgt, 'skills', 'already-there', 'SKILL.md'), 'utf-8')).toBe('TGT'); + }); + + it('returns zero counters when source ~/.kimi/skills/ is missing', async () => { + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + expect(r).toEqual({ copied: 0, skippedExisting: 0 }); + expect(existsSync(join(tgt, 'skills'))).toBe(false); + }); + + it('does not create the target dir when there is nothing to copy', async () => { + // Empty source skills/ — no files to copy, target dir must stay untouched. + await mkdir(join(src, 'skills'), { recursive: true }); + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + expect(r).toEqual({ copied: 0, skippedExisting: 0 }); + expect(existsSync(join(tgt, 'skills'))).toBe(false); + }); + + it('copies non-skill files at top level too (no filtering)', async () => { + // We intentionally do not filter — whatever the user kept under + // ~/.kimi/skills/ is preserved verbatim. The new scanner ignores anything + // that does not match the skill shape. + await mkdir(join(src, 'skills'), { recursive: true }); + await writeFile(join(src, 'skills', 'NOTES.txt'), 'stray notes'); + + const r = await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + expect(r.copied).toBe(1); + expect(await readFile(join(tgt, 'skills', 'NOTES.txt'), 'utf-8')).toBe('stray notes'); + }); + + it('atomic write: leaves no .tmp leftovers in target on success', async () => { + await mkdir(join(src, 'skills'), { recursive: true }); + await writeFile(join(src, 'skills', 'a.md'), 'A'); + + await migrateSkillsStep({ sourceHome: src, targetHome: tgt }); + + const entries = await readdir(join(tgt, 'skills')); + expect(entries.some((e) => e.endsWith('.tmp'))).toBe(false); + }); +}); From 2f11ab4ae887e05b8cddf0ed117ca8dd9534eb2e Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 25 May 2026 20:53:45 +0800 Subject: [PATCH 2/3] refactor(migration): hide OAuth from migration UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth credentials are deliberately never migrated (refresh tokens rotate server-side, so two installs sharing one token would fight over who gets refreshed). The previous UX framed this as a limitation: the result screen carried a yellow ⚠ "kimi-cli login not migrated — run /login" line, and the pre-migration summary listed "kimi-cli login (needs /login)" alongside real migratable data classes, making users think a login was about to be transferred and only the last step had failed. Drops both surfaces and short-circuits the pre-migration screen when the only legacy data is `credentials/*.json`. kimi-code's own /login flow handles re-auth on first use, so a dedicated migration notice is redundant. The `report.notices.oauthLoginsRequiringRelogin` JSON field is left intact for debugging. --- .../kimi-code/src/migration/detect-pending.ts | 7 ++-- .../src/migration/migration-screen.ts | 17 +++------ .../test/migration/detect-pending.test.ts | 21 +++++++++++ .../test/migration/migration-screen.test.ts | 35 ++++--------------- 4 files changed, 37 insertions(+), 43 deletions(-) diff --git a/apps/kimi-code/src/migration/detect-pending.ts b/apps/kimi-code/src/migration/detect-pending.ts index 60e2219d..48435d2b 100644 --- a/apps/kimi-code/src/migration/detect-pending.ts +++ b/apps/kimi-code/src/migration/detect-pending.ts @@ -39,12 +39,15 @@ export async function detectPendingMigration( return null; } + // OAuth credentials are deliberately not migrated, so an install whose + // only data is `credentials/*.json` has nothing to offer — kimi-code's own + // /login flow will pick up the auth conversation when the user first uses + // the app. Treat oauth-only as "nothing to migrate". const nothingToMigrate = plan.totalSessions === 0 && !plan.hasConfig && !plan.hasMcp && - !plan.hasUserHistory && - plan.oauthCredentials.length === 0; + !plan.hasUserHistory; if (nothingToMigrate) return null; return plan; diff --git a/apps/kimi-code/src/migration/migration-screen.ts b/apps/kimi-code/src/migration/migration-screen.ts index d713912c..a0800d84 100644 --- a/apps/kimi-code/src/migration/migration-screen.ts +++ b/apps/kimi-code/src/migration/migration-screen.ts @@ -318,13 +318,10 @@ export class MigrationScreenComponent extends Container implements Focusable { ), ); } - if (r.notices.oauthLoginsRequiringRelogin.length > 0) { - lines.push( - chalk.hex(colors.warning)( - ' ⚠ kimi-cli login not migrated — run /login in kimi-code to sign in', - ), - ); - } + // OAuth credentials are deliberately not migrated (refresh tokens cannot + // safely be held by two installs at once). kimi-code's normal auth flow + // will prompt for /login when the user first picks a model — surfacing a + // separate notice here reads as a migration limitation, which it is not. if (sum.config.droppedHooks > 0) { lines.push( chalk.hex(colors.warning)( @@ -496,12 +493,6 @@ function summarizePlan(plan: MigrationPlan): string { if (plan.hasConfig) parts.push('config.toml'); if (plan.hasMcp) parts.push('mcp.json'); if (plan.hasUserHistory) parts.push('REPL history'); - // OAuth credentials are detected but not migrated (refresh tokens rotate; - // re-login in kimi-code is the right answer). Surface the detection up - // front so an install whose only data is `credentials/*.json` does not - // render this line blank, and so the pre-migration screen stays consistent - // with the result screen's "kimi-cli login not migrated — run /login" line. - if (plan.oauthCredentials.length > 0) parts.push('kimi-cli login (needs /login)'); return parts.join(' · '); } diff --git a/apps/kimi-code/test/migration/detect-pending.test.ts b/apps/kimi-code/test/migration/detect-pending.test.ts index c674d054..10087bbc 100644 --- a/apps/kimi-code/test/migration/detect-pending.test.ts +++ b/apps/kimi-code/test/migration/detect-pending.test.ts @@ -40,6 +40,27 @@ describe('detectPendingMigration', () => { expect(plan).toBeNull(); }); + it('returns null when the only source data is OAuth credentials', async () => { + // OAuth credentials are deliberately never migrated. An install whose + // only legacy data is `credentials/*.json` therefore has nothing to + // offer the migration screen — kimi-code's own /login flow handles + // re-auth on first use. + await mkdir(join(src, 'credentials'), { recursive: true }); + await writeFile( + join(src, 'credentials', 'kimi-code.json'), + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + expires_at: 1, + scope: 's', + token_type: 'Bearer', + }), + 'utf-8', + ); + const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); + expect(plan).toBeNull(); + }); + it('returns a MigrationPlan when source has migratable data', async () => { await writeFile(join(src, 'config.toml'), 'default_thinking = true\n', 'utf-8'); const plan = await detectPendingMigration({ sourceHome: src, targetHome: tgt }); diff --git a/apps/kimi-code/test/migration/migration-screen.test.ts b/apps/kimi-code/test/migration/migration-screen.test.ts index 7424f8c3..240cea9a 100644 --- a/apps/kimi-code/test/migration/migration-screen.test.ts +++ b/apps/kimi-code/test/migration/migration-screen.test.ts @@ -46,10 +46,11 @@ describe('MigrationScreenComponent — ask phase', () => { expect(out).toContain('Never ask again'); }); - it('ask1 summary shows the kimi-cli login hint when OAuth credentials are detected', async () => { - // OAuth credentials are detected but not migrated — surface that up front - // so the pre-migration summary is consistent with the result screen's - // "⚠ kimi-cli login not migrated — run /login" line. + it('ask1 summary does not mention kimi-cli login (oauth is not a migrated kind)', async () => { + // OAuth credentials are deliberately never migrated, so the pre-migration + // summary must not list "kimi-cli login" alongside the real migratable + // data classes — that framing makes users believe their session will + // carry over, which it does not. const c = new MigrationScreenComponent({ plan: makePlan(), sourceHome: '/x/.kimi', @@ -58,30 +59,8 @@ describe('MigrationScreenComponent — ask phase', () => { onComplete: () => {}, }); const out = render(c); - expect(out).toContain('kimi-cli login'); - }); - - it('ask1 renders a non-empty summary when the only detected data is an OAuth login', async () => { - // Legacy install with nothing but `credentials/*.json` would otherwise - // render the summary line as blank — `summarizePlan` no longer treats - // OAuth as a migrated kind. The OAuth hint must keep that line useful. - const c = new MigrationScreenComponent({ - plan: makePlan({ - hasConfig: false, - hasMcp: false, - hasUserHistory: false, - totalSessions: 0, - workdirs: [], - }), - sourceHome: '/x/.kimi', - targetHome: '/y/.kimi-code', - colors: darkColors, - onComplete: () => {}, - }); - const out = render(c); - expect(out).toContain('kimi-cli login'); - // ...and the summary line is not blank — the OAuth hint is its content. - expect(out).toMatch(/Found an existing kimi-cli installation:\n\s+\S/); + expect(out).not.toContain('kimi-cli login'); + expect(out).not.toContain('/login'); }); it('picking "Ask me later" at ask1 completes with decision=later', () => { From b58a772e9d7aa4e1f0abb6355c64d15002fa88c6 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Mon, 25 May 2026 21:02:02 +0800 Subject: [PATCH 3/3] chore: changeset for skills migration and OAuth UX cleanup --- .changeset/hide-oauth-from-migration-ux.md | 5 +++++ .changeset/migrate-user-skills.md | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/hide-oauth-from-migration-ux.md create mode 100644 .changeset/migrate-user-skills.md diff --git a/.changeset/hide-oauth-from-migration-ux.md b/.changeset/hide-oauth-from-migration-ux.md new file mode 100644 index 00000000..b64581b0 --- /dev/null +++ b/.changeset/hide-oauth-from-migration-ux.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop mentioning OAuth credentials in the migration UI — they are never migrated, so the previous "needs /login" notice misread as a failure. OAuth-only installs no longer trigger the migration screen. diff --git a/.changeset/migrate-user-skills.md b/.changeset/migrate-user-skills.md new file mode 100644 index 00000000..be35d462 --- /dev/null +++ b/.changeset/migrate-user-skills.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/migration-legacy": patch +"@moonshot-ai/kimi-code": patch +--- + +Migrate user skills from `~/.kimi/skills/` to `~/.kimi-code/skills/` during the first-launch migration; existing target skills are kept.