mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-09 17:29:12 +00:00
Kimi For Coding
This commit is contained in:
commit
842e699a64
1131 changed files with 206253 additions and 0 deletions
109
.agents/skills/gen-changesets/SKILL.md
Normal file
109
.agents/skills/gen-changesets/SKILL.md
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
---
|
||||
name: gen-changesets
|
||||
description: Use when generating changesets in the kimi-code repository, including package bump selection, internal package and CLI bundle handling, bump levels, major confirmation, and English changelog wording.
|
||||
---
|
||||
|
||||
# Generate Changesets
|
||||
|
||||
`kimi-code` uses changesets to manage versions and changelogs. The current user-facing published package is:
|
||||
|
||||
- `@moonshot-ai/kimi-code`: the CLI
|
||||
|
||||
All other `@moonshot-ai/*` packages are treated as internal packages, including `@moonshot-ai/kimi-code-sdk`, `agent-core`, `kosong`, `kaos`, `kimi-code-oauth`, `kimi-telemetry`, and `migration-legacy`.
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Inspect the actual changes first.** Use `git status` / `git diff --name-only` to identify which packages were actually changed.
|
||||
2. **List packages that were actually changed.** Source code, build config, package metadata, and other changes that affect a package's output or behavior need a changeset entry for that package.
|
||||
3. **Do not list unchanged internal packages.** For example, if `packages/node-sdk` was not changed, do not list `@moonshot-ai/kimi-code-sdk` just because another internal package changed. The SDK follows the same rule as other internal packages: list it only when it was actually changed.
|
||||
4. **Internal package source changes that enter the CLI bundle must manually list the CLI.** `@moonshot-ai/kimi-code` inline-bundles `@moonshot-ai/*` source, but those internal packages are devDependencies from the CLI's perspective, so changesets will not automatically propagate bumps. If a change enters the CLI output, also list `@moonshot-ai/kimi-code`.
|
||||
5. **Docs-only and tests-only changes usually do not need a changeset.** README, internal docs, and `test/` changes that do not enter package output do not trigger a CLI bump.
|
||||
6. `@moonshot-ai/vis` / `vis-server` / `vis-web` are ignored by changesets and should not be handled.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. If the change includes `pnpm-lock.yaml`, `pnpm-workspace.yaml`, root or workspace `package.json`, `.npmrc`, `flake.nix`, or `flake.lock`:
|
||||
- First check `command -v nix >/dev/null 2>&1`.
|
||||
- If `nix` exists, run `nix run .#update-pnpm-deps`.
|
||||
- If `nix` is unavailable, skip this step and do not block the changeset; mention in the final response that the command was not run.
|
||||
2. List the packages that were actually changed.
|
||||
3. Choose a bump level for each package.
|
||||
4. If an internal package change enters the CLI bundle, add `@moonshot-ai/kimi-code`.
|
||||
5. Create a short kebab-case file under `.changeset/`.
|
||||
6. Split unrelated changes into separate changesets; keep one logical change in one file.
|
||||
|
||||
Format:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"<package A>": patch
|
||||
"<package B>": minor
|
||||
---
|
||||
|
||||
<English changelog entry>
|
||||
```
|
||||
|
||||
## Bump Levels
|
||||
|
||||
| Level | When to use |
|
||||
|---|---|
|
||||
| `patch` | Bug fixes; build/package fixes; internal refactors that do not change behavior; wording tweaks; small dependency upgrades |
|
||||
| `minor` | New backwards-compatible features or capabilities |
|
||||
| `major` | Breaking changes: incompatible config changes, renamed or removed commands/arguments, behavior semantics changes, and similar |
|
||||
|
||||
### Major Rule
|
||||
|
||||
Never write `major` on your own.
|
||||
|
||||
If you believe a change qualifies as major, stop first, explain why, and ask the user for confirmation. Only write `major` after the user explicitly agrees. If the user does not reply, replies ambiguously, or disagrees, fall back to `minor`; if `minor` is also unclear, fall back to `patch`.
|
||||
|
||||
## Wording Rules
|
||||
|
||||
- Changelog entries **must be written in English**.
|
||||
- User-facing CLI wording should only be used when CLI users can perceive the change.
|
||||
- Internal changes that do not affect CLI users can still share a changeset with the CLI, but the wording must describe the real change honestly and must not present it as a user-facing feature.
|
||||
- Do not mention file names, class names, function names, PR numbers, or commit hashes.
|
||||
- Avoid vague words such as `refactor`, `optimize`, and `improve`. Describe the actual change, or use more specific wording.
|
||||
|
||||
## Common Examples
|
||||
|
||||
An internal package fixes a bug visible to CLI users:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix occasional loss of tool call results in long conversations.
|
||||
```
|
||||
|
||||
An internal package has an internal-only change, but it enters the CLI bundle:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/agent-core": patch
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Unify tool execution metadata handling.
|
||||
```
|
||||
|
||||
Only SDK source changed, and the CLI does not use it:
|
||||
|
||||
```markdown
|
||||
---
|
||||
"@moonshot-ai/kimi-code-sdk": patch
|
||||
---
|
||||
|
||||
Clarify session status typing for internal SDK callers.
|
||||
```
|
||||
|
||||
## Red Flags
|
||||
|
||||
- You are about to write `major` without asking the user.
|
||||
- Internal package source enters the CLI bundle, but `@moonshot-ai/kimi-code` is missing.
|
||||
- `packages/node-sdk` was not changed, but `@moonshot-ai/kimi-code-sdk` was listed for "internal package sync".
|
||||
- The changelog entry is in Chinese.
|
||||
- The wording claims more than the diff actually did.
|
||||
- The CLI wording mentions internal package names, class names, or PR numbers.
|
||||
87
.agents/skills/gen-docs/SKILL.md
Normal file
87
.agents/skills/gen-docs/SKILL.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
name: gen-docs
|
||||
description: Update Kimi Code CLI user documentation after meaningful code changes that affect product behavior or user experience.
|
||||
---
|
||||
|
||||
# Gen Docs
|
||||
|
||||
## Overview
|
||||
|
||||
This repository maintains bilingual user documentation under `docs/`. `docs/en/` and `docs/zh/` are mirrored pairs for most pages; update both in the same change. **Changelog is the exception** — English is the source, and Chinese is translated from English.
|
||||
|
||||
Use this skill to update the corresponding documentation whenever the codebase has changes that affect product behavior or user experience.
|
||||
|
||||
For a **full pre-release audit** of all pages (detecting hallucinations and coverage gaps), use the `audit-docs` skill instead.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This skill depends on the following being in place. If any are missing, stop and report to the user before continuing:
|
||||
|
||||
- `docs/` directory with `docs/zh/`, `docs/en/`, and `docs/.vitepress/config.ts` set up (VitePress site).
|
||||
- `docs/AGENTS.md` style guide — defines source-of-truth rules, terminology table, typography, and writing style.
|
||||
- `docs/scripts/sync-changelog.mjs` — auto-syncs root `CHANGELOG.md` to `docs/en/release-notes/changelog.md`.
|
||||
- `translate-docs` skill in `.agents/skills/` — handles bilingual synchronization.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Inspect changes**
|
||||
|
||||
- `git log main..HEAD --oneline` — commits on the current branch
|
||||
- `git diff main..HEAD --stat` — file-level scope
|
||||
- `ls .changeset/*.md` (excluding `README.md`) — pending changeset entries
|
||||
- Read `CHANGELOG.md` and any subpackage `packages/*/CHANGELOG.md` for already-recorded entries.
|
||||
|
||||
2. **Understand user-facing impact**
|
||||
|
||||
For each change, read the actual implementation when needed; **do not infer behavior from commit messages or PR titles alone**. Skip:
|
||||
|
||||
- Internal refactors with no externally visible behavior change
|
||||
- Tests, CI, type-only changes
|
||||
- Tooling / build-system changes that do not change how users invoke the CLI
|
||||
|
||||
If after the scan you conclude there is no user-facing impact, say so and stop.
|
||||
|
||||
3. **Sync English changelog**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node docs/scripts/sync-changelog.mjs
|
||||
```
|
||||
|
||||
This updates `docs/en/release-notes/changelog.md` from the root `CHANGELOG.md`. Never edit the docs changelog by hand.
|
||||
|
||||
4. **Update user docs**
|
||||
|
||||
Following the rules in `docs/AGENTS.md`, edit the affected pages in whichever locale you are working in, then sync the mirror. Match terminology with the term table in `docs/AGENTS.md` and the existing wording in surrounding pages.
|
||||
|
||||
Cover all relevant sections:
|
||||
|
||||
- Guides (getting-started, use cases, interaction, sessions, IDE integration)
|
||||
- Customization (skills, agents, MCP, hooks, plugins, etc.)
|
||||
- Configuration (config files, env vars, providers, data locations)
|
||||
- Reference (CLI subcommands, slash commands, keyboard shortcuts)
|
||||
- Release notes (`docs/zh/release-notes/breaking-changes.md` if a breaking change is involved)
|
||||
|
||||
5. **Sync bilingual content**
|
||||
|
||||
Invoke the `translate-docs` skill. It will:
|
||||
|
||||
- Sync updated non-changelog pages between `docs/en/` and `docs/zh/`
|
||||
- Translate the English changelog → Chinese under `docs/zh/release-notes/changelog.md`
|
||||
|
||||
## Rules and conventions
|
||||
|
||||
- **Locale sync**: Non-changelog pages stay mirrored between `docs/en/` and `docs/zh/`. Changelog flows English → Chinese.
|
||||
- **Terminology**: Use the term table in `docs/AGENTS.md` exactly. Do not invent new translations or use synonyms.
|
||||
- **Scope discipline**: Only update sections affected by the recent changes. Do not opportunistically rewrite unrelated docs.
|
||||
- **Breaking changes**: If any change is breaking, also update `docs/en/release-notes/breaking-changes.md` (under `## Unreleased`) with `**Affected**` + `**Migration**` subsections, and mirror it in `docs/zh/release-notes/breaking-changes.md`.
|
||||
- **Do not edit auto-synced files**: `docs/en/release-notes/changelog.md` is regenerated by the sync script; any manual edit will be overwritten.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Describing what code changed instead of what the user can now do (or can no longer do).
|
||||
- Adding a new section heading per feature instead of weaving the change into existing prose.
|
||||
- Updating only one locale and leaving its mirror stale.
|
||||
- Editing only the mirror to fix wording that should be corrected in the locale you changed first.
|
||||
- Inventing new terminology that drifts from the `docs/AGENTS.md` term table.
|
||||
247
.agents/skills/sync-changelog/SKILL.md
Normal file
247
.agents/skills/sync-changelog/SKILL.md
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
---
|
||||
name: sync-changelog
|
||||
description: Use after a release succeeds, when maintainers need to sync apps/kimi-code/CHANGELOG.md into docs/en/release-notes/changelog.md and docs/zh/release-notes/changelog.md.
|
||||
---
|
||||
|
||||
# Sync Changelog
|
||||
|
||||
## Overview
|
||||
|
||||
`kimi-code` uses changesets for versioning. Each package gets its own `CHANGELOG.md`. The user-facing CLI package, `@moonshot-ai/kimi-code`, writes its changelog here:
|
||||
|
||||
```text
|
||||
apps/kimi-code/CHANGELOG.md
|
||||
```
|
||||
|
||||
This file is the **only upstream source** for the documentation-site changelog. Internal package changelogs such as `packages/*/CHANGELOG.md` do not go into the documentation site.
|
||||
|
||||
After the release flow finishes (Release PR merged → `Version Packages` completed → npm publish succeeded), maintainers manually run this skill to copy the new CLI changelog entries into the docs site and translate the English increment into Chinese.
|
||||
|
||||
## When To Use
|
||||
|
||||
- A new version has been published to npm.
|
||||
- The top of `apps/kimi-code/CHANGELOG.md` contains version blocks that are not yet in `docs/en/release-notes/changelog.md`.
|
||||
- The `gen-docs` flow does not run this automatically; maintainers must explicitly do it after release.
|
||||
|
||||
Do **not** run this before the Release PR is merged. At that point, changesets has not yet written the new version into `apps/kimi-code/CHANGELOG.md`.
|
||||
|
||||
## Source And Targets
|
||||
|
||||
| File | Role | Edited by |
|
||||
|---|---|---|
|
||||
| `apps/kimi-code/CHANGELOG.md` | **Only upstream source**, generated by changesets | Never edit manually |
|
||||
| `docs/en/release-notes/changelog.md` | English docs changelog; source of truth for docs | This skill |
|
||||
| `docs/zh/release-notes/changelog.md` | Chinese docs changelog, translated from English | This skill, following `translate-docs` |
|
||||
|
||||
Core rule: the English docs changelog is the source of truth, and Chinese is translated from English. This matches `translate-docs`.
|
||||
|
||||
## Preconditions
|
||||
|
||||
Before editing, confirm:
|
||||
|
||||
- The released version exists on npm (`npm view @moonshot-ai/kimi-code versions --json`) or has a matching GitHub Release tag.
|
||||
- The top of `apps/kimi-code/CHANGELOG.md` is that new version.
|
||||
- The current branch is clean, or you are on a dedicated docs-sync branch.
|
||||
|
||||
If any condition is not true, stop and confirm with the user.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Find The Version Range
|
||||
|
||||
```bash
|
||||
# Upstream versions
|
||||
rg '^## ' apps/kimi-code/CHANGELOG.md | head -20
|
||||
|
||||
# Latest version already synced into the English docs page
|
||||
rg '^## ' docs/en/release-notes/changelog.md | head -5
|
||||
```
|
||||
|
||||
- First sync: copy all upstream version blocks into the English page.
|
||||
- Incremental sync: copy every upstream version block above the latest version already present in the English page.
|
||||
|
||||
Use upstream order: newest version first.
|
||||
|
||||
### 2. Strip Decorations And Extract Entry Text
|
||||
|
||||
Upstream entries look like this:
|
||||
|
||||
```markdown
|
||||
- [#317](https://github.com/...) [`2f51db4`](https://github.com/...) - Clean up lint warnings ...
|
||||
```
|
||||
|
||||
Keep:
|
||||
|
||||
- Version headings such as `## 0.2.0`.
|
||||
- Only the body text of each entry, after the PR/hash decoration.
|
||||
|
||||
Remove:
|
||||
|
||||
- The upstream H1 `# @moonshot-ai/kimi-code` because the docs page already has `# Changelog`.
|
||||
- Changesets subheadings such as `### Patch Changes`, `### Minor Changes`, and `### Major Changes`.
|
||||
- PR links such as `[#317](...)`.
|
||||
- Commit hash links such as ``[`2f51db4`](...)``.
|
||||
|
||||
After stripping, each entry should be only:
|
||||
|
||||
```markdown
|
||||
- <body text>
|
||||
```
|
||||
|
||||
Upstream language rule: `gen-changesets` requires changelog entries to be English. If the upstream CLI changelog contains a non-English entry, stop and report it to the user. Do not silently rewrite it while syncing docs.
|
||||
|
||||
### 3. Classify Entries
|
||||
|
||||
The docs changelog uses four section types:
|
||||
|
||||
| English section | Chinese section | Meaning |
|
||||
|---|---|---|
|
||||
| `### Features` | `### 新功能` | New user-facing functionality |
|
||||
| `### Bug Fixes` | `### 修复` | Fixes for behavior that was broken |
|
||||
| `### Refactors` | `### 重构` | Internal changes with no user-visible behavior change, including build, CI, tests, dependency cleanup, and internal renames |
|
||||
| `### Other` | `### 其他` | Anything that does not fit above, such as user-visible adjustments that are not fixes or features, CDN/endpoint swaps, and docs-related artifacts |
|
||||
|
||||
Classification process:
|
||||
|
||||
1. Classify from the stripped entry text first.
|
||||
2. If unclear, inspect the related commit or PR:
|
||||
- Use the stripped commit hash with `git show <hash>`.
|
||||
- Or use the PR number with `gh pr view <NNN>`.
|
||||
3. If it is still unclear, put it in `Other`. Do not guess or force entries into `Features`.
|
||||
|
||||
Keyword hints:
|
||||
|
||||
- **Features**: `Add`, `Introduce`, `Support`, `Allow`, `Enable`, `Implement`, `New ... command/flag/option`
|
||||
- **Bug Fixes**: `Fix`, `Resolve`, `Correct`, `Address`, `Prevent ... from`, `Stop ... from`, `... no longer ...`
|
||||
- **Refactors**: `Refactor`, `Rename`, `Clean up`, `Simplify`, `Remove unused`, `Migrate to`, `Unify`, `Restructure`, `Internal`, dependency bumps, pure CI/build/test changes
|
||||
- **Other**: docs artifacts, CDN/endpoint switches, or small user-visible adjustments that are not fixes or new features
|
||||
|
||||
Within each version, section order is:
|
||||
|
||||
```text
|
||||
Features → Bug Fixes → Refactors → Other
|
||||
```
|
||||
|
||||
Omit empty sections. Preserve upstream entry order within each section.
|
||||
|
||||
### 4. Write The English Page
|
||||
|
||||
Never change the English page header:
|
||||
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
This page documents the changes in each Kimi Code CLI release.
|
||||
```
|
||||
|
||||
Insert new version blocks immediately after the header paragraph and before the previous latest version.
|
||||
|
||||
Example:
|
||||
|
||||
```markdown
|
||||
## 0.2.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix the TUI not restoring the current todo list after resuming a session.
|
||||
|
||||
### Refactors
|
||||
|
||||
- Clean up lint warnings across the CLI, SDK examples, and bundled runtime code without changing product behavior.
|
||||
- Update the native release workflow to use current GitHub artifact actions.
|
||||
```
|
||||
|
||||
### 5. Translate The Increment Into Chinese
|
||||
|
||||
After updating the English page, translate only the newly added English content into `docs/zh/release-notes/changelog.md`.
|
||||
|
||||
Follow `translate-docs`, direction `en → zh`. Changelog direction is English-to-Chinese even though many other docs flows use Chinese-to-English.
|
||||
|
||||
Chinese page requirements:
|
||||
|
||||
- Header:
|
||||
|
||||
```markdown
|
||||
# 变更记录
|
||||
|
||||
本页记录 Kimi Code CLI 每个版本的变更内容。
|
||||
```
|
||||
|
||||
- Preserve version headings exactly, such as `## 0.2.0`.
|
||||
- Translate section headings exactly:
|
||||
- `### Features` → `### 新功能`
|
||||
- `### Bug Fixes` → `### 修复`
|
||||
- `### Refactors` → `### 重构`
|
||||
- `### Other` → `### 其他`
|
||||
- The Chinese page must mirror the English page 1:1 for versions, sections, section order, and entry counts.
|
||||
- Keep the classification from the English page. Do not reclassify while translating.
|
||||
- Translate only entry body text. Do not add entries that are not present in English.
|
||||
- Follow `docs/AGENTS.md` for Chinese typography: full-width punctuation, spaces between Chinese and English, and the glossary.
|
||||
|
||||
### 6. Verify
|
||||
|
||||
Review:
|
||||
|
||||
```bash
|
||||
git diff docs/en/release-notes/changelog.md docs/zh/release-notes/changelog.md
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
- Versions and version counts match between English and Chinese.
|
||||
- Each version has the same section set and order on both pages.
|
||||
- Each section has the same number of entries on both pages.
|
||||
- PR links and commit hashes were stripped.
|
||||
- There are no empty sections.
|
||||
- Markdown indentation and blank lines are intact.
|
||||
|
||||
Then run the docs build:
|
||||
|
||||
```bash
|
||||
pnpm --filter docs run build
|
||||
```
|
||||
|
||||
### 7. Commit
|
||||
|
||||
Use a neutral docs-sync commit message:
|
||||
|
||||
```text
|
||||
docs(changelog): sync <version range> from apps/kimi-code/CHANGELOG.md
|
||||
```
|
||||
|
||||
Do **not** create a changeset for changelog docs sync. Docs sync does not enter the bundle.
|
||||
|
||||
## Rules
|
||||
|
||||
- The English docs changelog is the source of truth.
|
||||
- Never edit upstream `apps/kimi-code/CHANGELOG.md`.
|
||||
- Do not backfill unreleased `.changeset/*.md` drafts into the docs site.
|
||||
- If upstream wording is wrong, leave upstream alone and fix it in a future changeset.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Fix |
|
||||
|---|---|
|
||||
| Adding entries directly to the English docs page without reading upstream | Use `apps/kimi-code/CHANGELOG.md` as the source |
|
||||
| Copying PR links or commit hashes into docs | Strip them; keep only body text |
|
||||
| Rewording upstream English entries | Upstream is frozen; copy the body text unless the user explicitly asks otherwise |
|
||||
| Leaving English text untranslated in the Chinese page | The Chinese page must be fully Chinese except preserved technical terms |
|
||||
| Editing upstream changelog text | Do not edit upstream |
|
||||
| Losing two-space indentation in multi-line list items | Restore indentation so Markdown lists stay valid |
|
||||
| Copying `### Patch Changes` into docs | Remove changesets headings and classify under Features / Bug Fixes / Refactors / Other |
|
||||
| Guessing unclear entries as Features | Inspect commit/PR; if still unclear, use Other |
|
||||
| Reclassifying entries while translating | Chinese classification must mirror English |
|
||||
| Leaving empty sections | Delete sections with no entries |
|
||||
| Putting everything under Other for convenience | Classify what can be classified first |
|
||||
| Translating tool names, command names, or config keys | Keep them as written |
|
||||
| Creating a changeset for docs sync | Do not create one |
|
||||
| Using curly quotes or half-width Chinese punctuation | Follow `docs/AGENTS.md` |
|
||||
|
||||
## Stop Signals
|
||||
|
||||
- The top version in `apps/kimi-code/CHANGELOG.md` is not published on npm or GitHub Releases.
|
||||
- You are about to edit `apps/kimi-code/CHANGELOG.md`.
|
||||
- You are about to add docs sync to a changeset.
|
||||
- English and Chinese versions, entry counts, or section sets do not match.
|
||||
- A section is empty.
|
||||
- A Chinese term is uncertain and `docs/AGENTS.md` does not answer it.
|
||||
65
.agents/skills/translate-docs/SKILL.md
Normal file
65
.agents/skills/translate-docs/SKILL.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
name: translate-docs
|
||||
description: Translate and sync bilingual user documentation between docs/zh/ and docs/en/ following the source-of-truth rules in docs/AGENTS.md.
|
||||
---
|
||||
|
||||
# Translate Docs
|
||||
|
||||
## Overview
|
||||
|
||||
This repository keeps bilingual user documentation under `docs/zh/` and `docs/en/`. This skill synchronizes the two locales, page by page, after either side has been updated.
|
||||
|
||||
This skill is invoked by both `gen-docs` (incremental updates) and `audit-docs` (full pre-release audit) to keep locale mirrors in sync.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
If any of the following are missing, stop and report to the user before continuing:
|
||||
|
||||
- `docs/zh/` and `docs/en/` mirrored directory structure.
|
||||
- `docs/AGENTS.md` — terminology table, typography rules, and source-of-truth rules.
|
||||
|
||||
## Locale sync rules
|
||||
|
||||
- **Changelog** (`release-notes/changelog.md`): English is the source. Translate to Chinese.
|
||||
- **Breaking changes** (`release-notes/breaking-changes.md`): English is the source. Translate to Chinese.
|
||||
- **All other pages**: `docs/en/` and `docs/zh/` are mirrored pairs. After either side changes, update the other locale in the same change.
|
||||
|
||||
When non-changelog pages change in either locale, sync the mirror before release. When the English changelog changes, sync the Chinese changelog.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Detect what needs syncing**
|
||||
|
||||
- `git diff main..HEAD --stat docs/` — see which files changed
|
||||
- For each changed file under `docs/en/` or `docs/zh/`, locate its mirror in the other locale (same relative path).
|
||||
|
||||
2. **Translate page by page, section by section**
|
||||
|
||||
- Keep heading hierarchy, list structure, code blocks, callout blocks, and link targets identical between the two versions.
|
||||
- When in doubt about a technical term, **read the actual code** to confirm behavior rather than guessing.
|
||||
|
||||
3. **Apply terminology and typography rules from `docs/AGENTS.md`**
|
||||
|
||||
- Use the term table exactly. Do not invent translations or use synonyms.
|
||||
- English H2+ uses sentence case (proper nouns excepted, per the term table).
|
||||
- Chinese typography: full-width punctuation (`,。;:?!()`), space between Chinese and ASCII (letters / numbers / inline code / links).
|
||||
- Callout titles (`::: tip` / `::: warning` / `::: info` / `::: danger`) use the short Chinese labels from `docs/AGENTS.md`.
|
||||
|
||||
4. **Verify**
|
||||
|
||||
- `git diff docs/` — scan for terminology drift or punctuation regressions.
|
||||
- Run the docs build if available (`pnpm --filter docs run build` or equivalent) to catch broken links and Markdown errors.
|
||||
|
||||
## Rules and conventions
|
||||
|
||||
- **Do not one-sided fixes**: if the changed locale has an unclear or incorrect statement, fix it there first; do not patch only the mirror.
|
||||
- **Match style, not just words**: Chinese docs use a narrative tone (see `docs/AGENTS.md` writing-style examples); preserve that tone in Chinese; preserve sentence-case headings and concise English style in English.
|
||||
- **Code blocks and identifiers stay as-is**: do not translate code, command names, flag names, or file paths.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- Rewriting only the mirror because a phrase feels awkward in the target language — fix the changed locale first, then sync.
|
||||
- Letting English headings slip into Title Case (only sentence case is allowed for H2+).
|
||||
- Forgetting to add spaces between Chinese characters and inline code or English words.
|
||||
- Translating proper nouns listed in the term table (`Wire`, `MCP`, `ACP`, `JSON`, `OAuth`, `macOS`, `uv`, etc.).
|
||||
- Updating only one direction and leaving the other locale stale — always finish all pages flagged by the diff.
|
||||
152
.changeset/README.md
Normal file
152
.changeset/README.md
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
# Changesets
|
||||
|
||||
This repository uses [changesets](https://github.com/changesets/changesets) to manage npm package versions and releases.
|
||||
|
||||
## Package Publishing Strategy
|
||||
|
||||
This repository uses an **independent, manually-selected publishing** strategy. When generating a changeset, only select the publishable packages that this change actually affects. The repository's `.changeset/config.json` already filters out internal workspace packages via `ignore`, so only the publishable packages listed below should appear in the `pnpm changeset` prompt.
|
||||
|
||||
Current publishable packages:
|
||||
|
||||
| Package | Directory | Description |
|
||||
| --- | --- | --- |
|
||||
| `@moonshot-ai/kimi-code` | `apps/kimi-code` | CLI / TUI application — provides the `kimi` command after install |
|
||||
| `@moonshot-ai/kimi-code-sdk` | `packages/node-sdk` | Public TypeScript SDK |
|
||||
|
||||
All other workspace packages are private internal packages, are not published to npm, and are excluded via `ignore` in `.changeset/config.json`:
|
||||
|
||||
- `@moonshot-ai/agent-core`
|
||||
- `@moonshot-ai/kimi-code-oauth`
|
||||
- `@moonshot-ai/kimi-telemetry`
|
||||
- `@moonshot-ai/kaos`
|
||||
- `@moonshot-ai/kosong`
|
||||
- `@moonshot-ai/vis`
|
||||
- `@moonshot-ai/vis-server`
|
||||
- `@moonshot-ai/vis-web`
|
||||
|
||||
Version impact from internal dependencies must be judged manually. The published artifacts for CLI and SDK bundle internal workspace packages into the artifact itself; runtime `dependencies` of published packages must not include any `@moonshot-ai/*` internal workspace packages.
|
||||
|
||||
The repository's `.changeset/config.json` sets `updateInternalDependencies: "patch"`. Because internal packages are not published, you still need to manually select all affected publishable packages in the changeset — do not rely solely on automatic dependency bumps to express user-visible changes.
|
||||
|
||||
Example scenarios:
|
||||
|
||||
| Change | Changeset selection |
|
||||
| --- | --- |
|
||||
| Only modifies TUI behavior in `@moonshot-ai/kimi-code` | Add `patch` / `minor` / `major` to `@moonshot-ai/kimi-code` |
|
||||
| Only modifies internal packages, no user-visible change in SDK / CLI | Usually no changeset needed |
|
||||
| Internal package fix changes the CLI user experience | Add a changeset to `@moonshot-ai/kimi-code` describing the user-visible fix |
|
||||
| Internal package adds a new capability exposed by the SDK | Add a changeset to `@moonshot-ai/kimi-code-sdk` |
|
||||
| SDK behavior change affects CLI user experience | Add changesets to both `@moonshot-ai/kimi-code-sdk` and `@moonshot-ai/kimi-code` |
|
||||
| Provider abstraction change affects SDK / CLI | Add changesets to the affected `@moonshot-ai/kimi-code-sdk` and/or `@moonshot-ai/kimi-code` |
|
||||
| Test-only, internal refactor, docs, or private debug tooling changes | Usually no changeset needed |
|
||||
|
||||
## Prerequisite: NPM Trusted Publishing (OIDC)
|
||||
|
||||
This repository uses npm's **Trusted Publishing** (OIDC-based) for publishing — no `NPM_TOKEN` is required.
|
||||
|
||||
### Configuration steps
|
||||
|
||||
1. Open each publishable package's page on the npm website, e.g. `https://www.npmjs.com/package/@moonshot-ai/kimi-code`.
|
||||
2. Go to **Settings** -> **Publishing access**.
|
||||
3. Find **Automate publishing with GitHub Actions** or **Add trusted publisher**.
|
||||
4. Click **Add a new trusted publisher**.
|
||||
|
||||
Fill in the following:
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| GitHub Organization | `MoonshotAI` |
|
||||
| GitHub Repository | `kimi-code` |
|
||||
| GitHub Workflow | `release.yml` |
|
||||
| Environment | leave empty |
|
||||
|
||||
Each publishable package needs its Trusted Publisher configured once. The current GitHub Actions workflow lives at `.github/workflows/release.yml` and already has `id-token: write` configured.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Implement the feature or fix
|
||||
|
||||
Complete code, tests, and documentation changes as usual. A changeset is required when the change affects user-visible behavior, public API, dependency ranges, or release artifacts of a publishable package.
|
||||
|
||||
### 2. Generate a changeset
|
||||
|
||||
From the repository root:
|
||||
|
||||
```sh
|
||||
pnpm changeset
|
||||
```
|
||||
|
||||
Follow the prompts to choose:
|
||||
|
||||
- Which publishable packages this change affects;
|
||||
- The version bump level:
|
||||
- `patch`: bug fixes, small changes, follow-up dependency updates;
|
||||
- `minor`: backward-compatible new features;
|
||||
- `major`: breaking changes;
|
||||
- A user-facing description of the change.
|
||||
|
||||
The command creates a `.changeset/*.md` file that must be committed alongside the code.
|
||||
|
||||
### 3. Commit the changeset
|
||||
|
||||
```sh
|
||||
git add .changeset/
|
||||
git commit -m "chore: add changeset for package release"
|
||||
git push
|
||||
```
|
||||
|
||||
Commit messages must follow Conventional Commit style. Do not include any author/agent identity in the commit message.
|
||||
|
||||
### 4. CI generates the release PR
|
||||
|
||||
Once the changeset file is merged into `main`, `.github/workflows/release.yml` uses `changesets/action@v1` to create or update a release PR.
|
||||
|
||||
The release PR runs:
|
||||
|
||||
- `pnpm changeset version`: bumps publishable package versions and updates changelogs;
|
||||
- Deletes the consumed `.changeset/*.md` files;
|
||||
- Uses the title `[CI]: Release packages`.
|
||||
|
||||
### 5. Merge the release PR
|
||||
|
||||
Once the release PR is merged into `main`, the same workflow runs:
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- `pnpm build`
|
||||
- `pnpm changeset publish`
|
||||
|
||||
The packages are then published via npm Trusted Publishing, and a GitHub Release is created.
|
||||
|
||||
## Manual Publishing (Not Recommended)
|
||||
|
||||
Only publish manually when CI is unavailable. Before publishing manually, make sure you are logged into npm locally and using the Node.js and pnpm versions required by the repository.
|
||||
|
||||
```sh
|
||||
pnpm run version
|
||||
pnpm run publish
|
||||
```
|
||||
|
||||
The underlying changesets commands are:
|
||||
|
||||
```sh
|
||||
pnpm changeset version
|
||||
pnpm changeset publish
|
||||
```
|
||||
|
||||
The root-level `pnpm run publish` first runs typecheck, lint, sherif, test, build, and package lint, then runs `changeset publish`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Every PR that affects publishable-package behavior or public API should include a corresponding changeset.
|
||||
- Changeset files must be committed to the repository — release PRs are only triggered after they're merged.
|
||||
- Release PRs require human review and merge; they will not publish automatically.
|
||||
- Do not add release changesets for private internal packages; only select `@moonshot-ai/kimi-code` and `@moonshot-ai/kimi-code-sdk`.
|
||||
- If a change in an underlying internal package alters user-visible behavior or public API of a publishable package, add a changeset to the affected publishable package. For example, when a bug fixed in `@moonshot-ai/agent-core` resolves an issue CLI users encounter, add a changeset to `@moonshot-ai/kimi-code` describing the user-visible fix.
|
||||
- `@moonshot-ai/kimi-code` is the official CLI package name; after a global install it provides the `kimi` command.
|
||||
- Make sure each publishable package on npm has a Trusted Publisher configured.
|
||||
|
||||
## References
|
||||
|
||||
- [Changesets documentation](https://github.com/changesets/changesets)
|
||||
- [Changesets GitHub Action](https://github.com/changesets/action)
|
||||
- [npm Trusted Publishing documentation](https://docs.npmjs.com/trusted-publishers)
|
||||
18
.changeset/config.json
Normal file
18
.changeset/config.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"changelog": ["@changesets/changelog-github", { "repo": "MoonshotAI/kimi-code", "disableThanks": true }],
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [
|
||||
"@moonshot-ai/vis",
|
||||
"@moonshot-ai/vis-server",
|
||||
"@moonshot-ai/vis-web"
|
||||
],
|
||||
"snapshot": {
|
||||
"useCalculatedVersion": true,
|
||||
"prereleaseTemplate": "{tag}-{commit}"
|
||||
}
|
||||
}
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
65
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
Normal file
65
.github/ISSUE_TEMPLATE/1-bug-report.yml
vendored
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
name: Bug Report
|
||||
description: Report an issue that should be fixed
|
||||
labels:
|
||||
- bug
|
||||
- needs triage
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a bug report! It helps make Kimi Code better for everyone.
|
||||
|
||||
Make sure you are running the latest version of Kimi Code CLI. The bug you are experiencing may already have been fixed.
|
||||
|
||||
Please try to include as much information as possible.
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: What version of Kimi Code is running?
|
||||
description: Copy the output of `kimi --version` or `/version`
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: plan
|
||||
attributes:
|
||||
label: Which open platform/subscription were you using?
|
||||
description: The one you selected when running `/login`
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: model
|
||||
attributes:
|
||||
label: Which model were you using?
|
||||
description: The one you can see on the bottom status line, like `kimi-k2.6`, `kimi-for-coding`, etc.
|
||||
- type: input
|
||||
id: platform
|
||||
attributes:
|
||||
label: What platform is your computer?
|
||||
description: |
|
||||
For MacOS and Linux: copy the output of `uname -mprs`
|
||||
For Windows: copy the output of `"$([Environment]::OSVersion | ForEach-Object VersionString) $(if ([Environment]::Is64BitOperatingSystem) { "x64" } else { "x86" })"` in the PowerShell console
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: What issue are you seeing?
|
||||
description: Please include the full error messages and prompts with any private information redacted. If possible, please provide text instead of a screenshot.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: What steps can reproduce the bug?
|
||||
description: Explain the bug and provide a code snippet that can reproduce it. Please include session id and context usage if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: What is the expected behavior?
|
||||
description: If possible, please provide text instead of a screenshot.
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Is there anything else you think we should know?
|
||||
25
.github/ISSUE_TEMPLATE/2-feature-request.yml
vendored
Normal file
25
.github/ISSUE_TEMPLATE/2-feature-request.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Feature Request
|
||||
description: Propose a new feature for Kimi Code
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Is Kimi Code missing a feature that you'd like to see? Feel free to propose it here.
|
||||
|
||||
Before you submit a feature:
|
||||
1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one.
|
||||
2. The Kimi Code team will try to balance the varying needs of the community when prioritizing or rejecting new features. Please understand that not all features will be accepted.
|
||||
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: What feature would you like to see?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: notes
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Is there anything else you think we should know?
|
||||
12
.github/actions/macos-keychain-cleanup/action.yml
vendored
Normal file
12
.github/actions/macos-keychain-cleanup/action.yml
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
name: macOS keychain cleanup
|
||||
description: Delete the temporary keychain created by macos-keychain-setup.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Cleanup keychain
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${APPLE_KEYCHAIN_PATH:-}" && -f "${APPLE_KEYCHAIN_PATH}" ]]; then
|
||||
security delete-keychain "$APPLE_KEYCHAIN_PATH" || true
|
||||
fi
|
||||
82
.github/actions/macos-keychain-setup/action.yml
vendored
Normal file
82
.github/actions/macos-keychain-setup/action.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
name: macOS keychain setup
|
||||
description: |
|
||||
Create a temporary keychain, import a Developer ID Application certificate,
|
||||
discover the signing identity, and export APPLE_SIGNING_IDENTITY +
|
||||
APPLE_KEYCHAIN_PATH to GITHUB_ENV for subsequent steps. Must be paired
|
||||
with macos-keychain-cleanup in an if: always() step at job end.
|
||||
|
||||
inputs:
|
||||
certificate-p12:
|
||||
description: Base64-encoded P12 certificate
|
||||
required: true
|
||||
certificate-password:
|
||||
description: P12 password
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Import signing certificate
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12: ${{ inputs.certificate-p12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ inputs.certificate-password }}
|
||||
KEYCHAIN_PASSWORD: actions
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 0. Validate inputs early — base64 decoding "" gives 0-byte .p12 → security import reports
|
||||
# "SecKeychainItemImport: One or more parameters passed to a function were not valid"
|
||||
# which is cryptic. Fail loudly here instead.
|
||||
if [ -z "$APPLE_CERTIFICATE_P12" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE_P12 secret is empty. Configure it in repo settings, or pass sign-macos=false."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$APPLE_CERTIFICATE_PASSWORD" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE_PASSWORD secret is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Decode certificate
|
||||
cert_path="${RUNNER_TEMP}/certificate.p12"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12" | base64 -d > "$cert_path"
|
||||
if [ ! -s "$cert_path" ]; then
|
||||
echo "::error::Decoded certificate is empty. APPLE_CERTIFICATE_P12 secret value may not be valid base64."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Create temporary keychain (don't pollute runner default keychain)
|
||||
keychain_path="${RUNNER_TEMP}/signing.keychain-db"
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
security set-keychain-settings -lut 21600 "$keychain_path"
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$keychain_path"
|
||||
|
||||
# 3. Add to user keychain search list and set default
|
||||
security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"')
|
||||
security default-keychain -s "$keychain_path"
|
||||
|
||||
# 4. Import cert, authorize codesign + security tools
|
||||
security import "$cert_path" -k "$keychain_path" \
|
||||
-P "$APPLE_CERTIFICATE_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/security
|
||||
|
||||
# 5. Non-interactive private key access
|
||||
security set-key-partition-list -S apple-tool:,apple: \
|
||||
-s -k "$KEYCHAIN_PASSWORD" "$keychain_path" > /dev/null
|
||||
|
||||
# 6. Discover identity (don't hardcode team ID)
|
||||
IDENTITY=$(security find-identity -v -p codesigning "$keychain_path" \
|
||||
| grep "Developer ID Application" | head -1 \
|
||||
| sed -n 's/.*"\(Developer ID Application[^"]*\)".*/\1/p')
|
||||
|
||||
if [[ -z "$IDENTITY" ]]; then
|
||||
echo "::error::No Developer ID Application identity found in keychain"
|
||||
security find-identity -v -p codesigning "$keychain_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found signing identity: $IDENTITY"
|
||||
echo "APPLE_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV"
|
||||
echo "APPLE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
|
||||
|
||||
rm -f "$cert_path"
|
||||
95
.github/actions/macos-notarize/action.yml
vendored
Normal file
95
.github/actions/macos-notarize/action.yml
vendored
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
name: macOS notarize
|
||||
description: |
|
||||
Submit a signed binary to Apple notary service via notarytool, wait for
|
||||
result, and run Gatekeeper online check (spctl). Fails the job with the
|
||||
notarytool log on rejection.
|
||||
|
||||
inputs:
|
||||
binary-path:
|
||||
description: Absolute path to the signed binary
|
||||
required: true
|
||||
notarization-key-p8:
|
||||
description: Base64-encoded App Store Connect API Key (.p8)
|
||||
required: true
|
||||
notarization-key-id:
|
||||
description: API Key ID
|
||||
required: true
|
||||
notarization-issuer-id:
|
||||
description: Issuer ID
|
||||
required: true
|
||||
timeout:
|
||||
description: notarytool wait timeout (e.g. 15m)
|
||||
required: false
|
||||
default: '15m'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Submit to notary service
|
||||
shell: bash
|
||||
env:
|
||||
BINARY_PATH: ${{ inputs.binary-path }}
|
||||
APPLE_NOTARIZATION_KEY_P8: ${{ inputs.notarization-key-p8 }}
|
||||
APPLE_NOTARIZATION_KEY_ID: ${{ inputs.notarization-key-id }}
|
||||
APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.notarization-issuer-id }}
|
||||
NOTARY_TIMEOUT: ${{ inputs.timeout }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -f "$BINARY_PATH" ]]; then
|
||||
echo "::error::Binary not found at $BINARY_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Decode API key
|
||||
key_path="${RUNNER_TEMP}/AuthKey.p8"
|
||||
printf '%s' "$APPLE_NOTARIZATION_KEY_P8" | base64 -d > "$key_path"
|
||||
|
||||
# 2. Pack with ditto (--norsrc avoids ._AppleDouble files)
|
||||
binary_name=$(basename "$BINARY_PATH")
|
||||
zip_path="${RUNNER_TEMP}/${binary_name}.notarize.zip"
|
||||
ditto -c -k --norsrc --keepParent "$BINARY_PATH" "$zip_path"
|
||||
|
||||
echo "==> Submitting $binary_name for notarization..."
|
||||
|
||||
# 3. Submit and capture log
|
||||
log_path="${RUNNER_TEMP}/notarize-${binary_name}.log"
|
||||
set +e
|
||||
xcrun notarytool submit "$zip_path" \
|
||||
--key "$key_path" \
|
||||
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
|
||||
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" \
|
||||
--wait --timeout "$NOTARY_TIMEOUT" 2>&1 | tee "$log_path"
|
||||
notarize_rc=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
# 4. Inspect status
|
||||
if ! grep -q "status: Accepted" "$log_path"; then
|
||||
echo "::error::Notarization failed (exit=$notarize_rc)"
|
||||
submission_id=$(grep -m1 "id:" "$log_path" | awk '{print $2}' || true)
|
||||
if [[ -n "$submission_id" ]]; then
|
||||
echo "Fetching detailed log for submission $submission_id..."
|
||||
xcrun notarytool log "$submission_id" \
|
||||
--key "$key_path" \
|
||||
--key-id "$APPLE_NOTARIZATION_KEY_ID" \
|
||||
--issuer "$APPLE_NOTARIZATION_ISSUER_ID" || true
|
||||
fi
|
||||
rm -f "$key_path" "$zip_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "==> Notarization accepted"
|
||||
|
||||
# 5. Cleanup
|
||||
rm -f "$key_path" "$zip_path"
|
||||
|
||||
- name: Verify with Gatekeeper online check
|
||||
shell: bash
|
||||
env:
|
||||
BINARY_PATH: ${{ inputs.binary-path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "==> codesign -dv $BINARY_PATH"
|
||||
codesign -dv --verbose=2 "$BINARY_PATH"
|
||||
echo "==> spctl -a -vvv -t install $BINARY_PATH"
|
||||
spctl -a -vvv -t install "$BINARY_PATH"
|
||||
12
.github/pr-title-checker-config.json
vendored
Normal file
12
.github/pr-title-checker-config.json
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"LABEL": {
|
||||
"name": "Invalid PR Title",
|
||||
"color": "B60205"
|
||||
},
|
||||
"CHECKS": {
|
||||
"regexp": "^(feat|fix|test|refactor|chore|style|docs|perf|build|ci|revert)(\\(.*\\))?:.*"
|
||||
},
|
||||
"MESSAGES": {
|
||||
"failure": "The PR title is invalid. Please refer to https://www.conventionalcommits.org/en/v1.0.0/ for the convention."
|
||||
}
|
||||
}
|
||||
25
.github/pull_request_template.md
vendored
Normal file
25
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!--
|
||||
Thank you for your contribution to Kimi Code!
|
||||
Please make sure you already discussed the feature or bugfix you are proposing in an issue with the maintainers.
|
||||
Please understand that if you have not gotten confirmation from the maintainers, your pull request may be closed or ignored without further review due to limited bandwidth.
|
||||
|
||||
See https://github.com/MoonshotAI/kimi-code/blob/main/CONTRIBUTING.md for more.
|
||||
-->
|
||||
|
||||
## Related Issue
|
||||
|
||||
<!-- Please link to the issue here. -->
|
||||
|
||||
Resolve #(issue_number)
|
||||
|
||||
## Description
|
||||
|
||||
<!-- Please describe your changes in detail. -->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I have read the [CONTRIBUTING](https://github.com/MoonshotAI/kimi-code/blob/main/CONTRIBUTING.md) document.
|
||||
- [ ] I have linked the related issue, if any.
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works.
|
||||
- [ ] Ran `gen-changesets` skill, or this PR needs no changeset.
|
||||
- [ ] Ran `gen-docs` skill, or this PR needs no doc update.
|
||||
115
.github/workflows/_native-build.yml
vendored
Normal file
115
.github/workflows/_native-build.yml
vendored
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
name: _native-build
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
upload-artifact-prefix:
|
||||
description: 'Prefix for uploaded artifact name'
|
||||
required: false
|
||||
type: string
|
||||
default: 'kimi-code-native'
|
||||
retention-days:
|
||||
description: 'Artifact retention in days'
|
||||
required: false
|
||||
type: number
|
||||
default: 3
|
||||
sign-macos:
|
||||
description: 'Whether to sign + notarize macOS builds (requires Apple secrets)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
secrets:
|
||||
APPLE_CERTIFICATE_P12:
|
||||
required: false
|
||||
APPLE_CERTIFICATE_PASSWORD:
|
||||
required: false
|
||||
APPLE_NOTARIZATION_KEY_P8:
|
||||
required: false
|
||||
APPLE_NOTARIZATION_KEY_ID:
|
||||
required: false
|
||||
APPLE_NOTARIZATION_ISSUER_ID:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
native-bundle:
|
||||
name: Native bundle (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-24.04
|
||||
target: linux-x64
|
||||
- os: ubuntu-24.04-arm
|
||||
target: linux-arm64
|
||||
- os: macos-15-intel
|
||||
target: darwin-x64
|
||||
- os: macos-15
|
||||
target: darwin-arm64
|
||||
- os: windows-2025-vs2026
|
||||
target: win32-x64
|
||||
- os: windows-11-arm
|
||||
target: win32-arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Setup macOS keychain (release only)
|
||||
if: runner.os == 'macOS' && inputs.sign-macos
|
||||
uses: ./.github/actions/macos-keychain-setup
|
||||
with:
|
||||
certificate-p12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Build native executable (release profile, macOS signed)
|
||||
if: runner.os == 'macOS' && inputs.sign-macos
|
||||
run: pnpm --filter @moonshot-ai/kimi-code run build:native:release
|
||||
|
||||
- name: Build native executable (local profile)
|
||||
if: '!(runner.os == ''macOS'' && inputs.sign-macos)'
|
||||
run: pnpm --filter @moonshot-ai/kimi-code run build:native:sea
|
||||
|
||||
- name: Notarize macOS binary
|
||||
if: runner.os == 'macOS' && inputs.sign-macos
|
||||
uses: ./.github/actions/macos-notarize
|
||||
with:
|
||||
binary-path: ${{ github.workspace }}/apps/kimi-code/dist-native/bin/${{ matrix.target }}/kimi
|
||||
notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
|
||||
notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
|
||||
notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
|
||||
|
||||
- name: Cleanup macOS keychain
|
||||
if: always() && runner.os == 'macOS' && inputs.sign-macos
|
||||
uses: ./.github/actions/macos-keychain-cleanup
|
||||
|
||||
- name: Smoke test native executable
|
||||
run: pnpm --filter @moonshot-ai/kimi-code run test:native:smoke
|
||||
|
||||
- name: Package native artifact
|
||||
run: pnpm --filter @moonshot-ai/kimi-code run package:native
|
||||
|
||||
- name: Upload native artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ inputs.upload-artifact-prefix }}-${{ matrix.target }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
path: |
|
||||
apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip
|
||||
apps/kimi-code/dist-native/artifacts/kimi-code-${{ matrix.target }}.zip.sha256
|
||||
if-no-files-found: ignore
|
||||
84
.github/workflows/ci.yml
vendored
Normal file
84
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run build
|
||||
- name: Smoke test CLI bundle
|
||||
run: pnpm -C apps/kimi-code run smoke
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run test
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm run lint
|
||||
- run: pnpm run sherif
|
||||
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: Typecheck
|
||||
run: |
|
||||
pnpm dlx --package @typescript/native-preview@beta tsgo --version
|
||||
for config in packages/*/tsconfig.json apps/kimi-code/tsconfig.json; do
|
||||
echo "Typechecking ${config}"
|
||||
pnpm dlx --package @typescript/native-preview@beta tsgo -p "${config}" --noEmit
|
||||
done
|
||||
60
.github/workflows/docs-deploy.yml
vendored
Normal file
60
.github/workflows/docs-deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
name: Deploy Docs to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
- '.github/workflows/docs-deploy.yml'
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install docs dependencies
|
||||
run: pnpm --ignore-workspace -C docs install
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm -C docs run build
|
||||
env:
|
||||
VITEPRESS_BASE: /${{ github.event.repository.name }}/
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v5
|
||||
21
.github/workflows/native-bundle.yml
vendored
Normal file
21
.github/workflows/native-bundle.yml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
name: Native Bundle
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/_native-build.yml
|
||||
with:
|
||||
upload-artifact-prefix: kimi-code-native
|
||||
retention-days: 3
|
||||
sign-macos: true
|
||||
secrets:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
|
||||
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
|
||||
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
|
||||
15
.github/workflows/pr-title-checker.yml
vendored
Normal file
15
.github/workflows/pr-title-checker.yml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
name: PR Title Checker
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, labeled]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
name: pr-title-checker
|
||||
steps:
|
||||
- uses: thehanimo/pr-title-checker@v1.4.3
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
configuration_path: ".github/pr-title-checker-config.json"
|
||||
112
.github/workflows/release.yml
vendored
Normal file
112
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.repository_owner == 'MoonshotAI'
|
||||
outputs:
|
||||
kimi_native_release: ${{ steps.kimi-release.outputs.should_publish }}
|
||||
kimi_release_tag: ${{ steps.kimi-release.outputs.tag }}
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write # Required for NPM Trusted Publishing (OIDC)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: 'pnpm'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Upgrade npm for Trusted Publishing
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
id: changesets
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: pnpm changeset publish
|
||||
version: pnpm changeset version
|
||||
commit: 'ci: release packages'
|
||||
title: 'ci: release packages'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# NPM_TOKEN is not needed with Trusted Publishing (OIDC)
|
||||
|
||||
- name: Resolve Kimi Code native release
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
id: kimi-release
|
||||
run: node apps/kimi-code/scripts/native/resolve-release.mjs
|
||||
env:
|
||||
CHANGESETS_PUBLISHED_PACKAGES: ${{ steps.changesets.outputs.publishedPackages }}
|
||||
|
||||
native-artifacts:
|
||||
name: Native release artifact
|
||||
needs: release
|
||||
if: needs.release.outputs.kimi_native_release == 'true'
|
||||
uses: ./.github/workflows/_native-build.yml
|
||||
with:
|
||||
upload-artifact-prefix: kimi-code-native
|
||||
retention-days: 7
|
||||
sign-macos: true
|
||||
secrets:
|
||||
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
APPLE_NOTARIZATION_KEY_P8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }}
|
||||
APPLE_NOTARIZATION_KEY_ID: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }}
|
||||
APPLE_NOTARIZATION_ISSUER_ID: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }}
|
||||
|
||||
publish-native-assets:
|
||||
name: Publish native release assets
|
||||
needs:
|
||||
- release
|
||||
- native-artifacts
|
||||
if: needs.release.outputs.kimi_native_release == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download native artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: kimi-code-native-*
|
||||
path: dist-native-release
|
||||
merge-multiple: true
|
||||
|
||||
- name: Produce manifest.json
|
||||
env:
|
||||
RELEASE_TAG: ${{ needs.release.outputs.kimi_release_tag }}
|
||||
run: node apps/kimi-code/scripts/native/produce-manifest.mjs dist-native-release "$RELEASE_TAG"
|
||||
|
||||
- name: Upload assets to GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ needs.release.outputs.kimi_release_tag }}
|
||||
run: gh release upload "$RELEASE_TAG" dist-native-release/* --clobber
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
node_modules/
|
||||
dist/
|
||||
dist-native/
|
||||
.tmp-api-extractor/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.vitest-results/
|
||||
.DS_Store
|
||||
.playwright-mcp/
|
||||
.claude
|
||||
.conductor
|
||||
.kimi-stash-dir
|
||||
superpowers/
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
auto-install-peers=true
|
||||
engine-strict=true
|
||||
strict-peer-dependencies=false
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.15.0
|
||||
19
.oxfmtrc.json
Normal file
19
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"sortImports": {
|
||||
"groups": ["builtin", "external", "internal", ["parent", "sibling", "index"], "unknown"],
|
||||
"newlinesBetween": true,
|
||||
"order": "asc"
|
||||
},
|
||||
"sortPackageJson": true,
|
||||
"ignorePatterns": ["dist/", "coverage/", "pnpm-lock.yaml", "*.generated.ts"]
|
||||
}
|
||||
155
.oxlintrc.json
Normal file
155
.oxlintrc.json
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"suspicious": "warn",
|
||||
"pedantic": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"plugins": ["typescript", "import", "unicorn", "promise", "node"],
|
||||
"rules": {
|
||||
"eslint/no-useless-return": "off",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/eqeqeq": "error",
|
||||
"eslint/no-throw-literal": "off",
|
||||
"eslint/no-control-regex": "off",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/return-await": "error",
|
||||
"typescript/only-throw-error": "error",
|
||||
"import/no-cycle": "error",
|
||||
"import/no-self-import": "error",
|
||||
"import/no-unassigned-import": "off",
|
||||
"unicorn/prefer-node-protocol": "error",
|
||||
"unicorn/no-useless-undefined": "off",
|
||||
"unicorn/no-lonely-if": "off",
|
||||
"unicorn/no-array-callback-reference": "off",
|
||||
"typescript/prefer-promise-reject-errors": "off",
|
||||
"typescript/no-unsafe-function-type": "off",
|
||||
"typescript/no-unsafe-argument": "off",
|
||||
"typescript/no-unsafe-assignment": "off",
|
||||
"typescript/no-unsafe-call": "off",
|
||||
"typescript/no-unsafe-member-access": "off",
|
||||
"typescript/no-unsafe-return": "off",
|
||||
|
||||
"eslint/no-console": "warn",
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-non-null-assertion": "off",
|
||||
"typescript/use-unknown-in-catch-callback-variable": "off",
|
||||
"typescript/consistent-type-imports": "off",
|
||||
"typescript/ban-types": "off",
|
||||
"import/first": "warn",
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
"js": "never",
|
||||
"ts": "never",
|
||||
"tsx": "never"
|
||||
}
|
||||
],
|
||||
"import/no-duplicates": "warn",
|
||||
"import/no-mutable-exports": "warn",
|
||||
"unicorn/error-message": "warn",
|
||||
"unicorn/throw-new-error": "warn",
|
||||
"unicorn/catch-error-name": "warn",
|
||||
"unicorn/prefer-add-event-listener": "off",
|
||||
"node/no-new-require": "warn",
|
||||
"node/no-path-concat": "warn",
|
||||
|
||||
"promise/no-callback-in-promise": "warn",
|
||||
"promise/valid-params": "warn",
|
||||
"promise/no-return-in-finally": "warn",
|
||||
"promise/always-return": "off",
|
||||
|
||||
"eslint/max-classes-per-file": "off",
|
||||
"eslint/max-depth": "off",
|
||||
"eslint/max-lines": "off",
|
||||
"eslint/max-lines-per-function": "off",
|
||||
"eslint/max-nested-callbacks": "off",
|
||||
"eslint/no-warning-comments": "off",
|
||||
"eslint/no-inline-comments": "off",
|
||||
"eslint/no-negated-condition": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/require-await": "off",
|
||||
"typescript/require-await": "off",
|
||||
"eslint/sort-vars": "off",
|
||||
"eslint/radix": "off",
|
||||
"eslint/symbol-description": "off",
|
||||
"typescript/consistent-return": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"typescript/no-deprecated": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"typescript/strict-boolean-expressions": "off",
|
||||
"typescript/no-duplicate-type-constituents": "off",
|
||||
"import/max-dependencies": "off"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/examples/**/*.ts", "**/examples/**/*.tsx"],
|
||||
"rules": {
|
||||
"eslint/no-console": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["packages/kosong/src/providers/**/*.ts"],
|
||||
"rules": {
|
||||
"typescript/no-unsafe-argument": "off",
|
||||
"typescript/no-unsafe-assignment": "off",
|
||||
"typescript/no-unsafe-call": "off",
|
||||
"typescript/no-unsafe-member-access": "off",
|
||||
"typescript/no-unsafe-return": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": [
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/test/**/*.ts",
|
||||
"**/test/**/*.tsx"
|
||||
],
|
||||
"plugins": ["vitest"],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "off",
|
||||
"typescript/no-unsafe-assignment": "off",
|
||||
"typescript/no-unsafe-argument": "off",
|
||||
"typescript/no-non-null-assertion": "off",
|
||||
"typescript/unbound-method": "off",
|
||||
"typescript/no-redundant-type-constituents": "off",
|
||||
"eslint/no-console": "off",
|
||||
"eslint/no-promise-executor-return": "off",
|
||||
"typescript/no-unsafe-member-access": "off",
|
||||
"vitest/require-mock-type-parameters": "off",
|
||||
"eslint/no-shadow": "off",
|
||||
"unicorn/prefer-event-target": "off",
|
||||
"jest/no-disabled-tests": "off",
|
||||
"vitest/no-disabled-tests": "off",
|
||||
"eslint/no-control-regex": "off",
|
||||
"eslint/no-unused-vars": "off",
|
||||
"eslint/no-unsafe-optional-chaining": "off",
|
||||
"jest/valid-expect": "off",
|
||||
"unicorn/no-useless-spread": "off",
|
||||
"import/no-cycle": "off",
|
||||
"typescript/no-meaningless-void-operator": "off",
|
||||
"typescript/require-array-sort-compare": "off",
|
||||
"vitest/expect-expect": "error",
|
||||
"vitest/no-conditional-tests": "error",
|
||||
"vitest/no-focused-tests": "error",
|
||||
"vitest/no-identical-title": "error",
|
||||
"vitest/valid-expect": "off",
|
||||
"vitest/valid-describe-callback": "error",
|
||||
"vitest/no-standalone-expect": "error",
|
||||
"vitest/warn-todo": "off"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"dist/",
|
||||
"coverage/",
|
||||
"node_modules/",
|
||||
"apps/*/scripts/",
|
||||
"docs/smoke-archive/",
|
||||
"*.generated.ts"
|
||||
]
|
||||
}
|
||||
60
AGENTS.md
Normal file
60
AGENTS.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Repository-level Agent Guide
|
||||
|
||||
Reply in the same language as the user.
|
||||
|
||||
This is a TypeScript monorepo built for agent-assisted development. Keep the root `AGENTS.md` limited to hot-path rules: the project map, hard constraints, and workflow requirements — things every task needs to know.
|
||||
|
||||
## Working Principles
|
||||
|
||||
- Think from first principles. Start from real requirements, code facts, and verification results; if the goal is unclear, discuss it with the user first.
|
||||
- Treat code, not documentation, as the source of truth. Unless the user explicitly says otherwise, do not read ordinary Markdown just to understand the implementation.
|
||||
- Before making code changes, read the relevant code and the most recent constraints, and follow the nearest `AGENTS.md` in the directory tree.
|
||||
- Keep changes focused. Do not slip in unrelated refactors along the way.
|
||||
- When committing, do not add any co-author attribution, and do not reveal the identity of the agent in commit messages, PR descriptions, or any explanatory text.
|
||||
|
||||
## Project Map
|
||||
|
||||
- `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`.
|
||||
- `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays.
|
||||
- `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, and other core capabilities.
|
||||
- `packages/node-sdk`: the public TypeScript SDK and harness.
|
||||
- `packages/kosong`: the LLM / provider abstraction layer.
|
||||
- `packages/kaos`: the execution environment and file/process abstractions.
|
||||
- `packages/oauth`: Kimi OAuth and managed auth utilities.
|
||||
- `packages/telemetry`: shared client-side telemetry infrastructure.
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
- **Node.js**: `>=24.15.0` (from the root `package.json` `engines`; `.nvmrc` is `24.15.0`, used by nvm / fnm / mise to pick the minimum recommended version).
|
||||
- **pnpm**: `10.33.0` (from the root `package.json` `packageManager`).
|
||||
- `pnpm install` will fail when the Node version is not satisfied, because `.npmrc` sets `engine-strict=true`.
|
||||
|
||||
## General Coding Rules
|
||||
|
||||
- For optional object properties, pass `undefined` directly instead of using conditional spread.
|
||||
- YES: `{ user }`
|
||||
- NO: `{ ...(user ? { user } : undefined) }`
|
||||
- Optional object properties do not need to additionally allow `undefined` in the type.
|
||||
- YES: `interface Options { user?: User }`
|
||||
- NO: `interface Options { user?: User | undefined }`
|
||||
- Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity.
|
||||
- Except for a package's `index.ts`, other `index.ts` files should prefer `export * from './module';`.
|
||||
- The `Agent` class in `packages/agent-core/src/agent` must be usable on its own. The constructor must not force the caller to create a `Session` instance, nor require an `agentId` or `session`. It may accept an optional `sessionId` as a request-config hint — for example mapped to the provider's `prompt_cache_key` — but the instance must not hold `sessionId`, and must not depend on the Session lifecycle, metadata, or parent/child relationship logic.
|
||||
- Do not add too many new test files. Prefer adding tests to the existing test file of the corresponding component or module.
|
||||
- When a test fails because of a user modification, default to fixing the test first; do not change the implementation to satisfy an old test unless the implementation truly has a bug.
|
||||
- Do not sacrifice code quality for external compatibility unless the user explicitly asks for it. Breaking changes go through changesets and a `major` bump, gated by the rule below.
|
||||
|
||||
## Where to Update Instructions
|
||||
|
||||
- Hard rules that affect almost every task: update the root `AGENTS.md`.
|
||||
- Rules that only affect a specific directory: update the nearest sub-directory `AGENTS.md`.
|
||||
- Keep instruction updates focused and supported by code facts.
|
||||
|
||||
## Workflow Requirements
|
||||
|
||||
- Prefer `rg` / `rg --files` when reading code.
|
||||
- When designing changes, follow existing boundaries and local patterns first.
|
||||
- When creating a PR, the PR title must follow Conventional Commit style, e.g. `chore: remove legacy format commands`.
|
||||
- After finishing a task and before submitting a PR, you must run the `gen-changesets` skill (see `.agents/skills/gen-changesets/SKILL.md`) and generate a changeset under `.changeset/` according to its rules.
|
||||
- When generating a changeset, **never** decide on a `major` bump on your own. When you judge a change to meet the major criteria (breaking changes, incompatible user configuration, renamed or removed commands/arguments, changed behavior semantics, etc.), you must stop and explain it to the user and ask for confirmation. **Only write `major` after the user has explicitly agreed.** Otherwise default to `minor` (and fall back to `patch` if `minor` is unclear). See the "Hard rule: confirm with the user before writing `major`" section in `.agents/skills/gen-changesets/SKILL.md` for details.
|
||||
- Prefer importing via `import ... from '#/...'`, which serves the same purpose as `import ... from '@/...'`.
|
||||
96
CONTRIBUTING.md
Normal file
96
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Contributing to kimi-code
|
||||
|
||||
Thanks for taking the time to contribute! This project moves quickly, and thoughtful contributions from the community are what keep it sharp. The guide below walks you through how we work so your PR has the best chance of landing smoothly.
|
||||
|
||||
## Before You Start
|
||||
|
||||
- Open an issue or discussion before making changes larger than ~100 lines so we can align on direction early.
|
||||
- We only merge PRs that are aligned with the roadmap — drive-by refactors without context are unlikely to land.
|
||||
- Code quality bar: as good as code written by a strong human engineer or a competent coding agent. We hold AI-assisted contributions to the same standard as hand-written ones.
|
||||
|
||||
## Project Layout
|
||||
|
||||
This is a pnpm monorepo. The most relevant entry points are:
|
||||
|
||||
- `apps/kimi-code` — CLI / TUI
|
||||
- `apps/vis` — session replay & debugging visualizer
|
||||
- `packages/node-sdk` — public TypeScript SDK (`@moonshot-ai/kimi-code-sdk`)
|
||||
- `packages/agent-core`, `kosong`, `kaos`, `oauth`, `telemetry` — internal engine packages
|
||||
- `docs/` — VitePress bilingual docs site
|
||||
|
||||
For the full project map, see [AGENTS.md](AGENTS.md).
|
||||
|
||||
## Development Setup
|
||||
|
||||
Prerequisites: Node.js >= 24.15.0, pnpm 10.33.0, Git.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MoonshotAI/kimi-code.git
|
||||
cd kimi-code
|
||||
pnpm install
|
||||
```
|
||||
|
||||
Useful scripts:
|
||||
|
||||
- `pnpm dev:cli` — run the CLI in dev mode
|
||||
- `pnpm test` — run tests (vitest)
|
||||
- `pnpm typecheck` — TypeScript check (note: builds packages first)
|
||||
- `pnpm lint` — oxlint
|
||||
- `pnpm lint:fix` — oxlint with auto-fix
|
||||
- `pnpm build` — build all packages
|
||||
|
||||
## Commit Convention
|
||||
|
||||
All commits and PR titles must follow [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
| Type | Use for | Example |
|
||||
|----------|---------------------------------------------|-------------------------------------------|
|
||||
| feat | A new feature | feat(agent-core): add tool dedup |
|
||||
| fix | A bug fix | fix(tui): correct status bar alignment |
|
||||
| docs | Documentation only | docs: clarify install instructions |
|
||||
| chore | Tooling / housekeeping | chore: bump dependencies |
|
||||
| refactor | Internal refactor without behavior change | refactor(kosong): extract retry helper |
|
||||
| test | Adding or improving tests | test(agent-core): cover skill resolver |
|
||||
| ci | CI / build pipeline changes | ci: cache pnpm store |
|
||||
| build | Build system / artifact changes | build(native): add win32-arm64 target |
|
||||
| perf | Performance improvement | perf(session): batch event flushes |
|
||||
| style | Formatting only (no logic) | style: apply oxlint --fix |
|
||||
|
||||
PR titles are enforced by the `pr-title-checker` workflow — a non-conforming title will block merge.
|
||||
|
||||
## Changesets
|
||||
|
||||
This repo uses [changesets](https://github.com/changesets/changesets) to manage versioning and releases.
|
||||
|
||||
- Every PR that affects release artifacts (code, behavior, public API) **must** include a changeset.
|
||||
- Docs-only, test-only, or CI-only PRs may skip changesets.
|
||||
- Generate one with `pnpm changeset` and follow the prompts (which packages are touched, which bump level).
|
||||
- For repo-specific conventions, see `.changeset/README.md`.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
Before requesting review, make sure your PR ticks the following:
|
||||
|
||||
- [ ] Linked an issue (for non-trivial changes)
|
||||
- [ ] PR title follows Conventional Commits
|
||||
- [ ] Added or updated tests
|
||||
- [ ] Added a changeset if the PR affects release artifacts
|
||||
- [ ] Ran `pnpm lint && pnpm typecheck && pnpm test` locally
|
||||
- [ ] Updated user-facing docs in `docs/` if behavior changed
|
||||
|
||||
The `.github/pull_request_template.md` checklist is a shorter subset of this — both must pass.
|
||||
|
||||
## Code Style
|
||||
|
||||
- TypeScript across the codebase.
|
||||
- Linting via `oxlint` (config in `.oxlintrc.json`).
|
||||
- Auto-formatting via `pnpm lint:fix`.
|
||||
- Follow existing local patterns when the lint rules do not cover a style choice.
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Found a security issue? Please see [SECURITY.md](SECURITY.md) instead of opening a public issue.
|
||||
|
||||
## License
|
||||
|
||||
By contributing to this repository, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Moonshot AI
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
67
Makefile
Normal file
67
Makefile
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
.PHONY: prepare build typecheck lint lint-fix lint-pkg sherif test test-watch test-coverage clean changeset version publish release dev vis
|
||||
|
||||
## Setup
|
||||
|
||||
prepare:
|
||||
pnpm install
|
||||
|
||||
## Build
|
||||
|
||||
build:
|
||||
pnpm run build
|
||||
|
||||
## Quality
|
||||
|
||||
typecheck:
|
||||
pnpm run typecheck
|
||||
|
||||
lint:
|
||||
pnpm run lint
|
||||
|
||||
lint-fix:
|
||||
pnpm run lint:fix
|
||||
|
||||
sherif:
|
||||
pnpm run sherif
|
||||
|
||||
lint-pkg:
|
||||
pnpm run lint:pkg
|
||||
|
||||
## Test
|
||||
|
||||
test:
|
||||
pnpm run test
|
||||
|
||||
test-watch:
|
||||
pnpm run test:watch
|
||||
|
||||
test-coverage:
|
||||
pnpm run test:coverage
|
||||
|
||||
## Clean
|
||||
|
||||
clean:
|
||||
pnpm run clean
|
||||
|
||||
## Release
|
||||
|
||||
changeset:
|
||||
pnpm run changeset
|
||||
|
||||
version:
|
||||
pnpm run version
|
||||
|
||||
publish:
|
||||
pnpm run publish
|
||||
|
||||
release: version publish
|
||||
|
||||
## Development
|
||||
|
||||
dev:
|
||||
pnpm run dev:cli
|
||||
|
||||
## vis
|
||||
|
||||
vis:
|
||||
pnpm run vis
|
||||
100
README.md
Normal file
100
README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# Kimi Code CLI
|
||||
|
||||
[](LICENSE) [](https://moonshotai.github.io/kimi-code/en/) <br>
|
||||
[Documentation](https://moonshotai.github.io/kimi-code/en/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [中文](README.zh-CN.md)
|
||||
|
||||

|
||||
|
||||
## What is Kimi Code CLI
|
||||
|
||||
Kimi Code CLI is an AI coding agent that runs in your terminal — it can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI’s Kimi models and can also be configured to use other compatible providers.
|
||||
|
||||
## Install
|
||||
|
||||
Install with the official script. No Node.js required.
|
||||
|
||||
- **macOS or Linux**:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
|
||||
```
|
||||
|
||||
- **Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://code.kimi.com/kimi-code/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then, run it with a new shell session:
|
||||
|
||||
```sh
|
||||
kimi --version
|
||||
```
|
||||
|
||||
For npm install, upgrade, uninstall, see [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open a project and start the interactive UI:
|
||||
|
||||
```sh
|
||||
cd your-project
|
||||
kimi
|
||||
```
|
||||
|
||||
On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try your first task:
|
||||
|
||||
```
|
||||
Take a look at this project and explain its main directories.
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Single-binary distribution.** Install with one command: no Node.js setup, PATH gymnastics, or global module conflicts.
|
||||
- **Blazing-fast startup.** The TUI is ready in milliseconds, so starting a session never feels heavy.
|
||||
- **Purpose-built TUI.** A carefully tuned interface for long, focused agent sessions.
|
||||
- **Video input.** Drop a screen recording or demo clip into the chat, and let the agent watch what is hard to describe in words.
|
||||
- **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally with `/mcp-config`, without hand-editing JSON.
|
||||
- **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated contexts while keeping the main conversation clean.
|
||||
- **Lifecycle hooks.** Run local commands at key points to gate risky tool calls, audit decisions, trigger desktop notifications, or connect to your own automation.
|
||||
|
||||
## Docs
|
||||
|
||||
- [Getting Started](https://moonshotai.github.io/kimi-code/en/guides/getting-started)
|
||||
- [Interaction and approvals](https://moonshotai.github.io/kimi-code/en/guides/interaction)
|
||||
- [Sessions](https://moonshotai.github.io/kimi-code/en/guides/sessions)
|
||||
- [Configuration](https://moonshotai.github.io/kimi-code/en/configuration/config-files)
|
||||
- [Command reference](https://moonshotai.github.io/kimi-code/en/reference/kimi-command)
|
||||
|
||||
## Develop
|
||||
|
||||
Requirements: Node.js ≥ 24.15.0, pnpm 10.33.0.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MoonshotAI/kimi-code.git
|
||||
cd kimi-code
|
||||
pnpm install
|
||||
```
|
||||
|
||||
```sh
|
||||
pnpm dev:cli # run the CLI in dev mode
|
||||
pnpm test # run tests
|
||||
pnpm typecheck # TypeScript check
|
||||
pnpm lint # oxlint
|
||||
pnpm build # build all packages
|
||||
```
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution guide.
|
||||
|
||||
## Community
|
||||
|
||||
- [Issues](https://github.com/MoonshotAI/kimi-code/issues)
|
||||
- For security vulnerabilities, see [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Our TUI is built on top of [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui). We thank the authors of `pi-tui` for their valuable work.
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT License](LICENSE).
|
||||
104
README.zh-CN.md
Normal file
104
README.zh-CN.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# Kimi Code CLI
|
||||
|
||||
[](LICENSE) [](https://moonshotai.github.io/kimi-code/zh/)
|
||||
|
||||
[Documentation](https://moonshotai.github.io/kimi-code/zh/) · [Issues](https://github.com/MoonshotAI/kimi-code/issues) · [English](README.md)
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
## 什么是 Kimi Code CLI
|
||||
|
||||
Kimi Code CLI 是一个运行在终端里的 AI 编程 agent,可以帮你读写代码、执行 shell 命令、检索文件、抓取网页,并根据反馈自主决定下一步动作。开箱即用对接 Moonshot AI 的 Kimi 模型,也可指向其他兼容厂商。
|
||||
|
||||
## 安装
|
||||
|
||||
推荐使用官方安装脚本,不需要提前安装 Node.js。
|
||||
|
||||
- **macOS / Linux**:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
|
||||
```
|
||||
|
||||
- **Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://code.kimi.com/kimi-code/install.ps1 | iex
|
||||
```
|
||||
|
||||
随后在新的终端会话中运行:
|
||||
|
||||
```sh
|
||||
kimi --version
|
||||
```
|
||||
|
||||
npm 安装、升级、卸载方式,见[快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
进入项目目录并启动交互界面:
|
||||
|
||||
```sh
|
||||
cd your-project
|
||||
kimi
|
||||
```
|
||||
|
||||
首次启动时,在 Kimi Code CLI 里输入 `/login`,选择 Kimi Code OAuth 或 Moonshot AI Open Platform API 密钥登录。登录完成后,可以先让它熟悉项目:
|
||||
|
||||
```
|
||||
帮我看一下这个项目的目录结构,简单介绍一下每个目录是做什么的
|
||||
```
|
||||
|
||||
## 核心特性
|
||||
|
||||
- **二进制发行,零环境依赖** 一行命令安装,不需要预装 Node.js,不用折腾 PATH,也不会和全局模块冲突。
|
||||
- **极速启动** TUI 在毫秒级就绪,开一个新会话没有任何心智负担。
|
||||
- **精致的 TUI 体验** 为长时间、专注的 Agent 会话精心打磨的交互界面。
|
||||
- **视频也能输入** 屏幕录像、演示视频也能拖进对话。
|
||||
- **AI-native 的 MCP 配置** 通过 `/mcp-config` 对话式添加、编辑、认证 MCP 服务器,无需手写 JSON。
|
||||
- **子 Agent 聚焦并行工作** 内置 `coder`、`explore`、`plan` 子 Agent 在隔离上下文中处理子任务,主对话保持清爽。
|
||||
- **生命周期 hooks** 在关键节点执行本地命令:拦截高风险工具调用、审计决策、发送桌面通知,或对接你自己的自动化脚本。
|
||||
|
||||
|
||||
## 文档
|
||||
|
||||
- [快速上手](https://moonshotai.github.io/kimi-code/zh/guides/getting-started)
|
||||
- [交互与审批](https://moonshotai.github.io/kimi-code/zh/guides/interaction)
|
||||
- [会话](https://moonshotai.github.io/kimi-code/zh/guides/sessions)
|
||||
- [配置](https://moonshotai.github.io/kimi-code/zh/configuration/config-files)
|
||||
- [命令参考](https://moonshotai.github.io/kimi-code/zh/reference/kimi-command)
|
||||
|
||||
## 本地开发
|
||||
|
||||
环境要求:Node.js ≥ 24.15.0,pnpm 10.33.0。
|
||||
|
||||
```sh
|
||||
git clone https://github.com/MoonshotAI/kimi-code.git
|
||||
cd kimi-code
|
||||
pnpm install
|
||||
```
|
||||
|
||||
```sh
|
||||
pnpm dev:cli # 以开发模式运行 CLI
|
||||
pnpm test # 运行测试
|
||||
pnpm typecheck # TypeScript 检查
|
||||
pnpm lint # 运行 oxlint
|
||||
pnpm build # 构建所有包
|
||||
```
|
||||
|
||||
完整贡献流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## 社区
|
||||
|
||||
- [Issues](https://github.com/MoonshotAI/kimi-code/issues)
|
||||
- 安全漏洞反馈,请见 [SECURITY.md](SECURITY.md)。
|
||||
|
||||
## 致谢
|
||||
|
||||
我们的 TUI 构建在 [`pi-tui`](https://github.com/earendil-works/pi-mono/tree/main/packages/tui) 之上。我们忠心感谢 `pi-tui` 作者的工作。
|
||||
|
||||
## 许可证
|
||||
|
||||
基于 [MIT](LICENSE) 协议发布。
|
||||
33
SECURITY.md
Normal file
33
SECURITY.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Currently, Kimi Code only provides security support for the latest released version.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We take security seriously. **Please do not open a public issue for security vulnerabilities.**
|
||||
|
||||
Preferred channel:
|
||||
|
||||
- GitHub Security Advisories — https://github.com/MoonshotAI/kimi-code/security/advisories/new
|
||||
(private disclosure, tracked with the codebase)
|
||||
|
||||
Alternative channel:
|
||||
|
||||
- Email: code@moonshot.ai (please include "[security]" in the subject)
|
||||
|
||||
## What to Include
|
||||
|
||||
- Affected version (output of `kimi --version`)
|
||||
- Reproduction steps
|
||||
- Impact assessment
|
||||
- Any suggested mitigation
|
||||
|
||||
## Our Response
|
||||
|
||||
We will acknowledge your report and provide an initial assessment as soon as we can.
|
||||
|
||||
## Public Disclosure
|
||||
|
||||
We will coordinate with you on disclosure timing once a fix is ready.
|
||||
2
apps/kimi-code/.gitignore
vendored
Normal file
2
apps/kimi-code/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Copied from packages/kimi-core at build time
|
||||
agents/
|
||||
128
apps/kimi-code/AGENTS.md
Normal file
128
apps/kimi-code/AGENTS.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# apps/kimi-code Development Guide
|
||||
|
||||
This file only contains rules local to `apps/kimi-code`. For cross-repo rules, see the root `AGENTS.md`.
|
||||
|
||||
## TUI File Layout
|
||||
|
||||
`apps/kimi-code` is the terminal UI / CLI app. The entry chain is:
|
||||
|
||||
`src/main.ts` -> `src/cli/commands.ts` -> `src/cli/run-shell.ts` -> SDK `KimiHarness` -> `src/tui/kimi-tui.ts`
|
||||
|
||||
Main directories:
|
||||
|
||||
- `src/constant/`: non-copy constants shared by CLI/TUI — product, protocol, paths, terminal control, updates, and so on.
|
||||
- `src/cli/`: command-line arguments, subcommands, and CLI startup.
|
||||
- `src/tui/`: the interactive terminal UI.
|
||||
- `src/tui/kimi-tui.ts`: the TUI master assembler, responsible for wiring state, layout, editor, session, SDK events, and dialogs together.
|
||||
- `src/tui/actions/`: reusable TUI state/replay/projection logic. Pure logic that can be split out of `KimiTUI` should land here first.
|
||||
- `src/tui/commands/`: slash command definitions, parsing, ordering, and dynamic skill command generation.
|
||||
- `src/tui/components/`: pi-tui components, organized by UI type.
|
||||
- `src/tui/constant/`: non-copy constants reused across TUI modules — symbols, terminal sequences, render sizing, streaming-arg match rules, and so on.
|
||||
- `src/tui/components/chrome/`: persistent UI chrome — footer, todo panel, welcome, loader, device code.
|
||||
- `src/tui/components/dialogs/`: selectors, approval panels, question popups, and settings popups that temporarily replace the editor.
|
||||
- `src/tui/components/editor/`: the custom input box and the file mention provider.
|
||||
- `src/tui/components/media/`: image, diff, code highlight, and other media displays.
|
||||
- `src/tui/components/messages/`: message blocks in the transcript — assistant, user, tool call, thinking, usage, subagent, and so on.
|
||||
- `src/tui/components/panes/`: right-side / activity-area panes such as the activity pane and queue pane.
|
||||
- `src/tui/reverse-rpc/`: the adapter layer that bridges SDK approval/question callbacks to the UI.
|
||||
- `src/tui/theme/`: themes, color tokens, style helpers, and the pi-tui markdown theme.
|
||||
- `src/tui/utils/`: TUI-only utility functions.
|
||||
- `src/utils/`: app-wide utilities — clipboard, git, history, image, process, usage, and so on.
|
||||
|
||||
## Module Responsibilities
|
||||
|
||||
- `cli` only interprets command-line input, assembles startup arguments, and invokes the TUI. Do not put TUI interaction logic into the CLI.
|
||||
- `KimiTUI` coordinates; it does not accumulate complex business rules. New logic that can be tested independently should be split into `actions`, `commands`, `components`, `reverse-rpc`, or `utils` first.
|
||||
- `commands` only owns slash-command declaration, parsing, and the parsed-result types. The actual execution can be dispatched from `KimiTUI`, but complex logic should continue to sink downward.
|
||||
- `components` only handle presentation and local interaction; they must not call the SDK directly, and must not read or write session state directly.
|
||||
- `reverse-rpc` converts SDK approval/question requests into the data shape a UI panel/dialog needs, and converts the user's choice back into an SDK response.
|
||||
- `theme` is the single source of truth for colors and styles. Components must not bypass the theme system and use chalk named colors directly.
|
||||
- `utils` holds utility functions with no UI-state dependency. Logic that needs `TUIState` or a component instance must not live under app-level `src/utils`.
|
||||
- `apps/kimi-code` may only use core capabilities through `@moonshot-ai/kimi-code-sdk`. Do not import `@moonshot-ai/agent-core` directly in app code.
|
||||
|
||||
## KimiTUI Internal Sections
|
||||
|
||||
`src/tui/kimi-tui.ts` is large. When you modify it, place code into the existing responsibility section — do not just drop it where it happens to be convenient.
|
||||
|
||||
- Types and state creation: `KimiTUIStartupInput`, `TUIState`, `createInitialAppState`, `createTUIState`. Before adding new global UI state, decide whether it really belongs in `TUIState`.
|
||||
- Startup helpers: slash commands, autocomplete, skill commands, input history.
|
||||
- Lifecycle: `start`, `init`, `stop`. They only handle startup/shutdown order — do not stuff feature implementations into them.
|
||||
- Layout and editor: `buildLayout`, `setupEditorHandlers`, external editor, clipboard image, exit shortcuts.
|
||||
- User input: `handleUserInput`, `executeSlashCommand`, `handleBuiltInSlashCommand`, `sendNormalUserInput`.
|
||||
- Sending and queueing: `enqueueMessage`, `sendMessageInternal`, `sendMessage`, `steerMessage`, `finalizeTurn`.
|
||||
- Session management: create, restore, switch, close, sync runtime state, subscribe to session events.
|
||||
- Event routing: `handleEvent` only dispatches; concrete events go into the corresponding `handleXxx`.
|
||||
- Streaming rendering: assistant delta, thinking, tool call, tool result, compaction, subagent, background agent.
|
||||
- Transcript: `createTranscriptComponent`, `appendTranscriptEntry`, read/tool/agent group aggregation.
|
||||
- Activity / queue / footer: `updateActivityPane`, `resolveActivityPaneMode`, `updateQueueDisplay`, terminal progress.
|
||||
- Dialogs / selectors: help, session picker, editor/model/thinking/theme/permission/settings selectors, approval / question panels.
|
||||
- Slash command handlers: `handleThemeCommand`, `handleModelCommand`, `handlePlanCommand`, `handleCompactCommand`, `handleLoginCommand`, and so on.
|
||||
|
||||
If a section keeps growing, split pure functions, state projections, presentation components, and handler logic into the corresponding directories rather than continuing to expand `KimiTUI`.
|
||||
|
||||
## Where New Features Go
|
||||
|
||||
The feature type decides where it lands:
|
||||
|
||||
- New CLI arguments: change `src/cli/commands.ts` / `src/cli/options.ts`, then pass them into the TUI via `src/cli/run-shell.ts`. Do not let the CLI operate on the session directly.
|
||||
- New CLI subcommands: put them under `src/cli/sub/`, with non-interactive command logic only; when SDK access is needed, go through `@moonshot-ai/kimi-code-sdk`.
|
||||
- New slash commands: first change definition, parsing, and types under `src/tui/commands/`; put the execution entry into the slash-command handler section of `KimiTUI`; split complex execution logic into `actions` or `utils`.
|
||||
- New skill-derived commands: hook into `buildSkillSlashCommands` / the skill command map — do not hard-code a single skill.
|
||||
- New transcript message types: define the data shape in `src/tui/types.ts`, add or extend a component under `components/messages/`, and register the renderer in `createTranscriptComponent`.
|
||||
- New tool-result display: prefer extending `components/messages/tool-renderers/registry.ts` and the corresponding renderer; do not stack branches inside `ToolCallComponent`.
|
||||
- New popup / selector: put it under `components/dialogs/` and mount it via `mountEditorReplacement`; if the trigger comes from an SDK callback, also check whether `reverse-rpc/` needs an adapter/controller/handler.
|
||||
- New SDK event handling: add the dispatch in `handleEvent`, then add the corresponding `handleXxx`. If the event simply maps to a transcript entry.
|
||||
- New session start / resume behavior: put it in the session management section, keeping `init` focused only on startup orchestration.
|
||||
- New status bar, activity area, or queue display: change `chrome/footer`, `panes/activity`, `panes/queue`, and the corresponding `updateXxx` method.
|
||||
- New configuration option: first change the read/write and schema in `src/tui/config.ts`, then wire the settings UI; when persistence is needed, go through `saveTuiConfig`.
|
||||
- New constants: constants shared by CLI/TUI and not copy belong in `src/constant/`; non-copy constants reused only within the TUI belong in `src/tui/constant/`. Component-local copy, option labels, help descriptions, dialog title/footer text — keep these next to the corresponding component or command, do not centralize them into a global copy constants module.
|
||||
- New general-purpose capability: if it does not depend on TUI state, put it under `src/utils/`; if it depends on TUI state or a component, put it under `src/tui/utils/`.
|
||||
|
||||
Test placement rules:
|
||||
|
||||
- Component behavior tests live next to the corresponding component's tests.
|
||||
- Command parsing tests go under `test/tui/commands/`.
|
||||
- reverse-rpc tests go under `test/tui/reverse-rpc/`.
|
||||
- Pure utility tests go next to the corresponding utils tests.
|
||||
- Do not create a generic `some-feature.test.ts` just to land a small feature.
|
||||
|
||||
## TUI Coding Conventions
|
||||
|
||||
- Do not over-encapsulate, especially for one- or two-line functions — do not introduce a two-layer wrapper, just inline.
|
||||
- Functions with no state / UI side effects do not belong as private methods on the `KimiTUI` class; put them in external utils.
|
||||
- Constants must live in the corresponding `constant` directory; they must not be scattered through component or logic code.
|
||||
- Inside `handleInput(data)`, when comparing a printable character (letter, digit, space, punctuation), it is **forbidden** to write literal comparisons such as `data === 'q'`. With the Kitty keyboard protocol enabled in terminals like VSCode, these keys are sent as CSI-u sequences (e.g. `\x1b[113u`), and a bare comparison will never match. Decode with `printableChar(data)` from `src/tui/utils/printable-key.ts` first, then compare; function keys continue to use `matchesKey(data, Key.*)`; control characters (codepoint < 32) may still be compared against the raw `data`. `test/tui/printable-key-guard.test.ts` enforces this in CI.
|
||||
|
||||
## How to Set Themes
|
||||
|
||||
Themes are managed centrally under `src/tui/theme/`:
|
||||
|
||||
- `colors.ts` defines semantic tokens: `ColorPalette`, `darkColors`, `lightColors`.
|
||||
- `styles.ts` builds common chalk helpers on top of `ColorPalette`.
|
||||
- `pi-tui-theme.ts` produces the theme configuration markdown / pi-tui requires.
|
||||
- `bundle.ts` packs `colors`, `styles`, and `markdownTheme` into a `KimiTUIThemeBundle`.
|
||||
- `index.ts` / `detect.ts` handle the theme type and auto/dark/light resolution.
|
||||
|
||||
When setting or switching themes:
|
||||
|
||||
- The UI entry goes through `ThemeSelectorComponent`, `handleThemeCommand`, and `applyThemeChoice`.
|
||||
- The real apply step goes through `KimiTUI.applyTheme`, which should update `state.theme`, `state.appState.theme`, and notify the relevant components to refresh their palette.
|
||||
- Persisting the user's choice goes through `saveTuiConfig`. Do not let a component write the config file itself.
|
||||
|
||||
When writing color:
|
||||
|
||||
- Do not use chalk named colors such as `chalk.red`, `chalk.cyan`, `chalk.white`, `chalk.gray`, `chalk.dim`, or `chalk.yellow` directly.
|
||||
- If a component already has `colors`, use `chalk.hex(colors.<token>)(text)`.
|
||||
- If a component already has `state.theme.styles` or styles passed in, prefer helpers such as `styles.error(text)`, `styles.dim(text)`.
|
||||
- When new visual semantics have no token, first add a semantic field to `ColorPalette`, and fill in both `darkColors` and `lightColors`.
|
||||
- In light themes, text tokens against a white background must be at least 4.5:1; borders and large chrome must be at least 3:1.
|
||||
- Do not cache styled chalk functions at module top level. Theme switching must take effect within a single render, so styles must be generated on the render path from the current palette.
|
||||
|
||||
After a theme change, non-comment code must not contain chalk named colors such as `chalk.white`, `chalk.cyan`, `chalk.red`, `chalk.green`, `chalk.gray`, `chalk.yellow`, `chalk.blue`, `chalk.magenta`, `chalk.whiteBright`, or `chalk.blackBright`.
|
||||
|
||||
## General Coding Requirements
|
||||
|
||||
- For optional object properties, pass `undefined` directly — do not use conditional spread.
|
||||
- Optional object properties do not need to additionally allow `undefined` in the type.
|
||||
- Internal methods with only a single parameter should not be turned into options objects just for stylistic uniformity.
|
||||
- Except for a package's own `index.ts`, other `index.ts` files should prefer `export * from './module'`.
|
||||
88
apps/kimi-code/README.md
Normal file
88
apps/kimi-code/README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# @moonshot-ai/kimi-code
|
||||
|
||||
> The Starting Point for Next-Gen Agents
|
||||
|
||||
[](https://www.npmjs.com/package/@moonshot-ai/kimi-code) [](LICENSE) [](https://moonshotai.github.io/kimi-code/en/)
|
||||
|
||||
## What is Kimi Code CLI
|
||||
|
||||
Kimi Code CLI is an AI coding agent that runs in your terminal. It can read and edit code, run shell commands, search files, fetch web pages, and choose the next step based on the feedback it receives. It works out of the box with Moonshot AI's Kimi models and can also be configured to use other compatible providers.
|
||||
|
||||
## Install
|
||||
|
||||
The recommended install path is the official script. It does not require Node.js to be installed first.
|
||||
|
||||
- **macOS / Linux**:
|
||||
|
||||
```sh
|
||||
curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
|
||||
```
|
||||
|
||||
- **Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://code.kimi.com/kimi-code/install.ps1 | iex
|
||||
```
|
||||
|
||||
Then run it with a new Terminal session:
|
||||
|
||||
```sh
|
||||
kimi --version
|
||||
```
|
||||
|
||||
### Alternative: npm
|
||||
|
||||
If you prefer npm, use Node.js 22.19.0 or later:
|
||||
|
||||
```sh
|
||||
npm install -g @moonshot-ai/kimi-code
|
||||
```
|
||||
|
||||
Or with pnpm:
|
||||
|
||||
```sh
|
||||
pnpm add -g @moonshot-ai/kimi-code
|
||||
```
|
||||
|
||||
For upgrade and uninstall instructions, see the [Getting Started guide](https://moonshotai.github.io/kimi-code/en/guides/getting-started).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Open a project and start the interactive UI:
|
||||
|
||||
```sh
|
||||
cd your-project
|
||||
kimi
|
||||
```
|
||||
|
||||
On first launch, run `/login` inside Kimi Code CLI and choose either Kimi Code OAuth or a Moonshot AI Open Platform API key. After login, try a first task:
|
||||
|
||||
```
|
||||
Take a look at this project and explain the main directories.
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Single-binary distribution.** Install with one command — no Node.js setup, no PATH gymnastics, no global module conflicts.
|
||||
- **Blazing-fast startup.** The TUI is ready in milliseconds, so opening a session never feels heavy.
|
||||
- **Polished TUI.** A carefully tuned interface designed for long, focused agent sessions.
|
||||
- **Video input.** Drop a screen recording or demo clip into the chat — let the agent watch instead of typing out what's hard to describe in words.
|
||||
- **AI-native MCP configuration.** Add, edit, and authenticate Model Context Protocol servers conversationally via `/mcp-config` — no hand-editing JSON.
|
||||
- **Subagents for focused, parallel work.** Dispatch built-in `coder`, `explore`, and `plan` subagents in isolated context windows; the main conversation stays clean.
|
||||
- **Lifecycle hooks.** Run local commands at key points — gate risky tool calls, audit decisions, fire desktop notifications, wire into your own automation.
|
||||
|
||||
## Documentation
|
||||
|
||||
- Full docs: https://moonshotai.github.io/kimi-code/en/
|
||||
- 中文文档: https://moonshotai.github.io/kimi-code/zh/
|
||||
- Getting Started: https://moonshotai.github.io/kimi-code/en/guides/getting-started
|
||||
|
||||
## Repository & Issues
|
||||
|
||||
- Source: https://github.com/MoonshotAI/kimi-code
|
||||
- Issues: https://github.com/MoonshotAI/kimi-code/issues
|
||||
- Security: see SECURITY.md in the main repository
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
88
apps/kimi-code/package.json
Normal file
88
apps/kimi-code/package.json
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
{
|
||||
"name": "@moonshot-ai/kimi-code",
|
||||
"version": "0.1.1",
|
||||
"description": "The Starting Point for Next-Gen Agents",
|
||||
"license": "MIT",
|
||||
"author": "Moonshot AI",
|
||||
"homepage": "https://github.com/MoonshotAI/kimi-code/tree/main/apps/kimi-code#readme",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/MoonshotAI/kimi-code.git",
|
||||
"directory": "apps/kimi-code"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/MoonshotAI/kimi-code/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"kimi",
|
||||
"kimi-code",
|
||||
"cli",
|
||||
"agent",
|
||||
"coding-agent",
|
||||
"ai",
|
||||
"tui"
|
||||
],
|
||||
"bin": {
|
||||
"kimi": "dist/main.mjs"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"scripts/postinstall.mjs",
|
||||
"scripts/postinstall",
|
||||
"README.md"
|
||||
],
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#/*": [
|
||||
"./src/*.ts",
|
||||
"./src/*/index.ts"
|
||||
]
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"provenance": true
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"build:native:js": "node scripts/native/01-bundle.mjs",
|
||||
"build:native:sea": "node scripts/native/build.mjs --profile=local",
|
||||
"build:native:release": "node scripts/native/build.mjs --profile=release",
|
||||
"package:native": "node scripts/native/package.mjs",
|
||||
"produce:native:manifest": "node scripts/native/produce-manifest.mjs",
|
||||
"release:native:resolve": "node scripts/native/resolve-release.mjs",
|
||||
"test:native:smoke": "node scripts/native/smoke.mjs",
|
||||
"dev": "tsx --import ../../build/register-raw-text-loader.mjs ./src/main.ts",
|
||||
"dev:prod": "node dist/main.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "pnpm -w run build:packages && vitest run",
|
||||
"e2e": "pnpm -w run build:packages && KIMI_E2E=1 vitest run test/e2e",
|
||||
"e2e:real": "pnpm -w run build:packages && KIMI_E2E_REAL=1 vitest run test/e2e/real-llm-smoke.e2e.test.ts",
|
||||
"postinstall": "node scripts/postinstall.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@earendil-works/pi-tui": "^0.74.0",
|
||||
"@mariozechner/clipboard": "^0.3.2",
|
||||
"chalk": "^5.4.1",
|
||||
"cli-highlight": "^2.1.11",
|
||||
"commander": "^13.1.0",
|
||||
"semver": "^7.7.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@moonshot-ai/kimi-code-oauth": "workspace:^",
|
||||
"@moonshot-ai/kimi-code-sdk": "workspace:^",
|
||||
"@moonshot-ai/kimi-telemetry": "workspace:^",
|
||||
"@moonshot-ai/migration-legacy": "workspace:^",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/yazl": "^2.4.6",
|
||||
"postject": "1.0.0-alpha.6",
|
||||
"tsx": "^4.21.0",
|
||||
"yazl": "^3.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.19.0"
|
||||
}
|
||||
}
|
||||
17
apps/kimi-code/scripts/native/01-bundle.mjs
Normal file
17
apps/kimi-code/scripts/native/01-bundle.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { run } from './exec.mjs';
|
||||
|
||||
const requireFromScript = createRequire(import.meta.url);
|
||||
const tsdownCliPath = requireFromScript.resolve('tsdown/run');
|
||||
const checkBundlePath = resolve(import.meta.dirname, 'check-bundle.mjs');
|
||||
|
||||
export async function runBundleStep() {
|
||||
await run(process.execPath, [tsdownCliPath, '--config', 'tsdown.native.config.ts']);
|
||||
await run(process.execPath, [checkBundlePath]);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runBundleStep();
|
||||
}
|
||||
69
apps/kimi-code/scripts/native/02-sea-blob.mjs
Normal file
69
apps/kimi-code/scripts/native/02-sea-blob.mjs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import {
|
||||
collectNativeAssets,
|
||||
nativeAssetManifestKey,
|
||||
nativeAssetSummary,
|
||||
} from './assets.mjs';
|
||||
import { fail, run } from './exec.mjs';
|
||||
import {
|
||||
appRoot,
|
||||
nativeBlobPath,
|
||||
nativeIntermediatesDir,
|
||||
nativeJsBundlePath,
|
||||
nativeManifestDir,
|
||||
nativeSeaConfigPath,
|
||||
targetTriple,
|
||||
} from './paths.mjs';
|
||||
|
||||
async function ensureBundleExists() {
|
||||
try {
|
||||
await stat(nativeJsBundlePath());
|
||||
} catch {
|
||||
fail(`Native JS bundle not found at ${nativeJsBundlePath()}. Run 01-bundle.mjs first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSeaConfig(target) {
|
||||
await mkdir(nativeIntermediatesDir(), { recursive: true });
|
||||
const { manifest, manifestJson, assets } = await collectNativeAssets({
|
||||
appRoot,
|
||||
target,
|
||||
});
|
||||
const manifestPath = resolve(nativeManifestDir(target), 'manifest.json');
|
||||
await mkdir(dirname(manifestPath), { recursive: true });
|
||||
await writeFile(manifestPath, manifestJson);
|
||||
|
||||
const seaAssets = {
|
||||
[nativeAssetManifestKey(target)]: manifestPath,
|
||||
...assets,
|
||||
};
|
||||
const config = {
|
||||
main: nativeJsBundlePath(),
|
||||
output: nativeBlobPath(),
|
||||
assets: Object.fromEntries(
|
||||
Object.entries(seaAssets).sort(([a], [b]) => a.localeCompare(b)),
|
||||
),
|
||||
disableExperimentalSEAWarning: true,
|
||||
useCodeCache: false,
|
||||
useSnapshot: false,
|
||||
};
|
||||
await writeFile(nativeSeaConfigPath(), `${JSON.stringify(config, null, 2)}\n`);
|
||||
|
||||
console.log(`Collected native assets for ${manifest.target}:`);
|
||||
for (const line of nativeAssetSummary(manifest)) {
|
||||
console.log(`- ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSeaBlobStep() {
|
||||
await ensureBundleExists();
|
||||
const target = targetTriple();
|
||||
await writeSeaConfig(target);
|
||||
await run(process.execPath, ['--experimental-sea-config', nativeSeaConfigPath()]);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runSeaBlobStep();
|
||||
}
|
||||
65
apps/kimi-code/scripts/native/03-inject.mjs
Normal file
65
apps/kimi-code/scripts/native/03-inject.mjs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { copyFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { fail, run, tryRun } from './exec.mjs';
|
||||
import {
|
||||
appRoot,
|
||||
nativeBinDir,
|
||||
nativeBinPath,
|
||||
nativeBlobPath,
|
||||
SEA_SENTINEL_FUSE,
|
||||
targetTriple,
|
||||
} from './paths.mjs';
|
||||
|
||||
function postjectPath() {
|
||||
const command = process.platform === 'win32' ? 'postject.cmd' : 'postject';
|
||||
return resolve(appRoot, 'node_modules/.bin', command);
|
||||
}
|
||||
|
||||
async function ensureBlobExists() {
|
||||
try {
|
||||
await stat(nativeBlobPath());
|
||||
} catch {
|
||||
fail(`SEA blob not found at ${nativeBlobPath()}. Run 02-sea-blob.mjs first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyNodeExecutable(target) {
|
||||
await mkdir(nativeBinDir(target), { recursive: true });
|
||||
const out = nativeBinPath(target);
|
||||
await copyFile(process.execPath, out);
|
||||
if (process.platform !== 'win32') {
|
||||
await run('chmod', ['755', out]);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSignatureIfNeeded(target) {
|
||||
const out = nativeBinPath(target);
|
||||
if (process.platform === 'darwin') {
|
||||
await tryRun('codesign', ['--remove-signature', out]);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
await tryRun('signtool', ['remove', '/s', out]);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectSeaBlob(target) {
|
||||
const out = nativeBinPath(target);
|
||||
const args = [out, 'NODE_SEA_BLOB', nativeBlobPath(), '--sentinel-fuse', SEA_SENTINEL_FUSE];
|
||||
if (process.platform === 'darwin') {
|
||||
args.push('--macho-segment-name', 'NODE_SEA');
|
||||
}
|
||||
await run(postjectPath(), args);
|
||||
}
|
||||
|
||||
export async function runInjectStep() {
|
||||
const target = targetTriple();
|
||||
await ensureBlobExists();
|
||||
await copyNodeExecutable(target);
|
||||
await removeSignatureIfNeeded(target);
|
||||
await injectSeaBlob(target);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
await runInjectStep();
|
||||
}
|
||||
68
apps/kimi-code/scripts/native/04-sign.mjs
Normal file
68
apps/kimi-code/scripts/native/04-sign.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
|
||||
import { run } from './exec.mjs';
|
||||
import { nativeBinPath, targetTriple } from './paths.mjs';
|
||||
|
||||
const ENTITLEMENTS_PATH = resolve(import.meta.dirname, 'entitlements.plist');
|
||||
|
||||
export function buildCodesignArgs({ identity, executable, entitlementsPath, keychainPath }) {
|
||||
if (identity === '-') {
|
||||
return ['--sign', '-', executable];
|
||||
}
|
||||
const args = [
|
||||
'--sign',
|
||||
identity,
|
||||
'--options',
|
||||
'runtime',
|
||||
'--entitlements',
|
||||
entitlementsPath,
|
||||
'--timestamp',
|
||||
];
|
||||
if (keychainPath) {
|
||||
args.push('--keychain', keychainPath);
|
||||
}
|
||||
args.push('--force', executable);
|
||||
return args;
|
||||
}
|
||||
|
||||
async function sha256(path) {
|
||||
return await new Promise((resolveHash, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolveHash(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
async function writeChecksum(executable) {
|
||||
const digest = await sha256(executable);
|
||||
await writeFile(`${executable}.sha256`, `${digest} ${basename(executable)}\n`);
|
||||
}
|
||||
|
||||
export async function runSignStep({ identity = '-', keychainPath = null } = {}) {
|
||||
const target = targetTriple();
|
||||
const executable = nativeBinPath(target);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const args = buildCodesignArgs({
|
||||
identity,
|
||||
executable,
|
||||
entitlementsPath: ENTITLEMENTS_PATH,
|
||||
keychainPath,
|
||||
});
|
||||
await run('codesign', args);
|
||||
}
|
||||
|
||||
await writeChecksum(executable);
|
||||
console.log(`Signed and hashed: ${executable}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const identity = process.env.APPLE_SIGNING_IDENTITY ?? '-';
|
||||
const keychainPath = process.env.APPLE_KEYCHAIN_PATH ?? null;
|
||||
await runSignStep({ identity, keychainPath });
|
||||
}
|
||||
30
apps/kimi-code/scripts/native/05-verify.mjs
Normal file
30
apps/kimi-code/scripts/native/05-verify.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { run } from './exec.mjs';
|
||||
import { nativeBinPath, targetTriple } from './paths.mjs';
|
||||
|
||||
export async function runVerifyStep({ requireGatekeeper = false } = {}) {
|
||||
if (process.platform !== 'darwin') {
|
||||
console.log('Verify step skipped (not macOS)');
|
||||
return;
|
||||
}
|
||||
|
||||
const target = targetTriple();
|
||||
const executable = nativeBinPath(target);
|
||||
|
||||
console.log(`==> codesign -dv ${executable}`);
|
||||
await run('codesign', ['-dv', '--verbose=2', executable]);
|
||||
|
||||
if (requireGatekeeper) {
|
||||
// spctl in 'install' mode simulates the Gatekeeper online check — only a
|
||||
// fully notarized binary passes. Ad-hoc signed binaries fail, so this is
|
||||
// only run under the release profile.
|
||||
console.log(`==> spctl -a -vvv -t install ${executable}`);
|
||||
await run('spctl', ['-a', '-vvv', '-t', 'install', executable]);
|
||||
} else {
|
||||
console.log('Skipping spctl check (requireGatekeeper=false)');
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const requireGatekeeper = process.env.KIMI_VERIFY_GATEKEEPER === '1';
|
||||
await runVerifyStep({ requireGatekeeper });
|
||||
}
|
||||
276
apps/kimi-code/scripts/native/assets.mjs
Normal file
276
apps/kimi-code/scripts/native/assets.mjs
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, realpathSync } from 'node:fs';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, extname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import { NATIVE_ASSET_MANIFEST_VERSION, buildManifestKey } from './manifest.mjs';
|
||||
import { resolveTargetDeps, SUPPORTED_TARGETS } from './native-deps.mjs';
|
||||
|
||||
export { NATIVE_ASSET_MANIFEST_VERSION };
|
||||
|
||||
// Re-export for any external consumer that still needs it. Internally we
|
||||
// use resolveTargetDeps() exclusively — no more if/else against package names.
|
||||
export const NATIVE_TARGETS = Object.freeze(
|
||||
Object.fromEntries(
|
||||
SUPPORTED_TARGETS.map((t) => {
|
||||
const deps = resolveTargetDeps(t);
|
||||
const clipboardTarget = deps.find((d) => d.id === 'clipboard-target')?.resolvedName;
|
||||
const koffiNativeFile = deps.find((d) => d.id === 'koffi')?.nativeFileRelatives?.[0];
|
||||
const koffiTriplet = koffiNativeFile?.match(/koffi\/([^/]+)\/koffi\.node$/)?.[1] ?? null;
|
||||
return [t, { clipboardPackage: clipboardTarget, koffiTriplet }];
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const jsExtensions = ['.js', '.cjs', '.mjs', '.json', '.node'];
|
||||
const runtimeEntryNames = ['index.js', 'index.cjs', 'index.mjs'];
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function toPosixPath(path) {
|
||||
return path.split('\\').join('/');
|
||||
}
|
||||
|
||||
function sha256(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
async function readJson(path) {
|
||||
return JSON.parse(await readFile(path, 'utf-8'));
|
||||
}
|
||||
|
||||
async function listFiles(root) {
|
||||
const files = [];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(path);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile()) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolvePackageRootGeneric(requireFromApp, packageName, parentPackageName, appRoot, target) {
|
||||
try {
|
||||
return dirname(requireFromApp.resolve(`${packageName}/package.json`));
|
||||
} catch (rootError) {
|
||||
if (parentPackageName !== null) {
|
||||
try {
|
||||
const parentPackageJsonPath = realpathSync(
|
||||
requireFromApp.resolve(`${parentPackageName}/package.json`),
|
||||
);
|
||||
const requireFromParent = createRequire(pathToFileURL(parentPackageJsonPath));
|
||||
return dirname(requireFromParent.resolve(`${packageName}/package.json`));
|
||||
} catch {}
|
||||
}
|
||||
fail(
|
||||
[
|
||||
`Native asset package is not installed for target ${target}: ${packageName}`,
|
||||
parentPackageName ? `Searched via parent: ${parentPackageName}` : '',
|
||||
`Resolve root: ${appRoot}`,
|
||||
'Run pnpm install --frozen-lockfile before building native assets.',
|
||||
rootError instanceof Error ? rootError.message : String(rootError),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFileCandidate(path) {
|
||||
if (existsSync(path)) return path;
|
||||
for (const extension of jsExtensions) {
|
||||
const candidate = `${path}${extension}`;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
for (const entryName of runtimeEntryNames) {
|
||||
const candidate = join(path, entryName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePackageEntry(packageRoot, packageJson) {
|
||||
const rawMain =
|
||||
typeof packageJson.main === 'string'
|
||||
? packageJson.main
|
||||
: typeof packageJson.module === 'string'
|
||||
? packageJson.module
|
||||
: 'index.js';
|
||||
return resolveFileCandidate(resolve(packageRoot, rawMain));
|
||||
}
|
||||
|
||||
function relativeRuntimeSpecifiers(text) {
|
||||
const specifiers = new Set();
|
||||
for (const match of text.matchAll(/\brequire\(\s*["'](\.[^"']+)["']\s*\)/g)) {
|
||||
specifiers.add(match[1]);
|
||||
}
|
||||
for (const match of text.matchAll(/(?<![.\w])import\(\s*["'](\.[^"']+)["']\s*\)/g)) {
|
||||
specifiers.add(match[1]);
|
||||
}
|
||||
for (const match of text.matchAll(/\bfrom\s+["'](\.[^"']+)["']/g)) {
|
||||
specifiers.add(match[1]);
|
||||
}
|
||||
return [...specifiers];
|
||||
}
|
||||
|
||||
async function addRuntimeDependencyFiles(packageRoot, filePath, selected) {
|
||||
const extension = extname(filePath);
|
||||
if (!['.js', '.cjs', '.mjs'].includes(extension)) return;
|
||||
|
||||
let text;
|
||||
try {
|
||||
text = await readFile(filePath, 'utf-8');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const specifier of relativeRuntimeSpecifiers(text)) {
|
||||
const candidate = resolveFileCandidate(resolve(dirname(filePath), specifier));
|
||||
if (candidate === null) continue;
|
||||
if (candidate.endsWith('.node')) continue;
|
||||
const packageRelativePath = relative(packageRoot, candidate);
|
||||
if (
|
||||
packageRelativePath.startsWith('..') ||
|
||||
isAbsolute(packageRelativePath) ||
|
||||
packageRelativePath.length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (selected.has(candidate)) continue;
|
||||
selected.add(candidate);
|
||||
await addRuntimeDependencyFiles(packageRoot, candidate, selected);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPackageFiles({
|
||||
packageName,
|
||||
packageRoot,
|
||||
includeNativeFiles,
|
||||
nativeFileRelatives = [],
|
||||
}) {
|
||||
const packageJsonPath = join(packageRoot, 'package.json');
|
||||
const packageJson = await readJson(packageJsonPath);
|
||||
const selected = new Set([packageJsonPath]);
|
||||
|
||||
const entry = resolvePackageEntry(packageRoot, packageJson);
|
||||
if (entry !== null) {
|
||||
selected.add(entry);
|
||||
await addRuntimeDependencyFiles(packageRoot, entry, selected);
|
||||
}
|
||||
|
||||
for (const nativeFileRelative of nativeFileRelatives) {
|
||||
const nativeFile = resolve(packageRoot, nativeFileRelative);
|
||||
if (!existsSync(nativeFile)) {
|
||||
fail(`Native package ${packageName} does not contain ${nativeFileRelative} at ${packageRoot}`);
|
||||
}
|
||||
selected.add(nativeFile);
|
||||
}
|
||||
|
||||
if (includeNativeFiles) {
|
||||
const files = await listFiles(packageRoot);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.node')) {
|
||||
selected.add(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...selected].sort((a, b) => a.localeCompare(b));
|
||||
if (includeNativeFiles && !sorted.some((file) => file.endsWith('.node'))) {
|
||||
fail(`Native package ${packageName} does not contain a .node file at ${packageRoot}`);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
async function packageManifestEntries({ packageName, packageRoot, files, target }) {
|
||||
const root = `node_modules/${packageName}`;
|
||||
const entries = [];
|
||||
const assets = {};
|
||||
|
||||
for (const file of files) {
|
||||
const sourceBytes = await readFile(file);
|
||||
const packageRelativePath = toPosixPath(relative(packageRoot, file));
|
||||
const relativePath = `${root}/${packageRelativePath}`;
|
||||
const assetKey = `native/${target}/${relativePath}`;
|
||||
entries.push({
|
||||
assetKey,
|
||||
relativePath,
|
||||
sha256: sha256(sourceBytes),
|
||||
});
|
||||
assets[assetKey] = file;
|
||||
}
|
||||
|
||||
return {
|
||||
packageManifest: {
|
||||
name: packageName,
|
||||
root,
|
||||
files: entries,
|
||||
},
|
||||
assets,
|
||||
};
|
||||
}
|
||||
|
||||
export const nativeAssetManifestKey = buildManifestKey;
|
||||
|
||||
export function nativeAssetSummary(manifest) {
|
||||
return manifest.packages.map((pkg) => `${pkg.name}: ${pkg.files.length} files`);
|
||||
}
|
||||
|
||||
export async function collectNativeAssets({ appRoot, target }) {
|
||||
const requireFromApp = createRequire(pathToFileURL(resolve(appRoot, 'package.json')));
|
||||
const targetDeps = resolveTargetDeps(target); // throws on unsupported target
|
||||
|
||||
const manifestPackages = [];
|
||||
const assets = {};
|
||||
|
||||
for (const dep of targetDeps) {
|
||||
const packageRoot = resolvePackageRootGeneric(
|
||||
requireFromApp,
|
||||
dep.resolvedName,
|
||||
dep.parentName,
|
||||
appRoot,
|
||||
target,
|
||||
);
|
||||
const files = await collectPackageFiles({
|
||||
packageName: dep.resolvedName,
|
||||
packageRoot,
|
||||
includeNativeFiles: dep.collect === 'native-files',
|
||||
nativeFileRelatives: dep.nativeFileRelatives,
|
||||
});
|
||||
const result = await packageManifestEntries({
|
||||
packageName: dep.resolvedName,
|
||||
packageRoot,
|
||||
files,
|
||||
target,
|
||||
});
|
||||
manifestPackages.push(result.packageManifest);
|
||||
Object.assign(assets, result.assets);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
version: NATIVE_ASSET_MANIFEST_VERSION,
|
||||
target,
|
||||
packages: manifestPackages,
|
||||
};
|
||||
|
||||
return {
|
||||
manifest,
|
||||
manifestJson: `${JSON.stringify(manifest, null, 2)}\n`,
|
||||
assets,
|
||||
};
|
||||
}
|
||||
47
apps/kimi-code/scripts/native/build.mjs
Normal file
47
apps/kimi-code/scripts/native/build.mjs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { parseArgs } from 'node:util';
|
||||
|
||||
import { runBundleStep } from './01-bundle.mjs';
|
||||
import { runInjectStep } from './03-inject.mjs';
|
||||
import { runSeaBlobStep } from './02-sea-blob.mjs';
|
||||
import { runSignStep } from './04-sign.mjs';
|
||||
import { runVerifyStep } from './05-verify.mjs';
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
profile: { type: 'string', default: 'local' },
|
||||
},
|
||||
});
|
||||
|
||||
const profile = values.profile;
|
||||
if (!['local', 'release'].includes(profile)) {
|
||||
console.error(`Unknown profile: ${profile}. Expected 'local' or 'release'.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function ensureNodeVersion() {
|
||||
const [major, minor] = process.versions.node.split('.').map(Number);
|
||||
if (major < 24 || (major === 24 && minor < 15)) {
|
||||
console.error(
|
||||
`Kimi Code native SEA build requires Node.js >=24.15.0, current ${process.versions.node}.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
ensureNodeVersion();
|
||||
console.log(`==> Native build (profile=${profile})`);
|
||||
|
||||
await runBundleStep();
|
||||
await runSeaBlobStep();
|
||||
await runInjectStep();
|
||||
|
||||
const identity =
|
||||
profile === 'release' ? (process.env.APPLE_SIGNING_IDENTITY ?? '-') : '-';
|
||||
const keychainPath = profile === 'release' ? (process.env.APPLE_KEYCHAIN_PATH ?? null) : null;
|
||||
await runSignStep({ identity, keychainPath });
|
||||
|
||||
// Verify always runs (codesign -dv); spctl gatekeeper gate only after notarization
|
||||
// (CI macos-notarize composite action) — orchestrator just self-checks signing here.
|
||||
await runVerifyStep({ requireGatekeeper: false });
|
||||
|
||||
console.log('==> Native build complete');
|
||||
90
apps/kimi-code/scripts/native/check-bundle.mjs
Normal file
90
apps/kimi-code/scripts/native/check-bundle.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import { builtinModules } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { nativeJsBundlePath } from './paths.mjs';
|
||||
|
||||
const bundlePath = nativeJsBundlePath();
|
||||
const text = readFileSync(bundlePath, 'utf-8');
|
||||
|
||||
const builtins = new Set([
|
||||
...builtinModules,
|
||||
...builtinModules.map((name) => `node:${name}`),
|
||||
]);
|
||||
|
||||
const optionalRuntimeRequires = new Set([
|
||||
'ajv-formats/dist/formats',
|
||||
'ajv/dist/runtime/validation_error',
|
||||
'bufferutil',
|
||||
'canvas',
|
||||
'chokidar',
|
||||
'cpu-features',
|
||||
'utf-8-validate',
|
||||
]);
|
||||
const optionalRelativeRuntimeRequires = new Set(['./crypto/build/Release/sshcrypto.node']);
|
||||
const handledNativeRuntimeRequires = new Set(['koffi']);
|
||||
|
||||
function isAllowedSpecifier(specifier) {
|
||||
if (builtins.has(specifier) || specifier.startsWith('node:')) return true;
|
||||
if (optionalRuntimeRequires.has(specifier)) return true;
|
||||
if (handledNativeRuntimeRequires.has(specifier)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
|
||||
function executableLines() {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => {
|
||||
if (line.length === 0) return false;
|
||||
if (line.startsWith('*') || line.startsWith('//') || line.startsWith('/*')) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
for (const line of executableLines()) {
|
||||
for (const match of line.matchAll(/\brequire\(\s*["']([^"']+)["']\s*\)/g)) {
|
||||
const specifier = match[1];
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
||||
if (optionalRelativeRuntimeRequires.has(specifier)) continue;
|
||||
errors.push(`relative require remains: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (!isAllowedSpecifier(specifier)) {
|
||||
errors.push(`external require remains: ${specifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const match of line.matchAll(/(?<![.\w])import\(\s*["']([^"']+)["']\s*\)/g)) {
|
||||
const specifier = match[1];
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
||||
errors.push(`relative dynamic import remains: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (!isAllowedSpecifier(specifier)) {
|
||||
errors.push(`external dynamic import remains: ${specifier}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (line.startsWith('import ')) {
|
||||
for (const match of line.matchAll(/\bfrom\s+["']([^"']+)["']/g)) {
|
||||
const specifier = match[1];
|
||||
if (specifier.startsWith('.') || specifier.startsWith('/')) {
|
||||
errors.push(`relative import remains: ${specifier}`);
|
||||
continue;
|
||||
}
|
||||
if (!isAllowedSpecifier(specifier)) {
|
||||
errors.push(`external import remains: ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error(`Native JS bundle check failed for ${bundlePath}:`);
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
24
apps/kimi-code/scripts/native/entitlements.plist
Normal file
24
apps/kimi-code/scripts/native/entitlements.plist
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Node V8 needs JIT pages -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
|
||||
<!-- V8 writes to executable memory during codegen -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
|
||||
<!-- We dlopen koffi.node / clipboard.node at runtime; they're built by
|
||||
third parties and aren't signed by our Team. Without this they'd be
|
||||
rejected by hardened runtime. -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
|
||||
<!-- Some users set NODE_OPTIONS / DYLD_* envs; allow them. -->
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
61
apps/kimi-code/scripts/native/exec.mjs
Normal file
61
apps/kimi-code/scripts/native/exec.mjs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { basename } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { appRoot } from './paths.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export function commandForExecFile(command, args, platform = process.platform, env = process.env) {
|
||||
if (platform !== 'win32' || !/\.(?:bat|cmd)$/i.test(command)) {
|
||||
return { command, args };
|
||||
}
|
||||
const shellCommand = [command, ...args]
|
||||
.map((arg) => `"${String(arg).replaceAll('"', '""')}"`)
|
||||
.join(' ');
|
||||
return {
|
||||
command: env.ComSpec ?? 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', `"${shellCommand}"`],
|
||||
options: { windowsVerbatimArguments: true },
|
||||
};
|
||||
}
|
||||
|
||||
export function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export async function run(command, args, options = {}) {
|
||||
const exec = commandForExecFile(command, args);
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(exec.command, exec.args, {
|
||||
cwd: appRoot,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
...exec.options,
|
||||
...options,
|
||||
});
|
||||
if (stdout.trim()) console.log(stdout.trim());
|
||||
if (stderr.trim()) console.error(stderr.trim());
|
||||
} catch (error) {
|
||||
const details = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
fail(`Command failed: ${basename(command)} ${args.join(' ')}\n${details}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function tryRun(command, args, options = {}) {
|
||||
const exec = commandForExecFile(command, args);
|
||||
try {
|
||||
await execFileAsync(exec.command, exec.args, {
|
||||
cwd: appRoot,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
...exec.options,
|
||||
});
|
||||
} catch (error) {
|
||||
const details = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
console.warn(`Warning: ${basename(command)} ${args.join(' ')} failed.\n${details}`);
|
||||
}
|
||||
}
|
||||
13
apps/kimi-code/scripts/native/manifest.mjs
Normal file
13
apps/kimi-code/scripts/native/manifest.mjs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
export const NATIVE_ASSET_MANIFEST_VERSION = 1;
|
||||
|
||||
export function buildManifestKey(target) {
|
||||
return `native/${target}/manifest.json`;
|
||||
}
|
||||
|
||||
export function isManifestVersionSupported(version) {
|
||||
return version === NATIVE_ASSET_MANIFEST_VERSION;
|
||||
}
|
||||
|
||||
export function buildAssetKey(target, packageRoot, relativePath) {
|
||||
return `native/${target}/${packageRoot}/${relativePath}`;
|
||||
}
|
||||
103
apps/kimi-code/scripts/native/native-deps.mjs
Normal file
103
apps/kimi-code/scripts/native/native-deps.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Native dependency registry.
|
||||
*
|
||||
* Each entry describes one native-bearing npm package: how to resolve its
|
||||
* install root, what to collect (JS only / native binary only / both), and
|
||||
* which other registered dep it nests under (for `pnpm`-style nested resolves).
|
||||
*
|
||||
* Adding a new native package = appending one object here. No edits to
|
||||
* NATIVE_TARGETS table or resolvePackageRoot if/else chain.
|
||||
*/
|
||||
|
||||
export const SUPPORTED_TARGETS = Object.freeze([
|
||||
'darwin-arm64',
|
||||
'darwin-x64',
|
||||
'linux-arm64',
|
||||
'linux-x64',
|
||||
'win32-arm64',
|
||||
'win32-x64',
|
||||
]);
|
||||
|
||||
const clipboardSubpackageByTarget = Object.freeze({
|
||||
'darwin-arm64': '@mariozechner/clipboard-darwin-arm64',
|
||||
'darwin-x64': '@mariozechner/clipboard-darwin-x64',
|
||||
'linux-arm64': '@mariozechner/clipboard-linux-arm64-gnu',
|
||||
'linux-x64': '@mariozechner/clipboard-linux-x64-gnu',
|
||||
'win32-arm64': '@mariozechner/clipboard-win32-arm64-msvc',
|
||||
'win32-x64': '@mariozechner/clipboard-win32-x64-msvc',
|
||||
});
|
||||
|
||||
const koffiTripletByTarget = Object.freeze({
|
||||
'darwin-arm64': 'darwin_arm64',
|
||||
'darwin-x64': 'darwin_x64',
|
||||
'linux-arm64': 'linux_arm64',
|
||||
'linux-x64': 'linux_x64',
|
||||
'win32-arm64': 'win32_arm64',
|
||||
'win32-x64': 'win32_x64',
|
||||
});
|
||||
|
||||
export function isSupportedTarget(target) {
|
||||
return SUPPORTED_TARGETS.includes(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} NativeDepDescriptor
|
||||
* @property {string} id — stable internal id used for parent refs
|
||||
* @property {(target: string) => string} name
|
||||
* — npm package name (may depend on target)
|
||||
* @property {'js-only'|'native-files'|'js-and-native-file'|'virtual'} collect
|
||||
* @property {string|null} parent
|
||||
* — id of another registered dep this nests under (for pnpm),
|
||||
* or null for top-level (resolvable from app root)
|
||||
* @property {(target: string) => string[]} [nativeFileRelatives]
|
||||
* — explicit list of .node files relative to package root
|
||||
* (used by 'js-and-native-file'; native-files mode auto-scans *.node)
|
||||
*/
|
||||
|
||||
/** @type {readonly NativeDepDescriptor[]} */
|
||||
export const nativeDeps = Object.freeze([
|
||||
{
|
||||
id: 'clipboard-host',
|
||||
name: () => '@mariozechner/clipboard',
|
||||
collect: 'js-only',
|
||||
parent: null,
|
||||
},
|
||||
{
|
||||
id: 'clipboard-target',
|
||||
name: (target) => clipboardSubpackageByTarget[target],
|
||||
collect: 'native-files',
|
||||
parent: 'clipboard-host',
|
||||
},
|
||||
{
|
||||
id: 'pi-tui',
|
||||
name: () => '@earendil-works/pi-tui',
|
||||
// pi-tui is bundled into main.cjs at build time — we don't collect it as
|
||||
// a native dep, only register it so koffi can declare it as parent.
|
||||
collect: 'virtual',
|
||||
parent: null,
|
||||
},
|
||||
{
|
||||
id: 'koffi',
|
||||
name: () => 'koffi',
|
||||
collect: 'js-and-native-file',
|
||||
parent: 'pi-tui',
|
||||
nativeFileRelatives: (target) => [`build/koffi/${koffiTripletByTarget[target]}/koffi.node`],
|
||||
},
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve which deps need collecting for a given build target, with concrete names.
|
||||
*/
|
||||
export function resolveTargetDeps(target) {
|
||||
if (!isSupportedTarget(target)) {
|
||||
throw new Error(`Unsupported native asset target: ${target}`);
|
||||
}
|
||||
return nativeDeps
|
||||
.filter((d) => d.collect !== 'virtual')
|
||||
.map((d) => ({
|
||||
...d,
|
||||
resolvedName: d.name(target),
|
||||
nativeFileRelatives: d.nativeFileRelatives?.(target) ?? [],
|
||||
parentName: d.parent ? nativeDeps.find((p) => p.id === d.parent)?.name(target) ?? null : null,
|
||||
}));
|
||||
}
|
||||
53
apps/kimi-code/scripts/native/package.mjs
Normal file
53
apps/kimi-code/scripts/native/package.mjs
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream, createWriteStream } from 'node:fs';
|
||||
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
import { ZipFile } from 'yazl';
|
||||
|
||||
import { executableName, nativeArtifactsDir, nativeBinPath, targetTriple } from './paths.mjs';
|
||||
|
||||
const target = targetTriple();
|
||||
const execName = executableName();
|
||||
const sourceBinary = nativeBinPath(target);
|
||||
const artifactsDir = nativeArtifactsDir();
|
||||
|
||||
// Flat-name archive for GH Release (GitHub Release assets do not support subdirectories).
|
||||
const artifactName = `kimi-code-${target}.zip`;
|
||||
const artifactPath = resolve(artifactsDir, artifactName);
|
||||
const checksumPath = `${artifactPath}.sha256`;
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function sha256(path) {
|
||||
return await new Promise((resolveHash, reject) => {
|
||||
const hash = createHash('sha256');
|
||||
const stream = createReadStream(path);
|
||||
stream.on('error', reject);
|
||||
stream.on('data', (chunk) => hash.update(chunk));
|
||||
stream.on('end', () => resolveHash(hash.digest('hex')));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await stat(sourceBinary);
|
||||
} catch {
|
||||
fail(`Native executable not found at ${sourceBinary}. Run build:native:sea first.`);
|
||||
}
|
||||
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
|
||||
const zip = new ZipFile();
|
||||
zip.addFile(sourceBinary, execName, { mode: 0o100755 });
|
||||
zip.end();
|
||||
await pipeline(zip.outputStream, createWriteStream(artifactPath));
|
||||
|
||||
const digest = await sha256(artifactPath);
|
||||
await writeFile(checksumPath, `${digest} ${basename(artifactPath)}\n`);
|
||||
|
||||
console.log(`Wrote native artifact: ${artifactPath}`);
|
||||
console.log(`Wrote artifact checksum: ${checksumPath}`);
|
||||
57
apps/kimi-code/scripts/native/paths.mjs
Normal file
57
apps/kimi-code/scripts/native/paths.mjs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { resolve } from 'node:path';
|
||||
|
||||
export const appRoot = resolve(import.meta.dirname, '..', '..');
|
||||
|
||||
export function targetTriple({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
|
||||
return env.KIMI_CODE_BUILD_TARGET ?? `${platform}-${arch}`;
|
||||
}
|
||||
|
||||
export function executableName(platform = process.platform) {
|
||||
return platform === 'win32' ? 'kimi.exe' : 'kimi';
|
||||
}
|
||||
|
||||
export function nativeDistRoot() {
|
||||
return resolve(appRoot, 'dist-native');
|
||||
}
|
||||
|
||||
export function nativeIntermediatesDir() {
|
||||
return resolve(nativeDistRoot(), 'intermediates');
|
||||
}
|
||||
|
||||
export function nativeBinDir(target = targetTriple()) {
|
||||
return resolve(nativeDistRoot(), 'bin', target);
|
||||
}
|
||||
|
||||
export function nativeBinPath(target = targetTriple(), platform = process.platform) {
|
||||
return resolve(nativeBinDir(target), executableName(platform));
|
||||
}
|
||||
|
||||
export function nativeJsBundlePath() {
|
||||
return resolve(nativeIntermediatesDir(), 'main.cjs');
|
||||
}
|
||||
|
||||
export function nativeBlobPath() {
|
||||
return resolve(nativeIntermediatesDir(), 'kimi.blob');
|
||||
}
|
||||
|
||||
export function nativeSeaConfigPath() {
|
||||
return resolve(nativeIntermediatesDir(), 'sea-config.json');
|
||||
}
|
||||
|
||||
export function nativeManifestDir(target = targetTriple()) {
|
||||
return resolve(nativeIntermediatesDir(), 'native-assets', target);
|
||||
}
|
||||
|
||||
export function nativeArtifactsDir() {
|
||||
return resolve(nativeDistRoot(), 'artifacts');
|
||||
}
|
||||
|
||||
export function nativeSmokeHome() {
|
||||
return resolve(nativeDistRoot(), 'smoke-home');
|
||||
}
|
||||
|
||||
export function nativeManifestKey(target = targetTriple()) {
|
||||
return `native/${target}/manifest.json`;
|
||||
}
|
||||
|
||||
export const SEA_SENTINEL_FUSE = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
|
||||
55
apps/kimi-code/scripts/native/produce-manifest.mjs
Normal file
55
apps/kimi-code/scripts/native/produce-manifest.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* Aggregate per-platform zip archive `.sha256` files into a single
|
||||
* `manifest.json` written into the same input directory.
|
||||
*
|
||||
* Usage:
|
||||
* node produce-manifest.mjs <input-dir> <release-tag>
|
||||
*
|
||||
* Input dir must contain files matching: kimi-code-<target>.zip.sha256
|
||||
* (produced by package.mjs across the 6 native-build matrix runners).
|
||||
*
|
||||
* Output:
|
||||
* <input-dir>/manifest.json ← consumed by install.sh / install.ps1
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
|
||||
const [, , inputDir, tag] = process.argv;
|
||||
if (!inputDir || !tag) {
|
||||
console.error('Usage: produce-manifest.mjs <input-dir> <release-tag>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Tag 格式 `@moonshot-ai/kimi-code@x.y.z` 或 `vx.y.z` 或 `x.y.z`,都归一化到 x.y.z
|
||||
const version = tag.replace(/^@moonshot-ai\/kimi-code@/, '').replace(/^v/, '');
|
||||
|
||||
const entries = await readdir(inputDir);
|
||||
const sumFiles = entries.filter((f) => /^kimi-code-[a-z0-9-]+\.zip\.sha256$/.test(f));
|
||||
|
||||
if (sumFiles.length === 0) {
|
||||
console.error(`No kimi-code-<target>.zip.sha256 files found in ${inputDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const platforms = {};
|
||||
for (const sumFile of sumFiles.sort()) {
|
||||
const text = await readFile(resolve(inputDir, sumFile), 'utf-8');
|
||||
const [checksum] = text.trim().split(/\s+/, 1);
|
||||
if (!checksum || !/^[a-f0-9]{64}$/.test(checksum)) {
|
||||
console.error(`Invalid checksum in ${sumFile}: ${checksum}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const filename = basename(sumFile, '.sha256');
|
||||
// kimi-code-darwin-arm64.zip → darwin-arm64
|
||||
const target = filename.replace(/^kimi-code-/, '').replace(/\.zip$/, '');
|
||||
platforms[target] = { filename, checksum };
|
||||
}
|
||||
|
||||
const manifest = { version, tag, platforms };
|
||||
const manifestPath = resolve(inputDir, 'manifest.json');
|
||||
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
console.log(`Wrote ${manifestPath} (${Object.keys(platforms).length} platforms)`);
|
||||
55
apps/kimi-code/scripts/native/resolve-release.mjs
Normal file
55
apps/kimi-code/scripts/native/resolve-release.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { appendFile, readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
import { appRoot } from './paths.mjs';
|
||||
|
||||
const packageName = '@moonshot-ai/kimi-code';
|
||||
const packageJson = JSON.parse(
|
||||
await readFile(resolve(appRoot, 'package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
function parsePublishedPackages() {
|
||||
const raw = process.env['CHANGESETS_PUBLISHED_PACKAGES'];
|
||||
if (raw === undefined || raw.trim().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function outputLine(name, value) {
|
||||
return `${name}=${value}\n`;
|
||||
}
|
||||
|
||||
const publishedPackage = parsePublishedPackages().find(
|
||||
(entry) =>
|
||||
typeof entry === 'object' &&
|
||||
entry !== null &&
|
||||
entry.name === packageName &&
|
||||
typeof entry.version === 'string',
|
||||
);
|
||||
|
||||
const version = publishedPackage?.version ?? packageJson.version;
|
||||
const shouldPublish = publishedPackage !== undefined;
|
||||
const tag = `${packageName}@${version}`;
|
||||
const githubOutput = process.env['GITHUB_OUTPUT'];
|
||||
|
||||
if (githubOutput !== undefined) {
|
||||
await appendFile(
|
||||
githubOutput,
|
||||
[
|
||||
outputLine('should_publish', String(shouldPublish)),
|
||||
outputLine('version', version),
|
||||
outputLine('tag', tag),
|
||||
].join(''),
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`should_publish=${String(shouldPublish)}`);
|
||||
console.log(`version=${version}`);
|
||||
console.log(`tag=${tag}`);
|
||||
82
apps/kimi-code/scripts/native/smoke.mjs
Normal file
82
apps/kimi-code/scripts/native/smoke.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import { appRoot, nativeBinPath, nativeSmokeHome, targetTriple } from './paths.mjs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const target = targetTriple();
|
||||
const executablePath = nativeBinPath(target);
|
||||
const smokeHome = nativeSmokeHome();
|
||||
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
|
||||
const expectedVersion = packageJson.version;
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function ensureExecutableExists() {
|
||||
try {
|
||||
await stat(executablePath);
|
||||
} catch {
|
||||
fail(`Native executable not found at ${executablePath}. Run build:native:sea first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKimi(args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(executablePath, args, {
|
||||
cwd: appRoot,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
});
|
||||
return `${stdout}${stderr}`;
|
||||
} catch (error) {
|
||||
const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
fail(`Native smoke failed: ${executablePath} ${args.join(' ')}\n${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKimiWithEnv(args, env) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(executablePath, args, {
|
||||
cwd: appRoot,
|
||||
env: { ...process.env, ...env },
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
});
|
||||
return `${stdout}${stderr}`;
|
||||
} catch (error) {
|
||||
const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
fail(`Native smoke failed: ${executablePath} ${args.join(' ')}\n${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(output, expected, command) {
|
||||
if (!output.includes(expected)) {
|
||||
fail(`Native smoke output for "${command}" did not include "${expected}".\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureExecutableExists();
|
||||
|
||||
const versionOutput = await runKimi(['--version']);
|
||||
assertIncludes(versionOutput, expectedVersion, '--version');
|
||||
|
||||
const helpOutput = await runKimi(['--help']);
|
||||
assertIncludes(helpOutput, 'Usage: kimi', '--help');
|
||||
|
||||
const exportHelpOutput = await runKimi(['export', '--help']);
|
||||
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
|
||||
|
||||
const nativeAssetOutput = await runKimiWithEnv(['--version'], {
|
||||
KIMI_CODE_HOME: smokeHome,
|
||||
KIMI_CODE_NATIVE_ASSET_SMOKE: '1',
|
||||
});
|
||||
assertIncludes(nativeAssetOutput, `Native asset smoke passed: ${target}`, 'native asset smoke');
|
||||
|
||||
console.log(`Native smoke passed: ${executablePath}`);
|
||||
269
apps/kimi-code/scripts/postinstall.mjs
Normal file
269
apps/kimi-code/scripts/postinstall.mjs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Postinstall hook for @moonshot-ai/kimi-code.
|
||||
*
|
||||
* Goal: when this package is installed globally, ensure typing `kimi`
|
||||
* invokes the new TypeScript CLI. The npm `package.json` bin field
|
||||
* installs a fresh `kimi` shim into the global bin dir; this script
|
||||
* removes any pre-existing `kimi` shim left behind by the previous
|
||||
* Python CLI (installed via `uv tool install`, `pipx install`,
|
||||
* `pip install`, etc.) that would otherwise shadow ours via PATH
|
||||
* ordering. The renamed shim is kept as `kimi-legacy` so users can
|
||||
* still invoke the old CLI if they want to fall back.
|
||||
*
|
||||
* ## Hard rules
|
||||
*
|
||||
* - Only runs for global installs across npm, yarn (classic), and
|
||||
* pnpm. Non-global installs (npx, local project deps, workspace
|
||||
* bootstraps, `pnpm dlx`) are silent no-ops.
|
||||
* - Never fails the install. Any error here is caught and reported,
|
||||
* but the script always exits 0.
|
||||
* - Does not touch a `kimi` we don't recognize as the previous
|
||||
* Python CLI (matched by realpath-resolved shim head containing
|
||||
* `kimi_cli`).
|
||||
* - Cross-platform: POSIX and Windows. Windows-specific bits live
|
||||
* in the helpers (PATHEXT-aware PATH walking, whole-file marker
|
||||
* sniff for uv's Rust launcher .exe, extension-preserving
|
||||
* rename target like `kimi.exe` → `kimi-legacy.exe`).
|
||||
*
|
||||
* ## Code layout
|
||||
*
|
||||
* This file is the orchestrator; the actual logic lives in
|
||||
* sibling modules to keep each file under a manageable size:
|
||||
*
|
||||
* - `./postinstall/reach.mjs` — package-manager detection,
|
||||
* global-install gate, own-package-root resolution, user-shell
|
||||
* PATH lookup, reachability check.
|
||||
* - `./postinstall/migrate.mjs` — legacy detection,
|
||||
* `kimi`-vs-`kimi-legacy` classification, the rename / unlink
|
||||
* primitives.
|
||||
* - `./postinstall/ui.mjs` — `notify()` (with `/dev/tty` fallback),
|
||||
* ANSI styling, the fixed-width box, and the five outcome
|
||||
* renderers.
|
||||
*
|
||||
* ## Workflow
|
||||
*
|
||||
* What runs when a user types `npm install -g @moonshot-ai/kimi-code`
|
||||
* (or the yarn / pnpm equivalent):
|
||||
*
|
||||
* 1. The manager extracts the package and runs lifecycle scripts.
|
||||
* The `bin.kimi` mapping in `package.json` tells the manager to
|
||||
* install a `kimi` shim under its global bin directory.
|
||||
* 2. The manager invokes this script via the `scripts.postinstall`
|
||||
* entry — orchestrated by `main` below.
|
||||
* 3. Install-context gate: only proceed when this is a global
|
||||
* install (`isGlobalInstall` checks `npm_config_global` /
|
||||
* `pnpm_config_global` / `npm_config_location`).
|
||||
* 4. Probe PATH once via `postinstallPaths()`: detection uses the
|
||||
* union of shell PATH + process PATH; reachability uses the
|
||||
* shell PATH alone (with a fallback to process PATH if the
|
||||
* shell can't be probed). Sharing one probe keeps detection
|
||||
* and reachability symmetric and avoids running `$SHELL -l`
|
||||
* twice.
|
||||
* 5. Detect EVERY previous Python `kimi-cli` shim on the detection
|
||||
* PATH (`detectLegacyShims`). Returns `[]` for fresh-install /
|
||||
* no-op. Multiple results happen when the user has installed
|
||||
* `kimi-cli` through more than one Python tool (uv + pipx, or
|
||||
* sudo-pip + pip-user). PATH order is preserved.
|
||||
* 6. Pre-flight classify each shim (`classifyShim`) — pure
|
||||
* filesystem inspection, no writes. Each shim ends up
|
||||
* `renameable`, `consolidate`, `delete-only`, or `blocked`.
|
||||
* 7. Decide abort vs proceed against the WHOLE set:
|
||||
* `findFirstResolvableKimi` walks PATH treating the actionable
|
||||
* shims as gone and reports what wins:
|
||||
* - `own` → proceed to execute.
|
||||
* - `blocked-legacy` → a legacy we can't remove still wins.
|
||||
* Surface `logMigrationBlocked` with sudo / admin
|
||||
* instructions; touch nothing.
|
||||
* - `foreign` → some `kimi` we don't recognize (a user's own
|
||||
* file) wins. Surface `logForeignKimiInTheWay` asking the
|
||||
* user to delete or rename their own file; touch nothing.
|
||||
* - `none` → no `kimi` on PATH at all (our shim's bin dir
|
||||
* isn't in the shell's PATH). Surface
|
||||
* `logNewCliNotOnPath`; touch nothing.
|
||||
* 8. Execute. The FIRST classification in PATH order that we can
|
||||
* touch becomes `kimi-legacy` (preserves what `kimi` referred
|
||||
* to before this install). Each subsequent shim is `unlink`ed —
|
||||
* keeping it as a dormant duplicate adds no value. If the
|
||||
* first shim's `kimi-legacy` target is already user-managed,
|
||||
* we delete `kimi` anyway (still achieves takeover) and tell
|
||||
* the user we couldn't preserve a fallback. Extension is
|
||||
* preserved on Windows (`kimi.exe` → `kimi-legacy.exe`).
|
||||
* 9. One end-of-orchestration notice (`logMigrationDone`)
|
||||
* summarizes every action — renames, consolidates,
|
||||
* delete-only, deletes, and harmless blocked leftovers. The
|
||||
* takeover-success line only fires on this path because Step 7
|
||||
* already certified it.
|
||||
* 10. The manager completes the install with its usual summary.
|
||||
* This script always exits 0; any uncaught error is swallowed
|
||||
* by the top-level `catch` so the install never fails because
|
||||
* of the migration.
|
||||
*/
|
||||
|
||||
import {
|
||||
detectPackageManager,
|
||||
findFirstResolvableKimi,
|
||||
isGlobalInstall,
|
||||
ownPackageRoot,
|
||||
postinstallPaths,
|
||||
} from './postinstall/reach.mjs';
|
||||
import {
|
||||
classifyShim,
|
||||
deleteShim,
|
||||
detectLegacyShims,
|
||||
renameInPlace,
|
||||
} from './postinstall/migrate.mjs';
|
||||
import {
|
||||
logForeignKimiInTheWay,
|
||||
logMigrationBlocked,
|
||||
logMigrationDone,
|
||||
logNewCliNotOnPath,
|
||||
notify,
|
||||
} from './postinstall/ui.mjs';
|
||||
|
||||
async function main() {
|
||||
// Step 1: skip non-global installs (npx, local project deps,
|
||||
// workspace bootstraps). Windows is supported natively; the
|
||||
// platform-specific bits (PATHEXT-aware PATH walk, whole-file
|
||||
// marker sniff for uv's launcher .exe, extension-preserving
|
||||
// rename) live in the helpers.
|
||||
if (!isGlobalInstall()) return;
|
||||
|
||||
// Step 2: locate our own installed package root once and share it
|
||||
// with both detection (skip files inside our package) and
|
||||
// reachability (only count our shim as "found").
|
||||
const ownRoot = await ownPackageRoot(import.meta.dirname);
|
||||
const pm = detectPackageManager();
|
||||
|
||||
// Step 3: probe the user's shell PATH once so detection and
|
||||
// reachability share a single consistent view. Detection uses the
|
||||
// union of shell PATH + process PATH (so we catch a legacy shim
|
||||
// visible to either); reachability uses the shell PATH alone (so
|
||||
// we don't claim "kimi works now" when the shim only sits in the
|
||||
// installer's env).
|
||||
const paths = await postinstallPaths();
|
||||
|
||||
// Step 4: detect EVERY previous Python `kimi-cli` shim on the
|
||||
// detection PATH. A user with both `uv tool install` and `pipx
|
||||
// install` would have two; we must address all of them or the
|
||||
// survivor still shadows the new CLI.
|
||||
const detections = await detectLegacyShims(ownRoot, paths.detection);
|
||||
if (detections.length === 0) return;
|
||||
|
||||
// Step 5: pre-flight classify every shim WITHOUT touching the
|
||||
// filesystem yet. The orchestrator decides abort-or-proceed against
|
||||
// the whole set rather than discovering mid-loop that we got partway
|
||||
// and have to backtrack.
|
||||
const classifications = await Promise.all(
|
||||
detections.map(async (detection) => {
|
||||
const c = await classifyShim(detection.shimPath);
|
||||
return { ...c, detection };
|
||||
}),
|
||||
);
|
||||
|
||||
// Step 6: figure out what wins PATH resolution once every shim we
|
||||
// CAN touch is treated as gone. Three possible blockers:
|
||||
// - a legacy shim we couldn't classify as actionable (sudo/admin
|
||||
// needed)
|
||||
// - an unrelated `kimi` we don't recognize (a user's own wrapper
|
||||
// script — they own the decision)
|
||||
// - nothing resolves (our shim isn't on PATH at all)
|
||||
// For each we render a different notice and touch NOTHING. The
|
||||
// common-case fourth result is "our shim wins" — we proceed.
|
||||
const actionable = classifications.filter((c) => c.kind !== 'blocked');
|
||||
const blocked = classifications.filter((c) => c.kind === 'blocked');
|
||||
const actionableShimPaths = actionable.map((c) => c.shimPath);
|
||||
const allDetectedShimPaths = classifications.map((c) => c.shimPath);
|
||||
|
||||
const blocker = await findFirstResolvableKimi(
|
||||
ownRoot,
|
||||
paths.reachability,
|
||||
actionableShimPaths,
|
||||
allDetectedShimPaths,
|
||||
);
|
||||
if (blocker.kind !== 'own') {
|
||||
if (blocker.kind === 'blocked-legacy') {
|
||||
logMigrationBlocked(blocked, actionable, pm);
|
||||
} else if (blocker.kind === 'foreign') {
|
||||
logForeignKimiInTheWay(blocker.path, pm);
|
||||
} else {
|
||||
// 'none' — our shim isn't on PATH at all.
|
||||
logNewCliNotOnPath(detections[0], pm);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 7: execute. The FIRST classification in PATH order that
|
||||
// we can touch becomes `kimi-legacy` (preserves what the user's
|
||||
// `kimi` used to refer to). Every subsequent shim is just
|
||||
// deleted — keeping it as a dormant duplicate adds no value.
|
||||
const renames = [];
|
||||
const consolidates = [];
|
||||
const skippedForeignTarget = [];
|
||||
const deletes = [];
|
||||
const errors = [];
|
||||
let preservedFirst = false;
|
||||
|
||||
for (const c of classifications) {
|
||||
if (c.kind === 'blocked') continue; // already established harmless
|
||||
|
||||
if (!preservedFirst) {
|
||||
preservedFirst = true;
|
||||
if (c.kind === 'renameable') {
|
||||
const r = await renameInPlace(c.shimPath, c.target);
|
||||
if (r.success) {
|
||||
renames.push(c);
|
||||
} else {
|
||||
errors.push({ ...c, ...r });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c.kind === 'consolidate') {
|
||||
const r = await deleteShim(c.shimPath);
|
||||
if (r.success) {
|
||||
consolidates.push(c);
|
||||
} else {
|
||||
errors.push({ ...c, ...r });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c.kind === 'delete-only') {
|
||||
const r = await deleteShim(c.shimPath);
|
||||
if (r.success) {
|
||||
skippedForeignTarget.push(c);
|
||||
} else {
|
||||
errors.push({ ...c, ...r });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Not the first actionable shim. Just delete it.
|
||||
const r = await deleteShim(c.shimPath);
|
||||
if (r.success) {
|
||||
deletes.push(c);
|
||||
} else {
|
||||
errors.push({ ...c, ...r });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: one notice summarizing everything that happened. The
|
||||
// takeover-success language is only emitted when we know it's true
|
||||
// (we already passed the reachability gate above).
|
||||
logMigrationDone(
|
||||
{
|
||||
renames,
|
||||
consolidates,
|
||||
skippedForeignTarget,
|
||||
deletes,
|
||||
blockedHarmless: blocked,
|
||||
errors,
|
||||
},
|
||||
pm,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
notify(`[kimi-code] postinstall warning: ${message}`);
|
||||
});
|
||||
351
apps/kimi-code/scripts/postinstall/migrate.mjs
Normal file
351
apps/kimi-code/scripts/postinstall/migrate.mjs
Normal file
|
|
@ -0,0 +1,351 @@
|
|||
/**
|
||||
* Detection of the previous Python `kimi-cli` shim and the actual
|
||||
* filesystem operations that perform (or refuse) the rename.
|
||||
*
|
||||
* Detection:
|
||||
* - {@link detectLegacyShims}: walks the caller-supplied
|
||||
* `pathString` and returns every legacy `kimi` shim along the
|
||||
* way (in PATH order). A shim qualifies when it realpath-resolves
|
||||
* outside our own installed package root and its head 4 KiB
|
||||
* contains the `kimi_cli` module marker — the setuptools
|
||||
* entry-point format produced by `uv tool install`,
|
||||
* `pipx install`, `pip install`, etc. Returning all hits (not
|
||||
* just the first) matters because a user with both uv- and
|
||||
* pipx-installed `kimi-cli` has two legacy shims in different
|
||||
* dirs, and renaming only the earlier one leaves the later one
|
||||
* shadowing our new CLI. Callers should pass the `detection`
|
||||
* field from `postinstallPaths()` so detection sees the union of
|
||||
* the shell PATH and the installer's PATH.
|
||||
* - {@link isLegacyShim}: same criterion in standalone form, used to
|
||||
* decide whether an existing `kimi-legacy` is itself a legacy CLI
|
||||
* (safe to consolidate over) or a user-managed file (preserve).
|
||||
*
|
||||
* Classify + execute (two-phase):
|
||||
* - {@link classifyShim}: pre-flight inspection that says what we
|
||||
* COULD do to a given shim. Returns one of `renameable`,
|
||||
* `consolidate`, `delete-only`, `blocked`. No filesystem writes.
|
||||
* - {@link renameInPlace} / {@link deleteShim}: the primitive
|
||||
* operations that actually mutate the filesystem. Run after the
|
||||
* orchestrator has looked at the full set of classifications and
|
||||
* decided to proceed.
|
||||
*
|
||||
* Splitting classification from execution lets the orchestrator make
|
||||
* the abort-or-proceed decision once, against the whole detected set,
|
||||
* rather than discovering mid-loop that something failed and ending up
|
||||
* with a misleading "kimi now launches the new CLI" notice in front of
|
||||
* a "permission denied" notice. Uses `fs.lstat` (not `fs.access`) to
|
||||
* detect dangling symlinks at the target so we don't clobber them.
|
||||
*/
|
||||
|
||||
import { constants as fsConstants, promises as fs } from 'node:fs';
|
||||
import { delimiter, dirname, extname, join, sep } from 'node:path';
|
||||
|
||||
const LEGACY_BIN = 'kimi';
|
||||
const LEGACY_RENAME = 'kimi-legacy';
|
||||
const PYTHON_MARKER = 'kimi_cli';
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
// Read window for the marker sniff.
|
||||
// POSIX: setuptools entry-point scripts are a few hundred bytes —
|
||||
// 4 KiB is generous.
|
||||
// Windows: `uv tool install` produces a Rust-built launcher .exe
|
||||
// (~45 KiB) and the `kimi_cli` module name sits embedded
|
||||
// near the END of the file (verified offset ≈ 44103 on
|
||||
// uv 0.11). Cap reads at 256 KiB so a hostile or
|
||||
// unexpectedly large file can't make us hold a lot of
|
||||
// memory.
|
||||
const SHIM_SNIFF_BYTES_POSIX = 4096;
|
||||
const SHIM_SNIFF_BYTES_WINDOWS_MAX = 256 * 1024;
|
||||
|
||||
function pathEntries(pathString) {
|
||||
if (!pathString) return [];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const entry of pathString.split(delimiter)) {
|
||||
if (!entry || seen.has(entry)) continue;
|
||||
seen.add(entry);
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand `kimi` into the set of filenames that resolve as executables
|
||||
* on this platform. POSIX → just `['kimi']`. Windows → adds every
|
||||
* `PATHEXT` extension (so we find `kimi.exe`, `kimi.cmd`, etc).
|
||||
*/
|
||||
function executableCandidates(basename) {
|
||||
if (!IS_WINDOWS) return [basename];
|
||||
const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
|
||||
.toLowerCase()
|
||||
.split(';')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
return [basename, ...pathext.map((ext) => basename + ext)];
|
||||
}
|
||||
|
||||
async function isExecutableFile(filePath) {
|
||||
try {
|
||||
const info = await fs.stat(filePath);
|
||||
if (!info.isFile()) return false;
|
||||
// Windows: stat().mode doesn't reflect ACLs in any useful way.
|
||||
// Callers already restrict to PATHEXT candidates, so existence
|
||||
// suffices.
|
||||
if (IS_WINDOWS) return true;
|
||||
return (info.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readShimHead(filePath) {
|
||||
let handle;
|
||||
try {
|
||||
handle = await fs.open(filePath, 'r');
|
||||
const stat = await handle.stat();
|
||||
const limit = IS_WINDOWS ? SHIM_SNIFF_BYTES_WINDOWS_MAX : SHIM_SNIFF_BYTES_POSIX;
|
||||
const target = Math.min(stat.size, limit);
|
||||
const buffer = Buffer.alloc(target);
|
||||
const { bytesRead } = await handle.read(buffer, 0, target, 0);
|
||||
// `latin1` is a 1-to-1 byte→char mapping; we're searching for an
|
||||
// ASCII substring, so we don't want UTF-8 decoding to mangle the
|
||||
// bytes around the marker.
|
||||
return buffer.subarray(0, bytesRead).toString('latin1');
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (handle) await handle.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `pathString` and return every legacy `kimi` shim along the
|
||||
* way, in PATH order. The orchestrator renames each in turn — a
|
||||
* single rename is insufficient when the user has multiple legacy
|
||||
* installs (e.g. one from `uv tool install` and another from `pipx
|
||||
* install`) in different directories, because the survivor still
|
||||
* shadows the new CLI.
|
||||
*
|
||||
* Each entry has the same shape as the previous single-return
|
||||
* value: `{ shimPath, realPath }`. The empty array means
|
||||
* "fresh-install / no-op".
|
||||
*/
|
||||
export async function detectLegacyShims(ownRoot, pathString) {
|
||||
const ownRootPrefix = ownRoot ? ownRoot + sep : null;
|
||||
const candidates = executableCandidates(LEGACY_BIN);
|
||||
const results = [];
|
||||
const seenShims = new Set();
|
||||
|
||||
for (const dir of pathEntries(pathString)) {
|
||||
for (const name of candidates) {
|
||||
const shimPath = join(dir, name);
|
||||
if (seenShims.has(shimPath)) continue;
|
||||
if (!(await isExecutableFile(shimPath))) continue;
|
||||
|
||||
let realPath;
|
||||
try {
|
||||
realPath = await fs.realpath(shimPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Defence-in-depth: never touch a `kimi` that resolves into our
|
||||
// own installed package. The `kimi_cli` marker check below
|
||||
// already excludes the manager's generated wrapper today, but
|
||||
// this layer keeps us safe if anything in our bundle ever
|
||||
// happens to contain the marker substring.
|
||||
if (
|
||||
ownRootPrefix !== null &&
|
||||
(realPath === ownRoot || realPath.startsWith(ownRootPrefix))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const head = await readShimHead(realPath);
|
||||
if (!head || !head.includes(PYTHON_MARKER)) continue;
|
||||
|
||||
seenShims.add(shimPath);
|
||||
results.push({ shimPath, realPath });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the file at `p` look like the legacy Python `kimi-cli`?
|
||||
*
|
||||
* Same criterion as {@link detectLegacyShim} uses to recognize the
|
||||
* original shim: realpath-resolvable and the first 4 KiB of the
|
||||
* resolved file contains the `kimi_cli` module name. Used to decide
|
||||
* whether an existing `kimi-legacy` is itself a legacy shim (safe to
|
||||
* drop the duplicate `kimi`) or a user-managed file we must not
|
||||
* clobber.
|
||||
*/
|
||||
export async function isLegacyShim(p) {
|
||||
let real;
|
||||
try {
|
||||
real = await fs.realpath(p);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const head = await readShimHead(real);
|
||||
return Boolean(head && head.includes(PYTHON_MARKER));
|
||||
}
|
||||
|
||||
async function pathExists(p) {
|
||||
try {
|
||||
// lstat (not access/stat) so a dangling symlink at `p` still
|
||||
// reports as existing — `fs.access` follows symlinks and would
|
||||
// return ENOENT for a broken link, after which `fs.rename` would
|
||||
// silently replace it.
|
||||
await fs.lstat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute where `shimPath` should be renamed to. Preserves the file
|
||||
* extension so a Windows `C:\…\kimi.exe` ends up at `kimi-legacy.exe`
|
||||
* rather than an extension-less `kimi-legacy` that `kimi.exe -- legacy`
|
||||
* shells won't run.
|
||||
*/
|
||||
function renameTargetFor(shimPath) {
|
||||
const ext = extname(shimPath); // "" on POSIX, ".exe" on Windows
|
||||
return join(dirname(shimPath), LEGACY_RENAME + ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the directory containing `shimPath` a system-managed location
|
||||
* the current user can't write to?
|
||||
*
|
||||
* POSIX: dir owned by uid 0 (root) — captures
|
||||
* `sudo pip install kimi-cli` → `/usr/local/bin/`.
|
||||
*
|
||||
* Windows: dir under one of the well-known system roots
|
||||
* (`C:\Program Files`, `C:\ProgramData`, `C:\Windows`). uv tool
|
||||
* installs land in user space (`%USERPROFILE%\.local\bin`) so this
|
||||
* path almost never fires on Windows in practice, but the heuristic
|
||||
* correctly classifies the rare admin-prefix install case.
|
||||
*
|
||||
* The dedicated permission-denied notice (`logPermissionDenied`)
|
||||
* uses this to switch from a bare "rename it manually" message to a
|
||||
* sudo-aware / admin-aware explanation.
|
||||
*/
|
||||
async function isSystemOwnedDir(shimPath) {
|
||||
if (IS_WINDOWS) {
|
||||
const dir = dirname(shimPath).toLowerCase();
|
||||
const systemRoots = [
|
||||
'c:\\program files',
|
||||
'c:\\program files (x86)',
|
||||
'c:\\programdata',
|
||||
'c:\\windows',
|
||||
];
|
||||
return systemRoots.some(
|
||||
(root) => dir === root || dir.startsWith(root + '\\'),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const info = await fs.stat(dirname(shimPath));
|
||||
return info.uid === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function canWriteDir(dir) {
|
||||
try {
|
||||
// For `fs.rename` and `fs.unlink` the parent dir needs to be both
|
||||
// writable and executable (POSIX `wx`). `fs.access` follows the
|
||||
// same semantics. On Windows ACL details are coarse but
|
||||
// `fs.access(W_OK)` is a reasonable best-effort.
|
||||
await fs.access(dir, fsConstants.W_OK | fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight inspection of a single legacy shim. Returns what action
|
||||
* we could take WITHOUT executing it. The orchestrator uses these
|
||||
* classifications to decide the whole-set strategy (abort vs proceed,
|
||||
* which shim becomes kimi-legacy) before any filesystem writes
|
||||
* happen.
|
||||
*
|
||||
* Result shapes (all carry `shimPath` and `target` so the renderer
|
||||
* has the paths):
|
||||
* - `renameable` : can `fs.rename(shim → target)` cleanly;
|
||||
* target slot is free.
|
||||
* - `consolidate` : target already exists and is itself a legacy
|
||||
* shim; we'd `fs.unlink(shim)` and leave the
|
||||
* existing `kimi-legacy` as the canonical
|
||||
* fallback (functionally equivalent — same
|
||||
* upstream package).
|
||||
* - `delete-only` : target exists but is a user-managed file we
|
||||
* won't clobber. We can still `fs.unlink(shim)`
|
||||
* to stop the shadowing; the
|
||||
* "preserve original kimi" invariant fails for
|
||||
* THIS dir (we tell the user).
|
||||
* - `blocked` : we can't write to the parent dir. Carries
|
||||
* `isSystemPath` so the renderer can suggest
|
||||
* sudo (POSIX) or admin PowerShell (Windows).
|
||||
*/
|
||||
export async function classifyShim(shimPath) {
|
||||
const target = renameTargetFor(shimPath);
|
||||
const dir = dirname(shimPath);
|
||||
|
||||
if (!(await canWriteDir(dir))) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
shimPath,
|
||||
target,
|
||||
isSystemPath: await isSystemOwnedDir(shimPath),
|
||||
};
|
||||
}
|
||||
|
||||
if (await pathExists(target)) {
|
||||
if (await isLegacyShim(target)) {
|
||||
return { kind: 'consolidate', shimPath, target };
|
||||
}
|
||||
return { kind: 'delete-only', shimPath, target };
|
||||
}
|
||||
|
||||
return { kind: 'renameable', shimPath, target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an `fs.rename`. Pre-flight classification establishes
|
||||
* whether this is expected to succeed; this primitive just runs the
|
||||
* call and wraps the error.
|
||||
*/
|
||||
export async function renameInPlace(shimPath, target) {
|
||||
try {
|
||||
await fs.rename(shimPath, target);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const code = err && typeof err === 'object' ? err.code : undefined;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, code, message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an `fs.unlink`. Used when:
|
||||
* - we're consolidating onto an existing legacy `kimi-legacy`, or
|
||||
* - we couldn't rename (foreign target) but can still remove the
|
||||
* shadow, or
|
||||
* - this is a non-first shim and we just want to clear it out so
|
||||
* PATH order resolves to our new CLI.
|
||||
*/
|
||||
export async function deleteShim(shimPath) {
|
||||
try {
|
||||
await fs.unlink(shimPath);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const code = err && typeof err === 'object' ? err.code : undefined;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, code, message };
|
||||
}
|
||||
}
|
||||
457
apps/kimi-code/scripts/postinstall/reach.mjs
Normal file
457
apps/kimi-code/scripts/postinstall/reach.mjs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* Where the user actually types things, and what `kimi` resolves to
|
||||
* from there.
|
||||
*
|
||||
* Covers:
|
||||
*
|
||||
* - Package-manager detection ({@link detectPackageManager}) and
|
||||
* manager-specific command hints ({@link pmGlobalBinCommand},
|
||||
* {@link pmGlobalInstallCommand}) used in user-facing notices.
|
||||
* - Global-install gating ({@link isGlobalInstall}) — what counts as
|
||||
* a global install across npm / yarn classic / pnpm.
|
||||
* - Own-package-root location ({@link ownPackageRoot}) — walks up
|
||||
* from `import.meta.dirname` looking for `package.json`.
|
||||
* - User-shell PATH ({@link userShellPath}) — spawns `$SHELL -l` so
|
||||
* we can check reachability from the shell the user will type
|
||||
* `kimi` into, not just the installer's environment.
|
||||
* - The combined PATH dispatcher ({@link postinstallPaths}) —
|
||||
* called once by the orchestrator so detection and reachability
|
||||
* stay symmetric and the shell probe doesn't run twice.
|
||||
* - The reachability check ({@link findFirstResolvableKimi}) —
|
||||
* walks PATH treating the to-be-renamed legacy shims as gone
|
||||
* and reports what wins resolution. Distinguishes our own shim
|
||||
* from a blocked legacy that's still in the way vs. an
|
||||
* unknown/foreign `kimi` (e.g. a user-written wrapper).
|
||||
*
|
||||
* All functions are pure w.r.t. the filesystem (no mutations).
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { delimiter, dirname, join, sep } from 'node:path';
|
||||
|
||||
const LEGACY_BIN = 'kimi';
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
|
||||
/**
|
||||
* Expand a basename like `kimi` into the set of filenames the OS
|
||||
* would actually match on PATH.
|
||||
*
|
||||
* On POSIX: just `['kimi']`.
|
||||
*
|
||||
* On Windows: `['kimi', 'kimi.exe', 'kimi.cmd', …]` — every
|
||||
* extension in `PATHEXT`. Without this, our PATH walk would miss
|
||||
* the typical `kimi.exe` shim produced by `uv tool install` on
|
||||
* Windows.
|
||||
*/
|
||||
export function executableCandidates(basename) {
|
||||
if (!IS_WINDOWS) return [basename];
|
||||
const pathext = (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM')
|
||||
.toLowerCase()
|
||||
.split(';')
|
||||
.map((e) => e.trim())
|
||||
.filter(Boolean);
|
||||
return [basename, ...pathext.map((ext) => basename + ext)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify which package manager ran us. `npm_config_user_agent` is
|
||||
* set by npm, yarn (classic + berry), and pnpm, and starts with the
|
||||
* manager's name plus version. Defaults to `'npm'` for unknown /
|
||||
* missing values.
|
||||
*/
|
||||
export function detectPackageManager() {
|
||||
const ua = process.env['npm_config_user_agent'] ?? '';
|
||||
if (ua.startsWith('pnpm/')) return 'pnpm';
|
||||
if (ua.startsWith('yarn/')) return 'yarn';
|
||||
return 'npm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager-specific shell command that prints (or expands to) the
|
||||
* global bin directory. Used in the PATH-fix hint so the suggestion
|
||||
* is valid regardless of which manager the user ran.
|
||||
*/
|
||||
export function pmGlobalBinCommand(pm) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return 'pnpm bin -g';
|
||||
case 'yarn':
|
||||
return 'yarn global bin';
|
||||
case 'npm':
|
||||
default:
|
||||
return 'npm prefix -g';
|
||||
}
|
||||
}
|
||||
|
||||
/** Manager-specific reinstall command, used in the success-notice hint. */
|
||||
export function pmGlobalInstallCommand(pm, pkg) {
|
||||
switch (pm) {
|
||||
case 'pnpm':
|
||||
return `pnpm add -g ${pkg}`;
|
||||
case 'yarn':
|
||||
return `yarn global add ${pkg}`;
|
||||
case 'npm':
|
||||
default:
|
||||
return `npm install -g ${pkg}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Did the user run a global install via some Node package manager?
|
||||
*
|
||||
* We accept four signals, any of which means "this install is
|
||||
* landing in the manager's global bin directory":
|
||||
*
|
||||
* - `npm_config_global === 'true'` — set by `npm install -g`, and
|
||||
* by pnpm on its global paths for back-compat.
|
||||
* - `pnpm_config_global === 'true'` — set by pnpm in addition to
|
||||
* the npm-compat flag above.
|
||||
* - `npm_config_location === 'global'` — set by npm 7+ when the
|
||||
* user passes `--location=global` (or runs `npm config set
|
||||
* location global` persistently). npm intentionally does NOT
|
||||
* also set `npm_config_global` in this case, so without this
|
||||
* branch the migration silently no-ops for `npm install
|
||||
* --location=global @moonshot-ai/kimi-code` — verified on npm
|
||||
* 11.13.0.
|
||||
* - {@link isYarnClassicGlobalAdd} — yarn classic does NOT set
|
||||
* `npm_config_global` for `yarn global add`. The only reliable
|
||||
* signal is parsing `npm_config_argv` and seeing the `global`
|
||||
* subcommand. Verified on yarn 1.22.22.
|
||||
*
|
||||
* Local installs, `npx`, `pnpm dlx`, and workspace bootstraps leave
|
||||
* all four signals false.
|
||||
*
|
||||
* Yarn berry (v2+) intentionally has no global-install concept, so
|
||||
* it doesn't matter here. Postinstall on yarn berry runs in local
|
||||
* context.
|
||||
*/
|
||||
export function isGlobalInstall() {
|
||||
return (
|
||||
process.env['npm_config_global'] === 'true' ||
|
||||
process.env['pnpm_config_global'] === 'true' ||
|
||||
process.env['npm_config_location'] === 'global' ||
|
||||
isYarnClassicGlobalAdd()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `yarn global add` (yarn classic, v1.x) runs lifecycle scripts but
|
||||
* leaves both `npm_config_global` and `npm_config_location` unset.
|
||||
* The only reliable in-band signal is `npm_config_argv`, which yarn
|
||||
* populates with the original command line as JSON:
|
||||
* { original: ["global", "add", "<pkg>", "--prefix=..."] }
|
||||
* Parse it and require both:
|
||||
* - `npm_config_user_agent` starts with `yarn/1.` (yarn classic;
|
||||
* yarn berry has no global concept anyway).
|
||||
* - Some token in argv is literally `"global"` AND the very next
|
||||
* token is a known yarn-global subcommand (`add`, `remove`,
|
||||
* etc). This handles the simple case (`yarn global add foo` →
|
||||
* argv `["global","add",...]`) and the value-taking-flag case
|
||||
* (`yarn --cwd /tmp global add foo` → argv `["--cwd","/tmp",
|
||||
* "global","add",...]`) without having to maintain yarn's full
|
||||
* flag table. It rejects `yarn add global` (the next token is
|
||||
* undefined) and `yarn add @scope/global` (the literal string
|
||||
* `"global"` doesn't appear). The remaining false positives
|
||||
* (e.g., `yarn add global add` — installing two packages, one
|
||||
* literally named `global`) are caught downstream by the
|
||||
* reachability gate: a local install's bin dir isn't on PATH,
|
||||
* so `isOwnCliResolvableFirst` will refuse to migrate.
|
||||
*/
|
||||
function isYarnClassicGlobalAdd() {
|
||||
const ua = process.env['npm_config_user_agent'] ?? '';
|
||||
if (!ua.startsWith('yarn/1.')) return false;
|
||||
const raw = process.env['npm_config_argv'];
|
||||
if (!raw) return false;
|
||||
let argv;
|
||||
try {
|
||||
argv = JSON.parse(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(argv?.original)) return false;
|
||||
const globalIdx = argv.original.indexOf('global');
|
||||
if (globalIdx === -1) return false;
|
||||
const next = argv.original[globalIdx + 1];
|
||||
return typeof next === 'string' && YARN_GLOBAL_SUBCOMMANDS.has(next);
|
||||
}
|
||||
|
||||
// Yarn 1.x global subcommands. The install-class ones (`add`,
|
||||
// `upgrade`, `upgrade-interactive`) are the ones that actually run
|
||||
// our postinstall, but the read-only ones are included so the
|
||||
// detection is consistent across all `yarn global ...` invocations.
|
||||
const YARN_GLOBAL_SUBCOMMANDS = new Set([
|
||||
'add',
|
||||
'remove',
|
||||
'upgrade',
|
||||
'upgrade-interactive',
|
||||
'list',
|
||||
'bin',
|
||||
'dir',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Locate the realpath of our own installed package root.
|
||||
*
|
||||
* Callers pass the starting directory (typically `import.meta.dirname`
|
||||
* of the entry script, which lives at `<package-root>/scripts/`). We
|
||||
* walk up looking for the nearest `package.json`, then `realpath` the
|
||||
* directory so symlinked install layouts (e.g. `<prefix>/bin/kimi`
|
||||
* symlinked into `lib/node_modules/.../dist/main.mjs`) compare equal
|
||||
* in the caller's prefix check.
|
||||
*
|
||||
* Returns null if no `package.json` is found within a few levels —
|
||||
* callers should treat that as "can't locate ourselves" and bail.
|
||||
*/
|
||||
export async function ownPackageRoot(startDir) {
|
||||
let dir = startDir;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
await fs.access(join(dir, 'package.json'));
|
||||
try {
|
||||
return await fs.realpath(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
} catch {
|
||||
// No `package.json` here; walk up.
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isExecutableFile(filePath) {
|
||||
try {
|
||||
const info = await fs.stat(filePath);
|
||||
if (!info.isFile()) return false;
|
||||
// Windows: ACLs aren't visible through stat().mode meaningfully —
|
||||
// existence + a recognized extension is what PATHEXT-style lookup
|
||||
// checks. Callers only pass us candidates that already match an
|
||||
// extension in `executableCandidates()`, so "is a file" suffices.
|
||||
if (IS_WINDOWS) return true;
|
||||
return (info.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Package-manager shim generators embed the package's resolved path
|
||||
// into the wrapper file. So any shim whose first KB contains our
|
||||
// package name is one we own. Used as a fallback when realpath alone
|
||||
// can't catch the shim — that happens for:
|
||||
// - Windows cmd-shims (literal `.cmd` / `.ps1` files, not symlinks)
|
||||
// - pnpm POSIX shims (literal `/bin/sh` scripts, not symlinks; pnpm
|
||||
// does not symlink into the package root the way npm/yarn classic
|
||||
// do on POSIX)
|
||||
const PACKAGE_NAME_MARKERS = ['@moonshot-ai/kimi-code', '@moonshot-ai\\kimi-code'];
|
||||
|
||||
async function shimReferencesOwnPackage(shimPath) {
|
||||
try {
|
||||
const handle = await fs.open(shimPath, 'r');
|
||||
try {
|
||||
const buf = Buffer.alloc(4096);
|
||||
const { bytesRead } = await handle.read(buf, 0, 4096, 0);
|
||||
const text = buf.subarray(0, bytesRead).toString('latin1');
|
||||
return PACKAGE_NAME_MARKERS.some((m) => text.includes(m));
|
||||
} finally {
|
||||
await handle.close().catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function classifyShim(shim, ownRoot, ownPrefix) {
|
||||
let real;
|
||||
try {
|
||||
real = await fs.realpath(shim);
|
||||
} catch {
|
||||
return 'unreadable';
|
||||
}
|
||||
if (real === ownRoot || real.startsWith(ownPrefix)) return 'own';
|
||||
if (await shimReferencesOwnPackage(shim)) return 'own';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `pathString` and report what the user's shell would resolve
|
||||
* `kimi` to, AFTER the to-be-removed shims in `actionableShimPaths`
|
||||
* are pretended gone. Returns the first match by PATH order:
|
||||
*
|
||||
* - { kind: 'own' } — our shim wins.
|
||||
* - { kind: 'blocked-legacy', shim } — a legacy `kimi` we
|
||||
* detected but couldn't touch (a "blocked" shim) wins.
|
||||
* - { kind: 'foreign', path } — something else wins: a
|
||||
* `kimi` we didn't recognize as a legacy CLI and didn't generate
|
||||
* ourselves (e.g. a user-managed wrapper script in `~/bin`).
|
||||
* - { kind: 'none' } — no `kimi` resolves at all.
|
||||
*
|
||||
* Used by the orchestrator to give an accurate "why the takeover
|
||||
* can't proceed" notice: a blocked-legacy blocker needs different
|
||||
* remediation (sudo / admin delete) than a foreign blocker (the
|
||||
* user's own file, which only they can decide what to do with).
|
||||
*
|
||||
* `allDetectedShimPaths` is every legacy shim our detector found
|
||||
* (both blocked and actionable). It lets us distinguish a blocked
|
||||
* legacy that survived our hypothetical removal pass from a totally
|
||||
* unknown `kimi`.
|
||||
*/
|
||||
export async function findFirstResolvableKimi(
|
||||
ownRoot,
|
||||
pathString,
|
||||
actionableShimPaths,
|
||||
allDetectedShimPaths,
|
||||
) {
|
||||
if (!ownRoot || !pathString) return { kind: 'none' };
|
||||
const ownPrefix = ownRoot + sep;
|
||||
const candidates = executableCandidates(LEGACY_BIN);
|
||||
const skipSet = new Set(actionableShimPaths ?? []);
|
||||
const knownLegacySet = new Set(allDetectedShimPaths ?? []);
|
||||
const seenDirs = new Set();
|
||||
for (const dir of pathString.split(delimiter)) {
|
||||
if (!dir || seenDirs.has(dir)) continue;
|
||||
seenDirs.add(dir);
|
||||
for (const name of candidates) {
|
||||
const shim = join(dir, name);
|
||||
if (skipSet.has(shim)) continue;
|
||||
if (!(await isExecutableFile(shim))) continue;
|
||||
const kind = await classifyShim(shim, ownRoot, ownPrefix);
|
||||
if (kind === 'unreadable') continue;
|
||||
if (kind === 'own') return { kind: 'own' };
|
||||
if (knownLegacySet.has(shim)) {
|
||||
return { kind: 'blocked-legacy', shim };
|
||||
}
|
||||
return { kind: 'foreign', path: shim };
|
||||
}
|
||||
}
|
||||
return { kind: 'none' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the user's default shell's view of `PATH`.
|
||||
*
|
||||
* Why: `process.env.PATH` reflects the environment of whichever
|
||||
* shell the user happened to invoke the package manager from, which
|
||||
* may or may not match the daily-driver shell they'll later type
|
||||
* `kimi` into. A `~/.zshrc`-only PATH entry, a `~/.bash_profile`
|
||||
* that doesn't source `~/.bashrc`, or a sudo'd install that scrubs
|
||||
* HOME all break the installer's-PATH heuristic.
|
||||
*
|
||||
* We spawn `$SHELL -l -c 'printf %s "$PATH"'` so the shell reads
|
||||
* its login-profile files (`.profile`, `.bash_profile`, `.zprofile`).
|
||||
* `-i` would also pull in interactive rc files (`.bashrc`, `.zshrc`)
|
||||
* but it requires a tty for some shells and is much more likely to
|
||||
* have side effects (prompts, agent startup) — we accept the
|
||||
* narrower view and let `'unknown'` fall through to a gentler
|
||||
* check.
|
||||
*
|
||||
* Outcomes:
|
||||
* { kind: 'ok', path: string } — shell printed its PATH.
|
||||
* { kind: 'unknown', reason: string } — `$SHELL` missing, spawn
|
||||
* failed, timed out, or
|
||||
* shell printed no `PATH`.
|
||||
*/
|
||||
export async function userShellPath() {
|
||||
// Windows has no `$SHELL`/login-shell concept worth probing — cmd.exe
|
||||
// and PowerShell don't have profile files in the rc-chain sense, and
|
||||
// their PATH already comes from the user's persistent registry env
|
||||
// (which is what `process.env.PATH` reflects). Skip the spawn and
|
||||
// let the caller fall back to `process.env.PATH`.
|
||||
if (IS_WINDOWS) return { kind: 'unknown', reason: 'windows skip' };
|
||||
|
||||
const shell = process.env['SHELL'];
|
||||
if (!shell) return { kind: 'unknown', reason: 'no SHELL env var' };
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const settle = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
let stdout = '';
|
||||
// Wrap PATH in delimiters we can parse out, defensively, in case
|
||||
// the shell prints anything else (motd, prompt redraw, …).
|
||||
const probe =
|
||||
'printf "<<<KIMI_PATH_BEGIN>>>%s<<<KIMI_PATH_END>>>\\n" "$PATH"';
|
||||
const child = spawn(shell, ['-l', '-c', probe], {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
child.stdout.on('data', (chunk) => {
|
||||
stdout += chunk.toString('utf-8');
|
||||
});
|
||||
|
||||
// Bound the wait. Login rc files are usually quick, but a hostile
|
||||
// `.profile` could hang indefinitely.
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL');
|
||||
settle({ kind: 'unknown', reason: 'shell spawn timed out' });
|
||||
}, 5000);
|
||||
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer);
|
||||
const match = stdout.match(
|
||||
/<<<KIMI_PATH_BEGIN>>>([\s\S]*?)<<<KIMI_PATH_END>>>/,
|
||||
);
|
||||
if (match && match[1].length > 0) {
|
||||
settle({ kind: 'ok', path: match[1] });
|
||||
return;
|
||||
}
|
||||
settle({ kind: 'unknown', reason: `no PATH printed (exit ${code})` });
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer);
|
||||
settle({ kind: 'unknown', reason: `spawn error: ${err.message}` });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the two PATH strings the postinstall consults.
|
||||
*
|
||||
* - `detection`: a `delimiter`-joined union of the user's shell
|
||||
* PATH and `process.env.PATH`, deduplicated. Used by
|
||||
* {@link detectLegacyShim} to walk for legacy shims. We union
|
||||
* because either source may contain a legacy `kimi` we want to
|
||||
* rename — including one that lives in a directory the
|
||||
* installer's environment can't see but the user's shell can
|
||||
* (e.g. a sanitized lifecycle env from a packaged manager), and
|
||||
* vice versa.
|
||||
*
|
||||
* - `reachability`: the user's shell PATH if we can probe it, else
|
||||
* `process.env.PATH`. Used to verify our own shim is on the PATH
|
||||
* the user will actually type `kimi` into. We do NOT union here:
|
||||
* a shim that's only in the installer's PATH wouldn't help the
|
||||
* user after the install, so unioning would mis-classify the
|
||||
* install as reachable.
|
||||
*
|
||||
* Returns a single object so the (potentially slow) shell-probe runs
|
||||
* just once per postinstall.
|
||||
*/
|
||||
export async function postinstallPaths() {
|
||||
const shellResult = await userShellPath();
|
||||
const processPath = process.env['PATH'] ?? '';
|
||||
const shellPathStr = shellResult.kind === 'ok' ? shellResult.path : null;
|
||||
const reachability = shellPathStr ?? processPath;
|
||||
const detection = unionPaths(shellPathStr, processPath);
|
||||
return { detection, reachability };
|
||||
}
|
||||
|
||||
function unionPaths(...paths) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const p of paths) {
|
||||
if (!p) continue;
|
||||
for (const entry of p.split(delimiter)) {
|
||||
if (!entry || seen.has(entry)) continue;
|
||||
seen.add(entry);
|
||||
out.push(entry);
|
||||
}
|
||||
}
|
||||
return out.join(delimiter);
|
||||
}
|
||||
|
||||
458
apps/kimi-code/scripts/postinstall/ui.mjs
Normal file
458
apps/kimi-code/scripts/postinstall/ui.mjs
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
/**
|
||||
* User-facing output for the postinstall: where lines go, ANSI styling,
|
||||
* the fixed-width box layout, and the four outcome renderers:
|
||||
*
|
||||
* - `logMigrationDone` — the takeover succeeded (one or more legacy
|
||||
* shims were processed). Lists every action taken: renames,
|
||||
* consolidates, delete-only, plain deletes, and harmless blocked
|
||||
* leftovers. Footer branches three ways: preserved-somewhere
|
||||
* (standard "type kimi-legacy"), only skippedForeignTarget (we
|
||||
* couldn't save the old CLI because a user file took the name),
|
||||
* and only blockedHarmless (just notes the leftovers, no
|
||||
* phantom-file talk).
|
||||
* - `logMigrationBlocked` — a legacy `kimi` we can't remove sits
|
||||
* on PATH ahead of our shim. Nothing was touched; user is told
|
||||
* which paths need their manual attention with sudo / admin.
|
||||
* - `logForeignKimiInTheWay` — a `kimi` we don't recognize (not
|
||||
* ours, not a legacy CLI) sits ahead of our shim on PATH. User
|
||||
* needs to delete or rename their own file. Different remediation
|
||||
* from `logMigrationBlocked`.
|
||||
* - `logNewCliNotOnPath` — we found a legacy but our own shim
|
||||
* isn't on the user's shell PATH at all. Same "touch nothing"
|
||||
* behavior, different prose.
|
||||
*
|
||||
* This module is intentionally self-contained: no PATH walking, no fs
|
||||
* mutations, no shell spawning — just rendering. The orchestrator
|
||||
* (`postinstall.mjs`) makes the abort-or-proceed decision once and
|
||||
* calls exactly one renderer at the end.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
|
||||
import { pmGlobalInstallCommand, pmGlobalBinCommand } from './reach.mjs';
|
||||
|
||||
// Fixed-width box rendering. 80 cols is the de facto terminal default.
|
||||
// We can't reliably read TTY width from a piped postinstall context, so
|
||||
// we pin the width and truncate long content with an ellipsis if needed.
|
||||
const BOX_WIDTH = 80;
|
||||
const BOX_INNER = BOX_WIDTH - 2; // chars between the two vertical borders
|
||||
const BOX_PAD_LEFT = 2; // leading spaces inside the box for breathing room
|
||||
|
||||
// ANSI styling. Disabled when NO_COLOR is set (https://no-color.org/).
|
||||
// We can't reliably tell whether `/dev/tty`'s far end supports color,
|
||||
// but modern terminals all do; users who want plain output can set
|
||||
// NO_COLOR.
|
||||
const USE_COLOR = !process.env['NO_COLOR'];
|
||||
const ANSI_ESCAPE = /\x1b\[[0-9;]*[a-zA-Z]/g;
|
||||
const C_RESET = USE_COLOR ? '\x1b[0m' : '';
|
||||
const C_DIM = USE_COLOR ? '\x1b[2m' : '';
|
||||
const C_BOLD_GREEN = USE_COLOR ? '\x1b[1;32m' : '';
|
||||
const C_BOLD_YELLOW = USE_COLOR ? '\x1b[1;33m' : '';
|
||||
const C_CYAN = USE_COLOR ? '\x1b[36m' : '';
|
||||
|
||||
function color(c, text) {
|
||||
return USE_COLOR ? c + text + C_RESET : text;
|
||||
}
|
||||
|
||||
function visibleLength(s) {
|
||||
return s.replace(ANSI_ESCAPE, '').length;
|
||||
}
|
||||
|
||||
function stripAnsi(s) {
|
||||
return s.replace(ANSI_ESCAPE, '');
|
||||
}
|
||||
|
||||
// Platform-specific path to the controlling terminal device. Writing
|
||||
// here bypasses the package manager's lifecycle stdout capture (npm 7+
|
||||
// hides script stdout/stderr by default). On POSIX it's `/dev/tty`;
|
||||
// on Windows it's the special filename `CON`, which Node resolves to
|
||||
// the console device. (The fully-qualified `\\.\CON` form looks
|
||||
// equivalent but Node appends a trailing backslash that breaks the
|
||||
// open call — confirmed empirically on Windows 11 / Node 22.)
|
||||
const TERMINAL_DEVICE = process.platform === 'win32' ? 'CON' : '/dev/tty';
|
||||
|
||||
/**
|
||||
* Print a user-facing line. npm 7+ captures lifecycle stdout/stderr by
|
||||
* default, so messages here would be invisible to a user running
|
||||
* `npm install -g`. Writing directly to the platform's terminal
|
||||
* device bypasses the manager's capture when one is available
|
||||
* (interactive terminals). In CI / non-TTY contexts the device isn't
|
||||
* writable; fall back to stdout so the message is still preserved in
|
||||
* npm's lifecycle log under `~/.npm/_logs/`, with ANSI stripped so
|
||||
* the log file stays readable.
|
||||
*/
|
||||
export function notify(line) {
|
||||
const text = line + '\n';
|
||||
try {
|
||||
writeFileSync(TERMINAL_DEVICE, text);
|
||||
return;
|
||||
} catch {
|
||||
// Terminal device not writable (CI, sandboxed environments).
|
||||
}
|
||||
process.stdout.write(stripAnsi(text));
|
||||
}
|
||||
|
||||
// Single-quote `path` for safe interpolation in a POSIX `sh` command.
|
||||
// Wraps in single quotes and escapes any embedded `'` as `'\''`.
|
||||
function quotePosixPath(path) {
|
||||
return "'" + path.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Single-quote `path` for safe interpolation in a PowerShell command.
|
||||
// PowerShell single-quoted strings disable expansion; embedded `'` is
|
||||
// escaped by doubling.
|
||||
function quotePowerShellPath(path) {
|
||||
return "'" + path.replace(/'/g, "''") + "'";
|
||||
}
|
||||
|
||||
function boxBorder(left, right, fill = '─') {
|
||||
return color(C_DIM, left + fill.repeat(BOX_INNER) + right);
|
||||
}
|
||||
|
||||
function boxLine(content = '') {
|
||||
const visible = visibleLength(content);
|
||||
const padding =
|
||||
visible < BOX_INNER ? ' '.repeat(BOX_INNER - visible) : '';
|
||||
return color(C_DIM, '│') + content + padding + color(C_DIM, '│');
|
||||
}
|
||||
|
||||
function pad(content) {
|
||||
return ' '.repeat(BOX_PAD_LEFT) + content;
|
||||
}
|
||||
|
||||
function renderBox(lines) {
|
||||
const out = [boxBorder('╭', '╮'), boxLine('')];
|
||||
for (const line of lines) out.push(boxLine(line));
|
||||
out.push(boxLine(''), boxBorder('╰', '╯'));
|
||||
return out;
|
||||
}
|
||||
|
||||
function emit(lines) {
|
||||
notify('');
|
||||
for (const line of lines) notify(line);
|
||||
notify('');
|
||||
}
|
||||
|
||||
function pathInBox(path) {
|
||||
// 7-space lead = box pad (2) + prose indent (3) + nesting under
|
||||
// label (2). We intentionally do NOT truncate overflowing content:
|
||||
// for command lines (`sudo rm <path>`, `mv <a> <b>`), left-truncation
|
||||
// would swallow the command verb and leave the user with
|
||||
// un-copy-pasteable instructions. Long lines just overflow the box
|
||||
// border, which is visually less pretty but keeps the content
|
||||
// intact.
|
||||
const lead = ' '.repeat(BOX_PAD_LEFT + 5);
|
||||
return lead + color(C_CYAN, path);
|
||||
}
|
||||
|
||||
function successHeading(text) {
|
||||
return pad(color(C_BOLD_GREEN, '✓ ' + text));
|
||||
}
|
||||
|
||||
function warningHeading(text) {
|
||||
return pad(color(C_BOLD_YELLOW, '! ' + text));
|
||||
}
|
||||
|
||||
/**
|
||||
* The takeover completed. Renders one box that lists every action
|
||||
* taken, so the user sees a single coherent picture even when several
|
||||
* shims were involved.
|
||||
*
|
||||
* Sections (each only shown when non-empty):
|
||||
* - "Renamed" — the first PATH-order shim, preserved as
|
||||
* `kimi-legacy`. The "`kimi` now launches the new CLI" claim is
|
||||
* safe to make here because the orchestrator already verified
|
||||
* reachability after this set of removals.
|
||||
* - "Consolidated" — first shim's `kimi-legacy` already pointed at
|
||||
* a legacy file (re-migration case); we deleted the duplicate
|
||||
* source and kept the existing target. Same end state, different
|
||||
* mechanism.
|
||||
* - "Couldn't preserve as kimi-legacy" — first shim's `kimi-legacy`
|
||||
* slot was a user-managed file; we deleted the source `kimi` to
|
||||
* remove the shadow but left their file alone, so no fallback
|
||||
* exists in that dir.
|
||||
* - "Also removed" — non-first PATH-order shims that would have
|
||||
* shadowed our new shim. Just `unlink`ed.
|
||||
* - "Note: legacy left behind" — blocked shims that couldn't be
|
||||
* removed but PATH order means they don't shadow us; the user
|
||||
* can clean them up at leisure.
|
||||
* - "Errors" — anything that failed during execution despite
|
||||
* pre-flight saying it should work (race conditions, transient
|
||||
* fs errors). Listed last so the user can see what to retry.
|
||||
*/
|
||||
export function logMigrationDone(outcomes, pm) {
|
||||
const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code');
|
||||
const {
|
||||
renames,
|
||||
consolidates,
|
||||
skippedForeignTarget,
|
||||
deletes,
|
||||
blockedHarmless,
|
||||
errors,
|
||||
} = outcomes;
|
||||
|
||||
const lines = [successHeading('kimi now runs the new version'), ''];
|
||||
|
||||
if (renames.length > 0) {
|
||||
lines.push(pad(' Renamed your old kimi so you can still run it as'));
|
||||
lines.push(pad(' kimi-legacy:'));
|
||||
for (const c of renames) {
|
||||
lines.push(pathInBox(c.shimPath + ' -> ' + c.target));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (consolidates.length > 0) {
|
||||
lines.push(pad(' Removed an extra copy of your old kimi (kimi-legacy'));
|
||||
lines.push(pad(' was already set up here from before):'));
|
||||
for (const c of consolidates) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
lines.push(pathInBox(' (kimi-legacy is at ' + c.target + ')'));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (skippedForeignTarget.length > 0) {
|
||||
lines.push(pad(' Removed your old kimi (a file you created was already'));
|
||||
lines.push(pad(' using the name kimi-legacy, so we left it alone):'));
|
||||
for (const c of skippedForeignTarget) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
lines.push(pathInBox(' (your file at ' + c.target + ' is untouched)'));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (deletes.length > 0) {
|
||||
lines.push(pad(' Also removed (these would have run instead of the'));
|
||||
lines.push(pad(' new kimi if we left them):'));
|
||||
for (const c of deletes) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (blockedHarmless.length > 0) {
|
||||
lines.push(pad(' Note: we can\'t change these files, but it\'s OK —'));
|
||||
lines.push(pad(' they won\'t run instead of the new kimi:'));
|
||||
for (const c of blockedHarmless) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
lines.push(pad(' Some changes didn\'t go through:'));
|
||||
for (const e of errors) {
|
||||
lines.push(
|
||||
pathInBox(e.shimPath + ' (' + (e.message ?? e.code ?? 'error') + ')'),
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Footer has three branches based on what actually happened:
|
||||
//
|
||||
// 1. Preserved somewhere (rename or consolidate): the old CLI is
|
||||
// available as `kimi-legacy`. Standard takeover footer.
|
||||
// 2. Only skippedForeignTarget (no rename, no consolidate, no
|
||||
// blockedHarmless): user has a file they made called
|
||||
// `kimi-legacy`, so we couldn't save the old CLI under that
|
||||
// name. Explain the situation honestly.
|
||||
// 3. Only blockedHarmless (no rename, no consolidate, no
|
||||
// skippedForeignTarget): we have nothing to celebrate or
|
||||
// apologize for — the "Note: we can't change these files but
|
||||
// it's OK" section above already covers it. Plain footer.
|
||||
//
|
||||
// If both skippedForeignTarget AND blockedHarmless are present
|
||||
// (but no preservation), branch 2 wins — the foreign-target story
|
||||
// is the more useful one for the user to know about.
|
||||
const preservedSomewhere = renames.length > 0 || consolidates.length > 0;
|
||||
if (preservedSomewhere) {
|
||||
lines.push(
|
||||
pad(' Now typing `kimi` runs the new version. To run the old'),
|
||||
pad(' version, type `kimi-legacy` instead. Your settings from'),
|
||||
pad(' the old version will be moved over the first time you'),
|
||||
pad(' run `kimi`.'),
|
||||
'',
|
||||
pad(' Note: if you reinstall the old kimi later (e.g. with'),
|
||||
pad(' `uv tool`, `pip`, or `pipx`), it will put `kimi` back.'),
|
||||
pad(' Run this command again to switch to the new one:'),
|
||||
pathInBox(reinstallCmd),
|
||||
'',
|
||||
pad(' If typing `kimi` still runs the old version, open a new'),
|
||||
pad(' terminal window — your current one may have remembered'),
|
||||
pad(' the old path.'),
|
||||
);
|
||||
} else if (skippedForeignTarget.length > 0) {
|
||||
lines.push(
|
||||
pad(' Now typing `kimi` runs the new version. Your settings'),
|
||||
pad(' from the old version will be moved over the first time'),
|
||||
pad(' you run `kimi`.'),
|
||||
'',
|
||||
pad(' We couldn\'t save the old kimi as `kimi-legacy` because'),
|
||||
pad(' that name was already taken by a file you\'d created.'),
|
||||
pad(' If you need the old kimi back, install it again with'),
|
||||
pad(' `uv tool install kimi-cli` (or pipx / pip).'),
|
||||
'',
|
||||
pad(' If typing `kimi` still runs the old version, open a new'),
|
||||
pad(' terminal window — your current one may have remembered'),
|
||||
pad(' the old path.'),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
pad(' Now typing `kimi` runs the new version. Your settings'),
|
||||
pad(' from the old version will be moved over the first time'),
|
||||
pad(' you run `kimi`.'),
|
||||
'',
|
||||
pad(' If typing `kimi` still runs the old version, open a new'),
|
||||
pad(' terminal window — your current one may have remembered'),
|
||||
pad(' the old path.'),
|
||||
);
|
||||
}
|
||||
|
||||
emit(renderBox(lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* At least one blocked legacy shim still sits on PATH ahead of where
|
||||
* our new shim would land. We refused to touch anything (pre-flight
|
||||
* abort), so neither the user's existing setup nor the new install
|
||||
* gets a half-migrated state. List each blocking path with the
|
||||
* platform-appropriate manual fix.
|
||||
*/
|
||||
export function logMigrationBlocked(blocked, actionable, pm) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code');
|
||||
|
||||
const lines = [
|
||||
warningHeading('Can\'t switch to the new kimi yet'),
|
||||
'',
|
||||
pad(' There\'s an old kimi on your computer that we can\'t change.'),
|
||||
pad(' As long as it\'s there, typing `kimi` will still run the old'),
|
||||
pad(' version. Files we can\'t change:'),
|
||||
];
|
||||
|
||||
for (const c of blocked) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
}
|
||||
|
||||
lines.push('', pad(' Please delete them yourself, then install again:'));
|
||||
|
||||
for (const c of blocked) {
|
||||
if (isWindows && c.isSystemPath) {
|
||||
// Admin PowerShell needed.
|
||||
lines.push(pathInBox('# in an elevated PowerShell:'));
|
||||
lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath)));
|
||||
} else if (c.isSystemPath) {
|
||||
lines.push(pathInBox('sudo rm ' + quotePosixPath(c.shimPath)));
|
||||
} else if (isWindows) {
|
||||
lines.push(pathInBox('Remove-Item ' + quotePowerShellPath(c.shimPath)));
|
||||
} else {
|
||||
lines.push(pathInBox('rm ' + quotePosixPath(c.shimPath)));
|
||||
}
|
||||
}
|
||||
|
||||
if (actionable.length > 0) {
|
||||
lines.push(
|
||||
'',
|
||||
pad(' We also found these old kimi files. We could remove them'),
|
||||
pad(' ourselves, once the ones above are gone:'),
|
||||
);
|
||||
for (const c of actionable) {
|
||||
lines.push(pathInBox(c.shimPath));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(
|
||||
'',
|
||||
pad(' After deleting them, install again to finish:'),
|
||||
pathInBox(reinstallCmd),
|
||||
'',
|
||||
pad(' Nothing on your computer was changed.'),
|
||||
);
|
||||
|
||||
emit(renderBox(lines));
|
||||
}
|
||||
|
||||
/**
|
||||
* The reachability check found a `kimi` ahead of our shim on PATH
|
||||
* that's neither ours nor a legacy Python CLI — almost certainly a
|
||||
* wrapper the user wrote themselves (or installed from somewhere we
|
||||
* don't recognize). Deleting blocked legacy shims wouldn't help
|
||||
* here: the foreign file would still win resolution. So the
|
||||
* remediation is "delete or rename your own file", which only the
|
||||
* user can decide.
|
||||
*/
|
||||
export function logForeignKimiInTheWay(foreignPath, pm) {
|
||||
const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code');
|
||||
emit(
|
||||
renderBox([
|
||||
warningHeading('Can\'t switch to the new kimi yet'),
|
||||
'',
|
||||
pad(' There\'s another file called `kimi` on your computer that\'s'),
|
||||
pad(' not the new CLI and not the old one — it looks like'),
|
||||
pad(' something you set up yourself. As long as it\'s there,'),
|
||||
pad(' typing `kimi` will run it instead of the new version.'),
|
||||
'',
|
||||
pad(' We found it at:'),
|
||||
pathInBox(foreignPath),
|
||||
'',
|
||||
pad(' To use the new kimi, delete or rename that file, then'),
|
||||
pad(' install again:'),
|
||||
pathInBox(reinstallCmd),
|
||||
'',
|
||||
pad(' Nothing on your computer was changed.'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The legacy `kimi` was found, but the directory where the package
|
||||
* manager placed the new `kimi` shim is not on the user's PATH.
|
||||
* Renaming the legacy shim now would leave the user with NO reachable
|
||||
* `kimi` command — the new one would still not be discoverable by
|
||||
* their shell. Show them the PATH fix and leave the legacy CLI alone.
|
||||
*
|
||||
* The PATH-fix hint uses the manager-specific subshell command (one
|
||||
* of `npm prefix -g`, `yarn global bin`, `pnpm bin -g`) so it works
|
||||
* regardless of which manager the user ran, and renders in the
|
||||
* syntax of the user's likely shell:
|
||||
* - POSIX : `export PATH=...`.
|
||||
* - Windows: `$env:Path = ...` (PowerShell).
|
||||
* On Windows, npm places global shims directly under `<prefix>` (no
|
||||
* `bin` subdir), and pnpm/yarn already report the bin dir, so we
|
||||
* skip the `/bin` suffix the POSIX branch needs for npm.
|
||||
*/
|
||||
export function logNewCliNotOnPath(detection, pm) {
|
||||
const isWindows = process.platform === 'win32';
|
||||
const binCmd = pmGlobalBinCommand(pm);
|
||||
const reinstallCmd = pmGlobalInstallCommand(pm, '@moonshot-ai/kimi-code');
|
||||
|
||||
const newPathHint = isWindows
|
||||
? `$env:Path = "$(${binCmd});$env:Path"`
|
||||
: pm === 'npm'
|
||||
? `export PATH="$(${binCmd})/bin:$PATH"`
|
||||
: `export PATH="$(${binCmd}):$PATH"`;
|
||||
const rcLabel = isWindows ? 'PowerShell profile' : 'shell rc';
|
||||
|
||||
emit(
|
||||
renderBox([
|
||||
warningHeading('New kimi is installed, but your terminal can\'t find it'),
|
||||
'',
|
||||
pad(' The old kimi is still here:'),
|
||||
pathInBox(detection.shimPath),
|
||||
'',
|
||||
pad(' The new kimi was installed by ' + pm + ', but it landed in a'),
|
||||
pad(' folder your terminal doesn\'t search. (Your terminal looks'),
|
||||
pad(' for commands in folders listed in your PATH.) If we removed'),
|
||||
pad(' the old kimi now, typing `kimi` wouldn\'t find anything.'),
|
||||
'',
|
||||
pad(' Add the new kimi\'s folder to your PATH (and save the change'),
|
||||
pad(' in your ' + rcLabel + ' so it sticks), then install again:'),
|
||||
pathInBox(newPathHint),
|
||||
pathInBox(reinstallCmd),
|
||||
'',
|
||||
pad(' The old kimi is still where it was.'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
58
apps/kimi-code/scripts/smoke.mjs
Normal file
58
apps/kimi-code/scripts/smoke.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const bundlePath = resolve(appRoot, 'dist', 'main.mjs');
|
||||
const packageJson = JSON.parse(await readFile(resolve(appRoot, 'package.json'), 'utf-8'));
|
||||
const expectedVersion = packageJson.version;
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function ensureBundleExists() {
|
||||
try {
|
||||
await stat(bundlePath);
|
||||
} catch {
|
||||
fail(`Bundle not found at ${bundlePath}. Run \`pnpm build\` first.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runBundle(args) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(process.execPath, [bundlePath, ...args], {
|
||||
cwd: appRoot,
|
||||
maxBuffer: 1024 * 1024 * 16,
|
||||
});
|
||||
return `${stdout}${stderr}`;
|
||||
} catch (error) {
|
||||
const detail = [error.stdout?.trim(), error.stderr?.trim(), error.message]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
fail(`Bundle smoke failed: node ${bundlePath} ${args.join(' ')}\n${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertIncludes(output, expected, command) {
|
||||
if (!output.includes(expected)) {
|
||||
fail(`Bundle smoke output for "${command}" did not include "${expected}".\n${output}`);
|
||||
}
|
||||
}
|
||||
|
||||
await ensureBundleExists();
|
||||
|
||||
const versionOutput = await runBundle(['--version']);
|
||||
assertIncludes(versionOutput, expectedVersion, '--version');
|
||||
|
||||
const helpOutput = await runBundle(['--help']);
|
||||
assertIncludes(helpOutput, 'Usage: kimi', '--help');
|
||||
|
||||
const exportHelpOutput = await runBundle(['export', '--help']);
|
||||
assertIncludes(exportHelpOutput, 'Usage: kimi export', 'export --help');
|
||||
|
||||
console.log(`Bundle smoke passed: ${bundlePath}`);
|
||||
34
apps/kimi-code/src/cli/build-info.ts
Normal file
34
apps/kimi-code/src/cli/build-info.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
declare const __KIMI_CODE_VERSION__: string | undefined;
|
||||
declare const __KIMI_CODE_CHANNEL__: string | undefined;
|
||||
declare const __KIMI_CODE_COMMIT__: string | undefined;
|
||||
declare const __KIMI_CODE_BUILD_TARGET__: string | undefined;
|
||||
|
||||
export interface KimiBuildInfo {
|
||||
readonly version?: string;
|
||||
readonly channel?: string;
|
||||
readonly commit?: string;
|
||||
readonly buildTarget?: string;
|
||||
}
|
||||
|
||||
function optionalBuildString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
export const KIMI_BUILD_INFO: KimiBuildInfo = {
|
||||
version:
|
||||
typeof __KIMI_CODE_VERSION__ === 'string'
|
||||
? optionalBuildString(__KIMI_CODE_VERSION__)
|
||||
: undefined,
|
||||
channel:
|
||||
typeof __KIMI_CODE_CHANNEL__ === 'string'
|
||||
? optionalBuildString(__KIMI_CODE_CHANNEL__)
|
||||
: undefined,
|
||||
commit:
|
||||
typeof __KIMI_CODE_COMMIT__ === 'string'
|
||||
? optionalBuildString(__KIMI_CODE_COMMIT__)
|
||||
: undefined,
|
||||
buildTarget:
|
||||
typeof __KIMI_CODE_BUILD_TARGET__ === 'string'
|
||||
? optionalBuildString(__KIMI_CODE_BUILD_TARGET__)
|
||||
: undefined,
|
||||
};
|
||||
98
apps/kimi-code/src/cli/commands.ts
Normal file
98
apps/kimi-code/src/cli/commands.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Command, Option } from 'commander';
|
||||
|
||||
import { CLI_COMMAND_NAME } from '#/constant/app';
|
||||
|
||||
import { registerMigrateCommand } from '#/migration/index';
|
||||
|
||||
import type { CLIOptions } from './options';
|
||||
import { registerExportCommand } from './sub/export';
|
||||
|
||||
export type MainCommandHandler = (opts: CLIOptions) => void;
|
||||
export type MigrateCommandHandler = () => void;
|
||||
|
||||
export function createProgram(
|
||||
version: string,
|
||||
onMain: MainCommandHandler,
|
||||
onMigrate: MigrateCommandHandler,
|
||||
): Command {
|
||||
const program = new Command(CLI_COMMAND_NAME)
|
||||
.description('The Starting Point for Next-Gen Agents')
|
||||
.version(version, '-V, --version')
|
||||
.allowUnknownOption(false)
|
||||
.configureHelp({ helpWidth: 100 })
|
||||
.helpOption('-h, --help', 'Show help.')
|
||||
.addHelpText(
|
||||
'after',
|
||||
'\nDocumentation: https://moonshotai.github.io/kimi-code/\n'
|
||||
);
|
||||
|
||||
program
|
||||
.addOption(
|
||||
new Option(
|
||||
'-S, --session [id]',
|
||||
'Resume a session. With ID: resume that session. Without ID: interactively pick.',
|
||||
).argParser((val: string | boolean) => (val === true ? '' : (val as string))),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-r, --resume [id]')
|
||||
.hideHelp()
|
||||
.argParser((val: string | boolean) => (val === true ? '' : (val as string))),
|
||||
)
|
||||
.option('-C, --continue', 'Continue the previous session for the working directory.', false)
|
||||
.option('-y, --yolo', 'Automatically approve all actions.', false)
|
||||
.addOption(
|
||||
new Option(
|
||||
'-m, --model <model>',
|
||||
'LLM model alias to use for this invocation. Defaults to default_model in config.toml.',
|
||||
),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'-p, --prompt <prompt>',
|
||||
'Run one prompt non-interactively and print the response.',
|
||||
),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'--output-format <format>',
|
||||
'Output format for prompt mode. Defaults to text.',
|
||||
).choices(['text', 'stream-json']),
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
'--skills-dir <dir>',
|
||||
'Load skills from this directory instead of auto-discovered user and project directories. Can be repeated.',
|
||||
)
|
||||
.argParser((value: string, previous: string[] | undefined) => [...(previous ?? []), value])
|
||||
.default([]),
|
||||
)
|
||||
.addOption(new Option('--yes').hideHelp().default(false))
|
||||
.addOption(new Option('--auto-approve').hideHelp().default(false))
|
||||
.option('--plan', 'Start in plan mode.', false);
|
||||
|
||||
registerExportCommand(program);
|
||||
registerMigrateCommand(program, onMigrate);
|
||||
|
||||
program.action(() => {
|
||||
const raw = program.opts<Record<string, unknown>>();
|
||||
|
||||
const rawSession = raw['session'] ?? raw['resume'];
|
||||
const sessionValue = rawSession === true ? '' : (rawSession as string | undefined);
|
||||
const yoloValue = raw['yolo'] === true || raw['yes'] === true || raw['autoApprove'] === true;
|
||||
|
||||
const opts: CLIOptions = {
|
||||
session: sessionValue,
|
||||
continue: raw['continue'] as boolean,
|
||||
yolo: yoloValue,
|
||||
plan: raw['plan'] as boolean,
|
||||
model: raw['model'] as string | undefined,
|
||||
outputFormat: raw['outputFormat'] as CLIOptions['outputFormat'],
|
||||
prompt: raw['prompt'] as string | undefined,
|
||||
skillsDirs: raw['skillsDir'] as string[],
|
||||
};
|
||||
|
||||
onMain(opts);
|
||||
});
|
||||
|
||||
return program;
|
||||
}
|
||||
58
apps/kimi-code/src/cli/options.ts
Normal file
58
apps/kimi-code/src/cli/options.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
export type UIMode = 'shell' | 'print';
|
||||
export type PromptOutputFormat = 'text' | 'stream-json';
|
||||
|
||||
export interface CLIOptions {
|
||||
session: string | undefined;
|
||||
continue: boolean;
|
||||
yolo: boolean;
|
||||
plan: boolean;
|
||||
model: string | undefined;
|
||||
outputFormat: PromptOutputFormat | undefined;
|
||||
prompt: string | undefined;
|
||||
skillsDirs: string[];
|
||||
}
|
||||
|
||||
export interface ValidatedOptions {
|
||||
options: CLIOptions;
|
||||
uiMode: UIMode;
|
||||
}
|
||||
|
||||
export class OptionConflictError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'OptionConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export function validateOptions(opts: CLIOptions): ValidatedOptions {
|
||||
const prompt = opts.prompt;
|
||||
const promptMode = prompt !== undefined;
|
||||
if (promptMode && prompt.trim().length === 0) {
|
||||
throw new OptionConflictError('Prompt cannot be empty.');
|
||||
}
|
||||
if (opts.model !== undefined && opts.model.trim().length === 0) {
|
||||
throw new OptionConflictError('Model cannot be empty.');
|
||||
}
|
||||
if (!promptMode && opts.outputFormat !== undefined) {
|
||||
throw new OptionConflictError('Output format is only supported in prompt mode.');
|
||||
}
|
||||
if (promptMode && opts.yolo) {
|
||||
throw new OptionConflictError('Cannot combine --prompt with --yolo.');
|
||||
}
|
||||
if (promptMode && opts.plan) {
|
||||
throw new OptionConflictError('Cannot combine --prompt with --plan.');
|
||||
}
|
||||
if (promptMode && opts.session === '') {
|
||||
throw new OptionConflictError('Cannot use --session without an id in prompt mode.');
|
||||
}
|
||||
if (opts.continue && opts.session !== undefined) {
|
||||
throw new OptionConflictError('Cannot combine --continue, --session.');
|
||||
}
|
||||
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.yolo) {
|
||||
throw new OptionConflictError('Cannot combine --yolo with --continue or --session.');
|
||||
}
|
||||
if (!promptMode && (opts.continue || opts.session !== undefined) && opts.plan) {
|
||||
throw new OptionConflictError('Cannot combine --plan with --continue or --session.');
|
||||
}
|
||||
return { options: opts, uiMode: promptMode ? 'print' : 'shell' };
|
||||
}
|
||||
673
apps/kimi-code/src/cli/run-prompt.ts
Normal file
673
apps/kimi-code/src/cli/run-prompt.ts
Normal file
|
|
@ -0,0 +1,673 @@
|
|||
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
import {
|
||||
initializeTelemetry,
|
||||
setCrashPhase,
|
||||
setTelemetryContext,
|
||||
shutdownTelemetry,
|
||||
track,
|
||||
withTelemetryContext,
|
||||
} from '@moonshot-ai/kimi-telemetry';
|
||||
import {
|
||||
KimiHarness,
|
||||
log,
|
||||
type Event,
|
||||
type HookResultEvent,
|
||||
type Session,
|
||||
type SessionStatus,
|
||||
type TelemetryClient,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
|
||||
|
||||
import type { CLIOptions, PromptOutputFormat } from './options';
|
||||
import { createKimiCodeHostIdentity } from './version';
|
||||
|
||||
interface PromptOutput {
|
||||
readonly columns?: number | undefined;
|
||||
write(chunk: string): boolean;
|
||||
}
|
||||
|
||||
interface PromptRunIO {
|
||||
readonly stdout?: PromptOutput;
|
||||
readonly stderr?: PromptOutput;
|
||||
readonly process?: PromptProcess;
|
||||
}
|
||||
|
||||
interface PromptProcess {
|
||||
once(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
|
||||
off(signal: NodeJS.Signals, listener: () => Promise<void>): unknown;
|
||||
exit(code?: number): never | void;
|
||||
}
|
||||
|
||||
const PROMPT_UI_MODE = 'print';
|
||||
const PROMPT_MAIN_AGENT_ID = 'main';
|
||||
const PROMPT_BLOCK_BULLET = '• ';
|
||||
const PROMPT_BLOCK_INDENT = ' ';
|
||||
|
||||
export async function runPrompt(
|
||||
opts: CLIOptions,
|
||||
version: string,
|
||||
io: PromptRunIO = {},
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const stdout = io.stdout ?? process.stdout;
|
||||
const stderr = io.stderr ?? process.stderr;
|
||||
const promptProcess = io.process ?? process;
|
||||
const workDir = process.cwd();
|
||||
const telemetryClient: TelemetryClient = {
|
||||
track,
|
||||
withContext: withTelemetryContext,
|
||||
setContext: setTelemetryContext,
|
||||
};
|
||||
const harness = new KimiHarness({
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
uiMode: PROMPT_UI_MODE,
|
||||
skillDirs: opts.skillsDirs,
|
||||
telemetry: telemetryClient,
|
||||
onOAuthRefresh: (outcome) => {
|
||||
if (outcome.success) {
|
||||
track('oauth_refresh', { success: true });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', { success: false, reason: outcome.reason });
|
||||
},
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
uiMode: PROMPT_UI_MODE,
|
||||
nodeVersion: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
workDir,
|
||||
});
|
||||
let restorePromptSessionPermission = async (): Promise<void> => {};
|
||||
let removeTerminationCleanup: (() => void) | undefined;
|
||||
let cleanupPromise: Promise<void> | undefined;
|
||||
const cleanupPromptRun = async (): Promise<void> => {
|
||||
cleanupPromise ??= (async () => {
|
||||
removeTerminationCleanup?.();
|
||||
setCrashPhase('shutdown');
|
||||
try {
|
||||
await restorePromptSessionPermission();
|
||||
} finally {
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
await harness.close();
|
||||
}
|
||||
})();
|
||||
await cleanupPromise;
|
||||
};
|
||||
removeTerminationCleanup = installPromptTerminationCleanup(promptProcess, cleanupPromptRun);
|
||||
|
||||
try {
|
||||
await harness.ensureConfigFile();
|
||||
const config = await harness.getConfig();
|
||||
const { session, resumed, restorePermission, telemetryModel } = await resolvePromptSession(
|
||||
harness,
|
||||
opts,
|
||||
workDir,
|
||||
config.defaultModel,
|
||||
stderr,
|
||||
(restorePermission) => {
|
||||
restorePromptSessionPermission = restorePermission;
|
||||
},
|
||||
);
|
||||
restorePromptSessionPermission = restorePermission;
|
||||
|
||||
const deviceId = createKimiDeviceId(harness.homeDir);
|
||||
initializeTelemetry({
|
||||
homeDir: harness.homeDir,
|
||||
deviceId,
|
||||
enabled: config.telemetry !== false,
|
||||
appName: CLI_USER_AGENT_PRODUCT,
|
||||
version,
|
||||
uiMode: PROMPT_UI_MODE,
|
||||
model: telemetryModel,
|
||||
getAccessToken: async () =>
|
||||
(await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
||||
});
|
||||
setCrashPhase('runtime');
|
||||
|
||||
withTelemetryContext({ sessionId: session.id }).track('started', {
|
||||
resumed,
|
||||
yolo: false,
|
||||
plan: false,
|
||||
afk: true,
|
||||
});
|
||||
|
||||
await runPromptTurn(session, opts.prompt!, opts.outputFormat ?? 'text', stdout, stderr);
|
||||
stderr.write(`To resume this session: kimi -r ${session.id}\n`);
|
||||
|
||||
withTelemetryContext({ sessionId: session.id }).track('exit', {
|
||||
duration_s: (Date.now() - startedAt) / 1000,
|
||||
});
|
||||
} finally {
|
||||
await cleanupPromptRun();
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedPromptSession {
|
||||
readonly session: Session;
|
||||
readonly resumed: boolean;
|
||||
readonly restorePermission: () => Promise<void>;
|
||||
readonly telemetryModel?: string;
|
||||
}
|
||||
|
||||
async function resolvePromptSession(
|
||||
harness: KimiHarness,
|
||||
opts: CLIOptions,
|
||||
workDir: string,
|
||||
defaultModel: string | undefined,
|
||||
stderr: PromptOutput,
|
||||
setRestorePermission: (restorePermission: () => Promise<void>) => void,
|
||||
): Promise<ResolvedPromptSession> {
|
||||
if (opts.session !== undefined) {
|
||||
const session = await harness.resumeSession({ id: opts.session });
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
status.permission,
|
||||
setRestorePermission,
|
||||
);
|
||||
if (opts.model !== undefined) {
|
||||
await session.setModel(opts.model);
|
||||
}
|
||||
installHeadlessHandlers(session);
|
||||
return {
|
||||
session,
|
||||
resumed: true,
|
||||
restorePermission,
|
||||
telemetryModel: configuredModel(opts.model, status.model, defaultModel),
|
||||
};
|
||||
}
|
||||
|
||||
if (opts.continue) {
|
||||
const sessions = await harness.listSessions({ workDir });
|
||||
const previous = sessions[0];
|
||||
if (previous !== undefined) {
|
||||
const session = await harness.resumeSession({ id: previous.id });
|
||||
const status = await session.getStatus();
|
||||
const restorePermission = await forcePromptPermission(
|
||||
session,
|
||||
status.permission,
|
||||
setRestorePermission,
|
||||
);
|
||||
if (opts.model !== undefined) {
|
||||
await session.setModel(opts.model);
|
||||
}
|
||||
installHeadlessHandlers(session);
|
||||
return {
|
||||
session,
|
||||
resumed: true,
|
||||
restorePermission,
|
||||
telemetryModel: configuredModel(opts.model, status.model, defaultModel),
|
||||
};
|
||||
}
|
||||
stderr.write(`No sessions to continue under "${workDir}"; starting a fresh session.\n`);
|
||||
}
|
||||
|
||||
const model = requireConfiguredModel(opts.model, defaultModel);
|
||||
const session = await harness.createSession({ workDir, model, permission: 'auto' });
|
||||
installHeadlessHandlers(session);
|
||||
return { session, resumed: false, restorePermission: async () => {}, telemetryModel: model };
|
||||
}
|
||||
|
||||
async function forcePromptPermission(
|
||||
session: Session,
|
||||
previousPermission: SessionStatus['permission'],
|
||||
setRestorePermission: (restorePermission: () => Promise<void>) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
let overridePermission: Promise<void> | undefined;
|
||||
const restorePermission = async () => {
|
||||
await overridePermission?.catch(() => {});
|
||||
if (previousPermission !== 'auto') {
|
||||
await session.setPermission(previousPermission);
|
||||
}
|
||||
};
|
||||
setRestorePermission(restorePermission);
|
||||
if (previousPermission !== 'auto') {
|
||||
overridePermission = session.setPermission('auto');
|
||||
await overridePermission;
|
||||
}
|
||||
return restorePermission;
|
||||
}
|
||||
|
||||
function requireConfiguredModel(...models: readonly (string | undefined)[]): string {
|
||||
const model = configuredModel(...models);
|
||||
if (model === undefined) {
|
||||
throw new Error('No model configured. Set default_model in config.toml.');
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
function configuredModel(...models: readonly (string | undefined)[]): string | undefined {
|
||||
return models.find((model) => model !== undefined && model.trim().length > 0);
|
||||
}
|
||||
|
||||
function installHeadlessHandlers(session: Session): void {
|
||||
session.setApprovalHandler(() => ({ decision: 'approved' }));
|
||||
session.setQuestionHandler(() => null);
|
||||
}
|
||||
|
||||
function installPromptTerminationCleanup(
|
||||
promptProcess: PromptProcess,
|
||||
cleanup: () => Promise<void>,
|
||||
): () => void {
|
||||
let terminating = false;
|
||||
const exitAfterCleanup = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
if (terminating) return;
|
||||
terminating = true;
|
||||
try {
|
||||
await cleanup();
|
||||
} finally {
|
||||
promptProcess.exit(signalExitCode(signal));
|
||||
}
|
||||
};
|
||||
const onSigint = () => exitAfterCleanup('SIGINT');
|
||||
const onSigterm = () => exitAfterCleanup('SIGTERM');
|
||||
promptProcess.once('SIGINT', onSigint);
|
||||
promptProcess.once('SIGTERM', onSigterm);
|
||||
return () => {
|
||||
promptProcess.off('SIGINT', onSigint);
|
||||
promptProcess.off('SIGTERM', onSigterm);
|
||||
};
|
||||
}
|
||||
|
||||
function signalExitCode(signal: NodeJS.Signals): number {
|
||||
return signal === 'SIGINT' ? 130 : 143;
|
||||
}
|
||||
|
||||
function runPromptTurn(
|
||||
session: Session,
|
||||
prompt: string,
|
||||
outputFormat: PromptOutputFormat,
|
||||
stdout: PromptOutput,
|
||||
stderr: PromptOutput,
|
||||
): Promise<void> {
|
||||
let activeTurnId: number | undefined;
|
||||
let activeAgentId: string | undefined;
|
||||
const outputWriter =
|
||||
outputFormat === 'stream-json'
|
||||
? new PromptJsonWriter(stdout)
|
||||
: new PromptTranscriptWriter(stdout, stderr);
|
||||
let settled = false;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const finish = (error?: Error): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
unsubscribe?.();
|
||||
outputWriter.finish();
|
||||
if (error !== undefined) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
|
||||
unsubscribe = session.onEvent((event) => {
|
||||
if (event.type === 'error') {
|
||||
if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
|
||||
return;
|
||||
}
|
||||
finish(new Error(`${event.code}: ${event.message}`));
|
||||
return;
|
||||
}
|
||||
if (event.type === 'turn.started' && activeTurnId === undefined) {
|
||||
if (event.agentId !== PROMPT_MAIN_AGENT_ID) {
|
||||
return;
|
||||
}
|
||||
activeTurnId = event.turnId;
|
||||
activeAgentId = event.agentId;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
activeTurnId === undefined ||
|
||||
activeAgentId === undefined ||
|
||||
!hasTurnId(event) ||
|
||||
event.turnId !== activeTurnId ||
|
||||
event.agentId !== activeAgentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'turn.step.started':
|
||||
case 'turn.step.interrupted':
|
||||
outputWriter.flushAssistant();
|
||||
return;
|
||||
case 'turn.step.retrying':
|
||||
outputWriter.discardAssistant();
|
||||
return;
|
||||
case 'assistant.delta':
|
||||
outputWriter.writeAssistantDelta(event.delta);
|
||||
return;
|
||||
case 'hook.result':
|
||||
outputWriter.writeHookResult(event);
|
||||
return;
|
||||
case 'thinking.delta':
|
||||
outputWriter.writeThinkingDelta(event.delta);
|
||||
return;
|
||||
case 'tool.call.started':
|
||||
outputWriter.writeToolCall(event.toolCallId, event.name, event.args);
|
||||
return;
|
||||
case 'tool.call.delta':
|
||||
outputWriter.writeToolCallDelta(event.toolCallId, event.name, event.argumentsPart);
|
||||
return;
|
||||
case 'tool.result':
|
||||
outputWriter.writeToolResult(event.toolCallId, event.output);
|
||||
return;
|
||||
case 'tool.progress':
|
||||
if (event.update.text !== undefined && event.update.text.length > 0) {
|
||||
stderr.write(
|
||||
event.update.text.endsWith('\n') ? event.update.text : `${event.update.text}\n`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
case 'turn.ended':
|
||||
if (event.reason === 'completed') {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
finish(new Error(formatTurnEndedFailure(event)));
|
||||
return;
|
||||
case 'agent.status.updated':
|
||||
case 'background.task.started':
|
||||
case 'background.task.terminated':
|
||||
case 'background.task.updated':
|
||||
case 'compaction.blocked':
|
||||
case 'compaction.cancelled':
|
||||
case 'compaction.completed':
|
||||
case 'compaction.started':
|
||||
case 'mcp.server.status':
|
||||
case 'session.meta.updated':
|
||||
case 'skill.activated':
|
||||
case 'subagent.completed':
|
||||
case 'subagent.failed':
|
||||
case 'subagent.spawned':
|
||||
case 'tool.list.updated':
|
||||
case 'turn.started':
|
||||
case 'turn.step.completed':
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
session.prompt(prompt).catch((error: unknown) => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
interface PromptTurnWriter {
|
||||
writeAssistantDelta(delta: string): void;
|
||||
writeHookResult(event: HookResultEvent): void;
|
||||
writeThinkingDelta(delta: string): void;
|
||||
writeToolCall(toolCallId: string, name: string, args: unknown): void;
|
||||
writeToolCallDelta(
|
||||
toolCallId: string,
|
||||
name: string | undefined,
|
||||
argumentsPart: string | undefined,
|
||||
): void;
|
||||
writeToolResult(toolCallId: string, output: unknown): void;
|
||||
flushAssistant(): void;
|
||||
discardAssistant(): void;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
class PromptTranscriptWriter implements PromptTurnWriter {
|
||||
private readonly assistantWriter: PromptBlockWriter;
|
||||
private readonly thinkingWriter: PromptBlockWriter;
|
||||
|
||||
constructor(stdout: PromptOutput, stderr: PromptOutput) {
|
||||
this.assistantWriter = new PromptBlockWriter(stdout);
|
||||
this.thinkingWriter = new PromptBlockWriter(stderr);
|
||||
}
|
||||
|
||||
writeAssistantDelta(delta: string): void {
|
||||
this.thinkingWriter.finish();
|
||||
this.assistantWriter.write(delta);
|
||||
}
|
||||
|
||||
writeHookResult(event: HookResultEvent): void {
|
||||
this.thinkingWriter.finish();
|
||||
this.assistantWriter.finish();
|
||||
this.assistantWriter.write(formatHookResultPlain(event));
|
||||
this.assistantWriter.finish();
|
||||
}
|
||||
|
||||
writeThinkingDelta(delta: string): void {
|
||||
this.thinkingWriter.write(delta);
|
||||
}
|
||||
|
||||
writeToolCall(): void {}
|
||||
|
||||
writeToolCallDelta(): void {}
|
||||
|
||||
writeToolResult(): void {}
|
||||
|
||||
flushAssistant(): void {}
|
||||
|
||||
discardAssistant(): void {}
|
||||
|
||||
finish(): void {
|
||||
this.thinkingWriter.finish();
|
||||
this.assistantWriter.finish();
|
||||
}
|
||||
}
|
||||
|
||||
interface PromptJsonToolCall {
|
||||
type: 'function';
|
||||
id: string;
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PromptJsonAssistantMessage {
|
||||
role: 'assistant';
|
||||
content?: string;
|
||||
tool_calls?: PromptJsonToolCall[];
|
||||
}
|
||||
|
||||
interface PromptJsonToolMessage {
|
||||
role: 'tool';
|
||||
tool_call_id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
class PromptJsonWriter implements PromptTurnWriter {
|
||||
private assistantText = '';
|
||||
private readonly toolCalls: PromptJsonToolCall[] = [];
|
||||
|
||||
constructor(private readonly stdout: PromptOutput) {}
|
||||
|
||||
writeAssistantDelta(delta: string): void {
|
||||
this.assistantText += delta;
|
||||
}
|
||||
|
||||
writeHookResult(event: HookResultEvent): void {
|
||||
this.flushAssistant();
|
||||
this.writeJsonLine({
|
||||
role: 'assistant',
|
||||
content: formatHookResultPlain(event),
|
||||
});
|
||||
}
|
||||
|
||||
writeThinkingDelta(): void {}
|
||||
|
||||
writeToolCall(toolCallId: string, name: string, args: unknown): void {
|
||||
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
|
||||
if (existing !== undefined) {
|
||||
existing.function.name = name;
|
||||
existing.function.arguments = stringifyJsonValue(args);
|
||||
return;
|
||||
}
|
||||
this.toolCalls.push({
|
||||
type: 'function',
|
||||
id: toolCallId,
|
||||
function: {
|
||||
name,
|
||||
arguments: stringifyJsonValue(args),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
writeToolCallDelta(
|
||||
toolCallId: string,
|
||||
name: string | undefined,
|
||||
argumentsPart: string | undefined,
|
||||
): void {
|
||||
const toolCall = this.findOrCreateToolCall(toolCallId, name ?? '');
|
||||
if (name !== undefined) {
|
||||
toolCall.function.name = name;
|
||||
}
|
||||
if (argumentsPart !== undefined) {
|
||||
toolCall.function.arguments += argumentsPart;
|
||||
}
|
||||
}
|
||||
|
||||
writeToolResult(toolCallId: string, output: unknown): void {
|
||||
this.flushAssistant();
|
||||
this.writeJsonLine({
|
||||
role: 'tool',
|
||||
tool_call_id: toolCallId,
|
||||
content: stringifyToolOutput(output),
|
||||
});
|
||||
}
|
||||
|
||||
flushAssistant(): void {
|
||||
if (this.assistantText.length === 0 && this.toolCalls.length === 0) return;
|
||||
const message: PromptJsonAssistantMessage = {
|
||||
role: 'assistant',
|
||||
content: this.assistantText.length > 0 ? this.assistantText : undefined,
|
||||
tool_calls: this.toolCalls.length > 0 ? [...this.toolCalls] : undefined,
|
||||
};
|
||||
this.writeJsonLine(message);
|
||||
this.discardAssistant();
|
||||
}
|
||||
|
||||
discardAssistant(): void {
|
||||
this.assistantText = '';
|
||||
this.toolCalls.length = 0;
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
this.flushAssistant();
|
||||
}
|
||||
|
||||
private findOrCreateToolCall(toolCallId: string, name: string): PromptJsonToolCall {
|
||||
const existing = this.toolCalls.find((toolCall) => toolCall.id === toolCallId);
|
||||
if (existing !== undefined) return existing;
|
||||
const toolCall: PromptJsonToolCall = {
|
||||
type: 'function',
|
||||
id: toolCallId,
|
||||
function: {
|
||||
name,
|
||||
arguments: '',
|
||||
},
|
||||
};
|
||||
this.toolCalls.push(toolCall);
|
||||
return toolCall;
|
||||
}
|
||||
|
||||
private writeJsonLine(message: PromptJsonAssistantMessage | PromptJsonToolMessage): void {
|
||||
this.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
class PromptBlockWriter {
|
||||
private started = false;
|
||||
private atLineStart = false;
|
||||
private lineWidth = 0;
|
||||
private readonly wrapWidth: number | undefined;
|
||||
|
||||
constructor(private readonly output: PromptOutput) {
|
||||
this.wrapWidth =
|
||||
typeof output.columns === 'number' && output.columns > PROMPT_BLOCK_INDENT.length + 1
|
||||
? output.columns
|
||||
: undefined;
|
||||
}
|
||||
|
||||
write(chunk: string): void {
|
||||
if (chunk.length === 0) return;
|
||||
let rendered = this.start();
|
||||
for (const char of chunk) {
|
||||
if (this.atLineStart && char !== '\n') {
|
||||
rendered += PROMPT_BLOCK_INDENT;
|
||||
this.atLineStart = false;
|
||||
this.lineWidth = PROMPT_BLOCK_INDENT.length;
|
||||
}
|
||||
const charWidth = visibleCharWidth(char);
|
||||
if (
|
||||
this.wrapWidth !== undefined &&
|
||||
!this.atLineStart &&
|
||||
char !== '\n' &&
|
||||
this.lineWidth + charWidth > this.wrapWidth
|
||||
) {
|
||||
rendered += `\n${PROMPT_BLOCK_INDENT}`;
|
||||
this.lineWidth = PROMPT_BLOCK_INDENT.length;
|
||||
}
|
||||
rendered += char;
|
||||
if (char === '\n') {
|
||||
this.atLineStart = true;
|
||||
this.lineWidth = 0;
|
||||
} else {
|
||||
this.lineWidth += charWidth;
|
||||
}
|
||||
}
|
||||
this.output.write(rendered);
|
||||
}
|
||||
|
||||
finish(): void {
|
||||
if (!this.started) return;
|
||||
this.output.write(this.atLineStart ? '\n' : '\n\n');
|
||||
this.started = false;
|
||||
this.atLineStart = false;
|
||||
this.lineWidth = 0;
|
||||
}
|
||||
|
||||
private start(): string {
|
||||
if (this.started) return '';
|
||||
this.started = true;
|
||||
this.atLineStart = false;
|
||||
this.lineWidth = PROMPT_BLOCK_BULLET.length;
|
||||
return PROMPT_BLOCK_BULLET;
|
||||
}
|
||||
}
|
||||
|
||||
function visibleCharWidth(char: string): number {
|
||||
return char === '\t' ? 4 : 1;
|
||||
}
|
||||
|
||||
function formatHookResultPlain(event: HookResultEvent): string {
|
||||
return `${formatHookResultTitle(event)}\n\n${formatHookResultBody(event)}`;
|
||||
}
|
||||
|
||||
function formatHookResultTitle(event: HookResultEvent): string {
|
||||
return `${event.hookEvent} hook${event.blocked === true ? ' blocked' : ''}`;
|
||||
}
|
||||
|
||||
function formatHookResultBody(event: HookResultEvent): string {
|
||||
const content = event.content.trim();
|
||||
return content.length === 0 ? '(empty)' : content;
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
const json = JSON.stringify(value);
|
||||
return json ?? '';
|
||||
}
|
||||
|
||||
function stringifyToolOutput(output: unknown): string {
|
||||
if (typeof output === 'string') return output;
|
||||
const json = JSON.stringify(output);
|
||||
return json ?? String(output);
|
||||
}
|
||||
|
||||
function hasTurnId(event: Event): event is Event & { readonly turnId: number } {
|
||||
return 'turnId' in event;
|
||||
}
|
||||
|
||||
function formatTurnEndedFailure(event: Extract<Event, { type: 'turn.ended' }>): string {
|
||||
if (event.error !== undefined) return `${event.error.code}: ${event.error.message}`;
|
||||
return `Prompt turn ended with reason: ${event.reason}`;
|
||||
}
|
||||
180
apps/kimi-code/src/cli/run-shell.ts
Normal file
180
apps/kimi-code/src/cli/run-shell.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { execSync } from 'node:child_process';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
import {
|
||||
initializeTelemetry,
|
||||
setCrashPhase,
|
||||
setTelemetryContext,
|
||||
shutdownTelemetry,
|
||||
track,
|
||||
withTelemetryContext,
|
||||
} from '@moonshot-ai/kimi-telemetry';
|
||||
import { KimiHarness, log, type TelemetryClient } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
|
||||
import { detectPendingMigration } from '#/migration/index';
|
||||
import type { TuiConfig } from '#/tui/config';
|
||||
import { loadTuiConfig, TuiConfigParseError } from '#/tui/config';
|
||||
import { CHROME_GUTTER } from '#/tui/constant/rendering';
|
||||
import { KimiTUI } from '#/tui/index';
|
||||
import { detectTerminalTheme } from '#/tui/theme/detect';
|
||||
|
||||
import type { CLIOptions } from './options';
|
||||
import { createKimiCodeHostIdentity } from './version';
|
||||
|
||||
export async function runShell(
|
||||
opts: CLIOptions,
|
||||
version: string,
|
||||
runOptions: { readonly migrateOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
const configStartedAt = startedAt;
|
||||
let tuiConfig: TuiConfig;
|
||||
let configWarning: string | undefined;
|
||||
try {
|
||||
tuiConfig = await loadTuiConfig();
|
||||
} catch (error) {
|
||||
if (!(error instanceof TuiConfigParseError)) throw error;
|
||||
tuiConfig = error.fallback;
|
||||
configWarning = error.message;
|
||||
}
|
||||
|
||||
// Resolve `theme = "auto"` against the live terminal once, before pi-tui
|
||||
// grabs stdin. Explicit `dark` / `light` skip detection.
|
||||
const resolvedTheme = tuiConfig.theme === 'auto' ? await detectTerminalTheme() : tuiConfig.theme;
|
||||
|
||||
const workDir = process.cwd();
|
||||
const telemetryClient: TelemetryClient = {
|
||||
track,
|
||||
withContext: withTelemetryContext,
|
||||
setContext: setTelemetryContext,
|
||||
};
|
||||
const harness = new KimiHarness({
|
||||
identity: createKimiCodeHostIdentity(version),
|
||||
telemetry: telemetryClient,
|
||||
onOAuthRefresh: (outcome) => {
|
||||
if (outcome.success) {
|
||||
track('oauth_refresh', { success: true });
|
||||
return;
|
||||
}
|
||||
track('oauth_refresh', {
|
||||
success: false,
|
||||
reason: outcome.reason,
|
||||
});
|
||||
},
|
||||
});
|
||||
log.info('kimi-code starting', {
|
||||
version,
|
||||
uiMode: CLI_UI_MODE,
|
||||
nodeVersion: process.version,
|
||||
platform: `${process.platform}/${process.arch}`,
|
||||
workDir,
|
||||
});
|
||||
await harness.ensureConfigFile();
|
||||
const migrationPlan = await detectPendingMigration({
|
||||
sourceHome: join(homedir(), '.kimi'),
|
||||
targetHome: harness.homeDir,
|
||||
ignoreMarker: runOptions.migrateOnly,
|
||||
});
|
||||
if (runOptions.migrateOnly === true && migrationPlan === null) {
|
||||
process.stdout.write(' Nothing to migrate from ~/.kimi/.\n');
|
||||
await harness.close();
|
||||
return;
|
||||
}
|
||||
const config = await harness.getConfig();
|
||||
const configMs = Date.now() - configStartedAt;
|
||||
const tui = new KimiTUI(harness, {
|
||||
cliOptions: opts,
|
||||
tuiConfig,
|
||||
version,
|
||||
workDir,
|
||||
startupNotice: configWarning,
|
||||
resolvedTheme,
|
||||
migrationPlan,
|
||||
migrateOnly: runOptions.migrateOnly,
|
||||
});
|
||||
|
||||
let firstLaunch = false;
|
||||
const deviceId = createKimiDeviceId(harness.homeDir, {
|
||||
onFirstLaunch: () => {
|
||||
firstLaunch = true;
|
||||
},
|
||||
});
|
||||
initializeTelemetry({
|
||||
homeDir: harness.homeDir,
|
||||
deviceId,
|
||||
enabled: config.telemetry !== false,
|
||||
appName: CLI_USER_AGENT_PRODUCT,
|
||||
version,
|
||||
uiMode: CLI_UI_MODE,
|
||||
model: config.defaultModel,
|
||||
getAccessToken: async () =>
|
||||
(await harness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
||||
});
|
||||
setCrashPhase('runtime');
|
||||
|
||||
if (firstLaunch) {
|
||||
harness.track('first_launch');
|
||||
}
|
||||
const resumed = opts.continue || opts.session !== undefined;
|
||||
const trackLifecycleForSession = (
|
||||
sessionId: string,
|
||||
event: string,
|
||||
properties?: Parameters<KimiHarness['track']>[1],
|
||||
) => {
|
||||
if (sessionId.length === 0) {
|
||||
harness.track(event, properties);
|
||||
return;
|
||||
}
|
||||
withTelemetryContext({ sessionId }).track(event, properties);
|
||||
};
|
||||
const trackLifecycle = (event: string, properties?: Parameters<KimiHarness['track']>[1]) => {
|
||||
trackLifecycleForSession(tui.getCurrentSessionId(), event, properties);
|
||||
};
|
||||
|
||||
tui.onExit = async (exitCode = 0) => {
|
||||
const sessionId = tui.getCurrentSessionId();
|
||||
const hasContent = tui.hasSessionContent();
|
||||
setCrashPhase('shutdown');
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
const gutter = ' '.repeat(CHROME_GUTTER);
|
||||
process.stdout.write(`${gutter}Bye!\n`);
|
||||
if (sessionId !== '' && hasContent) {
|
||||
process.stderr.write(`\n${gutter}To resume this session: kimi -r ${sessionId}\n`);
|
||||
}
|
||||
process.exit(exitCode);
|
||||
};
|
||||
try {
|
||||
execSync('stty -ixon', { stdio: 'ignore' });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const initStartedAt = Date.now();
|
||||
await tui.start();
|
||||
const initMs = Date.now() - initStartedAt;
|
||||
trackLifecycle('started', {
|
||||
resumed,
|
||||
yolo: opts.yolo,
|
||||
plan: opts.plan,
|
||||
afk: false,
|
||||
});
|
||||
const startupSessionId = tui.getCurrentSessionId();
|
||||
const mcpMs = await tui.getStartupMcpMs();
|
||||
trackLifecycleForSession(startupSessionId, 'startup_perf', {
|
||||
duration_ms: Date.now() - startedAt,
|
||||
config_ms: configMs,
|
||||
init_ms: initMs,
|
||||
mcp_ms: mcpMs,
|
||||
});
|
||||
} catch (error) {
|
||||
setCrashPhase('shutdown');
|
||||
trackLifecycle('exit', { duration_s: (Date.now() - startedAt) / 1000 });
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
await harness.close();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
35
apps/kimi-code/src/cli/startup-error.ts
Normal file
35
apps/kimi-code/src/cli/startup-error.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { KIMI_ERROR_INFO, isKimiError } from '@moonshot-ai/kimi-code-sdk';
|
||||
import { chalkStderr } from 'chalk';
|
||||
|
||||
import { STARTUP_ERROR_COLOR } from '#/constant/startup-error';
|
||||
|
||||
export interface StartupErrorFormatOptions {
|
||||
readonly errorStyle?: (text: string) => string;
|
||||
readonly operation?: string;
|
||||
}
|
||||
|
||||
function formatUnknownErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
export function formatStartupError(
|
||||
error: unknown,
|
||||
options: StartupErrorFormatOptions = {},
|
||||
): string {
|
||||
const errorStyle = options.errorStyle ?? chalkStderr.hex(STARTUP_ERROR_COLOR);
|
||||
|
||||
if (!isKimiError(error)) {
|
||||
const operation = options.operation ?? 'start shell';
|
||||
return `${errorStyle(`error: failed to ${operation}: ${formatUnknownErrorMessage(error)}`)}\n`;
|
||||
}
|
||||
|
||||
const info = KIMI_ERROR_INFO[error.code];
|
||||
const lines = [
|
||||
errorStyle(`error: ${info.title}`),
|
||||
'',
|
||||
errorStyle('message:'),
|
||||
errorStyle(error.message),
|
||||
];
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
224
apps/kimi-code/src/cli/sub/export.ts
Normal file
224
apps/kimi-code/src/cli/sub/export.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* `kimi export` sub-command.
|
||||
*
|
||||
* CLI glue only: session lookup, previous-session confirmation, and output.
|
||||
* The actual ZIP/manifest export is owned by the SDK.
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises';
|
||||
|
||||
import { createKimiDeviceId, KIMI_CODE_PROVIDER_NAME } from '@moonshot-ai/kimi-code-oauth';
|
||||
import {
|
||||
initializeTelemetry,
|
||||
setTelemetryContext,
|
||||
shutdownTelemetry,
|
||||
track,
|
||||
withTelemetryContext,
|
||||
} from '@moonshot-ai/kimi-telemetry';
|
||||
import {
|
||||
KimiHarness,
|
||||
type ExportSessionInput,
|
||||
type ExportSessionResult,
|
||||
type SessionSummary,
|
||||
type TelemetryClient,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, CLI_USER_AGENT_PRODUCT } from '#/constant/app';
|
||||
import { createKimiCodeHostIdentity } from '#/cli/version';
|
||||
|
||||
interface WritableLike {
|
||||
write(chunk: string): boolean;
|
||||
}
|
||||
|
||||
export interface PreviousSessionSummary {
|
||||
readonly workDir: string;
|
||||
readonly sessionId: string;
|
||||
readonly sessionDir: string;
|
||||
readonly title?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ExportDeps {
|
||||
readonly listSessions: (workDir: string) => Promise<readonly SessionSummary[]>;
|
||||
readonly exportSession: (input: ExportSessionInput) => Promise<ExportSessionResult>;
|
||||
readonly confirmPreviousSession: (summary: PreviousSessionSummary) => Promise<boolean>;
|
||||
readonly version: string;
|
||||
readonly cwd: () => string;
|
||||
readonly stdout: WritableLike;
|
||||
readonly stderr: WritableLike;
|
||||
readonly exit: (code: number) => never;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
readonly yes: boolean;
|
||||
readonly includeGlobalLog: boolean;
|
||||
}
|
||||
|
||||
export async function handleExport(
|
||||
deps: ExportDeps,
|
||||
sessionId: string | undefined,
|
||||
output: string | undefined,
|
||||
opts: ExportOptions,
|
||||
): Promise<void> {
|
||||
const requestedId = normalizeOptionalSessionId(sessionId);
|
||||
const previousSummary = requestedId === undefined ? await findPreviousSession(deps) : undefined;
|
||||
|
||||
let resolvedId: string;
|
||||
if (requestedId !== undefined) {
|
||||
resolvedId = requestedId;
|
||||
} else {
|
||||
if (previousSummary === undefined) {
|
||||
deps.stderr.write('No previous session found to export.\n');
|
||||
deps.exit(1);
|
||||
}
|
||||
resolvedId = previousSummary.id;
|
||||
if (!opts.yes) {
|
||||
const confirmed = await deps.confirmPreviousSession(toPreviousSessionSummary(previousSummary));
|
||||
if (!confirmed) {
|
||||
deps.stdout.write('Export cancelled.\n');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await deps.exportSession({
|
||||
id: resolvedId,
|
||||
version: deps.version,
|
||||
...(output === undefined ? {} : { outputPath: output }),
|
||||
...(opts.includeGlobalLog ? { includeGlobalLog: true } : {}),
|
||||
});
|
||||
deps.stdout.write(`${result.zipPath}\n`);
|
||||
} catch (error) {
|
||||
deps.stderr.write(`${errorMessage(error)}\n`);
|
||||
deps.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerExportCommand(parent: Command, deps?: Partial<ExportDeps>): void {
|
||||
parent
|
||||
.command('export')
|
||||
.description('Export a session as a ZIP archive.')
|
||||
.option('-o, --output <path>', 'Output ZIP path.')
|
||||
.option('-y, --yes', 'Skip previous-session confirmation.')
|
||||
.option(
|
||||
'--no-include-global-log',
|
||||
'Skip bundling the active global diagnostic log (~/.kimi-code/logs/kimi-code.log, not rotated .1 files). By default the global log is included.',
|
||||
)
|
||||
.argument('[sessionId]', 'Session id to export. Defaults to the most recent session.')
|
||||
.action(
|
||||
async (
|
||||
sessionId: string | undefined,
|
||||
options: { output?: string; yes?: boolean; includeGlobalLog?: boolean },
|
||||
) => {
|
||||
await handleExport(createDefaultExportDeps(deps), sessionId, options.output, {
|
||||
yes: options.yes === true,
|
||||
includeGlobalLog: options.includeGlobalLog !== false,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createDefaultExportDeps(overrides: Partial<ExportDeps> = {}): ExportDeps {
|
||||
let harness: KimiHarness | undefined;
|
||||
let telemetryInitialized = false;
|
||||
let telemetryShutdown = false;
|
||||
const identity = createKimiCodeHostIdentity();
|
||||
const telemetryClient: TelemetryClient = {
|
||||
track,
|
||||
withContext: withTelemetryContext,
|
||||
setContext: setTelemetryContext,
|
||||
};
|
||||
const getHarness = (): KimiHarness => {
|
||||
harness ??= new KimiHarness({
|
||||
identity,
|
||||
telemetry: telemetryClient,
|
||||
});
|
||||
return harness;
|
||||
};
|
||||
const initializeDefaultTelemetry = async (): Promise<void> => {
|
||||
if (telemetryInitialized) return;
|
||||
const currentHarness = getHarness();
|
||||
await currentHarness.ensureConfigFile();
|
||||
const config = await currentHarness.getConfig();
|
||||
const deviceId = createKimiDeviceId(currentHarness.homeDir);
|
||||
initializeTelemetry({
|
||||
homeDir: currentHarness.homeDir,
|
||||
deviceId,
|
||||
enabled: config.telemetry !== false,
|
||||
appName: CLI_USER_AGENT_PRODUCT,
|
||||
version: identity.version,
|
||||
uiMode: CLI_UI_MODE,
|
||||
model: config.defaultModel,
|
||||
getAccessToken: async () =>
|
||||
(await currentHarness.auth.getCachedAccessToken(KIMI_CODE_PROVIDER_NAME)) ?? null,
|
||||
});
|
||||
telemetryInitialized = true;
|
||||
};
|
||||
const shutdownDefaultTelemetry = async (): Promise<void> => {
|
||||
if (!telemetryInitialized || telemetryShutdown) return;
|
||||
telemetryShutdown = true;
|
||||
await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS });
|
||||
};
|
||||
return {
|
||||
listSessions:
|
||||
overrides.listSessions ??
|
||||
((workDir: string) =>
|
||||
getHarness().listSessions({
|
||||
workDir,
|
||||
})),
|
||||
exportSession:
|
||||
overrides.exportSession ??
|
||||
(async (input: ExportSessionInput) => {
|
||||
await initializeDefaultTelemetry();
|
||||
try {
|
||||
return await getHarness().exportSession(input);
|
||||
} finally {
|
||||
await shutdownDefaultTelemetry();
|
||||
}
|
||||
}),
|
||||
version: overrides.version ?? identity.version,
|
||||
confirmPreviousSession: overrides.confirmPreviousSession ?? confirmPreviousSession,
|
||||
cwd: overrides.cwd ?? (() => process.cwd()),
|
||||
stdout: overrides.stdout ?? process.stdout,
|
||||
stderr: overrides.stderr ?? process.stderr,
|
||||
exit: overrides.exit ?? ((code: number) => process.exit(code)),
|
||||
};
|
||||
}
|
||||
|
||||
async function findPreviousSession(deps: Pick<ExportDeps, 'cwd' | 'listSessions'>): Promise<
|
||||
SessionSummary | undefined
|
||||
> {
|
||||
const sessions = await deps.listSessions(deps.cwd());
|
||||
return sessions[0];
|
||||
}
|
||||
|
||||
function toPreviousSessionSummary(summary: SessionSummary): PreviousSessionSummary {
|
||||
return {
|
||||
workDir: summary.workDir,
|
||||
sessionId: summary.id,
|
||||
sessionDir: summary.sessionDir,
|
||||
...(summary.title === undefined ? {} : { title: summary.title }),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOptionalSessionId(sessionId: string | undefined): string | undefined {
|
||||
const trimmed = sessionId?.trim();
|
||||
return trimmed === undefined || trimmed === '' ? undefined : trimmed;
|
||||
}
|
||||
|
||||
async function confirmPreviousSession(summary: PreviousSessionSummary): Promise<boolean> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
||||
try {
|
||||
const title = summary.title === undefined ? summary.sessionId : `${summary.title} (${summary.sessionId})`;
|
||||
const answer = await rl.question(`Export previous session "${title}"? [Y/n] `);
|
||||
const trimmed = answer.trim().toLowerCase();
|
||||
return trimmed === '' || trimmed === 'y' || trimmed === 'yes';
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
31
apps/kimi-code/src/cli/update/cache.ts
Normal file
31
apps/kimi-code/src/cli/update/cache.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getUpdateStateFile } from '#/utils/paths';
|
||||
import { readJsonFile, writeJsonFile } from '#/utils/persistence';
|
||||
|
||||
import { emptyUpdateCache, type UpdateCache } from './types';
|
||||
|
||||
const UpdateCacheSchema: z.ZodType<UpdateCache> = z
|
||||
.object({
|
||||
source: z.literal('cdn'),
|
||||
checkedAt: z.string().min(1).nullable(),
|
||||
latest: z.string().min(1).nullable(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function readUpdateCache(
|
||||
filePath: string = getUpdateStateFile(),
|
||||
): Promise<UpdateCache> {
|
||||
try {
|
||||
return await readJsonFile(filePath, UpdateCacheSchema, emptyUpdateCache());
|
||||
} catch {
|
||||
return emptyUpdateCache();
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeUpdateCache(
|
||||
value: UpdateCache,
|
||||
filePath: string = getUpdateStateFile(),
|
||||
): Promise<void> {
|
||||
await writeJsonFile(filePath, UpdateCacheSchema, value);
|
||||
}
|
||||
27
apps/kimi-code/src/cli/update/cdn.ts
Normal file
27
apps/kimi-code/src/cli/update/cdn.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { valid } from 'semver';
|
||||
|
||||
import { KIMI_CODE_CDN_LATEST_URL } from '#/constant/app';
|
||||
|
||||
/**
|
||||
* Fetch the latest published Kimi Code version from the CDN.
|
||||
*
|
||||
* **Throws** on any failure (network error, non-2xx, empty body, non-semver
|
||||
* text). Callers must catch — `refreshUpdateCache` deliberately lets the
|
||||
* error propagate so the existing cache stays intact instead of being
|
||||
* overwritten with a null `latest` on a transient blip.
|
||||
*
|
||||
* `fetchImpl` is injectable for tests; defaults to the global `fetch`.
|
||||
*/
|
||||
export async function fetchLatestVersionFromCdn(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const response = await fetchImpl(KIMI_CODE_CDN_LATEST_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`CDN /latest returned HTTP ${response.status}`);
|
||||
}
|
||||
const raw = (await response.text()).trim();
|
||||
if (valid(raw) === null) {
|
||||
throw new Error(`CDN /latest returned invalid semver: ${JSON.stringify(raw)}`);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
246
apps/kimi-code/src/cli/update/preflight.ts
Normal file
246
apps/kimi-code/src/cli/update/preflight.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
|
||||
import type { TelemetryProperties } from '@moonshot-ai/kimi-telemetry';
|
||||
|
||||
import {
|
||||
NATIVE_INSTALL_COMMAND_UNIX,
|
||||
NATIVE_INSTALL_COMMAND_WIN,
|
||||
} from '#/constant/app';
|
||||
|
||||
import { readUpdateCache } from './cache';
|
||||
import { promptForInstallConfirmation, type InstallPromptOptions } from './prompt';
|
||||
import { refreshUpdateCache } from './refresh';
|
||||
import { selectUpdateTarget } from './select';
|
||||
import { detectInstallSource } from './source';
|
||||
import {
|
||||
NPM_PACKAGE_NAME,
|
||||
type InstallSource,
|
||||
type UpdateDecision,
|
||||
type UpdatePreflightResult,
|
||||
type UpdateTarget,
|
||||
} from './types';
|
||||
|
||||
export type { UpdatePreflightResult } from './types';
|
||||
|
||||
export interface RunUpdatePreflightOptions {
|
||||
readonly stdout?: { write(chunk: string): boolean };
|
||||
readonly stderr?: { write(chunk: string): boolean };
|
||||
readonly isTTY?: boolean;
|
||||
readonly track?: (event: string, properties?: TelemetryProperties) => void;
|
||||
}
|
||||
|
||||
function withCmdSuffix(base: string, platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? `${base}.cmd` : base;
|
||||
}
|
||||
|
||||
function bunCommand(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'bun.exe' : 'bun';
|
||||
}
|
||||
|
||||
function installCommandFor(
|
||||
source: InstallSource,
|
||||
version: string,
|
||||
platform: NodeJS.Platform,
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'npm-global':
|
||||
return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
|
||||
case 'pnpm-global':
|
||||
return `pnpm add -g ${NPM_PACKAGE_NAME}@${version}`;
|
||||
case 'yarn-global':
|
||||
return `yarn global add ${NPM_PACKAGE_NAME}@${version}`;
|
||||
case 'bun-global':
|
||||
return `bun add -g ${NPM_PACKAGE_NAME}@${version}`;
|
||||
case 'native':
|
||||
return platform === 'win32' ? NATIVE_INSTALL_COMMAND_WIN : NATIVE_INSTALL_COMMAND_UNIX;
|
||||
case 'unsupported':
|
||||
return `npm install -g ${NPM_PACKAGE_NAME}@${version}`;
|
||||
}
|
||||
}
|
||||
|
||||
function canAutoInstall(source: InstallSource, platform: NodeJS.Platform): boolean {
|
||||
switch (source) {
|
||||
case 'npm-global':
|
||||
case 'pnpm-global':
|
||||
case 'yarn-global':
|
||||
case 'bun-global':
|
||||
return true;
|
||||
case 'native':
|
||||
return platform !== 'win32';
|
||||
case 'unsupported':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface SpawnCommand {
|
||||
readonly cmd: string;
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
|
||||
function spawnForSource(
|
||||
source: InstallSource,
|
||||
version: string,
|
||||
platform: NodeJS.Platform,
|
||||
): SpawnCommand {
|
||||
switch (source) {
|
||||
case 'npm-global':
|
||||
return { cmd: withCmdSuffix('npm', platform), args: ['install', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
||||
case 'pnpm-global':
|
||||
return { cmd: withCmdSuffix('pnpm', platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
||||
case 'yarn-global':
|
||||
return { cmd: withCmdSuffix('yarn', platform), args: ['global', 'add', `${NPM_PACKAGE_NAME}@${version}`] };
|
||||
case 'bun-global':
|
||||
return { cmd: bunCommand(platform), args: ['add', '-g', `${NPM_PACKAGE_NAME}@${version}`] };
|
||||
case 'native':
|
||||
return { cmd: 'bash', args: ['-c', NATIVE_INSTALL_COMMAND_UNIX] };
|
||||
case 'unsupported':
|
||||
throw new Error('unsupported install source cannot be auto-installed');
|
||||
}
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
function renderManualUpdateMessage(
|
||||
currentVersion: string,
|
||||
target: UpdateTarget,
|
||||
source: InstallSource,
|
||||
installCommand: string,
|
||||
): string {
|
||||
const sourceDesc =
|
||||
source === 'native'
|
||||
? 'native (windows). Auto-update is not supported on this platform.'
|
||||
: 'unsupported package manager or layout.';
|
||||
return (
|
||||
`A newer version of ${NPM_PACKAGE_NAME} is available ` +
|
||||
`(${currentVersion} -> ${target.version}).\n` +
|
||||
`Detected install source: ${sourceDesc}\n` +
|
||||
`To update manually, run: ${installCommand}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function renderInstallSuccessMessage(target: UpdateTarget): string {
|
||||
return `Updated ${NPM_PACKAGE_NAME} to ${target.version}. Restart the CLI to use the new version.\n`;
|
||||
}
|
||||
|
||||
function refreshInBackground(): void {
|
||||
void refreshUpdateCache().catch(() => {});
|
||||
}
|
||||
|
||||
function trackUpdatePrompted(
|
||||
track: RunUpdatePreflightOptions['track'],
|
||||
currentVersion: string,
|
||||
target: UpdateTarget,
|
||||
source: InstallSource,
|
||||
decision: UpdateDecision,
|
||||
): void {
|
||||
try {
|
||||
track?.('update_prompted', {
|
||||
current: currentVersion,
|
||||
latest: target.version,
|
||||
current_version: currentVersion,
|
||||
target_version: target.version,
|
||||
source,
|
||||
decision,
|
||||
});
|
||||
} catch {
|
||||
// Telemetry must never affect update prompting.
|
||||
}
|
||||
}
|
||||
|
||||
async function promptInstall(
|
||||
currentVersion: string,
|
||||
target: UpdateTarget,
|
||||
source: InstallSource,
|
||||
installCommand: string,
|
||||
): Promise<boolean> {
|
||||
const options: InstallPromptOptions = {
|
||||
currentVersion,
|
||||
target,
|
||||
installSource: source,
|
||||
installCommand,
|
||||
};
|
||||
return promptForInstallConfirmation(options);
|
||||
}
|
||||
|
||||
async function installUpdate(
|
||||
source: InstallSource,
|
||||
version: string,
|
||||
platform: NodeJS.Platform,
|
||||
): Promise<void> {
|
||||
const { cmd, args } = spawnForSource(source, version, platform);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, [...args], { stdio: 'inherit' });
|
||||
child.once('error', reject);
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const detail = signal !== null ? `signal ${signal}` : `code ${String(code)}`;
|
||||
reject(new Error(`${cmd} exited with ${detail}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function decideUpdateAction(
|
||||
target: UpdateTarget | null,
|
||||
isInteractive: boolean,
|
||||
source: InstallSource,
|
||||
platform: NodeJS.Platform,
|
||||
): UpdateDecision {
|
||||
if (target === null || !isInteractive) return 'none';
|
||||
return canAutoInstall(source, platform) ? 'prompt-install' : 'manual-command';
|
||||
}
|
||||
|
||||
export async function runUpdatePreflight(
|
||||
currentVersion: string,
|
||||
options: RunUpdatePreflightOptions = {},
|
||||
): Promise<UpdatePreflightResult> {
|
||||
const stdout = options.stdout ?? process.stdout;
|
||||
const stderr = options.stderr ?? process.stderr;
|
||||
const platform = process.platform;
|
||||
|
||||
try {
|
||||
const cache = await readUpdateCache().catch(() => null);
|
||||
const latest = cache?.latest ?? null;
|
||||
const target = selectUpdateTarget(currentVersion, latest);
|
||||
refreshInBackground();
|
||||
|
||||
const isInteractive =
|
||||
options.isTTY ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
const source: InstallSource =
|
||||
target === null || !isInteractive
|
||||
? 'unsupported'
|
||||
: await detectInstallSource().catch(() => 'unsupported' as const);
|
||||
|
||||
const decision = decideUpdateAction(target, isInteractive, source, platform);
|
||||
if (decision === 'none' || target === null) return 'continue';
|
||||
|
||||
const installCommand = installCommandFor(source, target.version, platform);
|
||||
trackUpdatePrompted(options.track, currentVersion, target, source, decision);
|
||||
|
||||
if (decision === 'manual-command') {
|
||||
stdout.write(renderManualUpdateMessage(currentVersion, target, source, installCommand));
|
||||
return 'continue';
|
||||
}
|
||||
|
||||
const confirmed = await promptInstall(currentVersion, target, source, installCommand);
|
||||
if (!confirmed) return 'continue';
|
||||
|
||||
try {
|
||||
await installUpdate(source, target.version, platform);
|
||||
stdout.write(renderInstallSuccessMessage(target));
|
||||
return 'exit';
|
||||
} catch (error) {
|
||||
stderr.write(
|
||||
`warning: failed to install ${NPM_PACKAGE_NAME}@${target.version}: ` +
|
||||
`${formatErrorMessage(error)}\n`,
|
||||
);
|
||||
return 'continue';
|
||||
}
|
||||
} catch {
|
||||
return 'continue';
|
||||
}
|
||||
}
|
||||
184
apps/kimi-code/src/cli/update/prompt.ts
Normal file
184
apps/kimi-code/src/cli/update/prompt.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { clearLine, cursorTo, emitKeypressEvents, moveCursor } from 'node:readline';
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
import { PRODUCT_NAME } from '#/constant/app';
|
||||
import { HIDE_CURSOR, SHOW_CURSOR } from '#/constant/terminal';
|
||||
import {
|
||||
UPDATE_PROMPT_MUTED,
|
||||
UPDATE_PROMPT_PRIMARY,
|
||||
UPDATE_PROMPT_SUCCESS,
|
||||
UPDATE_PROMPT_TEXT_DIM,
|
||||
UPDATE_PROMPT_WARNING,
|
||||
} from '#/constant/update';
|
||||
|
||||
import { type InstallSource, type UpdateTarget } from './types';
|
||||
|
||||
export type InstallPromptChoiceValue = 'install' | 'skip';
|
||||
|
||||
export interface InstallPromptChoice {
|
||||
readonly value: InstallPromptChoiceValue;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export interface InstallPromptOptions {
|
||||
readonly currentVersion: string;
|
||||
readonly target: UpdateTarget;
|
||||
readonly installCommand: string;
|
||||
readonly installSource: InstallSource;
|
||||
readonly input?: NodeJS.ReadStream;
|
||||
readonly output?: NodeJS.WriteStream;
|
||||
}
|
||||
|
||||
const INSTALL_HINT = 'Install update now';
|
||||
const SKIP_HINT = 'Continue with current version';
|
||||
|
||||
export function createInstallPromptChoices(target: UpdateTarget): readonly InstallPromptChoice[] {
|
||||
return [
|
||||
{ value: 'install', label: `${INSTALL_HINT} (${target.version})` },
|
||||
{ value: 'skip', label: SKIP_HINT },
|
||||
];
|
||||
}
|
||||
|
||||
export function getDefaultInstallPromptSelection(choices: readonly InstallPromptChoice[]): number {
|
||||
const installIndex = choices.findIndex((choice) => choice.value === 'install');
|
||||
return Math.max(installIndex, 0);
|
||||
}
|
||||
|
||||
export function moveInstallPromptSelection(
|
||||
currentIndex: number,
|
||||
direction: 'up' | 'down',
|
||||
choiceCount: number,
|
||||
): number {
|
||||
if (direction === 'up') {
|
||||
return Math.max(0, currentIndex - 1);
|
||||
}
|
||||
return Math.min(choiceCount - 1, currentIndex + 1);
|
||||
}
|
||||
|
||||
function renderInstallPrompt(
|
||||
options: InstallPromptOptions,
|
||||
choices: readonly InstallPromptChoice[],
|
||||
selectedIndex: number,
|
||||
): readonly string[] {
|
||||
const label = chalk.hex(UPDATE_PROMPT_TEXT_DIM).bold;
|
||||
const currentVersion = chalk.hex(UPDATE_PROMPT_WARNING).bold(options.currentVersion);
|
||||
const targetVersion = chalk.hex(UPDATE_PROMPT_SUCCESS).bold(options.target.version);
|
||||
const sourceLabel = chalk.hex(UPDATE_PROMPT_PRIMARY).bold(options.installSource);
|
||||
const command = chalk.hex(UPDATE_PROMPT_PRIMARY)(options.installCommand);
|
||||
const lines = [
|
||||
chalk.hex(UPDATE_PROMPT_PRIMARY).bold('Kimi Code Update Available'),
|
||||
chalk.hex(UPDATE_PROMPT_MUTED)(`${PRODUCT_NAME} has a newer release ready.`),
|
||||
'',
|
||||
`${label('Current')} ${currentVersion}`,
|
||||
`${label('Target ')} ${targetVersion}`,
|
||||
`${label('Source ')} ${sourceLabel}`,
|
||||
`${label('Command')} ${command}`,
|
||||
'',
|
||||
chalk.hex(UPDATE_PROMPT_MUTED)('↑↓ choose · Enter confirm · Esc continue'),
|
||||
'',
|
||||
];
|
||||
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const choice = choices[i];
|
||||
if (choice === undefined) continue;
|
||||
const isSelected = i === selectedIndex;
|
||||
const pointer = isSelected ? '❯' : ' ';
|
||||
const content = ` ${pointer} ${choice.label}`;
|
||||
if (isSelected) {
|
||||
lines.push(chalk.hex(UPDATE_PROMPT_PRIMARY).bold(content));
|
||||
continue;
|
||||
}
|
||||
lines.push(chalk.hex(UPDATE_PROMPT_TEXT_DIM)(content));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function writePromptFrame(
|
||||
output: NodeJS.WriteStream,
|
||||
lines: readonly string[],
|
||||
previousLineCount: number,
|
||||
): number {
|
||||
if (previousLineCount > 0) {
|
||||
moveCursor(output, 0, -(previousLineCount - 1));
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
clearLine(output, 0);
|
||||
cursorTo(output, 0);
|
||||
output.write(lines[i] ?? '');
|
||||
if (i < lines.length - 1) {
|
||||
output.write('\n');
|
||||
}
|
||||
}
|
||||
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
export async function promptForInstallConfirmation(
|
||||
options: InstallPromptOptions,
|
||||
): Promise<boolean> {
|
||||
const input = options.input ?? process.stdin;
|
||||
const output = options.output ?? process.stdout;
|
||||
const choices = createInstallPromptChoices(options.target);
|
||||
let selectedIndex = getDefaultInstallPromptSelection(choices);
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let lineCount = 0;
|
||||
const hadRawMode = 'isRaw' in input ? input.isRaw : false;
|
||||
const canSetRawMode = typeof input.setRawMode === 'function';
|
||||
|
||||
const cleanup = (): void => {
|
||||
input.off('keypress', onKeypress);
|
||||
if (canSetRawMode) {
|
||||
input.setRawMode(hadRawMode);
|
||||
}
|
||||
output.write(SHOW_CURSOR);
|
||||
output.write('\n');
|
||||
};
|
||||
|
||||
const finish = (choice: InstallPromptChoiceValue): void => {
|
||||
cleanup();
|
||||
resolve(choice === 'install');
|
||||
};
|
||||
|
||||
const render = (): void => {
|
||||
lineCount = writePromptFrame(
|
||||
output,
|
||||
renderInstallPrompt(options, choices, selectedIndex),
|
||||
lineCount,
|
||||
);
|
||||
};
|
||||
|
||||
const onKeypress = (_input: string, key: { name?: string; ctrl?: boolean }): void => {
|
||||
if (key.name === 'up') {
|
||||
selectedIndex = moveInstallPromptSelection(selectedIndex, 'up', choices.length);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
selectedIndex = moveInstallPromptSelection(selectedIndex, 'down', choices.length);
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return' || key.name === 'enter') {
|
||||
const chosen = choices[selectedIndex]?.value ?? 'skip';
|
||||
finish(chosen);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape' || (key.ctrl === true && key.name === 'c')) {
|
||||
finish('skip');
|
||||
}
|
||||
};
|
||||
|
||||
emitKeypressEvents(input);
|
||||
if (canSetRawMode) {
|
||||
input.setRawMode(true);
|
||||
}
|
||||
input.resume();
|
||||
input.on('keypress', onKeypress);
|
||||
output.write(HIDE_CURSOR);
|
||||
render();
|
||||
});
|
||||
}
|
||||
32
apps/kimi-code/src/cli/update/refresh.ts
Normal file
32
apps/kimi-code/src/cli/update/refresh.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { writeUpdateCache } from './cache';
|
||||
import { fetchLatestVersionFromCdn } from './cdn';
|
||||
import { type UpdateCache } from './types';
|
||||
|
||||
export interface RefreshUpdateCacheDeps {
|
||||
/** Resolves with the latest semver. **Throws** on any failure — callers
|
||||
* (including the default background invocation in preflight) must catch.
|
||||
* Errors intentionally skip `writeCache` so a transient CDN blip does not
|
||||
* overwrite a previously known `latest` with `null`. */
|
||||
readonly fetchLatest: () => Promise<string>;
|
||||
readonly writeCache: (cache: UpdateCache) => Promise<void>;
|
||||
readonly now: () => Date;
|
||||
}
|
||||
|
||||
export async function refreshUpdateCache(
|
||||
overrides: Partial<RefreshUpdateCacheDeps> = {},
|
||||
): Promise<UpdateCache> {
|
||||
const resolved: RefreshUpdateCacheDeps = {
|
||||
fetchLatest: overrides.fetchLatest ?? (() => fetchLatestVersionFromCdn()),
|
||||
writeCache: overrides.writeCache ?? writeUpdateCache,
|
||||
now: overrides.now ?? (() => new Date()),
|
||||
};
|
||||
|
||||
const latest = await resolved.fetchLatest();
|
||||
const cache: UpdateCache = {
|
||||
source: 'cdn',
|
||||
checkedAt: resolved.now().toISOString(),
|
||||
latest,
|
||||
};
|
||||
await resolved.writeCache(cache);
|
||||
return cache;
|
||||
}
|
||||
13
apps/kimi-code/src/cli/update/select.ts
Normal file
13
apps/kimi-code/src/cli/update/select.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { gt, valid } from 'semver';
|
||||
|
||||
import { type UpdateTarget } from './types';
|
||||
|
||||
export function selectUpdateTarget(
|
||||
currentVersion: string,
|
||||
latest: string | null,
|
||||
): UpdateTarget | null {
|
||||
if (latest === null) return null;
|
||||
if (valid(currentVersion) === null || valid(latest) === null) return null;
|
||||
if (!gt(latest, currentVersion)) return null;
|
||||
return { version: latest };
|
||||
}
|
||||
155
apps/kimi-code/src/cli/update/source.ts
Normal file
155
apps/kimi-code/src/cli/update/source.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { execFile } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { getHostPackageRoot } from '#/cli/version';
|
||||
|
||||
import { NPM_PACKAGE_NAME, type InstallSource } from './types';
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
interface NodeSeaModule {
|
||||
isSea(): boolean;
|
||||
}
|
||||
|
||||
let cachedSea: NodeSeaModule | null | undefined;
|
||||
|
||||
function loadSeaModule(): NodeSeaModule | null {
|
||||
if (cachedSea !== undefined) return cachedSea;
|
||||
try {
|
||||
cachedSea = nodeRequire('node:sea') as NodeSeaModule;
|
||||
} catch {
|
||||
cachedSea = null;
|
||||
}
|
||||
return cachedSea;
|
||||
}
|
||||
|
||||
/** Runtime SEA detection — true when running as a packaged native binary. */
|
||||
export function detectNativeInstall(): boolean {
|
||||
const sea = loadSeaModule();
|
||||
if (sea === null) return false;
|
||||
try {
|
||||
return sea.isSea();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Path heuristic markers (compared in lowercase; both forward and backward slashes accepted).
|
||||
const PNPM_PATH_SEGMENT = 'pnpm/global/';
|
||||
const YARN_PATH_SEGMENTS = ['.config/yarn/global/', '/.yarn/global/'];
|
||||
const BUN_PATH_SEGMENT = '.bun/install/global/';
|
||||
|
||||
function normalizeForHeuristic(filePath: string): string {
|
||||
return filePath.replaceAll('\\', '/').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic classification by package root path segments. Returns the
|
||||
* matching `InstallSource` or `null` if no heuristic matches (caller should
|
||||
* fall through to npm-prefix comparison).
|
||||
*/
|
||||
export function classifyByPathHeuristic(packageRoot: string): InstallSource | null {
|
||||
const normalized = normalizeForHeuristic(packageRoot);
|
||||
if (normalized.includes(PNPM_PATH_SEGMENT)) return 'pnpm-global';
|
||||
for (const seg of YARN_PATH_SEGMENTS) {
|
||||
if (normalized.includes(seg)) return 'yarn-global';
|
||||
}
|
||||
if (normalized.includes(BUN_PATH_SEGMENT)) return 'bun-global';
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface DetectInstallSourceDeps {
|
||||
readonly getPackageRoot: () => string;
|
||||
readonly getGlobalPrefix: () => Promise<string>;
|
||||
readonly detectNative: () => boolean;
|
||||
readonly platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
function npmCommand(platform: NodeJS.Platform): string {
|
||||
return platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
}
|
||||
|
||||
function execFileText(command: string, args: readonly string[]): Promise<string> {
|
||||
return new Promise((resolveOutput, reject) => {
|
||||
execFile(command, [...args], { encoding: 'utf-8' }, (error, stdout) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolveOutput(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePathForComparison(filePath: string, platform: NodeJS.Platform): string | null {
|
||||
const trimmed = filePath.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
try {
|
||||
return normalizeResolvedPath(realpathSync(trimmed), platform);
|
||||
} catch {
|
||||
return normalizeResolvedPath(resolve(trimmed), platform);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeResolvedPath(filePath: string, platform: NodeJS.Platform): string {
|
||||
const resolvedPath = resolve(filePath);
|
||||
return platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function candidateGlobalPackageDirs(
|
||||
globalPrefix: string,
|
||||
platform: NodeJS.Platform,
|
||||
): readonly string[] {
|
||||
if (platform === 'win32') {
|
||||
return [join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME)];
|
||||
}
|
||||
return [
|
||||
join(globalPrefix, 'lib', 'node_modules', NPM_PACKAGE_NAME),
|
||||
join(globalPrefix, 'node_modules', NPM_PACKAGE_NAME),
|
||||
];
|
||||
}
|
||||
|
||||
export function classifyInstallSource(
|
||||
packageRoot: string,
|
||||
globalPrefix: string,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): InstallSource {
|
||||
const normalizedPackageRoot = normalizePathForComparison(packageRoot, platform);
|
||||
if (normalizedPackageRoot === null) return 'unsupported';
|
||||
|
||||
for (const candidate of candidateGlobalPackageDirs(globalPrefix, platform)) {
|
||||
if (normalizePathForComparison(candidate, platform) === normalizedPackageRoot) {
|
||||
return 'npm-global';
|
||||
}
|
||||
}
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
export async function detectInstallSource(
|
||||
deps: Partial<DetectInstallSourceDeps> = {},
|
||||
): Promise<InstallSource> {
|
||||
const platform = deps.platform ?? process.platform;
|
||||
const resolved: DetectInstallSourceDeps = {
|
||||
getPackageRoot: deps.getPackageRoot ?? getHostPackageRoot,
|
||||
getGlobalPrefix:
|
||||
deps.getGlobalPrefix ??
|
||||
(() => execFileText(npmCommand(platform), ['prefix', '-g']).then((text) => text.trim())),
|
||||
detectNative: deps.detectNative ?? detectNativeInstall,
|
||||
platform,
|
||||
};
|
||||
|
||||
if (resolved.detectNative()) return 'native';
|
||||
|
||||
const packageRoot = resolved.getPackageRoot();
|
||||
const heuristic = classifyByPathHeuristic(packageRoot);
|
||||
if (heuristic !== null) return heuristic;
|
||||
|
||||
try {
|
||||
const globalPrefix = await resolved.getGlobalPrefix();
|
||||
return classifyInstallSource(packageRoot, globalPrefix, resolved.platform);
|
||||
} catch {
|
||||
return 'unsupported';
|
||||
}
|
||||
}
|
||||
33
apps/kimi-code/src/cli/update/types.ts
Normal file
33
apps/kimi-code/src/cli/update/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { NPM_PACKAGE_NAME } from '#/constant/app';
|
||||
|
||||
export { NPM_PACKAGE_NAME };
|
||||
|
||||
/** Where the running CLI was installed from. Drives update command + spawn. */
|
||||
export type InstallSource =
|
||||
| 'npm-global'
|
||||
| 'pnpm-global'
|
||||
| 'yarn-global'
|
||||
| 'bun-global'
|
||||
| 'native'
|
||||
| 'unsupported';
|
||||
|
||||
export interface UpdateTarget {
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
export interface UpdateCache {
|
||||
readonly source: 'cdn';
|
||||
readonly checkedAt: string | null;
|
||||
readonly latest: string | null;
|
||||
}
|
||||
|
||||
export type UpdateDecision = 'none' | 'prompt-install' | 'manual-command';
|
||||
export type UpdatePreflightResult = 'continue' | 'exit';
|
||||
|
||||
export function emptyUpdateCache(): UpdateCache {
|
||||
return {
|
||||
source: 'cdn',
|
||||
checkedAt: null,
|
||||
latest: null,
|
||||
};
|
||||
}
|
||||
63
apps/kimi-code/src/cli/version.ts
Normal file
63
apps/kimi-code/src/cli/version.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Kimi Code version helpers.
|
||||
*
|
||||
* `getVersion` reads the host CLI's `package.json#version`.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
import { createKimiDefaultHeaders, type KimiHostIdentity } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { CLI_USER_AGENT_PRODUCT } from '#/constant/app';
|
||||
|
||||
import { getDataDir } from '../utils/paths';
|
||||
import { KIMI_BUILD_INFO } from './build-info';
|
||||
|
||||
const MODULE_DIR = import.meta.dirname;
|
||||
|
||||
export function getHostPackageJsonPath(): string {
|
||||
// Walk upwards from this file's directory until a `package.json` shows up,
|
||||
// so both dev (`tsx src/main.ts` — this file in `src/cli/`, pkg 2 levels
|
||||
// up) and prod (`node dist/main.mjs` — this code bundled into `dist/`,
|
||||
// pkg 1 level up) resolve correctly.
|
||||
let dir = MODULE_DIR;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const candidate = resolve(dir, 'package.json');
|
||||
if (existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error(`Could not locate package.json near ${MODULE_DIR}`);
|
||||
}
|
||||
|
||||
export function getHostPackageRoot(): string {
|
||||
return dirname(getHostPackageJsonPath());
|
||||
}
|
||||
|
||||
export function getVersion(): string {
|
||||
if (KIMI_BUILD_INFO.version !== undefined) {
|
||||
return KIMI_BUILD_INFO.version;
|
||||
}
|
||||
const pkg = JSON.parse(readFileSync(getHostPackageJsonPath(), 'utf-8')) as {
|
||||
version: string;
|
||||
};
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
export function createKimiCodeHostIdentity(version = getVersion()): KimiHostIdentity {
|
||||
return {
|
||||
userAgentProduct: CLI_USER_AGENT_PRODUCT,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildKimiDefaultHeaders(version: string): Record<string, string> {
|
||||
return createKimiDefaultHeaders({
|
||||
homeDir: getDataDir(),
|
||||
...createKimiCodeHostIdentity(version),
|
||||
});
|
||||
}
|
||||
49
apps/kimi-code/src/constant/app.ts
Normal file
49
apps/kimi-code/src/constant/app.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
export const PRODUCT_NAME = 'Kimi Code';
|
||||
export const CLI_COMMAND_NAME = 'kimi';
|
||||
|
||||
// Used in telemetry app names and HTTP User-Agent headers.
|
||||
export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli';
|
||||
export const CLI_UI_MODE = 'shell';
|
||||
|
||||
// Give telemetry a short flush window without making CLI exit feel stuck.
|
||||
export const CLI_SHUTDOWN_TIMEOUT_MS = 3000;
|
||||
|
||||
// Published npm package name; this can differ from the executable command.
|
||||
export const NPM_PACKAGE_NAME = '@moonshot-ai/kimi-code';
|
||||
|
||||
// App-owned data paths. SDK/core runtime config is intentionally not routed here.
|
||||
export const KIMI_CODE_HOME_ENV = 'KIMI_CODE_HOME';
|
||||
export const KIMI_CODE_DATA_DIR_NAME = '.kimi-code';
|
||||
export const KIMI_CODE_LOG_DIR_NAME = 'logs';
|
||||
export const KIMI_CODE_UPDATE_DIR_NAME = 'updates';
|
||||
export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json';
|
||||
export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history';
|
||||
|
||||
// Managed Kimi auth provider key shared with OAuth/SDK config.
|
||||
export const DEFAULT_OAUTH_PROVIDER_NAME = 'managed:kimi-code';
|
||||
|
||||
// SDK/core error code that tells the TUI to show a login-required startup
|
||||
// notice. Derived from sdk's ErrorCodes so a future rename in core
|
||||
// auto-propagates instead of silently breaking the startup recovery path.
|
||||
export const OAUTH_LOGIN_REQUIRED_CODE = ErrorCodes.AUTH_LOGIN_REQUIRED;
|
||||
|
||||
export const FEEDBACK_ISSUE_URL = 'https://github.com/MoonshotAI/kimi-code/issues';
|
||||
|
||||
// Sent in the feedback `version` field so the backend can distinguish this
|
||||
// TypeScript client from clients that send a bare version.
|
||||
export const FEEDBACK_VERSION_PREFIX = 'kimi-code-';
|
||||
|
||||
// Telemetry event name; keep stable for dashboard queries.
|
||||
export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted';
|
||||
|
||||
// CDN source of truth: all version checks and native install scripts pull from here.
|
||||
export const KIMI_CODE_CDN_BASE = 'https://code.kimi.com/kimi-code';
|
||||
export const KIMI_CODE_CDN_LATEST_URL = `${KIMI_CODE_CDN_BASE}/latest`;
|
||||
export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`;
|
||||
export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`;
|
||||
|
||||
// Native install commands, split by platform. Use these for prompt copy and spawn calls only; do not assemble the strings elsewhere.
|
||||
export const NATIVE_INSTALL_COMMAND_UNIX = `curl -fsSL ${KIMI_CODE_INSTALL_SH_URL} | bash`;
|
||||
export const NATIVE_INSTALL_COMMAND_WIN = `irm ${KIMI_CODE_INSTALL_PS1_URL} | iex`;
|
||||
4
apps/kimi-code/src/constant/index.ts
Normal file
4
apps/kimi-code/src/constant/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './app';
|
||||
export * from './startup-error';
|
||||
export * from './terminal';
|
||||
export * from './update';
|
||||
2
apps/kimi-code/src/constant/startup-error.ts
Normal file
2
apps/kimi-code/src/constant/startup-error.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Pre-TUI startup errors render directly to stderr, before theme detection.
|
||||
export const STARTUP_ERROR_COLOR = '#E85454';
|
||||
8
apps/kimi-code/src/constant/terminal.ts
Normal file
8
apps/kimi-code/src/constant/terminal.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// C0/control-sequence bytes used to build terminal protocol messages.
|
||||
export const ESC = '\u001B';
|
||||
export const BEL = '\u0007';
|
||||
export const ST = '\\';
|
||||
|
||||
// ANSI cursor visibility toggles used by CLI prompts.
|
||||
export const HIDE_CURSOR = `${ESC}[?25l`;
|
||||
export const SHOW_CURSOR = `${ESC}[?25h`;
|
||||
7
apps/kimi-code/src/constant/update.ts
Normal file
7
apps/kimi-code/src/constant/update.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Local palette for the pre-TUI update prompt. TUI colors still come
|
||||
// from `src/tui/theme`; do not use these in interactive TUI components.
|
||||
export const UPDATE_PROMPT_PRIMARY = '#1783ff';
|
||||
export const UPDATE_PROMPT_SUCCESS = '#16A34A';
|
||||
export const UPDATE_PROMPT_WARNING = '#CA8A04';
|
||||
export const UPDATE_PROMPT_MUTED = '#999999';
|
||||
export const UPDATE_PROMPT_TEXT_DIM = '#6B7280';
|
||||
128
apps/kimi-code/src/main.ts
Normal file
128
apps/kimi-code/src/main.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
/**
|
||||
* Kimi Code entry point.
|
||||
*
|
||||
* Parses CLI arguments via Commander.js, validates options, runs the
|
||||
* outer update preflight, then delegates to the requested UI runner.
|
||||
*/
|
||||
|
||||
import {
|
||||
flushDiagnosticLogs,
|
||||
log,
|
||||
resolveGlobalLogPath,
|
||||
resolveKimiHome,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
import { installCrashHandlers, track } from '@moonshot-ai/kimi-telemetry';
|
||||
|
||||
import { createProgram } from './cli/commands';
|
||||
import type { CLIOptions } from './cli/options';
|
||||
import { OptionConflictError, validateOptions } from './cli/options';
|
||||
import { runPrompt } from './cli/run-prompt';
|
||||
import { runShell } from './cli/run-shell';
|
||||
import { formatStartupError } from './cli/startup-error';
|
||||
import { runUpdatePreflight } from './cli/update/preflight';
|
||||
import { getVersion } from './cli/version';
|
||||
import { cleanupStaleNativeCacheForCurrent } from './native/native-assets';
|
||||
import { installNativeModuleHook } from './native/module-hook';
|
||||
import { runNativeAssetSmokeIfRequested } from './native/smoke';
|
||||
import { initProcessName } from './utils/process/proctitle';
|
||||
|
||||
export async function handleMainCommand(opts: CLIOptions, version: string): Promise<void> {
|
||||
let validated: ReturnType<typeof validateOptions>;
|
||||
try {
|
||||
validated = validateOptions(opts);
|
||||
} catch (error) {
|
||||
if (error instanceof OptionConflictError) {
|
||||
process.stderr.write(`error: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const preflightResult = await runUpdatePreflight(
|
||||
version,
|
||||
validated.uiMode === 'print' ? { track, isTTY: false } : { track },
|
||||
);
|
||||
if (preflightResult === 'exit') {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (validated.uiMode === 'print') {
|
||||
await runPrompt(validated.options, version);
|
||||
return;
|
||||
}
|
||||
|
||||
await runShell(validated.options, version);
|
||||
}
|
||||
|
||||
/** `kimi migrate`: launch the migration screen only, then exit. */
|
||||
async function handleMigrateCommand(version: string): Promise<void> {
|
||||
await runShell(MIGRATE_CLI_OPTIONS, version, { migrateOnly: true });
|
||||
}
|
||||
|
||||
/** A neutral CLIOptions value — `kimi migrate` never opens a chat session. */
|
||||
const MIGRATE_CLI_OPTIONS: CLIOptions = {
|
||||
session: undefined,
|
||||
continue: false,
|
||||
yolo: false,
|
||||
plan: false,
|
||||
model: undefined,
|
||||
outputFormat: undefined,
|
||||
prompt: undefined,
|
||||
skillsDirs: [],
|
||||
};
|
||||
|
||||
export function main(): void {
|
||||
initProcessName();
|
||||
installCrashHandlers();
|
||||
installNativeModuleHook();
|
||||
if (runNativeAssetSmokeIfRequested()) return;
|
||||
|
||||
// Start the background cleanup of stale native cache. Fire-and-forget; must not block startup or throw.
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
cleanupStaleNativeCacheForCurrent();
|
||||
} catch {
|
||||
// ignore: cache GC must never affect process startup
|
||||
}
|
||||
});
|
||||
|
||||
const version = getVersion();
|
||||
|
||||
const program = createProgram(
|
||||
version,
|
||||
(opts) => {
|
||||
void handleMainCommand(opts, version).catch(async (error: unknown) => {
|
||||
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
|
||||
await logStartupFailure(operation, error);
|
||||
process.stderr.write(
|
||||
formatStartupError(error, {
|
||||
operation,
|
||||
}),
|
||||
);
|
||||
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
},
|
||||
() => {
|
||||
void handleMigrateCommand(version).catch(async (error: unknown) => {
|
||||
await logStartupFailure('run migration', error);
|
||||
process.stderr.write(formatStartupError(error, { operation: 'run migration' }));
|
||||
process.stderr.write(`See log: ${resolveGlobalLogPath(resolveKimiHome())}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
program.parse(process.argv);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
async function logStartupFailure(operation: string, error: unknown): Promise<void> {
|
||||
log.error('startup failed', { operation, error });
|
||||
try {
|
||||
await flushDiagnosticLogs();
|
||||
} catch {
|
||||
// Best-effort diagnostic flush only.
|
||||
}
|
||||
}
|
||||
27
apps/kimi-code/src/migration/badge.ts
Normal file
27
apps/kimi-code/src/migration/badge.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Pure helpers for composing session labels in the session picker.
|
||||
*
|
||||
* Detection rule for the `[imported]` badge: `metadata.imported_from_kimi_cli`
|
||||
* is strictly the boolean `true`. This mirrors the value written by
|
||||
* `migration-legacy` into the session's `state.json` `custom` block.
|
||||
*/
|
||||
|
||||
const IMPORTED_BADGE = '[imported]';
|
||||
const IMPORTED_FLAG_KEY = 'imported_from_kimi_cli';
|
||||
|
||||
export interface SessionLabelInput {
|
||||
readonly title: string;
|
||||
readonly metadata?: Readonly<Record<string, unknown>> | undefined;
|
||||
}
|
||||
|
||||
export function isImportedSession(
|
||||
metadata: Readonly<Record<string, unknown>> | undefined,
|
||||
): boolean {
|
||||
if (metadata === undefined) return false;
|
||||
return metadata[IMPORTED_FLAG_KEY] === true;
|
||||
}
|
||||
|
||||
export function formatSessionLabel(input: SessionLabelInput): string {
|
||||
const prefix = isImportedSession(input.metadata) ? `${IMPORTED_BADGE} ` : '';
|
||||
return `${prefix}${input.title}`;
|
||||
}
|
||||
19
apps/kimi-code/src/migration/command.ts
Normal file
19
apps/kimi-code/src/migration/command.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* `kimi migrate` sub-command.
|
||||
*
|
||||
* A bare, flagless subcommand: it launches the native pi-tui migration screen
|
||||
* (the same one shown on first launch), then exits. The screen collects the
|
||||
* migration scope interactively, so there are no CLI options. The actual
|
||||
* launch is delegated to a host-provided handler.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
export function registerMigrateCommand(parent: Command, onMigrate: () => void): void {
|
||||
parent
|
||||
.command('migrate')
|
||||
.description('Migrate data from a legacy kimi-cli installation into kimi-code.')
|
||||
.action(() => {
|
||||
onMigrate();
|
||||
});
|
||||
}
|
||||
69
apps/kimi-code/src/migration/detect-pending.ts
Normal file
69
apps/kimi-code/src/migration/detect-pending.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Pre-TUI detection: decide whether a first-launch migration screen should be
|
||||
* shown. Cheap, synchronous-ish, no TTY required. Returns the MigrationPlan to
|
||||
* drive the screen, or null when there is nothing to offer.
|
||||
*/
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
import { detectMigration, type MigrationPlan } from '@moonshot-ai/migration-legacy';
|
||||
|
||||
export interface DetectPendingInput {
|
||||
readonly sourceHome: string;
|
||||
readonly targetHome: string;
|
||||
/**
|
||||
* When true, skip the marker-based suppression (`.migrated-to-kimi-code` /
|
||||
* `.skip-migration-from-kimi-cli`). The explicit `kimi migrate` command sets
|
||||
* this so a deliberate invocation always runs regardless of prior runs.
|
||||
*/
|
||||
readonly ignoreMarker?: boolean;
|
||||
}
|
||||
|
||||
export async function detectPendingMigration(
|
||||
input: DetectPendingInput,
|
||||
): Promise<MigrationPlan | null> {
|
||||
const { sourceHome, targetHome } = input;
|
||||
if (!existsSync(sourceHome)) return null;
|
||||
if (input.ignoreMarker !== true) {
|
||||
if (migrationAlreadyTargeted(join(sourceHome, '.migrated-to-kimi-code'), targetHome)) {
|
||||
return null;
|
||||
}
|
||||
if (existsSync(join(targetHome, '.skip-migration-from-kimi-cli'))) return null;
|
||||
}
|
||||
|
||||
let plan: MigrationPlan;
|
||||
try {
|
||||
plan = await detectMigration({ sourcePath: sourceHome });
|
||||
} catch {
|
||||
// Detection failure must never block startup; skip the screen.
|
||||
return null;
|
||||
}
|
||||
|
||||
const nothingToMigrate =
|
||||
plan.totalSessions === 0 &&
|
||||
!plan.hasConfig &&
|
||||
!plan.hasMcp &&
|
||||
!plan.hasUserHistory &&
|
||||
plan.oauthCredentials.length === 0;
|
||||
if (nothingToMigrate) return null;
|
||||
|
||||
return plan;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the legacy `.migrated-to-kimi-code` marker records a migration
|
||||
* into *this* target home. A marker written for a different `KIMI_CODE_HOME`
|
||||
* must not suppress the prompt — that target has never received migrated data.
|
||||
* An unreadable/old marker without `target_path` is treated as "matches"
|
||||
* (conservative: do not re-prompt when the marker exists but is ambiguous).
|
||||
*/
|
||||
function migrationAlreadyTargeted(markerPath: string, targetHome: string): boolean {
|
||||
if (!existsSync(markerPath)) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(markerPath, 'utf-8')) as { target_path?: unknown };
|
||||
if (typeof parsed.target_path !== 'string') return true;
|
||||
return resolve(parsed.target_path) === resolve(targetHome);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
12
apps/kimi-code/src/migration/index.ts
Normal file
12
apps/kimi-code/src/migration/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* kimi-cli → kimi-code migration: host integration surface.
|
||||
*
|
||||
* Removable glue: the `kimi migrate` sub-command, the first-launch detection,
|
||||
* the native pi-tui migration screen, and the session-picker `[imported]`
|
||||
* badge helper. Migration logic itself lives in
|
||||
* `@moonshot-ai/migration-legacy`.
|
||||
*/
|
||||
export { registerMigrateCommand } from './command';
|
||||
export { formatSessionLabel, isImportedSession, type SessionLabelInput } from './badge';
|
||||
export { detectPendingMigration } from './detect-pending';
|
||||
export { MigrationScreenComponent, type MigrationScreenResult } from './migration-screen';
|
||||
532
apps/kimi-code/src/migration/migration-screen.ts
Normal file
532
apps/kimi-code/src/migration/migration-screen.ts
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
/**
|
||||
* MigrationScreenComponent — native pi-tui first-launch migration experience.
|
||||
*
|
||||
* A single mounted Container & Focusable that runs a 3-phase state machine:
|
||||
* ask (2-step choice wizard) -> progress -> result
|
||||
*
|
||||
* Pure decision mapping (choices -> MigrationScope) is delegated to the
|
||||
* package's `resolveMigrationScope`. Rendering follows the `ChoicePicker`
|
||||
* conventions in `apps/kimi-code/src/tui/components/dialogs/choice-picker.ts`.
|
||||
*
|
||||
* This file implements the ask, progress, and result phases. `beginMigration`
|
||||
* drives the real runMigration flow (injectable for tests).
|
||||
*/
|
||||
import { Container, matchesKey, Key, truncateToWidth, type Focusable } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import {
|
||||
resolveMigrationScope,
|
||||
runMigration as realRunMigration,
|
||||
type AnyChoice,
|
||||
type MigrationPlan,
|
||||
type MigrationPromptResult,
|
||||
type MigrationReport,
|
||||
type MigrationScope,
|
||||
type Prompt1Choice,
|
||||
type Prompt2Choice,
|
||||
type RunMigrationInput,
|
||||
} from '@moonshot-ai/migration-legacy';
|
||||
|
||||
type Phase = 'ask1' | 'ask2' | 'progress' | 'result';
|
||||
|
||||
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] as const;
|
||||
|
||||
/** Spinner frame cadence — one full braille cycle every ~800ms. */
|
||||
const SPINNER_INTERVAL_MS = 80;
|
||||
|
||||
const STEP_LABELS: ReadonlyArray<readonly [string, string]> = [
|
||||
['config', 'Config'],
|
||||
['mcp', 'MCP'],
|
||||
['user-history', 'REPL history'],
|
||||
['sessions', 'Sessions'],
|
||||
];
|
||||
|
||||
export interface MigrationScreenOptions {
|
||||
readonly plan: MigrationPlan;
|
||||
readonly sourceHome: string;
|
||||
readonly targetHome: string;
|
||||
readonly colors: ColorPalette;
|
||||
/** Called once the screen is finished; the host then restores the editor. */
|
||||
readonly onComplete: (result: MigrationScreenResult) => void;
|
||||
/** Triggers a re-render; the host wires this to `ui.requestRender()`. */
|
||||
readonly requestRender?: () => void;
|
||||
/** Injectable for tests; defaults to the package's runMigration. */
|
||||
readonly runMigration?: (input: RunMigrationInput) => Promise<MigrationReport>;
|
||||
/**
|
||||
* When true, the screen starts at the scope question and skips the
|
||||
* now/later/never gate — used by the explicit `kimi migrate` command, where
|
||||
* invoking the command is itself the decision to migrate.
|
||||
*/
|
||||
readonly skipDecisionStep?: boolean;
|
||||
}
|
||||
|
||||
/** What the screen reports back to the host when finished. */
|
||||
export interface MigrationScreenResult {
|
||||
readonly decision: 'now' | 'later' | 'never';
|
||||
/** Resolved migration scope; present only when decision === 'now'. */
|
||||
readonly scope?: MigrationScope;
|
||||
// present only when decision === 'now' and migration ran
|
||||
readonly migrated?: boolean;
|
||||
}
|
||||
|
||||
interface StepDef {
|
||||
readonly title: string;
|
||||
readonly options: ReadonlyArray<{ readonly label: string; readonly value: AnyChoice }>;
|
||||
}
|
||||
|
||||
export class MigrationScreenComponent extends Container implements Focusable {
|
||||
focused = false;
|
||||
private readonly opts: MigrationScreenOptions;
|
||||
private phase: Phase = 'ask1';
|
||||
private selectedIndex = 0;
|
||||
private readonly choices: AnyChoice[] = [];
|
||||
private progressDone = 0;
|
||||
private progressTotal = 0;
|
||||
private readonly stepStatus = new Map<string, 'pending' | 'done'>([
|
||||
['config', 'pending'],
|
||||
['mcp', 'pending'],
|
||||
['user-history', 'pending'],
|
||||
['sessions', 'pending'],
|
||||
]);
|
||||
private spinnerFrame = 0;
|
||||
private spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
||||
private report: MigrationReport | undefined;
|
||||
private migrationFailed = false;
|
||||
|
||||
constructor(opts: MigrationScreenOptions) {
|
||||
super();
|
||||
this.opts = opts;
|
||||
if (opts.skipDecisionStep === true) {
|
||||
// Explicit `kimi migrate`: the now/later/never gate is meaningless, so
|
||||
// start at the scope question with the decision already fixed to 'now'.
|
||||
this.phase = 'ask2';
|
||||
this.choices.push('now');
|
||||
}
|
||||
}
|
||||
|
||||
/** Host calls this once runMigration resolves. */
|
||||
showResult(report: MigrationReport): void {
|
||||
this.report = report;
|
||||
this.phase = 'result';
|
||||
this.stopSpinner();
|
||||
}
|
||||
|
||||
/** Host calls this if runMigration threw. */
|
||||
showFailure(): void {
|
||||
this.migrationFailed = true;
|
||||
this.phase = 'result';
|
||||
this.stopSpinner();
|
||||
}
|
||||
|
||||
/** Host calls this when migration starts. */
|
||||
enterProgress(): void {
|
||||
this.phase = 'progress';
|
||||
}
|
||||
|
||||
/** Host wires this to runMigration's onProgress (step-level messages). */
|
||||
reportStep(msg: string): void {
|
||||
// msg is like 'config done', 'mcp done', 'sessions done'
|
||||
const key = msg.replace(/ done$/, '');
|
||||
if (this.stepStatus.has(key)) this.stepStatus.set(key, 'done');
|
||||
}
|
||||
|
||||
/** Host wires this to runMigration's onSessionProgress. */
|
||||
reportSessionProgress(done: number, total: number): void {
|
||||
this.progressDone = done;
|
||||
this.progressTotal = total;
|
||||
}
|
||||
|
||||
// The braille spinner advances on its own timer so the progress screen stays
|
||||
// visibly alive even while a single step (e.g. session translation) runs for
|
||||
// a while without emitting progress events. Runs only for the progress
|
||||
// phase: started on entering it, stopped the moment it ends.
|
||||
private startSpinner(): void {
|
||||
this.stopSpinner();
|
||||
this.spinnerTimer = setInterval(() => {
|
||||
this.spinnerFrame = (this.spinnerFrame + 1) % SPINNER_FRAMES.length;
|
||||
this.opts.requestRender?.();
|
||||
}, SPINNER_INTERVAL_MS);
|
||||
// A decorative timer must never keep the process alive on its own.
|
||||
this.spinnerTimer.unref();
|
||||
}
|
||||
|
||||
private stopSpinner(): void {
|
||||
if (this.spinnerTimer !== undefined) {
|
||||
clearInterval(this.spinnerTimer);
|
||||
this.spinnerTimer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// test hooks (thin aliases so tests don't depend on host wiring)
|
||||
_testEnterProgress(): void {
|
||||
this.enterProgress();
|
||||
}
|
||||
_testUpdateStep(msg: string): void {
|
||||
this.reportStep(msg);
|
||||
}
|
||||
_testUpdateSessionProgress(done: number, total: number): void {
|
||||
this.reportSessionProgress(done, total);
|
||||
}
|
||||
_testShowResult(report: MigrationReport): void {
|
||||
this.showResult(report);
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
if (this.phase === 'ask1' || this.phase === 'ask2') {
|
||||
this.handleAskInput(data);
|
||||
return;
|
||||
}
|
||||
if (this.phase === 'result') {
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
this.opts.onComplete({ decision: 'now', migrated: !this.migrationFailed });
|
||||
}
|
||||
return;
|
||||
}
|
||||
// progress phase: ignore input
|
||||
}
|
||||
|
||||
private currentStep(): StepDef {
|
||||
return stepFor(this.phase, this.opts.plan);
|
||||
}
|
||||
|
||||
private handleAskInput(data: string): void {
|
||||
const step = this.currentStep();
|
||||
if (matchesKey(data, Key.up)) {
|
||||
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.down)) {
|
||||
this.selectedIndex = Math.min(step.options.length - 1, this.selectedIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.escape)) {
|
||||
// Esc anywhere in ask == "later"
|
||||
this.opts.onComplete({ decision: 'later' });
|
||||
return;
|
||||
}
|
||||
if (matchesKey(data, Key.enter)) {
|
||||
const chosen = step.options[this.selectedIndex];
|
||||
if (chosen === undefined) return;
|
||||
this.advance(chosen.value);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply a chosen value and move the state machine forward. */
|
||||
private advance(value: AnyChoice): void {
|
||||
this.choices.push(value);
|
||||
this.selectedIndex = 0;
|
||||
|
||||
const result: MigrationPromptResult = resolveMigrationScope(this.choices);
|
||||
if (this.phase === 'ask1') {
|
||||
if (value === 'now') {
|
||||
this.phase = 'ask2';
|
||||
return;
|
||||
}
|
||||
// 'later' | 'never'
|
||||
this.opts.onComplete({ decision: value as 'later' | 'never' });
|
||||
return;
|
||||
}
|
||||
// ask2 — either choice resolves the full scope; run migration immediately.
|
||||
this.beginMigration(result);
|
||||
}
|
||||
|
||||
/** Enter the progress phase and run the migration to completion. */
|
||||
private beginMigration(result: MigrationPromptResult): void {
|
||||
if (result.decision !== 'now' || result.scope === undefined) {
|
||||
this.opts.onComplete({ decision: 'later' });
|
||||
return;
|
||||
}
|
||||
this.enterProgress();
|
||||
this.startSpinner();
|
||||
this.opts.requestRender?.();
|
||||
const run = this.opts.runMigration ?? realRunMigration;
|
||||
void run({
|
||||
plan: this.opts.plan,
|
||||
scope: result.scope,
|
||||
source: this.opts.sourceHome,
|
||||
target: this.opts.targetHome,
|
||||
onProgress: (msg) => {
|
||||
this.reportStep(msg);
|
||||
this.opts.requestRender?.();
|
||||
},
|
||||
onSessionProgress: (done, total) => {
|
||||
this.reportSessionProgress(done, total);
|
||||
this.opts.requestRender?.();
|
||||
},
|
||||
}).then(
|
||||
(report) => {
|
||||
this.showResult(report);
|
||||
this.opts.requestRender?.();
|
||||
},
|
||||
() => {
|
||||
this.showFailure();
|
||||
this.opts.requestRender?.();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
if (this.phase === 'ask1' || this.phase === 'ask2') {
|
||||
return this.renderAsk(width);
|
||||
}
|
||||
if (this.phase === 'progress') return this.renderProgress(width);
|
||||
return this.renderResult(width);
|
||||
}
|
||||
|
||||
private renderResult(width: number): string[] {
|
||||
const { colors } = this.opts;
|
||||
const lines: string[] = [chalk.hex(colors.primary)('─'.repeat(width))];
|
||||
if (this.migrationFailed) {
|
||||
lines.push(chalk.hex(colors.error).bold(' Migration failed'));
|
||||
lines.push('');
|
||||
lines.push(chalk.hex(colors.text)(' You can retry later by running "kimi migrate".'));
|
||||
lines.push('');
|
||||
lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code'));
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((l) => truncateToWidth(l, width));
|
||||
}
|
||||
const r = this.report;
|
||||
lines.push(chalk.hex(colors.primary).bold(' Migration complete'));
|
||||
lines.push('');
|
||||
if (r !== undefined) {
|
||||
const sum = r.summary;
|
||||
if (sum.sessions.sessionsMigrated > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.success)(` ✓ ${sum.sessions.sessionsMigrated} sessions migrated`),
|
||||
);
|
||||
}
|
||||
// Only claim a data class was migrated when the summary says it was —
|
||||
// a skipped/failed step (e.g. malformed config.toml) must not show ✓.
|
||||
const migratedKinds: string[] = [];
|
||||
if (sum.config.migrated) migratedKinds.push('config');
|
||||
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 (migratedKinds.length > 0) {
|
||||
lines.push(chalk.hex(colors.success)(` ✓ ${migratedKinds.join(' · ')}`));
|
||||
}
|
||||
if (sum.sessions.sessionsMigrated === 0 && migratedKinds.length === 0) {
|
||||
lines.push(chalk.hex(colors.textMuted)(' Nothing needed migrating.'));
|
||||
}
|
||||
if (r.notices.detectedPlugins.length > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${r.notices.detectedPlugins.length} kimi-cli plugins — not yet supported for migration`,
|
||||
),
|
||||
);
|
||||
}
|
||||
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',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sum.config.droppedHooks > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${sum.config.droppedHooks} hooks dropped (incompatible)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Conflicts and partial failures: the report records them, so surface
|
||||
// them here too — otherwise "✓ config / MCP" hides that the data only
|
||||
// landed in a *.migrated-from-kimi-cli.* sibling or that sessions failed.
|
||||
if (sum.config.configConflicts.length > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${sum.config.configConflicts.length} config conflicts kept yours: ${sum.config.configConflicts.join(' · ')}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sum.config.wroteSiblingDueToConflict) {
|
||||
// Sibling mode: the live config.toml could not be parsed, so the
|
||||
// migrated content went to `config.migrated-from-kimi-cli.toml` and
|
||||
// the user must merge it by hand. Show the enumeration of contents
|
||||
// on a SEPARATE line below — a single-line message with the contents
|
||||
// appended would overflow 80 columns and be truncated, silently
|
||||
// hiding the very info we want users to see.
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
' ⚠ config.toml could not be parsed — review config.migrated-from-kimi-cli.toml',
|
||||
),
|
||||
);
|
||||
const sc = sum.config.siblingContents;
|
||||
const items: string[] = [];
|
||||
if (sc.providers.length > 0) {
|
||||
items.push(`${sc.providers.length} provider${sc.providers.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (sc.models.length > 0) {
|
||||
items.push(`${sc.models.length} model${sc.models.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (sc.hooks > 0) {
|
||||
items.push(`${sc.hooks} hook${sc.hooks === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (items.length > 0) {
|
||||
lines.push(chalk.hex(colors.warning)(` contains: ${items.join(', ')}`));
|
||||
}
|
||||
}
|
||||
if (sum.config.wroteTuiSibling) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
' ⚠ tui.toml conflicted — review tui.migrated-from-kimi-cli.toml',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sum.mcp.wroteSiblingDueToConflict) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
' ⚠ mcp.json unreadable — review mcp.migrated-from-kimi-cli.json',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (r.notices.mcpOauthServersRequiringReauth.length > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${r.notices.mcpOauthServersRequiringReauth.length} MCP servers need re-authentication`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sum.sessions.sessionsFailed.length > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${sum.sessions.sessionsFailed.length} sessions failed to migrate`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sum.sessions.sessionsConflicts.length > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.warning)(
|
||||
` ⚠ ${sum.sessions.sessionsConflicts.length} sessions skipped (target already occupied)`,
|
||||
),
|
||||
);
|
||||
}
|
||||
// Empty / user-cleared sessions carry no conversation — neutral info,
|
||||
// not a failure, so it is shown muted rather than as a ⚠ warning.
|
||||
if (sum.sessions.sessionsSkippedEmpty > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.textMuted)(
|
||||
` ${sum.sessions.sessionsSkippedEmpty} empty sessions skipped`,
|
||||
),
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
chalk.hex(colors.textMuted)(' Old data kept at ~/.kimi/ — kimi-cli still works.'),
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(chalk.hex(colors.textMuted)(' ⏎ continue to kimi-code'));
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((l) => truncateToWidth(l, width));
|
||||
}
|
||||
|
||||
private renderProgress(width: number): string[] {
|
||||
const { colors } = this.opts;
|
||||
const spinner = SPINNER_FRAMES[this.spinnerFrame] ?? SPINNER_FRAMES[0];
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(' Migrating from kimi-cli'),
|
||||
'',
|
||||
];
|
||||
if (this.progressTotal > 0) {
|
||||
lines.push(
|
||||
chalk.hex(colors.accent)(` ${spinner} `) +
|
||||
chalk.hex(colors.text)(
|
||||
`Translating sessions… ${this.progressDone} / ${this.progressTotal}`,
|
||||
),
|
||||
);
|
||||
lines.push('');
|
||||
}
|
||||
for (const [key, label] of STEP_LABELS) {
|
||||
const status = this.stepStatus.get(key) ?? 'pending';
|
||||
const mark =
|
||||
status === 'done'
|
||||
? chalk.hex(colors.success)('✓')
|
||||
: chalk.hex(colors.textDim)('◐');
|
||||
lines.push(` ${mark} ${chalk.hex(colors.text)(label)}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((l) => truncateToWidth(l, width));
|
||||
}
|
||||
|
||||
private renderAsk(width: number): string[] {
|
||||
const { colors } = this.opts;
|
||||
const step = this.currentStep();
|
||||
const lines: string[] = [
|
||||
chalk.hex(colors.primary)('─'.repeat(width)),
|
||||
chalk.hex(colors.primary).bold(' Migrate from kimi-cli'),
|
||||
'',
|
||||
];
|
||||
if (this.phase === 'ask1') {
|
||||
lines.push(chalk.hex(colors.text)(' Found an existing kimi-cli installation:'));
|
||||
lines.push(chalk.hex(colors.textMuted)(` ${summarizePlan(this.opts.plan)}`));
|
||||
lines.push('');
|
||||
}
|
||||
lines.push(chalk.hex(colors.text)(` ${step.title}`));
|
||||
lines.push('');
|
||||
for (let i = 0; i < step.options.length; i++) {
|
||||
const opt = step.options[i]!;
|
||||
const isSel = i === this.selectedIndex;
|
||||
const pointer = isSel ? '❯' : ' ';
|
||||
const labelStyle = isSel ? chalk.hex(colors.primary).bold : chalk.hex(colors.text);
|
||||
lines.push(
|
||||
chalk.hex(isSel ? colors.primary : colors.textDim)(` ${pointer} `) +
|
||||
labelStyle(opt.label),
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push(
|
||||
chalk.hex(colors.textMuted)(
|
||||
` ↑/↓ move · ⏎ select · esc ${this.opts.skipDecisionStep === true ? 'cancel' : 'later'}`,
|
||||
),
|
||||
);
|
||||
lines.push(chalk.hex(colors.primary)('─'.repeat(width)));
|
||||
return lines.map((l) => truncateToWidth(l, width));
|
||||
}
|
||||
}
|
||||
|
||||
function summarizePlan(plan: MigrationPlan): string {
|
||||
const parts: string[] = [];
|
||||
if (plan.totalSessions > 0) parts.push(`${plan.totalSessions} sessions`);
|
||||
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(' · ');
|
||||
}
|
||||
|
||||
function stepFor(phase: Phase, plan: MigrationPlan): StepDef {
|
||||
if (phase === 'ask1') {
|
||||
return {
|
||||
title: 'Migrate this data to kimi-code?',
|
||||
options: [
|
||||
{ label: 'Migrate now', value: 'now' satisfies Prompt1Choice },
|
||||
{ label: 'Ask me later', value: 'later' satisfies Prompt1Choice },
|
||||
{ label: 'Never ask again', value: 'never' satisfies Prompt1Choice },
|
||||
],
|
||||
};
|
||||
}
|
||||
// ask2 — the second option carries the actual session count so users can see
|
||||
// the cost they are signing up for. Falls back to the singular "sessions"
|
||||
// word only (no count) when no sessions were detected.
|
||||
const sessionsLabel =
|
||||
plan.totalSessions > 0
|
||||
? `Config + ${plan.totalSessions} sessions`
|
||||
: 'Config + all sessions';
|
||||
return {
|
||||
title: 'Migrate chat sessions too? (they are bulky and slower)',
|
||||
options: [
|
||||
{ label: 'Config only', value: 'config-only' satisfies Prompt2Choice },
|
||||
{ label: sessionsLabel, value: 'all-sessions' satisfies Prompt2Choice },
|
||||
],
|
||||
};
|
||||
}
|
||||
40
apps/kimi-code/src/native/module-hook.ts
Normal file
40
apps/kimi-code/src/native/module-hook.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { createRequire } from 'node:module';
|
||||
|
||||
import { loadNativePackage } from './native-require';
|
||||
|
||||
type ModuleLoad = (request: string, parent: unknown, isMain: boolean) => unknown;
|
||||
|
||||
interface ModuleWithLoad {
|
||||
_load?: ModuleLoad;
|
||||
}
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
let installed = false;
|
||||
let loadingNativePackage = false;
|
||||
|
||||
export function installNativeModuleHook(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const moduleBuiltin = nodeRequire('node:module') as ModuleWithLoad;
|
||||
const originalLoad = moduleBuiltin._load;
|
||||
if (originalLoad === undefined) return;
|
||||
|
||||
moduleBuiltin._load = function loadWithNativeAssets(
|
||||
this: unknown,
|
||||
request: string,
|
||||
parent: unknown,
|
||||
isMain: boolean,
|
||||
): unknown {
|
||||
if (request === 'koffi' && !loadingNativePackage) {
|
||||
loadingNativePackage = true;
|
||||
try {
|
||||
const pkg = loadNativePackage<unknown>('koffi');
|
||||
if (pkg !== null) return pkg;
|
||||
} finally {
|
||||
loadingNativePackage = false;
|
||||
}
|
||||
}
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
}
|
||||
371
apps/kimi-code/src/native/native-assets.ts
Normal file
371
apps/kimi-code/src/native/native-assets.ts
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { dirname, join, win32 as pathWin32 } from 'node:path';
|
||||
|
||||
import { KIMI_BUILD_INFO } from '#/cli/build-info';
|
||||
import { NATIVE_ASSET_MANIFEST_VERSION as MANIFEST_VERSION, buildManifestKey } from '../../scripts/native/manifest.mjs';
|
||||
|
||||
export const NATIVE_ASSET_MANIFEST_VERSION = MANIFEST_VERSION;
|
||||
|
||||
export interface NativeAssetFile {
|
||||
readonly assetKey: string;
|
||||
readonly relativePath: string;
|
||||
readonly sha256: string;
|
||||
readonly mode?: number;
|
||||
}
|
||||
|
||||
export interface NativeAssetPackage {
|
||||
readonly name: string;
|
||||
readonly root: string;
|
||||
readonly files: readonly NativeAssetFile[];
|
||||
}
|
||||
|
||||
export interface NativeAssetManifest {
|
||||
readonly version: typeof NATIVE_ASSET_MANIFEST_VERSION;
|
||||
readonly target: string;
|
||||
readonly packages: readonly NativeAssetPackage[];
|
||||
}
|
||||
|
||||
export interface NativeAssetSource {
|
||||
getAssetKeys(): readonly string[];
|
||||
getRawAsset(assetKey: string): ArrayBuffer | ArrayBufferView | Buffer | string;
|
||||
}
|
||||
|
||||
export interface NativeAssetOptions {
|
||||
readonly source?: NativeAssetSource | null;
|
||||
readonly manifest?: NativeAssetManifest | null;
|
||||
readonly cacheBase?: string;
|
||||
readonly env?: NodeJS.ProcessEnv;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly homeDir?: string;
|
||||
readonly version?: string;
|
||||
}
|
||||
|
||||
type RawNativeAssetManifest = Omit<NativeAssetManifest, 'version'> & {
|
||||
readonly version: number;
|
||||
};
|
||||
|
||||
interface NodeSeaModule {
|
||||
isSea(): boolean;
|
||||
getAssetKeys(): string[];
|
||||
getRawAsset(assetKey: string): ArrayBuffer;
|
||||
}
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
let seaModule: NodeSeaModule | null | undefined;
|
||||
|
||||
function loadSeaModule(): NodeSeaModule | null {
|
||||
if (seaModule !== undefined) return seaModule;
|
||||
try {
|
||||
seaModule = nodeRequire('node:sea') as NodeSeaModule;
|
||||
} catch {
|
||||
seaModule = null;
|
||||
}
|
||||
return seaModule;
|
||||
}
|
||||
|
||||
function currentTarget(): string {
|
||||
return KIMI_BUILD_INFO.buildTarget ?? `${process.platform}-${process.arch}`;
|
||||
}
|
||||
|
||||
export function nativeAssetManifestKey(target: string = currentTarget()): string {
|
||||
return buildManifestKey(target);
|
||||
}
|
||||
|
||||
function toBuffer(value: ArrayBuffer | ArrayBufferView | Buffer | string): Buffer {
|
||||
if (Buffer.isBuffer(value)) return value;
|
||||
if (typeof value === 'string') return Buffer.from(value);
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
||||
}
|
||||
return Buffer.from(value);
|
||||
}
|
||||
|
||||
function sha256(bytes: Buffer | Uint8Array | string): string {
|
||||
return createHash('sha256').update(bytes).digest('hex');
|
||||
}
|
||||
|
||||
function optionalEnvValue(env: NodeJS.ProcessEnv, key: string): string | null {
|
||||
const value = env[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_');
|
||||
return sanitized.length > 0 ? sanitized : 'unknown';
|
||||
}
|
||||
|
||||
export function getSeaAssetSource(): NativeAssetSource | null {
|
||||
const sea = loadSeaModule();
|
||||
if (sea === null || !sea.isSea()) return null;
|
||||
return {
|
||||
getAssetKeys: () => sea.getAssetKeys(),
|
||||
getRawAsset: (assetKey) => sea.getRawAsset(assetKey),
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmbeddedNativeAssetManifest(
|
||||
source = getSeaAssetSource(),
|
||||
target = currentTarget(),
|
||||
): NativeAssetManifest | null {
|
||||
if (source === null) return null;
|
||||
const key = nativeAssetManifestKey(target);
|
||||
if (!source.getAssetKeys().includes(key)) return null;
|
||||
const raw = source.getRawAsset(key);
|
||||
const manifest = JSON.parse(toBuffer(raw).toString('utf-8')) as RawNativeAssetManifest;
|
||||
if (manifest.version !== NATIVE_ASSET_MANIFEST_VERSION) {
|
||||
throw new Error(`Unsupported native asset manifest version: ${manifest.version}`);
|
||||
}
|
||||
if (manifest.target !== target) {
|
||||
throw new Error(`Native asset manifest target mismatch: ${manifest.target} !== ${target}`);
|
||||
}
|
||||
return manifest as NativeAssetManifest;
|
||||
}
|
||||
|
||||
export function getNativeCacheBase(options: NativeAssetOptions = {}): string {
|
||||
if (options.cacheBase !== undefined) return options.cacheBase;
|
||||
|
||||
const env = options.env ?? process.env;
|
||||
const platform = options.platform ?? process.platform;
|
||||
const home = options.homeDir ?? homedir();
|
||||
|
||||
const cacheDirEnv = optionalEnvValue(env, 'KIMI_CODE_CACHE_DIR');
|
||||
if (cacheDirEnv !== null) return cacheDirEnv;
|
||||
|
||||
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'kimi-code');
|
||||
if (platform === 'win32') {
|
||||
const localAppData = optionalEnvValue(env, 'LOCALAPPDATA');
|
||||
return localAppData !== null
|
||||
? pathWin32.join(localAppData, 'kimi-code')
|
||||
: pathWin32.join(home, 'AppData', 'Local', 'kimi-code', 'Cache');
|
||||
}
|
||||
|
||||
return join(optionalEnvValue(env, 'XDG_CACHE_HOME') ?? join(home, '.cache'), 'kimi-code');
|
||||
}
|
||||
|
||||
export function getNativeAssetCacheRoot(
|
||||
manifest: NativeAssetManifest,
|
||||
options: NativeAssetOptions = {},
|
||||
): string {
|
||||
const version = sanitizeSegment(options.version ?? KIMI_BUILD_INFO.version ?? 'dev');
|
||||
const manifestHash = sha256(JSON.stringify(manifest));
|
||||
return join(
|
||||
getNativeCacheBase(options),
|
||||
'native',
|
||||
version,
|
||||
sanitizeSegment(manifest.target),
|
||||
manifestHash,
|
||||
);
|
||||
}
|
||||
|
||||
function readFileSha256(path: string): string | null {
|
||||
try {
|
||||
return sha256(readFileSync(path));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureFile(path: string, bytes: Buffer, expectedSha256: string, mode?: number): void {
|
||||
if (readFileSha256(path) === expectedSha256) return;
|
||||
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
||||
writeFileSync(tempPath, bytes, { mode: mode ?? 0o644 });
|
||||
|
||||
try {
|
||||
renameSync(tempPath, path);
|
||||
return;
|
||||
} catch {
|
||||
if (readFileSha256(path) === expectedSha256) {
|
||||
rmSync(tempPath, { force: true });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(path, { force: true });
|
||||
renameSync(tempPath, path);
|
||||
} catch (error) {
|
||||
rmSync(tempPath, { force: true });
|
||||
if (readFileSha256(path) === expectedSha256) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureEntryFile(cacheRoot: string): void {
|
||||
const entryPath = join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs');
|
||||
ensureFile(
|
||||
entryPath,
|
||||
Buffer.from('module.exports = require;\n'),
|
||||
sha256('module.exports = require;\n'),
|
||||
0o644,
|
||||
);
|
||||
}
|
||||
|
||||
export function ensureNativeAssetTree(options: NativeAssetOptions = {}): string | null {
|
||||
const source = options.source ?? getSeaAssetSource();
|
||||
if (source === null) return null;
|
||||
|
||||
const manifest =
|
||||
options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget());
|
||||
if (manifest === null) return null;
|
||||
|
||||
const cacheRoot = getNativeAssetCacheRoot(manifest, options);
|
||||
for (const pkg of manifest.packages) {
|
||||
for (const file of pkg.files) {
|
||||
const bytes = toBuffer(source.getRawAsset(file.assetKey));
|
||||
const actualSha256 = sha256(bytes);
|
||||
if (actualSha256 !== file.sha256) {
|
||||
throw new Error(
|
||||
`Native asset checksum mismatch for ${file.assetKey}: ${actualSha256} !== ${file.sha256}`,
|
||||
);
|
||||
}
|
||||
ensureFile(join(cacheRoot, file.relativePath), bytes, file.sha256, file.mode);
|
||||
}
|
||||
}
|
||||
ensureEntryFile(cacheRoot);
|
||||
return cacheRoot;
|
||||
}
|
||||
|
||||
export function getNativePackageRoot(
|
||||
packageName: string,
|
||||
options: NativeAssetOptions = {},
|
||||
): string | null {
|
||||
const source = options.source ?? getSeaAssetSource();
|
||||
if (source === null) return null;
|
||||
|
||||
const manifest =
|
||||
options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget());
|
||||
if (manifest === null) return null;
|
||||
|
||||
const pkg = manifest.packages.find((entry) => entry.name === packageName);
|
||||
if (pkg === undefined) return null;
|
||||
|
||||
const cacheRoot = ensureNativeAssetTree({ ...options, source, manifest });
|
||||
return cacheRoot === null ? null : join(cacheRoot, pkg.root);
|
||||
}
|
||||
|
||||
export function hasNativePackage(packageName: string, manifest: NativeAssetManifest): boolean {
|
||||
return manifest.packages.some((pkg) => pkg.name === packageName);
|
||||
}
|
||||
|
||||
export function nativeAssetCacheExists(
|
||||
packageName: string,
|
||||
options: NativeAssetOptions = {},
|
||||
): boolean {
|
||||
const root = getNativePackageRoot(packageName, options);
|
||||
return root !== null && existsSync(root);
|
||||
}
|
||||
|
||||
export interface CleanupOptions {
|
||||
readonly cacheBase: string;
|
||||
readonly version: string;
|
||||
readonly target: string;
|
||||
readonly currentRoot: string;
|
||||
}
|
||||
|
||||
export interface CleanupResult {
|
||||
readonly kept: string[];
|
||||
readonly removed: string[];
|
||||
readonly errors: Array<{ path: string; error: unknown }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stale native asset cache directories for the current (version, target).
|
||||
*
|
||||
* Keeps:
|
||||
* - the currentRoot (passed in by caller)
|
||||
* - the most recently modified sibling (defensive: in case currentRoot calc changed)
|
||||
*
|
||||
* Deletes all other sibling <manifest-hash> directories. Other versions and
|
||||
* other targets are never touched. Errors per-entry are collected and returned
|
||||
* (never throw — this is fire-and-forget background work).
|
||||
*/
|
||||
export function cleanupStaleNativeCache(options: CleanupOptions): CleanupResult {
|
||||
const { cacheBase, version, target, currentRoot } = options;
|
||||
const targetDir = join(cacheBase, 'native', version, target);
|
||||
const result: CleanupResult = { kept: [], removed: [], errors: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(targetDir);
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const siblings: Array<{ path: string; mtimeMs: number }> = [];
|
||||
for (const name of entries) {
|
||||
const path = join(targetDir, name);
|
||||
try {
|
||||
const st = statSync(path);
|
||||
if (!st.isDirectory()) continue;
|
||||
siblings.push({ path, mtimeMs: st.mtimeMs });
|
||||
} catch (error) {
|
||||
(result.errors as Array<{ path: string; error: unknown }>).push({ path, error });
|
||||
}
|
||||
}
|
||||
|
||||
if (siblings.length === 0) return result;
|
||||
|
||||
// sort newest first
|
||||
siblings.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
// Defensive: keep the most recently modified sibling that is not currentRoot
|
||||
// so a previously-written cache survives in case currentRoot calc changed.
|
||||
const mostRecentOther = siblings.find((entry) => entry.path !== currentRoot)?.path;
|
||||
const keepSet = new Set<string>(
|
||||
mostRecentOther === undefined ? [currentRoot] : [currentRoot, mostRecentOther],
|
||||
);
|
||||
|
||||
for (const { path } of siblings) {
|
||||
if (keepSet.has(path)) {
|
||||
result.kept.push(path);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
result.removed.push(path);
|
||||
} catch (error) {
|
||||
(result.errors as Array<{ path: string; error: unknown }>).push({ path, error });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: discover currentRoot from embedded manifest + run cleanup.
|
||||
* Safe to call without args from main.ts startup. Returns null if not in SEA mode.
|
||||
*/
|
||||
export function cleanupStaleNativeCacheForCurrent(
|
||||
options: NativeAssetOptions = {},
|
||||
): CleanupResult | null {
|
||||
const source = options.source ?? getSeaAssetSource();
|
||||
if (source === null) return null;
|
||||
|
||||
const manifest =
|
||||
options.manifest ?? getEmbeddedNativeAssetManifest(source, currentTarget());
|
||||
if (manifest === null) return null;
|
||||
|
||||
const cacheBase = getNativeCacheBase(options);
|
||||
const version = KIMI_BUILD_INFO.version ?? 'dev';
|
||||
const currentRoot = getNativeAssetCacheRoot(manifest, options);
|
||||
|
||||
return cleanupStaleNativeCache({
|
||||
cacheBase,
|
||||
version,
|
||||
target: manifest.target,
|
||||
currentRoot,
|
||||
});
|
||||
}
|
||||
30
apps/kimi-code/src/native/native-require.ts
Normal file
30
apps/kimi-code/src/native/native-require.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { createRequire } from 'node:module';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
ensureNativeAssetTree,
|
||||
getNativePackageRoot,
|
||||
type NativeAssetOptions,
|
||||
} from './native-assets';
|
||||
|
||||
export function createNativePackageRequire(
|
||||
packageName: string,
|
||||
options: NativeAssetOptions = {},
|
||||
): ReturnType<typeof createRequire> | null {
|
||||
const packageRoot = getNativePackageRoot(packageName, options);
|
||||
if (packageRoot === null) return null;
|
||||
|
||||
const cacheRoot = ensureNativeAssetTree(options);
|
||||
if (cacheRoot === null) return null;
|
||||
|
||||
return createRequire(join(cacheRoot, 'node_modules', '.kimi-native-entry.cjs'));
|
||||
}
|
||||
|
||||
export function loadNativePackage<T>(
|
||||
packageName: string,
|
||||
options: NativeAssetOptions = {},
|
||||
): T | null {
|
||||
const nativeRequire = createNativePackageRequire(packageName, options);
|
||||
if (nativeRequire === null) return null;
|
||||
return nativeRequire(packageName) as T;
|
||||
}
|
||||
26
apps/kimi-code/src/native/smoke.ts
Normal file
26
apps/kimi-code/src/native/smoke.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { getEmbeddedNativeAssetManifest, getNativePackageRoot } from './native-assets';
|
||||
|
||||
const smokePackages = ['@mariozechner/clipboard', 'koffi'];
|
||||
|
||||
export function runNativeAssetSmokeIfRequested(): boolean {
|
||||
if (process.env['KIMI_CODE_NATIVE_ASSET_SMOKE'] !== '1') return false;
|
||||
|
||||
try {
|
||||
const manifest = getEmbeddedNativeAssetManifest();
|
||||
if (manifest === null) {
|
||||
throw new Error('Native asset manifest is not available.');
|
||||
}
|
||||
for (const packageName of smokePackages) {
|
||||
const packageRoot = getNativePackageRoot(packageName, { manifest });
|
||||
if (packageRoot === null) {
|
||||
throw new Error(`Native package is not available: ${packageName}`);
|
||||
}
|
||||
}
|
||||
process.stdout.write(`Native asset smoke passed: ${manifest.target}\n`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
process.stderr.write(`Native asset smoke failed: ${message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
699
apps/kimi-code/src/tui/actions/replay-ops.ts
Normal file
699
apps/kimi-code/src/tui/actions/replay-ops.ts
Normal file
|
|
@ -0,0 +1,699 @@
|
|||
/**
|
||||
* Session replay hydration.
|
||||
*
|
||||
* Core owns durable history as raw session records. The TUI projects those
|
||||
* records into the same transcript entries/components used by live events,
|
||||
* without mutating core session state or responding to replayed data.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AgentReplayRecord,
|
||||
BackgroundTaskInfo,
|
||||
ContentPart,
|
||||
ContextMessage,
|
||||
PromptOrigin,
|
||||
PermissionMode,
|
||||
ResumedAgentState,
|
||||
Session,
|
||||
ToolCall,
|
||||
} from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import { AgentGroupComponent } from '#/tui/components/messages/agent-group';
|
||||
import type { TodoItem } from '#/tui/components/chrome/todo-panel';
|
||||
import { ToolCallComponent } from '#/tui/components/messages/tool-call';
|
||||
import type { TUIState } from '#/tui/kimi-tui';
|
||||
import type {
|
||||
AppState,
|
||||
BackgroundAgentMetadata,
|
||||
BackgroundAgentStatusData,
|
||||
ToolCallBlockData,
|
||||
TranscriptEntry,
|
||||
} from '#/tui/types';
|
||||
import { formatErrorMessage, isTodoItemShape } from '#/tui/utils/event-payload';
|
||||
import { formatBackgroundAgentTranscript } from '#/tui/utils/background-agent-status';
|
||||
import { mediaUrlPartToText } from '#/tui/utils/media-url';
|
||||
import { nextTranscriptId } from '#/tui/utils/transcript-id';
|
||||
|
||||
export interface ReplayHydrationHooks {
|
||||
readonly setAppState: (patch: Partial<AppState>) => void;
|
||||
readonly appendEntry: (entry: TranscriptEntry) => void;
|
||||
readonly setTodoList: (todos: readonly TodoItem[]) => void;
|
||||
readonly emitError: (message: string) => void;
|
||||
}
|
||||
|
||||
interface ReplayProjection {
|
||||
readonly entries: readonly TranscriptEntry[];
|
||||
/**
|
||||
* Background subagents still not completed or failed when replay ends,
|
||||
* keyed by agent_id. `hydrateTranscriptFromReplay` seeds
|
||||
* `state.backgroundAgents` from this so the footer badge starts accurate.
|
||||
*/
|
||||
readonly backgroundAgents: ReadonlySet<string>;
|
||||
/**
|
||||
* Background agent metadata that remains needed after replay so live
|
||||
* terminal events can keep rendering transcript copy after resume.
|
||||
*/
|
||||
readonly backgroundAgentMetadata: ReadonlyMap<string, BackgroundAgentMetadata>;
|
||||
}
|
||||
|
||||
interface OpenAssistant {
|
||||
thinking: string[];
|
||||
text: string[];
|
||||
}
|
||||
|
||||
interface ProjectionState {
|
||||
entries: TranscriptEntry[];
|
||||
toolCalls: Map<string, ToolCallBlockData>;
|
||||
assistant: OpenAssistant;
|
||||
skillActivationIds: Set<string>;
|
||||
permissionMode?: PermissionMode;
|
||||
backgroundAgents: Set<string>;
|
||||
backgroundAgentMetadata: Map<string, BackgroundAgentMetadata>;
|
||||
backgroundTasks: ReadonlyMap<string, BackgroundTaskInfo>;
|
||||
}
|
||||
|
||||
type BackgroundTaskOrigin = Extract<PromptOrigin, { kind: 'background_task' }>;
|
||||
|
||||
const REPLAY_TURN_LIMIT = 10;
|
||||
|
||||
export async function hydrateTranscriptFromReplay(
|
||||
state: TUIState,
|
||||
hooks: ReplayHydrationHooks,
|
||||
session: Session,
|
||||
): Promise<boolean> {
|
||||
hooks.setAppState({ isReplaying: true });
|
||||
try {
|
||||
const main = session.getResumeState()?.agents['main'];
|
||||
if (main === undefined) {
|
||||
hooks.emitError('Session history is unavailable for this session.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const projection = projectReplayRecords(main.replay, main.background);
|
||||
hydrateProjectedEntries(state, projection.entries, hooks.appendEntry);
|
||||
hydrateTodoPanelFromResume(main, hooks);
|
||||
state.backgroundAgents = new Set(projection.backgroundAgents);
|
||||
state.backgroundAgentMetadata = new Map(projection.backgroundAgentMetadata);
|
||||
|
||||
// Seed the BPM-derived store from the resume snapshot. This is the
|
||||
// authoritative source for footer count + transcript dedupe; the
|
||||
// subagent-derived `backgroundAgents` set above is kept for legacy
|
||||
// metadata lookups (agent name / description) until removed.
|
||||
state.backgroundTasks = new Map<string, BackgroundTaskInfo>(
|
||||
main.background.map((info) => [info.taskId, info]),
|
||||
);
|
||||
state.backgroundTaskTranscriptedTerminal.clear();
|
||||
// Resumed terminal tasks should not re-emit transcript cards.
|
||||
for (const info of main.background) {
|
||||
if (
|
||||
info.status === 'completed' ||
|
||||
info.status === 'failed' ||
|
||||
info.status === 'killed' ||
|
||||
info.status === 'lost'
|
||||
) {
|
||||
state.backgroundTaskTranscriptedTerminal.add(info.taskId);
|
||||
}
|
||||
}
|
||||
const counts = countActiveBackgroundTasks(state.backgroundTasks);
|
||||
state.footer.setBackgroundCounts(counts);
|
||||
hooks.setAppState(appStateFromResumeAgent(main));
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = formatErrorMessage(error);
|
||||
hooks.emitError(`Failed to replay session history: ${message}`);
|
||||
return false;
|
||||
} finally {
|
||||
hooks.setAppState({ isReplaying: false });
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateTodoPanelFromResume(
|
||||
agent: ResumedAgentState,
|
||||
hooks: ReplayHydrationHooks,
|
||||
): void {
|
||||
const rawTodos = agent.toolStore?.['todo'];
|
||||
if (!Array.isArray(rawTodos)) {
|
||||
hooks.setTodoList([]);
|
||||
return;
|
||||
}
|
||||
const todos = rawTodos
|
||||
.filter((todo): todo is TodoItem => isTodoItemShape(todo))
|
||||
.map((todo) => ({ title: todo.title, status: todo.status }));
|
||||
hooks.setTodoList(todos);
|
||||
}
|
||||
|
||||
function countActiveBackgroundTasks(tasks: ReadonlyMap<string, BackgroundTaskInfo>): {
|
||||
bashTasks: number;
|
||||
agentTasks: number;
|
||||
} {
|
||||
let bashTasks = 0;
|
||||
let agentTasks = 0;
|
||||
for (const info of tasks.values()) {
|
||||
if (
|
||||
info.status === 'completed' ||
|
||||
info.status === 'failed' ||
|
||||
info.status === 'killed' ||
|
||||
info.status === 'lost'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (info.taskId.startsWith('agent-')) {
|
||||
agentTasks += 1;
|
||||
} else {
|
||||
bashTasks += 1;
|
||||
}
|
||||
}
|
||||
return { bashTasks, agentTasks };
|
||||
}
|
||||
|
||||
function appStateFromResumeAgent(agent: ResumedAgentState): Partial<AppState> {
|
||||
const maxContextTokens = agent.config.modelCapabilities?.max_context_tokens ?? 0;
|
||||
const contextTokens = agent.context.tokenCount;
|
||||
const contextUsage = maxContextTokens > 0 ? contextTokens / maxContextTokens : 0;
|
||||
return {
|
||||
// `?? ''` so a resumed session with no resolvable model yields a string,
|
||||
// not `undefined` — the editor's `appState.model.trim()` would otherwise
|
||||
// throw. An empty model surfaces the normal "LLM not set" state.
|
||||
model: agent.config.modelAlias ?? agent.config.provider?.model ?? '',
|
||||
contextTokens,
|
||||
maxContextTokens,
|
||||
contextUsage,
|
||||
planMode: agent.plan !== null,
|
||||
yolo: agent.permission.mode === 'yolo',
|
||||
permissionMode: agent.permission.mode,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectReplayRecords(
|
||||
records: readonly AgentReplayRecord[],
|
||||
backgroundTasks: readonly BackgroundTaskInfo[] = [],
|
||||
): ReplayProjection {
|
||||
const state: ProjectionState = {
|
||||
entries: [],
|
||||
toolCalls: new Map(),
|
||||
assistant: { thinking: [], text: [] },
|
||||
skillActivationIds: new Set<string>(),
|
||||
backgroundAgents: new Set<string>(),
|
||||
backgroundAgentMetadata: new Map<string, BackgroundAgentMetadata>(),
|
||||
backgroundTasks: new Map(backgroundTasks.map((info) => [info.taskId, info])),
|
||||
};
|
||||
|
||||
for (const record of limitReplayRecordsByTurn(records, REPLAY_TURN_LIMIT)) {
|
||||
projectReplayRecord(state, record);
|
||||
}
|
||||
flushAssistant(state);
|
||||
|
||||
return {
|
||||
entries: state.entries,
|
||||
backgroundAgents: state.backgroundAgents,
|
||||
backgroundAgentMetadata: state.backgroundAgentMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
function limitReplayRecordsByTurn(
|
||||
records: readonly AgentReplayRecord[],
|
||||
maxTurns: number,
|
||||
): readonly AgentReplayRecord[] {
|
||||
if (maxTurns <= 0) return [];
|
||||
|
||||
const turnStarts = records.flatMap((record, index) =>
|
||||
isReplayUserTurnRecord(record) ? [index] : [],
|
||||
);
|
||||
if (turnStarts.length <= maxTurns) return records;
|
||||
|
||||
return records.slice(turnStarts[turnStarts.length - maxTurns]);
|
||||
}
|
||||
|
||||
function isReplayUserTurnRecord(record: AgentReplayRecord): boolean {
|
||||
if (record.type !== 'message') return false;
|
||||
const { message } = record;
|
||||
if (message.role !== 'user') return false;
|
||||
switch (message.origin?.kind) {
|
||||
case undefined:
|
||||
case 'user':
|
||||
return true;
|
||||
case 'skill_activation':
|
||||
return message.origin.trigger === 'user-slash';
|
||||
case 'background_task':
|
||||
case 'compaction_summary':
|
||||
case 'hook_result':
|
||||
case 'injection':
|
||||
case 'system_trigger':
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function projectReplayRecord(state: ProjectionState, record: AgentReplayRecord): void {
|
||||
switch (record.type) {
|
||||
case 'message':
|
||||
projectContextMessage(state, record.message);
|
||||
return;
|
||||
case 'plan_updated':
|
||||
flushAssistant(state);
|
||||
state.entries.push(entry('status', `Plan mode: ${record.enabled ? 'ON' : 'OFF'}`, 'notice'));
|
||||
return;
|
||||
case 'permission_updated':
|
||||
flushAssistant(state);
|
||||
projectPermissionUpdate(state, record.mode);
|
||||
return;
|
||||
case 'approval_result': {
|
||||
flushAssistant(state);
|
||||
const { record: approvalRecord } = record;
|
||||
const { result } = approvalRecord;
|
||||
const parts: string[] = [];
|
||||
switch (result.decision) {
|
||||
case 'approved':
|
||||
parts.push(result.scope === 'session' ? 'Approved for session' : 'Approved');
|
||||
break;
|
||||
case 'rejected':
|
||||
parts.push('Rejected');
|
||||
break;
|
||||
case 'cancelled':
|
||||
parts.push('Cancelled');
|
||||
break;
|
||||
}
|
||||
parts.push(`: ${approvalRecord.action}`);
|
||||
if (result.feedback !== undefined && result.feedback.length > 0) {
|
||||
parts.push(` — "${result.feedback}"`);
|
||||
}
|
||||
state.entries.push(entry('status', parts.join(''), 'notice'));
|
||||
return;
|
||||
}
|
||||
case 'config_updated':
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function projectPermissionUpdate(state: ProjectionState, mode: PermissionMode): void {
|
||||
if (mode === 'yolo') {
|
||||
state.entries.push(
|
||||
entry('status', 'YOLO mode: ON', 'notice', {
|
||||
detail: 'All actions will be approved automatically. Use with caution.',
|
||||
}),
|
||||
);
|
||||
state.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
if (state.permissionMode === 'yolo' && mode === 'manual') {
|
||||
state.entries.push(entry('status', 'YOLO mode: OFF', 'notice'));
|
||||
state.permissionMode = mode;
|
||||
return;
|
||||
}
|
||||
state.entries.push(entry('status', `Permission mode: ${mode}`, 'notice'));
|
||||
state.permissionMode = mode;
|
||||
}
|
||||
|
||||
interface SkillActivationProjection {
|
||||
readonly activationId: string;
|
||||
readonly skillName: string;
|
||||
readonly skillArgs?: string;
|
||||
}
|
||||
|
||||
function projectSkillActivation(
|
||||
state: ProjectionState,
|
||||
skill: SkillActivationProjection | undefined,
|
||||
): void {
|
||||
if (skill === undefined) return;
|
||||
if (state.skillActivationIds.has(skill.activationId)) return;
|
||||
state.skillActivationIds.add(skill.activationId);
|
||||
state.entries.push(
|
||||
entry('skill_activation', `Activated skill: ${skill.skillName}`, 'plain', {
|
||||
skillActivationId: skill.activationId,
|
||||
skillName: skill.skillName,
|
||||
skillArgs: skill.skillArgs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function projectContextMessage(state: ProjectionState, message: ContextMessage): void {
|
||||
switch (message.role) {
|
||||
case 'user': {
|
||||
const origin = backgroundOrigin(message);
|
||||
if (origin !== undefined) {
|
||||
flushAssistant(state);
|
||||
projectBackgroundTaskNotification(state, origin);
|
||||
return;
|
||||
}
|
||||
if (message.origin?.kind === 'hook_result') {
|
||||
projectHookResultMessage(state, message);
|
||||
return;
|
||||
}
|
||||
if (message.origin?.kind === 'injection') {
|
||||
return;
|
||||
}
|
||||
flushAssistant(state);
|
||||
const skill = skillActivationFromOrigin(message.origin);
|
||||
if (skill !== undefined) {
|
||||
projectSkillActivation(state, skill);
|
||||
return;
|
||||
}
|
||||
state.entries.push(entry('user', contentPartsToText(message.content), 'plain'));
|
||||
return;
|
||||
}
|
||||
case 'assistant':
|
||||
if (message.origin?.kind === 'hook_result') {
|
||||
projectHookResultMessage(state, message);
|
||||
projectMessageToolCalls(state, message.toolCalls);
|
||||
return;
|
||||
}
|
||||
collectMessageContent(state.assistant, message.content);
|
||||
flushAssistant(state);
|
||||
projectMessageToolCalls(state, message.toolCalls);
|
||||
return;
|
||||
case 'tool':
|
||||
flushAssistant(state);
|
||||
projectMessageToolResult(state, message);
|
||||
return;
|
||||
case 'system':
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function projectMessageToolCalls(state: ProjectionState, toolCalls: readonly ToolCall[]): void {
|
||||
for (const rawToolCall of toolCalls) {
|
||||
const toolCall = toolCallFromMessage(rawToolCall);
|
||||
if (toolCall === undefined) continue;
|
||||
state.toolCalls.set(toolCall.id, toolCall);
|
||||
state.entries.push(
|
||||
entry('tool_call', '', 'plain', {
|
||||
toolCallData: toolCall,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function projectMessageToolResult(state: ProjectionState, message: ContextMessage): void {
|
||||
const toolCallId = message.toolCallId;
|
||||
if (toolCallId === undefined) return;
|
||||
const call = state.toolCalls.get(toolCallId);
|
||||
if (call === undefined) return;
|
||||
call.result = {
|
||||
tool_call_id: toolCallId,
|
||||
output: toolResultOutput(message.content),
|
||||
is_error: message.isError,
|
||||
};
|
||||
}
|
||||
|
||||
function projectBackgroundTaskNotification(
|
||||
state: ProjectionState,
|
||||
origin: BackgroundTaskOrigin,
|
||||
): void {
|
||||
const task = state.backgroundTasks.get(origin.taskId);
|
||||
const meta: BackgroundAgentMetadata = {
|
||||
agentId: origin.taskId,
|
||||
parentToolCallId: origin.taskId,
|
||||
description: task?.description,
|
||||
};
|
||||
let status = formatBackgroundAgentTranscript(
|
||||
origin.status === 'completed' ? 'completed' : 'failed',
|
||||
meta,
|
||||
);
|
||||
if (origin.status === 'lost') {
|
||||
status = {
|
||||
...status,
|
||||
headline: status.headline.replace(' failed in background', ' lost in background'),
|
||||
};
|
||||
} else if (origin.status === 'killed') {
|
||||
status = {
|
||||
...status,
|
||||
headline: status.headline.replace(' failed in background', ' stopped'),
|
||||
};
|
||||
}
|
||||
state.entries.push(
|
||||
entry('status', status.headline, 'plain', {
|
||||
detail: status.detail,
|
||||
backgroundAgentStatus: status,
|
||||
}),
|
||||
);
|
||||
state.backgroundAgents.delete(meta.agentId);
|
||||
state.backgroundAgentMetadata.delete(meta.agentId);
|
||||
}
|
||||
|
||||
function toolResultOutput(content: readonly ContentPart[]): string {
|
||||
if (content.some((part) => part.type !== 'text')) {
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
return contentPartsToText(content);
|
||||
}
|
||||
|
||||
function flushAssistant(state: ProjectionState): void {
|
||||
const thinking = state.assistant.thinking.join('');
|
||||
const text = state.assistant.text.join('');
|
||||
state.assistant = { thinking: [], text: [] };
|
||||
if (thinking.length > 0) {
|
||||
state.entries.push(entry('thinking', thinking, 'plain'));
|
||||
}
|
||||
if (text.length > 0) {
|
||||
state.entries.push(entry('assistant', text, 'markdown'));
|
||||
}
|
||||
}
|
||||
|
||||
function projectHookResultMessage(state: ProjectionState, message: ContextMessage): void {
|
||||
if (message.origin?.kind !== 'hook_result') return;
|
||||
flushAssistant(state);
|
||||
state.entries.push(
|
||||
entry(
|
||||
'assistant',
|
||||
formatHookResultMessageForTranscript(
|
||||
contentPartsToText(message.content),
|
||||
message.origin.event,
|
||||
message.origin.blocked === true,
|
||||
),
|
||||
'markdown',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const HOOK_RESULT_RE =
|
||||
/<hook_result\s+hook_event="([^"]+)">\n?([\s\S]*?)\n?<\/hook_result>/g;
|
||||
|
||||
function formatHookResultMessageForTranscript(
|
||||
text: string,
|
||||
fallbackEvent: string,
|
||||
blocked: boolean,
|
||||
): string {
|
||||
const results: Array<{ event: string; body: string }> = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of text.matchAll(HOOK_RESULT_RE)) {
|
||||
if (text.slice(lastIndex, match.index).trim().length > 0) {
|
||||
return formatHookResultBlock(fallbackEvent, text, blocked);
|
||||
}
|
||||
const event = match[1];
|
||||
const body = match[2];
|
||||
if (event === undefined || body === undefined) {
|
||||
return formatHookResultBlock(fallbackEvent, text, blocked);
|
||||
}
|
||||
results.push({ event, body });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (results.length === 0 || text.slice(lastIndex).trim().length > 0) {
|
||||
return formatHookResultBlock(fallbackEvent, text, blocked);
|
||||
}
|
||||
|
||||
return results.map(({ event, body }) => formatHookResultBlock(event, body, blocked)).join('\n\n');
|
||||
}
|
||||
|
||||
function formatHookResultBlock(event: string, body: string, blocked: boolean): string {
|
||||
return `*${event} hook${blocked ? ' blocked' : ''}*\n\n${body.trim() || '(empty)'}`;
|
||||
}
|
||||
|
||||
function collectMessageContent(target: OpenAssistant, content: readonly ContentPart[]): void {
|
||||
for (const part of content) {
|
||||
switch (part.type) {
|
||||
case 'think':
|
||||
target.thinking.push(part.think);
|
||||
break;
|
||||
case 'text':
|
||||
target.text.push(part.text);
|
||||
break;
|
||||
case 'audio_url':
|
||||
case 'image_url':
|
||||
case 'video_url':
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toolCallFromMessage(rawToolCall: ToolCall): ToolCallBlockData | undefined {
|
||||
const id = rawToolCall.id;
|
||||
const name = rawToolCall.function.name;
|
||||
if (id.length === 0 || name.length === 0) return undefined;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
args: parseToolArguments(rawToolCall.function.arguments),
|
||||
};
|
||||
}
|
||||
|
||||
function parseToolArguments(value: string | null): Record<string, unknown> {
|
||||
if (value === null || value.length === 0) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return isObject(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function entry(
|
||||
kind: TranscriptEntry['kind'],
|
||||
content: string,
|
||||
renderMode: TranscriptEntry['renderMode'],
|
||||
extras?: {
|
||||
turnId?: string;
|
||||
toolCallData?: ToolCallBlockData;
|
||||
detail?: string;
|
||||
color?: string;
|
||||
backgroundAgentStatus?: BackgroundAgentStatusData;
|
||||
skillActivationId?: string;
|
||||
skillName?: string;
|
||||
skillArgs?: string;
|
||||
},
|
||||
): TranscriptEntry {
|
||||
return {
|
||||
id: nextTranscriptId(),
|
||||
kind,
|
||||
renderMode,
|
||||
content,
|
||||
turnId: extras?.turnId,
|
||||
detail: extras?.detail,
|
||||
color: extras?.color,
|
||||
toolCallData: extras?.toolCallData,
|
||||
backgroundAgentStatus: extras?.backgroundAgentStatus,
|
||||
skillActivationId: extras?.skillActivationId,
|
||||
skillName: extras?.skillName,
|
||||
skillArgs: extras?.skillArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function contentPartsToText(content: readonly ContentPart[]): string {
|
||||
return content.map(userPartToText).join('');
|
||||
}
|
||||
|
||||
function backgroundOrigin(message: ContextMessage): BackgroundTaskOrigin | undefined {
|
||||
return message.origin?.kind === 'background_task' ? message.origin : undefined;
|
||||
}
|
||||
|
||||
function userPartToText(part: ContentPart): string {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return part.text;
|
||||
case 'think':
|
||||
return part.think;
|
||||
case 'image_url':
|
||||
return mediaUrlPartToText('image', part.imageUrl.url);
|
||||
case 'video_url':
|
||||
return mediaUrlPartToText('video', part.videoUrl.url);
|
||||
case 'audio_url':
|
||||
return mediaUrlPartToText('audio', part.audioUrl.url);
|
||||
}
|
||||
}
|
||||
|
||||
function skillActivationFromOrigin(
|
||||
origin: PromptOrigin | undefined,
|
||||
): SkillActivationProjection | undefined {
|
||||
if (origin?.kind !== 'skill_activation') return undefined;
|
||||
return {
|
||||
activationId: origin.activationId,
|
||||
skillName: origin.skillName,
|
||||
skillArgs: origin.skillArgs,
|
||||
};
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject projected flat entries into live state. Adjacent Agent tool_call
|
||||
* entries sharing `(turnId, step)` are grouped into an AgentGroupComponent so
|
||||
* replay matches live behavior. Other entries use the original append path.
|
||||
*
|
||||
* Unlike `tryAttachAgentToolCall`, this does not write
|
||||
* `state.pendingAgentGroup`; after replay, live events must take over from a
|
||||
* clean pending group state.
|
||||
*/
|
||||
export function hydrateProjectedEntries(
|
||||
state: TUIState,
|
||||
entries: readonly TranscriptEntry[],
|
||||
appendEntry: (entry: TranscriptEntry) => void,
|
||||
): void {
|
||||
let i = 0;
|
||||
while (i < entries.length) {
|
||||
const cur = entries[i];
|
||||
if (cur === undefined) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (cur.kind === 'skill_activation' && cur.skillActivationId !== undefined) {
|
||||
if (state.renderedSkillActivationIds.has(cur.skillActivationId)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
state.renderedSkillActivationIds.add(cur.skillActivationId);
|
||||
}
|
||||
const tc = cur.toolCallData;
|
||||
if (
|
||||
cur.kind === 'tool_call' &&
|
||||
tc !== undefined &&
|
||||
tc.name === 'Agent' &&
|
||||
tc.step !== undefined
|
||||
) {
|
||||
// Collect all adjacent Agent calls with the same step and turn id.
|
||||
const batch: TranscriptEntry[] = [cur];
|
||||
let j = i + 1;
|
||||
while (j < entries.length) {
|
||||
const next = entries[j];
|
||||
if (next === undefined) break;
|
||||
const nextTc = next.toolCallData;
|
||||
if (
|
||||
next.kind === 'tool_call' &&
|
||||
nextTc !== undefined &&
|
||||
nextTc.name === 'Agent' &&
|
||||
nextTc.step === tc.step &&
|
||||
nextTc.turnId === tc.turnId
|
||||
) {
|
||||
batch.push(next);
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (batch.length >= 2) {
|
||||
attachAgentBatchAsGroup(state, batch);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
// A single Agent stays on the standalone card path.
|
||||
}
|
||||
appendEntry(cur);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
function attachAgentBatchAsGroup(state: TUIState, batch: readonly TranscriptEntry[]): void {
|
||||
const group = new AgentGroupComponent(state.theme.colors, state.ui);
|
||||
state.transcriptContainer.addChild(group);
|
||||
for (const item of batch) {
|
||||
const tc = item.toolCallData;
|
||||
if (tc === undefined) continue;
|
||||
state.transcriptEntries.push(item);
|
||||
const component = new ToolCallComponent(
|
||||
tc,
|
||||
tc.result,
|
||||
state.theme.colors,
|
||||
state.ui,
|
||||
state.theme.markdownTheme,
|
||||
state.appState.workDir,
|
||||
);
|
||||
if (state.toolOutputExpanded) component.setExpanded(true);
|
||||
if (state.planExpanded) component.setPlanExpanded(true);
|
||||
state.pendingToolComponents.set(tc.id, component);
|
||||
group.attach(tc.id, component);
|
||||
}
|
||||
state.ui.requestRender();
|
||||
}
|
||||
5
apps/kimi-code/src/tui/commands/index.ts
Normal file
5
apps/kimi-code/src/tui/commands/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './parse';
|
||||
export * from './registry';
|
||||
export * from './resolve';
|
||||
export * from './skills';
|
||||
export * from './types';
|
||||
12
apps/kimi-code/src/tui/commands/parse.ts
Normal file
12
apps/kimi-code/src/tui/commands/parse.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ParsedSlashInput } from './types';
|
||||
|
||||
export function parseSlashInput(input: string): ParsedSlashInput | null {
|
||||
if (!input.startsWith('/')) return null;
|
||||
const trimmed = input.slice(1).trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
const spaceIdx = trimmed.indexOf(' ');
|
||||
const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx);
|
||||
const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim();
|
||||
if (name.includes('/')) return null;
|
||||
return { name, args };
|
||||
}
|
||||
181
apps/kimi-code/src/tui/commands/registry.ts
Normal file
181
apps/kimi-code/src/tui/commands/registry.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import type { KimiSlashCommand, SlashCommandAvailability } from './types';
|
||||
|
||||
export const BUILTIN_SLASH_COMMANDS = [
|
||||
{
|
||||
name: 'yolo',
|
||||
aliases: ['yes'],
|
||||
description: 'Toggle auto-approve mode',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'permission',
|
||||
aliases: [],
|
||||
description: 'Select permission mode',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'settings',
|
||||
aliases: ['config'],
|
||||
description: 'Open TUI settings',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'plan',
|
||||
aliases: [],
|
||||
description: 'Toggle plan mode',
|
||||
priority: 100,
|
||||
availability: (args) => (args.trim().toLowerCase() === 'clear' ? 'idle-only' : 'always'),
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
aliases: [],
|
||||
description: 'Switch LLM model',
|
||||
priority: 100,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'help',
|
||||
aliases: ['h', '?'],
|
||||
description: 'Show available commands and shortcuts',
|
||||
priority: 80,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'new',
|
||||
aliases: ['clear'],
|
||||
description: 'Start a fresh session in the current workspace',
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
name: 'sessions',
|
||||
aliases: ['resume'],
|
||||
description: 'Browse and resume sessions',
|
||||
priority: 80,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'tasks',
|
||||
aliases: ['task'],
|
||||
description: 'Browse background tasks',
|
||||
priority: 80,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'mcp',
|
||||
aliases: [],
|
||||
description: 'Show MCP server status',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'compact',
|
||||
aliases: [],
|
||||
description: 'Compact the conversation context',
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
name: 'init',
|
||||
aliases: [],
|
||||
description: 'Analyze the codebase and generate AGENTS.md',
|
||||
},
|
||||
{
|
||||
name: 'fork',
|
||||
aliases: [],
|
||||
description: 'Fork the current session',
|
||||
priority: 80,
|
||||
},
|
||||
{
|
||||
name: 'title',
|
||||
aliases: ['rename'],
|
||||
description: 'Set or show session title',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'usage',
|
||||
aliases: [],
|
||||
description: 'Show session tokens + context window + plan quotas',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
aliases: [],
|
||||
description: 'Show current session and runtime status',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'feedback',
|
||||
aliases: [],
|
||||
description: 'Send feedback to make Kimi Code better',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'editor',
|
||||
aliases: [],
|
||||
description: 'Set the external editor for Ctrl-G',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'theme',
|
||||
aliases: [],
|
||||
description: 'Set the terminal UI theme',
|
||||
priority: 60,
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
name: 'logout',
|
||||
aliases: [],
|
||||
description: 'Clear credentials for the current platform',
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: 'login',
|
||||
aliases: [],
|
||||
description: 'Select a platform and authenticate',
|
||||
priority: 40,
|
||||
},
|
||||
{
|
||||
name: 'exit',
|
||||
aliases: ['quit', 'q'],
|
||||
description: 'Exit the application',
|
||||
priority: 20,
|
||||
},
|
||||
{
|
||||
name: 'version',
|
||||
aliases: [],
|
||||
description: 'Show version information',
|
||||
priority: 20,
|
||||
availability: 'always',
|
||||
},
|
||||
] as const satisfies readonly KimiSlashCommand[];
|
||||
|
||||
export type BuiltinSlashCommand = (typeof BUILTIN_SLASH_COMMANDS)[number];
|
||||
export type BuiltinSlashCommandName = BuiltinSlashCommand['name'];
|
||||
|
||||
export function findBuiltInSlashCommand(commandName: string): BuiltinSlashCommand | undefined {
|
||||
const commands = BUILTIN_SLASH_COMMANDS as readonly KimiSlashCommand<BuiltinSlashCommandName>[];
|
||||
return commands.find(
|
||||
(command) => command.name === commandName || command.aliases.includes(commandName),
|
||||
) as BuiltinSlashCommand | undefined;
|
||||
}
|
||||
|
||||
export function resolveSlashCommandAvailability(
|
||||
command: KimiSlashCommand,
|
||||
args: string,
|
||||
): SlashCommandAvailability {
|
||||
const availability = command.availability ?? 'idle-only';
|
||||
return typeof availability === 'function' ? availability(args) : availability;
|
||||
}
|
||||
|
||||
export function sortSlashCommands(commands: readonly KimiSlashCommand[]): KimiSlashCommand[] {
|
||||
return [...commands].toSorted(
|
||||
(a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
115
apps/kimi-code/src/tui/commands/resolve.ts
Normal file
115
apps/kimi-code/src/tui/commands/resolve.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import {
|
||||
findBuiltInSlashCommand,
|
||||
resolveSlashCommandAvailability,
|
||||
type BuiltinSlashCommand,
|
||||
type BuiltinSlashCommandName,
|
||||
} from './registry';
|
||||
import { parseSlashInput } from './parse';
|
||||
import type { SlashCommandBusyReason, SlashCommandInvalidReason } from './types';
|
||||
|
||||
export type SlashCommandIntent =
|
||||
| { readonly kind: 'not-command' }
|
||||
| {
|
||||
readonly kind: 'builtin';
|
||||
readonly command: BuiltinSlashCommand;
|
||||
readonly name: BuiltinSlashCommandName;
|
||||
readonly args: string;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'skill';
|
||||
readonly commandName: string;
|
||||
readonly skillName: string;
|
||||
readonly args: string;
|
||||
}
|
||||
| { readonly kind: 'message'; readonly input: string }
|
||||
| {
|
||||
readonly kind: 'blocked';
|
||||
readonly commandName: string;
|
||||
readonly reason: SlashCommandBusyReason;
|
||||
}
|
||||
| {
|
||||
readonly kind: 'invalid';
|
||||
readonly commandName: string;
|
||||
readonly reason: SlashCommandInvalidReason;
|
||||
};
|
||||
|
||||
export interface ResolveSlashCommandInput {
|
||||
readonly input: string;
|
||||
readonly skillCommandMap: ReadonlyMap<string, string>;
|
||||
readonly isStreaming: boolean;
|
||||
readonly isCompacting: boolean;
|
||||
}
|
||||
|
||||
export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent {
|
||||
const parsed = parseSlashInput(options.input);
|
||||
if (parsed === null) return { kind: 'not-command' };
|
||||
|
||||
const command = findBuiltInSlashCommand(parsed.name);
|
||||
if (command !== undefined) {
|
||||
const busyReason = slashCommandBusyReason(options);
|
||||
if (
|
||||
busyReason !== undefined &&
|
||||
resolveSlashCommandAvailability(command, parsed.args) === 'idle-only'
|
||||
) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
commandName: parsed.name,
|
||||
reason: busyReason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'builtin',
|
||||
command,
|
||||
name: command.name,
|
||||
args: parsed.args,
|
||||
};
|
||||
}
|
||||
|
||||
const skillName = resolveSkillCommand(options.skillCommandMap, parsed.name);
|
||||
if (skillName !== undefined) {
|
||||
const busyReason = slashCommandBusyReason(options);
|
||||
if (busyReason !== undefined) {
|
||||
return {
|
||||
kind: 'blocked',
|
||||
commandName: parsed.name,
|
||||
reason: busyReason,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'skill',
|
||||
commandName: parsed.name,
|
||||
skillName,
|
||||
args: parsed.args.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'message',
|
||||
input: options.input,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSkillCommand(
|
||||
skillCommandMap: ReadonlyMap<string, string>,
|
||||
commandName: string,
|
||||
): string | undefined {
|
||||
return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`);
|
||||
}
|
||||
|
||||
export function slashCommandBusyReason(
|
||||
options: Pick<ResolveSlashCommandInput, 'isStreaming' | 'isCompacting'>,
|
||||
): SlashCommandBusyReason | undefined {
|
||||
if (options.isStreaming) return 'streaming';
|
||||
if (options.isCompacting) return 'compacting';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function slashBusyMessage(
|
||||
commandName: string,
|
||||
reason: SlashCommandBusyReason,
|
||||
): string {
|
||||
if (reason === 'streaming') {
|
||||
return `Cannot /${commandName} while streaming — press Esc or Ctrl-C first.`;
|
||||
}
|
||||
return `Cannot /${commandName} while compacting — wait for compaction to finish first.`;
|
||||
}
|
||||
33
apps/kimi-code/src/tui/commands/skills.ts
Normal file
33
apps/kimi-code/src/tui/commands/skills.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { Session, SkillSummary } from '@moonshot-ai/kimi-code-sdk';
|
||||
|
||||
import type { KimiSlashCommand } from './types';
|
||||
|
||||
export type SkillListSession = Pick<Session, 'listSkills'>;
|
||||
|
||||
export interface SkillSlashCommands {
|
||||
readonly commands: readonly KimiSlashCommand[];
|
||||
readonly commandMap: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export function isUserActivatableSkill(skill: SkillSummary): boolean {
|
||||
return (
|
||||
skill.type === undefined ||
|
||||
skill.type === 'prompt' ||
|
||||
skill.type === 'inline' ||
|
||||
skill.type === 'flow'
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSkillSlashCommands(skills: readonly SkillSummary[]): SkillSlashCommands {
|
||||
const commandMap = new Map<string, string>();
|
||||
const commands = skills.filter(isUserActivatableSkill).map((skill) => {
|
||||
const commandName = `skill:${skill.name}`;
|
||||
commandMap.set(commandName, skill.name);
|
||||
return {
|
||||
name: commandName,
|
||||
aliases: [],
|
||||
description: skill.description ?? '',
|
||||
};
|
||||
});
|
||||
return { commands, commandMap };
|
||||
}
|
||||
20
apps/kimi-code/src/tui/commands/types.ts
Normal file
20
apps/kimi-code/src/tui/commands/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { SlashCommand } from '@earendil-works/pi-tui';
|
||||
|
||||
export type SlashCommandAvailability = 'always' | 'idle-only';
|
||||
|
||||
export interface KimiSlashCommand<Name extends string = string> extends SlashCommand {
|
||||
readonly name: Name;
|
||||
readonly aliases: readonly string[];
|
||||
readonly description: string;
|
||||
readonly priority?: number;
|
||||
readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability);
|
||||
}
|
||||
|
||||
export interface ParsedSlashInput {
|
||||
readonly name: string;
|
||||
readonly args: string;
|
||||
}
|
||||
|
||||
export type SlashCommandBusyReason = 'streaming' | 'compacting';
|
||||
|
||||
export type SlashCommandInvalidReason = 'unknown';
|
||||
76
apps/kimi-code/src/tui/components/chrome/device-code-box.ts
Normal file
76
apps/kimi-code/src/tui/components/chrome/device-code-box.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* OAuth device-code panel rendered inside the transcript.
|
||||
*
|
||||
* Borrows the rounded-border layout from `WelcomeComponent` so the login
|
||||
* prompt matches the rest of the chrome. All colors flow through the
|
||||
* active palette so theme switches take effect on the next render.
|
||||
*/
|
||||
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
|
||||
export interface DeviceCodeBoxParams {
|
||||
readonly title: string;
|
||||
readonly url: string;
|
||||
readonly code: string;
|
||||
readonly hint?: string;
|
||||
readonly colors: ColorPalette;
|
||||
}
|
||||
|
||||
export class DeviceCodeBoxComponent implements Component {
|
||||
private readonly params: DeviceCodeBoxParams;
|
||||
|
||||
constructor(params: DeviceCodeBoxParams) {
|
||||
this.params = params;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const { title, url, code, hint, colors } = this.params;
|
||||
const border = (s: string): string => chalk.hex(colors.primary)(s);
|
||||
const safeWidth = Math.max(28, width);
|
||||
const innerWidth = Math.max(10, safeWidth - 4);
|
||||
const pad = ' ';
|
||||
|
||||
const titleLine = truncateToWidth(chalk.bold.hex(colors.textStrong)(title), innerWidth, '…');
|
||||
const promptLine = truncateToWidth(
|
||||
chalk.hex(colors.textDim)('Visit the URL below in your browser to authorize:'),
|
||||
innerWidth,
|
||||
'…',
|
||||
);
|
||||
const urlLine = truncateToWidth(chalk.hex(colors.primary)(url), innerWidth, '…');
|
||||
|
||||
const codeLabel = chalk.bold.hex(colors.textDim)('Verification code: ');
|
||||
const codeValue = chalk.bold.hex(colors.accent)(code);
|
||||
const codeLine = truncateToWidth(`${codeLabel}${codeValue}`, innerWidth, '…');
|
||||
|
||||
const contentLines: string[] = [titleLine, '', promptLine, urlLine, '', codeLine];
|
||||
if (hint !== undefined && hint.length > 0) {
|
||||
contentLines.push('');
|
||||
contentLines.push(truncateToWidth(chalk.hex(colors.textDim)(hint), innerWidth, '…'));
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
'',
|
||||
border('╭' + '─'.repeat(safeWidth - 2) + '╮'),
|
||||
border('│') + ' '.repeat(safeWidth - 2) + border('│'),
|
||||
];
|
||||
|
||||
for (const content of contentLines) {
|
||||
const truncated = content;
|
||||
const vis = visibleWidth(truncated);
|
||||
const rightPad = Math.max(0, innerWidth - vis);
|
||||
lines.push(border('│') + pad + truncated + ' '.repeat(rightPad) + border('│'));
|
||||
}
|
||||
|
||||
lines.push(border('│') + ' '.repeat(safeWidth - 2) + border('│'));
|
||||
lines.push(border('╰' + '─'.repeat(safeWidth - 2) + '╯'));
|
||||
lines.push('');
|
||||
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
266
apps/kimi-code/src/tui/components/chrome/footer.ts
Normal file
266
apps/kimi-code/src/tui/components/chrome/footer.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
/**
|
||||
* Footer/status bar — multi-line status display at the bottom of the TUI.
|
||||
*
|
||||
* Layout:
|
||||
* Line 1: [yolo] [plan] <model> <cwd> <git-badge> <shortcut hints>
|
||||
* Line 2: context: XX.X% (tokens/max)
|
||||
*/
|
||||
|
||||
import type { Component } from '@earendil-works/pi-tui';
|
||||
import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui';
|
||||
import chalk from 'chalk';
|
||||
|
||||
import type { ColorPalette } from '#/tui/theme/colors';
|
||||
import type { AppState } from '#/tui/types';
|
||||
import {
|
||||
createGitStatusCache,
|
||||
formatGitBadgeBase,
|
||||
formatPullRequestBadge,
|
||||
type GitStatus,
|
||||
type GitStatusCache,
|
||||
} from '#/utils/git/git-status';
|
||||
import { safeUsageRatio } from '#/utils/usage/usage-format';
|
||||
|
||||
const MAX_CWD_SEGMENTS = 3;
|
||||
|
||||
// Toolbar tips — rotates every 30s, shows 2 tips joined by " | " when
|
||||
// space allows, falls back to 1.
|
||||
const TIP_ROTATE_INTERVAL_MS = 30_000;
|
||||
const TIP_SEPARATOR = ' | ';
|
||||
const TOOLBAR_TIPS: readonly string[] = [
|
||||
'shift+tab: plan mode',
|
||||
'/yolo: toggle yolo',
|
||||
'ctrl+c: cancel',
|
||||
'/help: show commands',
|
||||
'/model: switch model',
|
||||
'@: mention files',
|
||||
];
|
||||
|
||||
function currentTipIndex(): number {
|
||||
return Math.floor(Date.now() / TIP_ROTATE_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function twoRotatingTips(index: number): string {
|
||||
const n = TOOLBAR_TIPS.length;
|
||||
if (n === 0) return '';
|
||||
if (n === 1) return TOOLBAR_TIPS[0]!;
|
||||
const offset = ((index % n) + n) % n;
|
||||
return TOOLBAR_TIPS[offset]! + TIP_SEPARATOR + TOOLBAR_TIPS[(offset + 1) % n]!;
|
||||
}
|
||||
|
||||
function oneRotatingTip(index: number): string {
|
||||
const n = TOOLBAR_TIPS.length;
|
||||
if (n === 0) return '';
|
||||
const offset = ((index % n) + n) % n;
|
||||
return TOOLBAR_TIPS[offset]!;
|
||||
}
|
||||
|
||||
function shortenModel(model: string): string {
|
||||
if (!model) return model;
|
||||
const slash = model.lastIndexOf('/');
|
||||
return slash >= 0 ? model.slice(slash + 1) : model;
|
||||
}
|
||||
|
||||
function modelDisplayName(state: AppState): string {
|
||||
const model = state.availableModels[state.model];
|
||||
return model?.displayName ?? model?.model ?? state.model;
|
||||
}
|
||||
|
||||
function shortenCwd(path: string): string {
|
||||
if (!path) return path;
|
||||
const home = process.env['HOME'] ?? '';
|
||||
let work = path;
|
||||
if (home && path === home) {
|
||||
return '~';
|
||||
}
|
||||
if (home && path.startsWith(home + '/')) {
|
||||
work = '~' + path.slice(home.length);
|
||||
}
|
||||
|
||||
const segments = work.split('/').filter((s) => s.length > 0);
|
||||
if (segments.length <= MAX_CWD_SEGMENTS) return work;
|
||||
const tail = segments.slice(-MAX_CWD_SEGMENTS).join('/');
|
||||
return `…/${tail}`;
|
||||
}
|
||||
|
||||
function formatTokenCount(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function safeUsage(usage: number): number {
|
||||
return safeUsageRatio(usage);
|
||||
}
|
||||
|
||||
function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string {
|
||||
const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`;
|
||||
if (maxTokens && maxTokens > 0 && tokens !== undefined) {
|
||||
return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`;
|
||||
}
|
||||
return `context: ${pct}`;
|
||||
}
|
||||
|
||||
export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string {
|
||||
const base = chalk.hex(colors.status)(formatGitBadgeBase(status));
|
||||
if (status.pullRequest === null) return base;
|
||||
|
||||
const pullRequest = chalk.hex(colors.primary)(
|
||||
formatPullRequestBadge(status.pullRequest, { linkPullRequest: true }),
|
||||
);
|
||||
return `${base} ${pullRequest}`;
|
||||
}
|
||||
|
||||
export class FooterComponent implements Component {
|
||||
private state: AppState;
|
||||
private colors: ColorPalette;
|
||||
private readonly onGitStatusChange: () => void;
|
||||
private gitCache: GitStatusCache;
|
||||
private gitCacheWorkDir: string;
|
||||
private transientHint: string | null = null;
|
||||
/**
|
||||
* Non-terminal background-task counts split by kind so the footer can
|
||||
* render two distinct badges. `bashTasks` covers `bash-*` BPM tasks
|
||||
* spawned via `Shell run_in_background=true`; `agentTasks` covers
|
||||
* `agent-*` BPM tasks (background subagents). Either zero hides its
|
||||
* respective badge.
|
||||
*/
|
||||
private backgroundBashTaskCount = 0;
|
||||
private backgroundAgentCount = 0;
|
||||
|
||||
constructor(state: AppState, colors: ColorPalette, onGitStatusChange: () => void = () => {}) {
|
||||
this.state = state;
|
||||
this.colors = colors;
|
||||
this.onGitStatusChange = onGitStatusChange;
|
||||
this.gitCacheWorkDir = state.workDir;
|
||||
this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
|
||||
}
|
||||
|
||||
setState(state: AppState): void {
|
||||
if (state.workDir !== this.gitCacheWorkDir) {
|
||||
this.gitCacheWorkDir = state.workDir;
|
||||
this.gitCache = createGitStatusCache(state.workDir, { onChange: this.onGitStatusChange });
|
||||
}
|
||||
this.state = state;
|
||||
}
|
||||
|
||||
setColors(colors: ColorPalette): void {
|
||||
this.colors = colors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Short-lived hint that replaces the rotating toolbar tips on line 1.
|
||||
* Used by the exit-confirmation double-tap flow to show "Press Ctrl+C
|
||||
* again to exit" without requiring a toast/overlay subsystem.
|
||||
* Pass `null` to clear.
|
||||
*/
|
||||
setTransientHint(hint: string | null): void {
|
||||
this.transientHint = hint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync both background-task badges with live counts. Each non-zero
|
||||
* count produces its own bracketed badge on line 1; zeros hide them
|
||||
* independently.
|
||||
*/
|
||||
setBackgroundCounts(counts: { bashTasks: number; agentTasks: number }): void {
|
||||
this.backgroundBashTaskCount = Math.max(0, counts.bashTasks);
|
||||
this.backgroundAgentCount = Math.max(0, counts.agentTasks);
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(width: number): string[] {
|
||||
const colors = this.colors;
|
||||
const state = this.state;
|
||||
|
||||
// ── Line 1: mode badges + model + [N task(s) running] + [N agent(s) running] + cwd + git + hints ──
|
||||
const left: string[] = [];
|
||||
if (state.permissionMode === 'auto') left.push(chalk.hex(colors.warning).bold('auto'));
|
||||
if (state.permissionMode === 'yolo') left.push(chalk.hex(colors.warning).bold('yolo'));
|
||||
if (state.planMode) left.push(chalk.hex(colors.primary).bold('plan'));
|
||||
|
||||
const model = shortenModel(modelDisplayName(state));
|
||||
if (model) {
|
||||
const thinkingLabel = state.thinking ? ' thinking' : '';
|
||||
left.push(chalk.hex(colors.text)(`${model}${thinkingLabel}`));
|
||||
}
|
||||
|
||||
// Background-task badges sit immediately before cwd. `bash-*` tasks
|
||||
// (shell processes) and `agent-*` tasks (background subagents) get
|
||||
// separate badges so the user can distinguish them at a glance.
|
||||
if (this.backgroundBashTaskCount > 0) {
|
||||
const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks';
|
||||
left.push(
|
||||
chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`),
|
||||
);
|
||||
}
|
||||
if (this.backgroundAgentCount > 0) {
|
||||
const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents';
|
||||
left.push(
|
||||
chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`),
|
||||
);
|
||||
}
|
||||
|
||||
const cwd = shortenCwd(state.workDir);
|
||||
if (cwd) left.push(chalk.hex(colors.status)(cwd));
|
||||
|
||||
const git = this.gitCache.getStatus();
|
||||
if (git !== null) {
|
||||
left.push(formatFooterGitBadge(git, colors));
|
||||
}
|
||||
|
||||
const leftLine = left.join(' ');
|
||||
const leftWidth = visibleWidth(leftLine);
|
||||
|
||||
// Rotating hint tips, fill remaining space on line 1.
|
||||
const tipIndex = currentTipIndex();
|
||||
const tipTwo = twoRotatingTips(tipIndex);
|
||||
const tipOne = oneRotatingTip(tipIndex);
|
||||
const gap = 2;
|
||||
const remaining = Math.max(0, width - leftWidth - gap);
|
||||
let tipText = '';
|
||||
if (tipTwo && visibleWidth(tipTwo) <= remaining) {
|
||||
tipText = tipTwo;
|
||||
} else if (tipOne && visibleWidth(tipOne) <= remaining) {
|
||||
tipText = tipOne;
|
||||
}
|
||||
|
||||
let line1: string;
|
||||
if (tipText) {
|
||||
const pad = width - leftWidth - visibleWidth(tipText);
|
||||
line1 = leftLine + ' '.repeat(Math.max(0, pad)) + chalk.hex(colors.textMuted)(tipText);
|
||||
} else if (leftWidth <= width) {
|
||||
line1 = leftLine;
|
||||
} else {
|
||||
line1 = truncateToWidth(leftLine, width, '…');
|
||||
}
|
||||
|
||||
// ── Line 2: transient hint (bottom-left) + context (right) ──
|
||||
const contextText = formatContextStatus(
|
||||
state.contextUsage,
|
||||
state.contextTokens,
|
||||
state.maxContextTokens,
|
||||
);
|
||||
const contextWidth = visibleWidth(contextText);
|
||||
let line2: string;
|
||||
if (this.transientHint) {
|
||||
const maxHintWidth = Math.max(0, width - contextWidth - 1);
|
||||
const shownHint =
|
||||
visibleWidth(this.transientHint) <= maxHintWidth
|
||||
? this.transientHint
|
||||
: truncateToWidth(this.transientHint, maxHintWidth, '…');
|
||||
const hintWidth = visibleWidth(shownHint);
|
||||
const pad = Math.max(0, width - hintWidth - contextWidth);
|
||||
line2 =
|
||||
chalk.hex(colors.warning).bold(shownHint) +
|
||||
' '.repeat(pad) +
|
||||
chalk.hex(colors.text)(contextText);
|
||||
} else {
|
||||
const leftPad = Math.max(0, width - contextWidth);
|
||||
line2 = ' '.repeat(leftPad) + chalk.hex(colors.text)(contextText);
|
||||
}
|
||||
|
||||
return [truncateToWidth(line1, width), truncateToWidth(line2, width)];
|
||||
}
|
||||
}
|
||||
33
apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Normal file
33
apps/kimi-code/src/tui/components/chrome/gutter-container.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/**
|
||||
* Container that reserves left/right gutter columns around its children,
|
||||
* so the chrome (statusline, transcript, panels) lines up with the input
|
||||
* box's inner content area instead of butting up against the terminal edge.
|
||||
*
|
||||
* Children are rendered at `width - left - right` and each emitted line is
|
||||
* prefixed with `left` plain spaces. Right padding is logical only — we
|
||||
* never emit trailing spaces, since terminals already paint background to
|
||||
* the edge and adding them would just churn the diff renderer.
|
||||
*/
|
||||
|
||||
import { Container } from '@earendil-works/pi-tui';
|
||||
|
||||
export class GutterContainer extends Container {
|
||||
constructor(
|
||||
private readonly leftPad: number,
|
||||
private readonly rightPad: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const inner = Math.max(1, width - this.leftPad - this.rightPad);
|
||||
const lead = ' '.repeat(this.leftPad);
|
||||
const out: string[] = [];
|
||||
for (const child of this.children) {
|
||||
for (const line of child.render(inner)) {
|
||||
out.push(lead + line);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
68
apps/kimi-code/src/tui/components/chrome/moon-loader.ts
Normal file
68
apps/kimi-code/src/tui/components/chrome/moon-loader.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Text } from '@earendil-works/pi-tui';
|
||||
import type { TUI } from '@earendil-works/pi-tui';
|
||||
|
||||
import {
|
||||
BRAILLE_SPINNER_FRAMES,
|
||||
BRAILLE_SPINNER_INTERVAL_MS,
|
||||
MOON_SPINNER_FRAMES,
|
||||
MOON_SPINNER_INTERVAL_MS,
|
||||
} from '#/tui/constant/rendering';
|
||||
|
||||
export type SpinnerStyle = 'moon' | 'braille';
|
||||
|
||||
export class MoonLoader extends Text {
|
||||
private currentFrame = 0;
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private ui: TUI;
|
||||
private frames: string[];
|
||||
private interval: number;
|
||||
private colorFn?: (s: string) => string;
|
||||
private label: string;
|
||||
|
||||
constructor(
|
||||
ui: TUI,
|
||||
style: SpinnerStyle = 'moon',
|
||||
colorFn?: (s: string) => string,
|
||||
label: string = '',
|
||||
) {
|
||||
super('', 1, 0);
|
||||
this.ui = ui;
|
||||
this.frames = style === 'moon' ? [...MOON_SPINNER_FRAMES] : [...BRAILLE_SPINNER_FRAMES];
|
||||
this.interval = style === 'moon' ? MOON_SPINNER_INTERVAL_MS : BRAILLE_SPINNER_INTERVAL_MS;
|
||||
this.colorFn = colorFn;
|
||||
this.label = label;
|
||||
this.start();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.updateDisplay();
|
||||
this.intervalId = setInterval(() => {
|
||||
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
|
||||
this.updateDisplay();
|
||||
}, this.interval);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
}
|
||||
|
||||
setLabel(label: string): void {
|
||||
this.label = label;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
setColorFn(colorFn: (s: string) => string): void {
|
||||
this.colorFn = colorFn;
|
||||
this.updateDisplay();
|
||||
}
|
||||
|
||||
private updateDisplay(): void {
|
||||
const frame = this.frames[this.currentFrame]!;
|
||||
const coloredFrame = this.colorFn ? this.colorFn(frame) : frame;
|
||||
this.setText(this.label ? `${coloredFrame} ${this.label}` : coloredFrame);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue