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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
All notable changes to this project are documented in this file.
Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [SemVer](https://semver.org/).

## [1.0.8] - 2026-07-29

### Added
- `configurePermissionMode` step: TTY select (YOLO ON/OFF, seeded with the current value) persists `default_permission_mode` in `~/.kimi-code/config.toml` via a surgical line edit; `FUSENGINE_PERMISSION_MODE` env override for non-interactive runs.
- `configureExperimentalFlag` step: proposes `KIMI_CODE_EXPERIMENTAL_FLAG=1` in the detected shell rc (default YES, guarded idempotent block, zsh/bash/fish) so custom agents are discovered by the v2 engine while the v1 backport (upstream #2232) is unreleased.

### Changed
- MCP preselection parity with the Claude installer: explicit `default` wins, otherwise no-key servers plus key-required ones whose `apiKeyEnv` is already set; key-required entries are labelled ✓ / ⚠ key missing in the multiselect.




Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@fusengine/kimi-code",
"version": "1.0.7",
"version": "1.0.8",
"private": true,
"type": "module",
"description": "Fusengine plugin ecosystem migrated to Kimi Code CLI",
Expand Down
9 changes: 6 additions & 3 deletions scripts/install-kimi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,18 @@
*
* Steps: backup → installRuntimeDeps (harness) → installAgentsMd →
* mergeKimiRules (fences) → selectMcpServers → promptMcpKeys → mergeMcp →
* configureHarness → installHooks → installAgents → writeMarketplace →
* summary → assertInstalledState (--yes only).
* configureHarness → configurePermissionMode (YOLO) →
* configureExperimentalFlag (v2 engine, shell rc) → installHooks →
* installAgents → writeMarketplace → summary → assertInstalledState (--yes only).
*
* Flags: --yes (write; default is --dry-run) · --skip-env (ignore
* $KIMI_HOME/.env for ${VAR} resolution) · --skip-mcp · --verbose.
* Env overrides: KIMI_CODE_HOME (target, default ~/.kimi-code),
* KIMI_PLUGINS_ROOT (plugins dir; tests point it at a synthetic repo),
* FUSENGINE_HARNESS_SRC (harness source dir override),
* FUSENGINE_MCP_SERVERS (non-TTY MCP allowlist, comma-separated).
* FUSENGINE_MCP_SERVERS (non-TTY MCP allowlist, comma-separated),
* FUSENGINE_PERMISSION_MODE (non-TTY permission mode: manual|yolo|auto),
* FUSENGINE_EXPERIMENTAL_FLAG=1 (non-TTY opt-in: v2 engine export → shell rc).
* Dry-run writes nothing outside the repo except marketplace.json (in-repo).
* TTY runs use @clack/prompts; non-TTY keeps the plain console output.
*/
Expand Down
77 changes: 77 additions & 0 deletions scripts/lib/install/experimental-flag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* experimental-flag.ts — Enable the v2 engine by default so custom agents
* (~/.kimi-code/agents/*.md) are discovered: kimi's v1 engine never scans
* them (custom agent files are v2-only until the v1 backport ships), and the
* only switch is KIMI_CODE_EXPERIMENTAL_FLAG=1 in the kimi process env.
* Kimi does NOT read $KIMI_HOME/.env — the export must land in the shell rc
* (Claude-installer parity). TTY: clack confirm, default YES. Non-TTY: only
* with the explicit FUSENGINE_EXPERIMENTAL_FLAG=1 opt-in, so CI/test runs
* never touch the real shell rc. Idempotent: an existing export is kept.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import type { InstallContext, InstallStepResult } from "../../../src/interfaces/index.ts";
import { initUi, info, plan } from "./ui";

const FLAG = "KIMI_CODE_EXPERIMENTAL_FLAG";
const BEGIN = "# >>> fusengine kimi (v2 engine: custom agents) >>>";
const END = "# <<< fusengine kimi <<<";

/** Shell rc path + export line for the detected shell; null when unsupported. */
export function shellTarget(shell: string, home: string): { path: string; line: string } | null {
const name = shell.split("/").pop() ?? "";
if (name === "zsh") return { path: join(home, ".zshrc"), line: `export ${FLAG}=1` };
if (name === "bash") return { path: join(home, ".bashrc"), line: `export ${FLAG}=1` };
if (name === "fish") return { path: join(home, ".config", "fish", "config.fish"), line: `set -gx ${FLAG} 1` };
return null;
}

/** Append the guarded export block to the rc file (creates it when missing). */
export async function writeFlagBlock(path: string, line: string): Promise<void> {
const current = await Bun.file(path)
.text()
.catch(() => "");
await Bun.write(path, `${current.replace(/\n*$/, "\n")}${BEGIN}\n${line}\n${END}\n`);
}

/** Propose/apply the default-on v2 engine flag in the user's shell rc. */
export async function configureExperimentalFlag(ctx: InstallContext): Promise<InstallStepResult> {
const res: InstallStepResult = { name: "configureExperimentalFlag", status: "ok", notes: [] };
const target = shellTarget(process.env.SHELL ?? "", homedir());
if (!target) {
res.status = "skip";
res.notes.push(`unsupported shell: ${process.env.SHELL ?? "unknown"}`);
return res;
}
const rc = await Bun.file(target.path)
.text()
.catch(() => "");
if (rc.includes(FLAG)) {
res.status = "skip";
res.notes.push(`${FLAG} already in ${target.path}`);
return res;
}
if (ctx.dryRun) {
plan(`append ${FLAG}=1 to ${target.path} (v2 engine → custom agents)`);
res.notes.push(`target: ${target.path}`);
return res;
}
let apply = process.env.FUSENGINE_EXPERIMENTAL_FLAG === "1";
const p = await initUi();
if (p && process.stdin.isTTY) {
const wants = await p.confirm({
message: `Enable the v2 engine by default (${FLAG}=1 in ${target.path})? Required for custom agents.`,
initialValue: true,
});
if (!p.isCancel(wants)) apply = wants;
}
if (!apply) {
res.status = "skip";
res.notes.push("declined — custom agents stay v2-gated");
return res;
}
await writeFlagBlock(target.path, target.line);
info(`${FLAG}=1 → ${target.path} (open a new shell or source it)`);
res.notes.push(`flag → ${target.path}`);
return res;
}
45 changes: 36 additions & 9 deletions scripts/lib/install/mcp-select.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
/**
* mcp-select.ts — MCP server selection BEFORE key prompting (parity with the
* Claude installer). TTY: clack multiselect over catalog + .bak servers,
* initialValues = entries whose `default` is not false, required: false.
* Claude installer). TTY: clack multiselect over catalog + .bak servers;
* initialValues follow the Claude rule — explicit `default` wins, otherwise
* no-key servers plus key-required ones whose apiKeyEnv is already set —
* and key-required entries are labelled ✓ / ⚠ key missing. required: false.
* Non-TTY: the explicit FUSENGINE_MCP_SERVERS="a,b,c" allowlist, else all.
* The choice lands in ctx.mcpSelection (undefined = all) and is honored by
* missingKeys and scanMcpServers. Dry-run never prompts: selection stays
* undefined so the plan lists every server as before.
*/
import type { InstallContext, InstallStepResult, McpServerConfig } from "../../../src/interfaces/index.ts";
import { collectRawServers } from "./mcp-resolve";
import { resolutionEnv } from "./env-file";
import { initUi, plan, warn } from "./ui";

type RawServer = { name: string; cfg: McpServerConfig; origin: string };
export type RawServer = { name: string; cfg: McpServerConfig; origin: string };

/** Dedupe by name, first wins — the same rule the merge applies. */
function dedupe(raw: RawServer[]): RawServer[] {
Expand All @@ -23,6 +26,25 @@ function dedupe(raw: RawServer[]): RawServer[] {
});
}

/** True when a server's apiKeyEnv var is set in the resolution environment. */
function keyPresent(cfg: McpServerConfig, env: Record<string, string>): boolean {
return typeof cfg.apiKeyEnv === "string" && !!env[cfg.apiKeyEnv];
}

/**
* Preselection, Claude-installer parity: explicit `default` wins; otherwise
* no-key servers, plus key-required ones whose apiKeyEnv is already set.
*/
export function defaultMcpSelection(all: RawServer[], env: Record<string, string>): string[] {
return all
.filter((s) => {
if (s.cfg.default === true) return true;
if (s.cfg.default === false) return false;
return s.cfg.requiresApiKey !== true || keyPresent(s.cfg, env);
})
.map((s) => s.name);
}

/** Non-interactive selection: explicit env allowlist, else undefined (= all). */
function envSelection(all: RawServer[]): Set<string> | undefined {
const raw = process.env.FUSENGINE_MCP_SERVERS;
Expand Down Expand Up @@ -59,14 +81,19 @@ export async function selectMcpServers(ctx: InstallContext): Promise<InstallStep
if (!p) {
ctx.mcpSelection = envSelection(all);
} else {
const defaults = all.filter((s) => s.cfg.default !== false).map((s) => s.name);
const env = await resolutionEnv(ctx);
const defaults = defaultMcpSelection(all, env);
const picked = await p.multiselect({
message: "Select MCP servers to install:",
options: all.map((s) => ({
value: s.name,
label: s.name,
hint: (s.cfg._description as string) ?? s.origin,
})),
options: all.map((s) => {
const needsKey = s.cfg.requiresApiKey === true;
const status = needsKey ? ` [${keyPresent(s.cfg, env) ? "✓" : "⚠ key missing"}]` : "";
return {
value: s.name,
label: `${s.name}${status}`,
hint: (s.cfg._description as string) ?? s.origin,
};
}),
initialValues: defaults,
required: false,
});
Expand Down
82 changes: 82 additions & 0 deletions scripts/lib/install/permission-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* permission-mode.ts — Propose the default permission mode (YOLO ON/OFF) and
* persist it as `default_permission_mode` in $KIMI_HOME/config.toml.
* TTY: clack select seeded with the current value. Non-TTY: the explicit
* FUSENGINE_PERMISSION_MODE=yolo|manual|auto override, else the step is a
* no-op — an existing config value is never clobbered without a choice.
* The write is a surgical line edit: every other key/section of config.toml
* is preserved verbatim. Dry-run never prompts and never writes.
*/
import { join } from "node:path";
import type { InstallContext, InstallStepResult } from "../../../src/interfaces/index.ts";
import { initUi, info, plan, warn } from "./ui";

const MODES = ["manual", "yolo", "auto"] as const;
type PermissionMode = (typeof MODES)[number];
const KEY_RE = /^default_permission_mode\s*=.*$/m;
const VALUE_RE = /^default_permission_mode\s*=\s*"([a-z]+)"\s*$/m;

/** Current top-level default_permission_mode, or undefined when unset/unreadable. */
export async function currentPermissionMode(path: string): Promise<string | undefined> {
try {
return (await Bun.file(path).text()).match(VALUE_RE)?.[1];
} catch {
return undefined;
}
}

/** Upsert the top-level key, preserving every other line verbatim. */
export function withPermissionMode(toml: string, mode: PermissionMode): string {
const line = `default_permission_mode = "${mode}"`;
if (KEY_RE.test(toml)) return toml.replace(KEY_RE, line);
// Top-level keys must precede any [table] header: prepend at the very top.
return `${line}\n${toml}`;
}

/** Non-interactive mode from FUSENGINE_PERMISSION_MODE; undefined when unset/invalid. */
function envMode(): PermissionMode | undefined {
const raw = process.env.FUSENGINE_PERMISSION_MODE;
if (raw === undefined) return undefined;
if ((MODES as readonly string[]).includes(raw)) return raw as PermissionMode;
warn(`FUSENGINE_PERMISSION_MODE: invalid '${raw}' — expected ${MODES.join("|")}`);
return undefined;
}

/** Ask (TTY) or read the env override, then persist the chosen mode. */
export async function configurePermissionMode(ctx: InstallContext): Promise<InstallStepResult> {
const res: InstallStepResult = { name: "configurePermissionMode", status: "ok", notes: [] };
const path = join(ctx.kimiHome, "config.toml");
const current = (await currentPermissionMode(path)) ?? "manual";
if (ctx.dryRun) {
plan(`propose YOLO mode (current default_permission_mode: ${current})`);
res.notes.push(`current: ${current}`);
return res;
}
const p = await initUi();
let mode: PermissionMode | undefined;
if (p && process.stdin.isTTY) {
const picked = await p.select({
message: "YOLO mode (auto-approve tool actions)?",
options: [
{ value: "manual", label: "OFF — manual (ask before tool actions)" },
{ value: "yolo", label: "ON — yolo (auto-approve tool actions)" },
],
initialValue: current === "yolo" ? "yolo" : "manual",
});
if (!p.isCancel(picked)) mode = picked as PermissionMode;
} else {
mode = envMode();
}
if (!mode) {
res.status = "skip";
res.notes.push(`kept: ${current}`);
return res;
}
const toml = await Bun.file(path)
.text()
.catch(() => "");
await Bun.write(path, withPermissionMode(toml, mode));
info(`default_permission_mode = "${mode}" → config.toml`);
res.notes.push(`mode: ${mode}`);
return res;
}
4 changes: 4 additions & 0 deletions scripts/lib/install/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { mergeMcp } from "./mcp-merge";
import { selectMcpServers } from "./mcp-select";
import { promptMcpKeys } from "./mcp-key-prompt";
import { configureHarness } from "./harness-config";
import { configurePermissionMode } from "./permission-mode";
import { configureExperimentalFlag } from "./experimental-flag";
import { installHooks } from "./hooks-config";
import { installAgents } from "./agents-install";
import { writeMarketplace } from "./marketplace";
Expand Down Expand Up @@ -46,6 +48,8 @@ export async function runInstaller(ctx: InstallContext): Promise<RunOutcome> {
await runStep(results, "promptMcpKeys", () => promptMcpKeys(ctx));
await runStep(results, "mergeMcp", () => mergeMcp(ctx));
await runStep(results, "configureHarness", () => configureHarness(ctx));
await runStep(results, "configurePermissionMode", () => configurePermissionMode(ctx));
await runStep(results, "configureExperimentalFlag", () => configureExperimentalFlag(ctx));
await runStep(results, "installHooks", () => installHooks(ctx));
await runStep(results, "installAgents", () => installAgents(ctx));
step("writeMarketplace");
Expand Down
73 changes: 73 additions & 0 deletions scripts/tests/install-experimental-flag.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* install-experimental-flag.test.mts — v2 engine flag step.
* Non-TTY applies only with FUSENGINE_EXPERIMENTAL_FLAG=1 (never touches the
* real shell rc otherwise): the guarded export block lands in the fake-HOME
* rc, is idempotent across runs, and unsupported shells are skipped.
*/
import { describe, expect, test } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { makeHarness, makeRepo, runInstaller, tmp } from "./install-fixtures.mts";
import { shellTarget } from "../lib/install/experimental-flag.ts";

function fixture() {
const root = tmp("kimi-exp-flag-");
const repo = join(root, "repo");
const harness = join(root, "harness");
makeRepo(repo);
makeHarness(harness);
return { repo, harness, home: join(root, "home"), fakeOsHome: join(root, "oshome") };
}

const FLAG_ENV = { FUSENGINE_EXPERIMENTAL_FLAG: "1", SHELL: "/bin/zsh" };

describe("install-kimi experimental flag", () => {
test("opt-in appends the guarded export to the shell rc", () => {
const { repo, harness, home, fakeOsHome } = fixture();
const r = runInstaller(home, repo, harness, ["--yes"], { ...FLAG_ENV, HOME: fakeOsHome });
expect(r.code).toBe(0);
const rc = readFileSync(join(fakeOsHome, ".zshrc"), "utf8");
expect(rc).toContain("export KIMI_CODE_EXPERIMENTAL_FLAG=1");
expect(rc).toContain("# >>> fusengine kimi");
});

test("a second run does not duplicate the block", () => {
const { repo, harness, home, fakeOsHome } = fixture();
runInstaller(home, repo, harness, ["--yes"], { ...FLAG_ENV, HOME: fakeOsHome });
const r = runInstaller(home, repo, harness, ["--yes"], { ...FLAG_ENV, HOME: fakeOsHome });
expect(r.code).toBe(0);
expect(r.out).toContain("already in");
const rc = readFileSync(join(fakeOsHome, ".zshrc"), "utf8");
expect(rc.split("KIMI_CODE_EXPERIMENTAL_FLAG").length - 1).toBe(1); // export line only
});

test("without the opt-in the shell rc is left alone", () => {
const { repo, harness, home, fakeOsHome } = fixture();
const r = runInstaller(home, repo, harness, ["--yes"], { HOME: fakeOsHome, SHELL: "/bin/zsh" });
expect(r.code).toBe(0);
expect(existsSync(join(fakeOsHome, ".zshrc"))).toBe(false);
});

test("an unsupported shell skips the step", () => {
const { repo, harness, home, fakeOsHome } = fixture();
const r = runInstaller(home, repo, harness, ["--yes"], {
...FLAG_ENV,
HOME: fakeOsHome,
SHELL: "/bin/tcsh",
});
expect(r.code).toBe(0);
expect(r.out).toContain("unsupported shell");
});
});

describe("shellTarget", () => {
test("zsh/bash export, fish set -gx, others unsupported", () => {
expect(shellTarget("/bin/zsh", "/h")?.line).toBe("export KIMI_CODE_EXPERIMENTAL_FLAG=1");
expect(shellTarget("/usr/local/bin/bash", "/h")?.path).toBe("/h/.bashrc");
expect(shellTarget("/opt/homebrew/bin/fish", "/h")).toEqual({
path: "/h/.config/fish/config.fish",
line: "set -gx KIMI_CODE_EXPERIMENTAL_FLAG 1",
});
expect(shellTarget("/bin/tcsh", "/h")).toBeNull();
});
});
Loading