diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d07afd..b1f2526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + diff --git a/package.json b/package.json index 9c1c6d0..2df5d60 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/install-kimi.ts b/scripts/install-kimi.ts index 69579a1..e837ed5 100644 --- a/scripts/install-kimi.ts +++ b/scripts/install-kimi.ts @@ -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. */ diff --git a/scripts/lib/install/experimental-flag.ts b/scripts/lib/install/experimental-flag.ts new file mode 100644 index 0000000..f08c107 --- /dev/null +++ b/scripts/lib/install/experimental-flag.ts @@ -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 { + 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 { + 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; +} diff --git a/scripts/lib/install/mcp-select.ts b/scripts/lib/install/mcp-select.ts index 47e68bc..6e05bb7 100644 --- a/scripts/lib/install/mcp-select.ts +++ b/scripts/lib/install/mcp-select.ts @@ -1,7 +1,9 @@ /** * 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 @@ -9,9 +11,10 @@ */ 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[] { @@ -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): 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[] { + 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 | undefined { const raw = process.env.FUSENGINE_MCP_SERVERS; @@ -59,14 +81,19 @@ export async function selectMcpServers(ctx: InstallContext): Promise 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, }); diff --git a/scripts/lib/install/permission-mode.ts b/scripts/lib/install/permission-mode.ts new file mode 100644 index 0000000..28add85 --- /dev/null +++ b/scripts/lib/install/permission-mode.ts @@ -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 { + 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 { + 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; +} diff --git a/scripts/lib/install/runner.ts b/scripts/lib/install/runner.ts index 0b3e0de..3030a9b 100644 --- a/scripts/lib/install/runner.ts +++ b/scripts/lib/install/runner.ts @@ -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"; @@ -46,6 +48,8 @@ export async function runInstaller(ctx: InstallContext): Promise { 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"); diff --git a/scripts/tests/install-experimental-flag.test.mts b/scripts/tests/install-experimental-flag.test.mts new file mode 100644 index 0000000..b7462a6 --- /dev/null +++ b/scripts/tests/install-experimental-flag.test.mts @@ -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(); + }); +}); diff --git a/scripts/tests/install-mcp-select.test.mts b/scripts/tests/install-mcp-select.test.mts index 8da451b..140a329 100644 --- a/scripts/tests/install-mcp-select.test.mts +++ b/scripts/tests/install-mcp-select.test.mts @@ -8,6 +8,7 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import { makeHarness, makeRepo, runInstaller, tmp } from "./install-fixtures.mts"; +import { defaultMcpSelection } from "../lib/install/mcp-select.ts"; function fixture() { const root = tmp("kimi-mcp-select-"); @@ -56,3 +57,25 @@ describe("install-kimi MCP selection", () => { expect(Object.keys(servers(home))).toEqual(["fake-stdio"]); }); }); + +describe("defaultMcpSelection (Claude parity)", () => { + const servers = [ + { name: "no-key", cfg: {}, origin: "catalog" }, + { name: "key-missing", cfg: { requiresApiKey: true, apiKeyEnv: "MISSING_TOKEN" }, origin: "catalog" }, + { name: "key-present", cfg: { requiresApiKey: true, apiKeyEnv: "PRESENT_TOKEN" }, origin: "catalog" }, + { name: "default-on", cfg: { default: true, requiresApiKey: true, apiKeyEnv: "MISSING_TOKEN" }, origin: "catalog" }, + { name: "default-off", cfg: { default: false }, origin: "catalog" }, + ]; + + test("no-key + configured-key servers are preselected, key-missing is not", () => { + const picked = defaultMcpSelection(servers, { PRESENT_TOKEN: "tok" }); + expect(picked).toEqual(["no-key", "key-present", "default-on"]); + }); + + test("explicit default=false always loses, default=true always wins", () => { + const picked = defaultMcpSelection(servers, {}); + expect(picked).not.toContain("default-off"); + expect(picked).toContain("default-on"); + expect(picked).not.toContain("key-present"); + }); +}); diff --git a/scripts/tests/install-permission-mode.test.mts b/scripts/tests/install-permission-mode.test.mts new file mode 100644 index 0000000..bb12450 --- /dev/null +++ b/scripts/tests/install-permission-mode.test.mts @@ -0,0 +1,60 @@ +/** + * install-permission-mode.test.mts — YOLO permission mode step. + * TTY: clack select (untestable here). Non-TTY: FUSENGINE_PERMISSION_MODE + * writes default_permission_mode into $KIMI_HOME/config.toml, preserving any + * existing content; unset/invalid env values leave the file alone. + */ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { makeHarness, makeRepo, runInstaller, tmp, write } from "./install-fixtures.mts"; + +function fixture() { + const root = tmp("kimi-perm-mode-"); + const repo = join(root, "repo"); + const harness = join(root, "harness"); + makeRepo(repo); + makeHarness(harness); + return { repo, harness, home: join(root, "home") }; +} + +const configToml = (home: string) => readFileSync(join(home, "config.toml"), "utf8"); + +describe("install-kimi permission mode", () => { + test("FUSENGINE_PERMISSION_MODE=yolo writes default_permission_mode", () => { + const { repo, harness, home } = fixture(); + const r = runInstaller(home, repo, harness, ["--yes"], { FUSENGINE_PERMISSION_MODE: "yolo" }); + expect(r.code).toBe(0); + expect(configToml(home)).toContain('default_permission_mode = "yolo"'); + }); + + test("existing config.toml content is preserved, key replaced", () => { + const { repo, harness, home } = fixture(); + write(join(home, "config.toml"), 'default_permission_mode = "manual"\ntelemetry = false\n'); + const r = runInstaller(home, repo, harness, ["--yes"], { FUSENGINE_PERMISSION_MODE: "yolo" }); + expect(r.code).toBe(0); + const toml = configToml(home); + expect(toml).toContain('default_permission_mode = "yolo"'); + expect(toml).toContain("telemetry = false"); + expect(toml).not.toContain('default_permission_mode = "manual"'); + }); + + test("without the env override the step is a no-op", () => { + const { repo, harness, home } = fixture(); + const env: Record = {}; + delete process.env.FUSENGINE_PERMISSION_MODE; + const r = runInstaller(home, repo, harness, ["--yes"], env); + expect(r.code).toBe(0); + // config.toml exists (hooks step) but carries no permission mode. + expect(configToml(home)).not.toContain("default_permission_mode"); + }); + + test("an invalid override warns and writes nothing", () => { + const { repo, harness, home } = fixture(); + const r = runInstaller(home, repo, harness, ["--yes"], { FUSENGINE_PERMISSION_MODE: "yeet" }); + expect(r.code).toBe(0); + expect(r.out).toContain("invalid 'yeet'"); + expect(configToml(home)).not.toContain("default_permission_mode"); + }); +}); +