Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hide-oauth-from-migration-ux.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/migrate-user-skills.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 5 additions & 2 deletions apps/kimi-code/src/migration/detect-pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines 47 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Include skills in pending-migration detection

This nothingToMigrate check now ignores OAuth (intended) but still only considers sessions/config/MCP/history, so a legacy install that contains only ~/.kimi/skills/ is treated as “nothing to migrate.” In that case detectPendingMigration returns null, the first-launch migration UI is skipped, and kimi migrate (migrateOnly) also exits early with “Nothing to migrate,” so the newly added skills migration path never runs for that user segment.

Useful? React with 👍 / 👎.

if (nothingToMigrate) return null;

return plan;
Expand Down
18 changes: 5 additions & 13 deletions apps/kimi-code/src/migration/migration-screen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(' · ')}`));
}
Expand All @@ -317,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)(
Expand Down Expand Up @@ -495,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(' · ');
}

Expand Down
21 changes: 21 additions & 0 deletions apps/kimi-code/test/migration/detect-pending.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
36 changes: 8 additions & 28 deletions apps/kimi-code/test/migration/migration-screen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -298,6 +277,7 @@ function makeReport(
},
mcp: { mergedServers: [], keptNewForConflicts: [], droppedServers: [], wroteSiblingDueToConflict: false },
userHistory: { copied: 12, skippedExisting: 0 },
skills: { copied: 0, skippedExisting: 0 },
sessions: {
scope: 'all',
bucketsScanned: 0,
Expand Down
2 changes: 2 additions & 0 deletions packages/migration-legacy/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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');
Expand Down
1 change: 1 addition & 0 deletions packages/migration-legacy/src/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function resolveMigrationScope(
config: true,
mcp: true,
userHistory: true,
skills: true,
sessions: c2 === 'all-sessions',
},
};
Expand Down
8 changes: 8 additions & 0 deletions packages/migration-legacy/src/run-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -64,6 +65,11 @@ export async function runMigration(input: RunMigrationInput): Promise<MigrationR
: { copied: 0, skippedExisting: 0 };
log('user-history done');

const skills = input.scope.skills
? await migrateSkillsStep({ sourceHome: input.source, targetHome: input.target })
: { copied: 0, skippedExisting: 0 };
log('skills done');

const sessions: SessionsSummary = input.scope.sessions
? await migrateSessionsStep({
sourceHome: input.source,
Expand All @@ -85,6 +91,7 @@ export async function runMigration(input: RunMigrationInput): Promise<MigrationR
config,
mcp,
userHistory,
skills,
sessions,
},
notices: {
Expand All @@ -110,6 +117,7 @@ export async function runMigration(input: RunMigrationInput): Promise<MigrationR
config,
mcp,
userHistory,
skills,
sessions,
};

Expand Down
78 changes: 78 additions & 0 deletions packages/migration-legacy/src/steps/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { cp, mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { sourceSkillsDir, targetSkillsDir } from '../paths.js';

export interface SkillsStepInput {
readonly sourceHome: string;
readonly targetHome: string;
}

export interface SkillsStepResult {
readonly copied: number;
readonly skippedExisting: number;
}

/**
* Copy the user's legacy skills tree (~/.kimi/skills/) into kimi-code's
* default user skills root (~/.kimi-code/skills/). Granularity is one
* top-level entry per "skill unit" — that matches how the new scanner
* treats a directory containing SKILL.md as a bundle and a flat .md as a
* skill on its own. We do not filter non-skill entries; the new scanner
* ignores anything it cannot parse, so passing it through preserves
* arbitrary user assets without imposing a schema here.
*/
export async function migrateSkillsStep(input: SkillsStepInput): Promise<SkillsStepResult> {
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) {

Check warning on line 70 in packages/migration-legacy/src/steps/skills.ts

View workflow job for this annotation

GitHub Actions / lint

eslint-plugin-unicorn(catch-error-name)

The catch parameter "err" should be named "error"
await rm(tmpPath, { recursive: true, force: true }).catch(() => {});
throw err;
Comment on lines +68 to +72
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip unsupported skill entries instead of aborting migration

The step intentionally copies every top-level entry in ~/.kimi/skills, but any cp() error is rethrown, which aborts the entire migration run. fs.cp throws on special entries (for example FIFOs with ERR_FS_CP_FIFO_PIPE), so one such item can make the whole migration fail and block unrelated data (sessions/config/history) from being migrated. This should be handled per-entry as a skip/report instead of terminating the run.

Useful? React with 👍 / 👎.

}
copied++;
}

return { copied, skippedExisting };
}
2 changes: 2 additions & 0 deletions packages/migration-legacy/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface MigrationScope {
readonly config: boolean;
readonly mcp: boolean;
readonly userHistory: boolean;
readonly skills: boolean;
readonly sessions: boolean;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading