kimi-plugin-cc 2.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.claude-plugin/marketplace.json +16 -0
  3. package/.claude-plugin/plugin.json +32 -0
  4. package/CHANGELOG.md +1096 -0
  5. package/LICENSE +201 -0
  6. package/README.md +772 -0
  7. package/agents/kimi-ask.md +82 -0
  8. package/agents/kimi-challenge.md +82 -0
  9. package/agents/kimi-pursue.md +91 -0
  10. package/agents/kimi-rescue.md +85 -0
  11. package/agents/kimi-review.md +82 -0
  12. package/agents/kimi-swarm-write.md +92 -0
  13. package/agents/kimi-swarm.md +83 -0
  14. package/commands/README.md +16 -0
  15. package/commands/ask.md +32 -0
  16. package/commands/cancel.md +17 -0
  17. package/commands/challenge.md +29 -0
  18. package/commands/pursue.md +37 -0
  19. package/commands/replay.md +13 -0
  20. package/commands/rescue.md +32 -0
  21. package/commands/result.md +13 -0
  22. package/commands/review.md +29 -0
  23. package/commands/setup.md +23 -0
  24. package/commands/status.md +13 -0
  25. package/commands/swarm.md +38 -0
  26. package/dist/background-spawn.js +224 -0
  27. package/dist/cli-cancellation.js +43 -0
  28. package/dist/cli-client.js +845 -0
  29. package/dist/commands/ask.js +327 -0
  30. package/dist/commands/cancel.js +178 -0
  31. package/dist/commands/cli-helpers.js +112 -0
  32. package/dist/commands/models.js +140 -0
  33. package/dist/commands/not-implemented.js +4 -0
  34. package/dist/commands/pursue.js +311 -0
  35. package/dist/commands/registry.js +87 -0
  36. package/dist/commands/repair-sessions.js +159 -0
  37. package/dist/commands/replay.js +123 -0
  38. package/dist/commands/rescue.js +333 -0
  39. package/dist/commands/result.js +58 -0
  40. package/dist/commands/review-gate.js +393 -0
  41. package/dist/commands/review.js +218 -0
  42. package/dist/commands/setup.js +1132 -0
  43. package/dist/commands/status.js +27 -0
  44. package/dist/commands/swarm.js +576 -0
  45. package/dist/commands/worker.js +31 -0
  46. package/dist/companion.js +114 -0
  47. package/dist/config.js +22 -0
  48. package/dist/errors.js +40 -0
  49. package/dist/git.js +215 -0
  50. package/dist/hooks/approval-hook.js +112 -0
  51. package/dist/hooks/approval-policy.js +254 -0
  52. package/dist/hooks/config-safety.js +637 -0
  53. package/dist/hooks/install-paths.js +504 -0
  54. package/dist/hooks/install.js +281 -0
  55. package/dist/hooks/managed-block.js +745 -0
  56. package/dist/hooks/review-gate-stop.js +19 -0
  57. package/dist/job-store.js +559 -0
  58. package/dist/jobs.js +202 -0
  59. package/dist/kimi-command.js +72 -0
  60. package/dist/kimi-engine.js +624 -0
  61. package/dist/kimi-errors.js +220 -0
  62. package/dist/kimi-home.js +9 -0
  63. package/dist/kimi-timeouts.js +68 -0
  64. package/dist/kimi-version-probe.js +1410 -0
  65. package/dist/logging.js +22 -0
  66. package/dist/native-v2-preflight.js +417 -0
  67. package/dist/package.json +1 -0
  68. package/dist/parsing.js +553 -0
  69. package/dist/paths.js +27 -0
  70. package/dist/plugin-data.js +98 -0
  71. package/dist/process-tree.js +448 -0
  72. package/dist/render.js +159 -0
  73. package/dist/rescue-approval.js +951 -0
  74. package/dist/schemas/review-gate-output.js +43 -0
  75. package/dist/session-title.js +293 -0
  76. package/dist/stream-json.js +498 -0
  77. package/dist/types.js +1 -0
  78. package/dist/vendor/shell-quote/LICENSE +24 -0
  79. package/dist/vendor/shell-quote/index.js +1 -0
  80. package/dist/vendor/shell-quote/parse.js +229 -0
  81. package/dist/vendor/smol-toml/LICENSE +24 -0
  82. package/dist/vendor/smol-toml/date.js +127 -0
  83. package/dist/vendor/smol-toml/error.js +63 -0
  84. package/dist/vendor/smol-toml/extract.js +100 -0
  85. package/dist/vendor/smol-toml/parse.js +148 -0
  86. package/dist/vendor/smol-toml/primitive.js +179 -0
  87. package/dist/vendor/smol-toml/struct.js +184 -0
  88. package/dist/vendor/smol-toml/util.js +113 -0
  89. package/dist/version.js +7 -0
  90. package/docs/migration.md +215 -0
  91. package/docs/models.md +82 -0
  92. package/docs/registry-releases.md +70 -0
  93. package/docs/safety.md +318 -0
  94. package/docs/session-visibility.md +32 -0
  95. package/hooks/README.md +9 -0
  96. package/hooks/hooks.json +16 -0
  97. package/package.json +67 -0
  98. package/plugins/kimi-codex/.codex-plugin/plugin.json +39 -0
  99. package/plugins/kimi-codex/dist/background-spawn.js +224 -0
  100. package/plugins/kimi-codex/dist/cli-cancellation.js +43 -0
  101. package/plugins/kimi-codex/dist/cli-client.js +845 -0
  102. package/plugins/kimi-codex/dist/commands/ask.js +327 -0
  103. package/plugins/kimi-codex/dist/commands/cancel.js +178 -0
  104. package/plugins/kimi-codex/dist/commands/cli-helpers.js +112 -0
  105. package/plugins/kimi-codex/dist/commands/models.js +140 -0
  106. package/plugins/kimi-codex/dist/commands/not-implemented.js +4 -0
  107. package/plugins/kimi-codex/dist/commands/pursue.js +311 -0
  108. package/plugins/kimi-codex/dist/commands/registry.js +87 -0
  109. package/plugins/kimi-codex/dist/commands/repair-sessions.js +159 -0
  110. package/plugins/kimi-codex/dist/commands/replay.js +123 -0
  111. package/plugins/kimi-codex/dist/commands/rescue.js +333 -0
  112. package/plugins/kimi-codex/dist/commands/result.js +58 -0
  113. package/plugins/kimi-codex/dist/commands/review-gate.js +393 -0
  114. package/plugins/kimi-codex/dist/commands/review.js +218 -0
  115. package/plugins/kimi-codex/dist/commands/setup.js +1132 -0
  116. package/plugins/kimi-codex/dist/commands/status.js +27 -0
  117. package/plugins/kimi-codex/dist/commands/swarm.js +576 -0
  118. package/plugins/kimi-codex/dist/commands/worker.js +31 -0
  119. package/plugins/kimi-codex/dist/companion.js +114 -0
  120. package/plugins/kimi-codex/dist/config.js +22 -0
  121. package/plugins/kimi-codex/dist/errors.js +40 -0
  122. package/plugins/kimi-codex/dist/git.js +215 -0
  123. package/plugins/kimi-codex/dist/hooks/approval-hook.js +112 -0
  124. package/plugins/kimi-codex/dist/hooks/approval-policy.js +254 -0
  125. package/plugins/kimi-codex/dist/hooks/config-safety.js +637 -0
  126. package/plugins/kimi-codex/dist/hooks/install-paths.js +504 -0
  127. package/plugins/kimi-codex/dist/hooks/install.js +281 -0
  128. package/plugins/kimi-codex/dist/hooks/managed-block.js +745 -0
  129. package/plugins/kimi-codex/dist/hooks/review-gate-stop.js +19 -0
  130. package/plugins/kimi-codex/dist/job-store.js +559 -0
  131. package/plugins/kimi-codex/dist/jobs.js +202 -0
  132. package/plugins/kimi-codex/dist/kimi-command.js +72 -0
  133. package/plugins/kimi-codex/dist/kimi-engine.js +624 -0
  134. package/plugins/kimi-codex/dist/kimi-errors.js +220 -0
  135. package/plugins/kimi-codex/dist/kimi-home.js +9 -0
  136. package/plugins/kimi-codex/dist/kimi-timeouts.js +68 -0
  137. package/plugins/kimi-codex/dist/kimi-version-probe.js +1410 -0
  138. package/plugins/kimi-codex/dist/logging.js +22 -0
  139. package/plugins/kimi-codex/dist/native-v2-preflight.js +417 -0
  140. package/plugins/kimi-codex/dist/package.json +1 -0
  141. package/plugins/kimi-codex/dist/parsing.js +553 -0
  142. package/plugins/kimi-codex/dist/paths.js +27 -0
  143. package/plugins/kimi-codex/dist/plugin-data.js +98 -0
  144. package/plugins/kimi-codex/dist/process-tree.js +448 -0
  145. package/plugins/kimi-codex/dist/render.js +159 -0
  146. package/plugins/kimi-codex/dist/rescue-approval.js +951 -0
  147. package/plugins/kimi-codex/dist/schemas/review-gate-output.js +43 -0
  148. package/plugins/kimi-codex/dist/session-title.js +293 -0
  149. package/plugins/kimi-codex/dist/stream-json.js +498 -0
  150. package/plugins/kimi-codex/dist/types.js +1 -0
  151. package/plugins/kimi-codex/dist/vendor/shell-quote/LICENSE +24 -0
  152. package/plugins/kimi-codex/dist/vendor/shell-quote/index.js +1 -0
  153. package/plugins/kimi-codex/dist/vendor/shell-quote/parse.js +229 -0
  154. package/plugins/kimi-codex/dist/vendor/smol-toml/LICENSE +24 -0
  155. package/plugins/kimi-codex/dist/vendor/smol-toml/date.js +127 -0
  156. package/plugins/kimi-codex/dist/vendor/smol-toml/error.js +63 -0
  157. package/plugins/kimi-codex/dist/vendor/smol-toml/extract.js +100 -0
  158. package/plugins/kimi-codex/dist/vendor/smol-toml/parse.js +148 -0
  159. package/plugins/kimi-codex/dist/vendor/smol-toml/primitive.js +179 -0
  160. package/plugins/kimi-codex/dist/vendor/smol-toml/struct.js +184 -0
  161. package/plugins/kimi-codex/dist/vendor/smol-toml/util.js +113 -0
  162. package/plugins/kimi-codex/dist/version.js +7 -0
  163. package/plugins/kimi-codex/scripts/companion.sh +37 -0
  164. package/plugins/kimi-codex/scripts/review-gate-hook.sh +32 -0
  165. package/plugins/kimi-codex/skills/kimi-ask/SKILL.md +31 -0
  166. package/plugins/kimi-codex/skills/kimi-ask/agents/openai.yaml +6 -0
  167. package/plugins/kimi-codex/skills/kimi-cancel/SKILL.md +25 -0
  168. package/plugins/kimi-codex/skills/kimi-cancel/agents/openai.yaml +6 -0
  169. package/plugins/kimi-codex/skills/kimi-challenge/SKILL.md +31 -0
  170. package/plugins/kimi-codex/skills/kimi-challenge/agents/openai.yaml +6 -0
  171. package/plugins/kimi-codex/skills/kimi-pursue/SKILL.md +30 -0
  172. package/plugins/kimi-codex/skills/kimi-pursue/agents/openai.yaml +6 -0
  173. package/plugins/kimi-codex/skills/kimi-replay/SKILL.md +25 -0
  174. package/plugins/kimi-codex/skills/kimi-replay/agents/openai.yaml +6 -0
  175. package/plugins/kimi-codex/skills/kimi-rescue/SKILL.md +30 -0
  176. package/plugins/kimi-codex/skills/kimi-rescue/agents/openai.yaml +6 -0
  177. package/plugins/kimi-codex/skills/kimi-result/SKILL.md +25 -0
  178. package/plugins/kimi-codex/skills/kimi-result/agents/openai.yaml +6 -0
  179. package/plugins/kimi-codex/skills/kimi-review/SKILL.md +31 -0
  180. package/plugins/kimi-codex/skills/kimi-review/agents/openai.yaml +6 -0
  181. package/plugins/kimi-codex/skills/kimi-setup/SKILL.md +32 -0
  182. package/plugins/kimi-codex/skills/kimi-setup/agents/openai.yaml +6 -0
  183. package/plugins/kimi-codex/skills/kimi-status/SKILL.md +25 -0
  184. package/plugins/kimi-codex/skills/kimi-status/agents/openai.yaml +6 -0
  185. package/plugins/kimi-codex/skills/kimi-swarm/SKILL.md +31 -0
  186. package/plugins/kimi-codex/skills/kimi-swarm/agents/openai.yaml +6 -0
  187. package/plugins/kimi-codex/skills/kimi-swarm-write/SKILL.md +32 -0
  188. package/plugins/kimi-codex/skills/kimi-swarm-write/agents/openai.yaml +6 -0
  189. package/scripts/companion.sh +37 -0
  190. package/scripts/review-gate-hook.sh +32 -0
@@ -0,0 +1,1132 @@
1
+ // Managed-block installer for the kimi-code PreToolUse hook.
2
+ //
3
+ // Replaces the v0.4 Wire-based setup probe. In v1.0 the load-bearing
4
+ // safety control is the PreToolUse hook (see runtime/hooks/approval-policy.ts) —
5
+ // without it, `kimi -p` auto-approves every tool call from review /
6
+ // challenge / review_gate / ask. PR 3 made rescue REFUSE to run when the
7
+ // hook is missing, so /kimi:setup is the only path that wires the
8
+ // plugin's safety story into ~/.kimi-code/config.toml.
9
+ //
10
+ // What this command does:
11
+ //
12
+ // - Writes (or refreshes, idempotently) a BEGIN/END marker block to
13
+ // `~/.kimi-code/config.toml` containing a [[hooks]] entry that
14
+ // invokes `<process.execPath> /abs/path/to/dist/hooks/approval-hook.js`
15
+ // on PreToolUse. The absolute Node binary is used (not bare `node`)
16
+ // so the hook keeps working when kimi-code is launched from a
17
+ // GUI/LaunchAgent with a sanitized PATH (nvm/asdf/mise users).
18
+ // - Probes the installed hook in TWO ways: (a) directly via the same
19
+ // in-process Node binary, and (b) via `/bin/sh -c "<command>"` to
20
+ // mirror kimi-code's actual hook-runner spawn shape (kimi-code
21
+ // shells out via `/bin/sh -c` per agent-core hooks/runner.ts).
22
+ // - Scans the user's existing `[[permission.rules]]` for broad denies
23
+ // that would interfere with read-only commands; warns on stdout.
24
+ // - Detects and refuses to install over duplicate managed blocks,
25
+ // orphan markers, or malformed blocks (matcher present, wrong
26
+ // event, missing command). Same parser shared with PR 2's
27
+ // runtime/hooks/install.ts verifier.
28
+ //
29
+ // Subcommands:
30
+ //
31
+ // /kimi:setup — install (idempotent) + probe
32
+ // /kimi:setup --check — probe only (no write)
33
+ // /kimi:setup --uninstall — remove managed block (and orphan markers)
34
+ // /kimi:setup --enable-review-gate / --disable-review-gate
35
+ // — flip the plugin-side review-gate flag in
36
+ // CLAUDE_PLUGIN_DATA/config.json
37
+ //
38
+ // TOML manipulation is line-based, not via a TOML parser. The marker
39
+ // block is owned by us; the rest of the file is the user's, untouched.
40
+ import { spawn } from "node:child_process";
41
+ import { access, mkdir, open, readFile, rename, unlink, } from "node:fs/promises";
42
+ import { constants as fsConstants, existsSync } from "node:fs";
43
+ import { randomBytes } from "node:crypto";
44
+ import path from "node:path";
45
+ import { readPluginConfig, writePluginConfig } from "../config.js";
46
+ import { RuntimeError } from "../errors.js";
47
+ import { normalizeTopLevelInlineHooks, validateKimiHookSetForEnvironment, withKimiConfigLock, } from "../hooks/config-safety.js";
48
+ import { buildHookShellCommand, hostIdFromHookCommand, resolveHookScriptPath, resolveHostId, resolveNodeBinary, } from "../hooks/install-paths.js";
49
+ import { decodeManagedCommandLine, effectiveHost, evaluateInstalled, findUnmanagedApprovalHookBlocks, MARKERS, parseManagedBlock, } from "../hooks/managed-block.js";
50
+ import { formatVersionOutOfRangeWarning, probeKimiVersion, } from "../kimi-version-probe.js";
51
+ import { isNativeV2CertifiedVersion } from "../kimi-engine.js";
52
+ import { resolveKimiHome } from "../kimi-home.js";
53
+ import { ensurePluginPaths, resolvePluginPaths } from "../paths.js";
54
+ import { KIMI_PLUGIN_CC_VERSION } from "../version.js";
55
+ const BEGIN_MARKER_PREFIX = "# === BEGIN kimi-plugin-cc-managed";
56
+ const DEFAULT_HOOK_TIMEOUT_S = 15;
57
+ // Probe timing. The direct script probe is local and fast; allow 5s for
58
+ // node startup on cold CI runners. The shell probe adds /bin/sh fork
59
+ // overhead but should still complete well within the same budget.
60
+ const PROBE_TIMEOUT_MS = 5_000;
61
+ // Reject hook script paths that contain characters TOML basic strings
62
+ // reserve as escape sequences (other than the ones we already escape)
63
+ // or shell-special characters whose presence would force us into
64
+ // fragile quoting. The realistic install location lives under
65
+ // `${plugin_root}/dist/hooks/approval-hook.js` and never legitimately
66
+ // contains these — anyone hitting this hard error has either a hostile
67
+ // `KIMI_PLUGIN_CC_HOOK_SCRIPT` override or an unusual install layout.
68
+ const PATH_FORBIDDEN_RE = /[\x00-\x1f"\\\n\r\t]/;
69
+ export async function runSetup(argv, context) {
70
+ const parsed = parseArgs(argv);
71
+ const paths = resolvePluginPaths(context.env);
72
+ await ensurePluginPaths(paths);
73
+ // Reconcile the review-gate flag first — it's independent of the
74
+ // kimi-code hook and we want the answer reflected in the result even
75
+ // if the rest of setup fails.
76
+ const existingConfig = await readPluginConfig(paths);
77
+ const reviewGateEnabled = parsed.enableReviewGate
78
+ ? true
79
+ : parsed.disableReviewGate
80
+ ? false
81
+ : existingConfig.reviewGateEnabled;
82
+ if (reviewGateEnabled !== existingConfig.reviewGateEnabled) {
83
+ await writePluginConfig(paths, { reviewGateEnabled });
84
+ }
85
+ const configPath = resolveKimiCodeConfigPath(context.env);
86
+ const hookScriptPath = resolveHookScriptPath(context.env);
87
+ assertHookPathTomlSafe(hookScriptPath);
88
+ // Host id keys the managed block so Claude Code and Codex share one
89
+ // ~/.kimi-code/config.toml without clobbering each other (v1.7.0).
90
+ const hostId = resolveHostId(context.env, hookScriptPath);
91
+ const warnings = [];
92
+ switch (parsed.mode) {
93
+ case "uninstall":
94
+ return await runUninstall(configPath, hookScriptPath, hostId, parsed.removeAllHosts, reviewGateEnabled, warnings, context);
95
+ case "check":
96
+ return await runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context);
97
+ case "install":
98
+ return await runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context);
99
+ }
100
+ }
101
+ function parseArgs(argv) {
102
+ let mode = "install";
103
+ let enableReviewGate = false;
104
+ let disableReviewGate = false;
105
+ let removeAllHosts = false;
106
+ for (const token of argv) {
107
+ switch (token) {
108
+ case "--check":
109
+ if (mode === "uninstall") {
110
+ throw new RuntimeError("INVALID_ARGS", "setup accepts at most one of --check, --uninstall.", "setup.parse");
111
+ }
112
+ mode = "check";
113
+ break;
114
+ case "--uninstall":
115
+ if (mode === "check") {
116
+ throw new RuntimeError("INVALID_ARGS", "setup accepts at most one of --check, --uninstall.", "setup.parse");
117
+ }
118
+ mode = "uninstall";
119
+ break;
120
+ case "--all":
121
+ removeAllHosts = true;
122
+ break;
123
+ case "--enable-review-gate":
124
+ enableReviewGate = true;
125
+ break;
126
+ case "--disable-review-gate":
127
+ disableReviewGate = true;
128
+ break;
129
+ default:
130
+ throw new RuntimeError("INVALID_ARGS", `Unknown setup flag ${token}. Supported flags: --check, --uninstall, --all (with --uninstall), --enable-review-gate, --disable-review-gate.`, "setup.parse");
131
+ }
132
+ }
133
+ if (enableReviewGate && disableReviewGate) {
134
+ throw new RuntimeError("INVALID_ARGS", "setup accepts either --enable-review-gate or --disable-review-gate, not both.", "setup.parse");
135
+ }
136
+ if (removeAllHosts && mode !== "uninstall") {
137
+ throw new RuntimeError("INVALID_ARGS", "--all is only valid with --uninstall (it removes EVERY host's managed block).", "setup.parse");
138
+ }
139
+ return { mode, enableReviewGate, disableReviewGate, removeAllHosts };
140
+ }
141
+ async function runInstall(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context) {
142
+ await assertHookScriptExists(hookScriptPath);
143
+ const mutation = await withKimiConfigLock(configPath, async () => {
144
+ const original = await readConfigSafe(configPath);
145
+ const lineEnding = detectLineEnding(original);
146
+ const inlineHooks = normalizeTopLevelInlineHooks(original, lineEnding);
147
+ if (inlineHooks.found) {
148
+ // Validate before normalizing so an invalid inline entry is reported
149
+ // against the user's original bytes and the config remains untouched.
150
+ await assertKimiHookSetValid(original, configPath, "setup.install", context.env);
151
+ if (!inlineHooks.converted) {
152
+ throw new RuntimeError("SETUP_INVALID_HOOKS_CONFIG", `Could not safely convert the top-level inline hooks assignment in ${configPath}; the existing config was left unchanged.`, "setup.install", { details: { configPath } });
153
+ }
154
+ warnings.push(`Converted the top-level inline hooks array (${inlineHooks.entryCount} entr${inlineHooks.entryCount === 1 ? "y" : "ies"}) to canonical [[hooks]] tables before installing. Surrounding config bytes were preserved; formatting and comments inside that hooks assignment were normalized.`);
155
+ }
156
+ // Prune THIS HOST's orphaned, marker-less approval-hook [[hooks]] entries
157
+ // BEFORE resolving the managed block, so line numbers used for splicing are
158
+ // computed against the cleaned text. Host-scoped (v1.8.2): after a
159
+ // kimi-code config rewrite strips marker comments, ANOTHER host's
160
+ // marker-less table is that host's LIVE hook — pruning it here was the
161
+ // pre-v1.8.2 seesaw where each host's setup disarmed the other.
162
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
163
+ const { pruned, count: prunedCount, commands: prunedCommands } = pruneOrphanApprovalHooks(inlineHooks.contents, lineEnding, hostId, expectedCommand);
164
+ const existing = pruned;
165
+ if (prunedCount > 0) {
166
+ const readorned = prunedCommands.includes(expectedCommand);
167
+ warnings.push(readorned
168
+ ? `Re-adorned this host's managed-block markers: kimi-code rewrites its config on login/settings changes and strips all comments (markers included), leaving the hook enforcing but unmarked. Replaced the bare hook table with a freshly marked block (${prunedCount} table(s) refreshed). Enforcement was never interrupted.`
169
+ : `Pruned ${prunedCount} stale marker-less kimi-plugin-cc hook block(s) left by this host's earlier installs. This is a cleanup — your active hook is unaffected.`);
170
+ }
171
+ const { state, blocks } = parseManagedBlock(existing, hostId);
172
+ if (state.kind === "orphan") {
173
+ throw new RuntimeError("SETUP_ORPHAN_MARKERS", [
174
+ `kimi-code config at ${configPath} contains an orphaned ${state.detail} marker.`,
175
+ "Run `/kimi:setup --uninstall` to clean up, then re-run `/kimi:setup`.",
176
+ ].join(" "), "setup.install", { details: { configPath, orphan: state.detail } });
177
+ }
178
+ if (state.kind === "duplicate") {
179
+ throw new RuntimeError("SETUP_DUPLICATE_BLOCKS", [
180
+ `kimi-code config at ${configPath} contains ${state.beginLines.length} kimi-plugin-cc managed blocks for host ${hostId} (lines ${state.beginLines
181
+ .map((line) => line + 1)
182
+ .join(", ")}).`,
183
+ "This usually means two /kimi:setup runs raced. Run `/kimi:setup --uninstall` to clear them, then `/kimi:setup` again.",
184
+ ].join(" "), "setup.install", { details: { configPath, beginLines: state.beginLines } });
185
+ }
186
+ // Non-blocking note: other hosts (e.g. Codex) manage their own block in the
187
+ // same shared config — surface so an operator understands the coexistence.
188
+ const otherHosts = [
189
+ ...new Set(blocks.map((b) => effectiveHost(b, hostId)).filter((h) => h !== hostId)),
190
+ ];
191
+ if (otherHosts.length > 0) {
192
+ warnings.push(`Other host(s) also manage a block in this shared config: ${otherHosts.join(", ")}. ` +
193
+ `They are left untouched — each host owns its own PreToolUse block.`);
194
+ }
195
+ const block = buildManagedBlock(hookScriptPath, context.env, lineEnding, hostId);
196
+ const next = state.kind === "found"
197
+ ? spliceBlock(existing, state.beginLine, state.endLine, block, lineEnding)
198
+ : appendBlock(existing, block, lineEnding);
199
+ await assertKimiHookSetValid(next, configPath, "setup.install", context.env);
200
+ const blockWritten = next !== original;
201
+ if (blockWritten)
202
+ await writeConfigAtomic(configPath, next);
203
+ return { blockWritten, next };
204
+ });
205
+ const { blockWritten, next } = mutation;
206
+ collectPermissionRuleWarnings(next, warnings);
207
+ await collectKimiVersionWarnings(context.env, warnings);
208
+ await collectInstalledKimiPluginsNotice(context.env, warnings);
209
+ const probe = await probeHook(hookScriptPath, context.env);
210
+ const summary = probe.ok
211
+ ? blockWritten
212
+ ? `Installed kimi-plugin-cc PreToolUse hook in ${configPath}.`
213
+ : `kimi-plugin-cc PreToolUse hook already up to date in ${configPath}.`
214
+ : `Wrote managed block to ${configPath} but the hook script probe failed.`;
215
+ return {
216
+ action: "install",
217
+ summary,
218
+ configPath,
219
+ hookScriptPath,
220
+ blockWritten,
221
+ blockRemoved: false,
222
+ probe: probe.ok ? "ok" : "failed",
223
+ probeError: probe.ok ? undefined : probe.reason,
224
+ warnings,
225
+ reviewGateEnabled,
226
+ nextStep: probe.ok
227
+ ? "Run /kimi:review, /kimi:challenge, /kimi:ask, or /kimi:rescue. Codex users can invoke the matching $kimi-* skills."
228
+ : "Re-run /kimi:setup after installing kimi-code and Node. If the probe keeps failing, run /kimi:setup --uninstall and inspect ~/.kimi-code/config.toml manually.",
229
+ details: buildDetails({
230
+ configPath,
231
+ hookScriptPath,
232
+ reviewGateEnabled,
233
+ probe,
234
+ warnings,
235
+ hostId,
236
+ }),
237
+ };
238
+ }
239
+ async function runCheck(configPath, hookScriptPath, hostId, reviewGateEnabled, warnings, context) {
240
+ const existing = await readConfigSafe(configPath);
241
+ const hookSet = await validateKimiHookSetForEnvironment(existing, context.env);
242
+ if (!hookSet.valid) {
243
+ const reason = hookSet.reason ?? "configured hooks failed validation";
244
+ return {
245
+ action: "check",
246
+ summary: `kimi-code hook configuration is invalid; the configured hooks array will not load.`,
247
+ configPath,
248
+ hookScriptPath,
249
+ blockWritten: false,
250
+ blockRemoved: false,
251
+ probe: "failed",
252
+ probeError: reason,
253
+ warnings,
254
+ reviewGateEnabled,
255
+ nextStep: "Repair or remove the invalid [[hooks]] entry named above, then run /kimi:setup --check again. No model call is required.",
256
+ details: buildDetails({
257
+ configPath,
258
+ hookScriptPath,
259
+ reviewGateEnabled,
260
+ probe: { ok: false, reason },
261
+ warnings,
262
+ hostId,
263
+ }),
264
+ };
265
+ }
266
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
267
+ // nodeExists → a command mismatch is reported as a classified H4 drift
268
+ // diagnosis (Node upgrade / version-manager switch vs. plugin path move).
269
+ // hostId → check THIS host's block in the shared config (v1.7.0 scoping).
270
+ const installedCheck = evaluateInstalled(existing, expectedCommand, {
271
+ hostId,
272
+ nodeExists: (binPath) => existsSync(binPath),
273
+ });
274
+ // Non-blocking coexistence + cruft notices.
275
+ const { blocks: allBlocks } = parseManagedBlock(existing, hostId);
276
+ const otherHosts = [
277
+ ...new Set(allBlocks.map((b) => effectiveHost(b, hostId)).filter((h) => h !== hostId)),
278
+ ];
279
+ if (otherHosts.length > 0) {
280
+ warnings.push(`Other host(s) also manage a block in this shared config: ${otherHosts.join(", ")} (coexisting, untouched).`);
281
+ }
282
+ // Marker-less table triage (v1.8.2): after a kimi-code config rewrite strips
283
+ // all comments, each host's [[hooks]] table survives unmarked and KEEPS
284
+ // ENFORCING. Ours-with-the-exact-current-command is the live hook (covered
285
+ // by the installedCheck note below, not "cruft"); ours-with-a-stale-command
286
+ // is prunable by this host's setup; another host's is THEIR live hook —
287
+ // report it informationally and leave it strictly alone.
288
+ const bareTables = findUnmanagedApprovalHookBlocks(existing);
289
+ const ownStale = bareTables.filter((t) => hostIdFromHookCommand(t.command) === hostId && t.command !== expectedCommand);
290
+ const foreignHosts = [
291
+ ...new Set(bareTables
292
+ // Exclude any table whose command byte-equals what THIS host writes —
293
+ // it is unambiguously ours even if a `KIMI_PLUGIN_CC_HOST_ID` override
294
+ // makes its path-derived host differ from `hostId` (Opus review NIT:
295
+ // otherwise runCheck would mislabel the host's own table as foreign).
296
+ .filter((t) => t.command !== expectedCommand)
297
+ .map((t) => hostIdFromHookCommand(t.command))
298
+ .filter((h) => h !== null && h !== hostId)),
299
+ ];
300
+ if (ownStale.length > 0) {
301
+ warnings.push(`Found ${ownStale.length} stale marker-less kimi-plugin-cc hook block(s) from this host's earlier installs. ` +
302
+ `Run /kimi:setup to refresh, or /kimi:setup --uninstall --all to clear everything.`);
303
+ }
304
+ for (const host of foreignHosts) {
305
+ warnings.push(`Host "${host}" has a marker-less kimi-plugin-cc hook block (kimi-code config rewrites strip comments). ` +
306
+ `It still enforces and is left untouched; run that host's setup to re-adorn its markers.`);
307
+ }
308
+ if (installedCheck.installed && installedCheck.note !== undefined) {
309
+ warnings.push(`Note: ${installedCheck.note}`);
310
+ }
311
+ if (!installedCheck.installed) {
312
+ return {
313
+ action: "check",
314
+ summary: installedCheck.state.kind === "absent"
315
+ ? `kimi-plugin-cc managed block is NOT installed in ${configPath}.`
316
+ : `kimi-plugin-cc managed block is present but invalid: ${installedCheck.reason}.`,
317
+ configPath,
318
+ hookScriptPath,
319
+ blockWritten: false,
320
+ blockRemoved: false,
321
+ probe: "failed",
322
+ probeError: installedCheck.reason ?? "managed block missing or invalid",
323
+ warnings,
324
+ reviewGateEnabled,
325
+ nextStep: installedCheck.state.kind === "absent"
326
+ ? "Run /kimi:setup (without --check) to install the managed block."
327
+ : "Run /kimi:setup --uninstall, then /kimi:setup to repair.",
328
+ details: buildDetails({
329
+ configPath,
330
+ hookScriptPath,
331
+ reviewGateEnabled,
332
+ probe: { ok: false, reason: installedCheck.reason ?? "block missing/invalid" },
333
+ warnings,
334
+ }),
335
+ };
336
+ }
337
+ collectPermissionRuleWarnings(existing, warnings);
338
+ await collectKimiVersionWarnings(context.env, warnings);
339
+ await collectInstalledKimiPluginsNotice(context.env, warnings);
340
+ // Block is structurally valid AND points at the resolved hook
341
+ // script — now confirm the script itself still loads and behaves.
342
+ try {
343
+ await assertHookScriptExists(hookScriptPath);
344
+ }
345
+ catch (err) {
346
+ const message = err instanceof Error ? err.message : String(err);
347
+ return {
348
+ action: "check",
349
+ summary: `Managed block references ${hookScriptPath} but that path is unreadable.`,
350
+ configPath,
351
+ hookScriptPath,
352
+ blockWritten: false,
353
+ blockRemoved: false,
354
+ probe: "failed",
355
+ probeError: message,
356
+ warnings,
357
+ reviewGateEnabled,
358
+ nextStep: "Reinstall the plugin so dist/hooks/approval-hook.js is present, or run /kimi:setup to refresh the managed block.",
359
+ details: buildDetails({
360
+ configPath,
361
+ hookScriptPath,
362
+ reviewGateEnabled,
363
+ probe: { ok: false, reason: message },
364
+ warnings,
365
+ }),
366
+ };
367
+ }
368
+ const probe = await probeHook(hookScriptPath, context.env);
369
+ return {
370
+ action: "check",
371
+ summary: probe.ok
372
+ ? `kimi-plugin-cc PreToolUse hook is installed and probe passed.`
373
+ : `Managed block is installed but probe failed (${probe.reason}).`,
374
+ configPath,
375
+ hookScriptPath,
376
+ blockWritten: false,
377
+ blockRemoved: false,
378
+ probe: probe.ok ? "ok" : "failed",
379
+ probeError: probe.ok ? undefined : probe.reason,
380
+ warnings,
381
+ reviewGateEnabled,
382
+ nextStep: !probe.ok
383
+ ? "Run /kimi:setup to repair the managed block, or /kimi:setup --uninstall if you want to remove the integration."
384
+ : installedCheck.via === "bare-table"
385
+ // Enforcement is active via the marker-less table, but the markers are
386
+ // gone (kimi-code stripped them). Don't say "No action needed" while a
387
+ // warning tells the user to re-adorn — that contradiction confused
388
+ // operators (Kimi review F3). Recommend the harmless re-adorn.
389
+ ? "Enforcement is active. Run /kimi:setup to re-adorn this host's managed-block markers — kimi-code stripped them on its last config write (comments are not preserved). Your hook keeps working meanwhile."
390
+ : "No action needed.",
391
+ details: buildDetails({
392
+ configPath,
393
+ hookScriptPath,
394
+ reviewGateEnabled,
395
+ probe,
396
+ warnings,
397
+ hostId,
398
+ }),
399
+ };
400
+ }
401
+ async function runUninstall(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context) {
402
+ return await withKimiConfigLock(configPath, () => runUninstallLocked(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context));
403
+ }
404
+ async function runUninstallLocked(configPath, hookScriptPath, hostId, removeAllHosts, reviewGateEnabled, warnings, context) {
405
+ const existing = await readConfigSafe(configPath);
406
+ if (existing.length === 0) {
407
+ return {
408
+ action: "uninstall",
409
+ summary: `Nothing to remove — ${configPath} does not exist or is empty.`,
410
+ configPath,
411
+ hookScriptPath,
412
+ blockWritten: false,
413
+ blockRemoved: false,
414
+ probe: "skipped",
415
+ warnings,
416
+ reviewGateEnabled,
417
+ nextStep: "Run /kimi:setup to install the managed block again.",
418
+ details: buildDetails({
419
+ configPath,
420
+ hookScriptPath,
421
+ reviewGateEnabled,
422
+ probe: { ok: true, reason: "no-op (config absent)" },
423
+ warnings,
424
+ }),
425
+ };
426
+ }
427
+ // Default: remove THIS host's block(s) — its own suffixed block plus any
428
+ // legacy block whose command path derives to this host — and THIS HOST's
429
+ // orphaned marker-less hook entries, leaving OTHER hosts' blocks AND their
430
+ // marker-less tables intact (a marker-less table is likely that host's LIVE
431
+ // hook after a kimi-code comment strip — v1.8.2 host scoping). `--all`:
432
+ // remove EVERY host's managed block and every one of our hook tables (the
433
+ // full nuke).
434
+ const { stripped: strippedMarkers, removedBlocks, orphansLeft } = stripManagedBlocks(existing, hostId, removeAllHosts);
435
+ const lineEnding = detectLineEnding(existing);
436
+ // Pass `expectedCommand` as `alsoMatchCommand` (both scoped and --all) so
437
+ // uninstall removes THIS host's own current hook table by byte-exact command
438
+ // even when a `KIMI_PLUGIN_CC_HOST_ID` override disagrees with the command's
439
+ // path-derived host, or when the hook lives under a non-standard (dev) path
440
+ // that `isOurApprovalHookCommand` wouldn't recognize — the install path
441
+ // already does this; uninstall now matches it (Codex P2 / Opus A / Kimi F4).
442
+ const expectedCommand = buildHookShellCommand(hookScriptPath, context.env);
443
+ const { pruned: stripped, count: prunedCount } = pruneOrphanApprovalHooks(strippedMarkers, lineEnding, removeAllHosts ? undefined : hostId, expectedCommand);
444
+ const changed = stripped !== existing;
445
+ if (changed) {
446
+ await writeConfigAtomic(configPath, stripped);
447
+ }
448
+ if (orphansLeft.length > 0) {
449
+ warnings.push(`Detected ${orphansLeft.length} orphan marker line(s) at ${orphansLeft
450
+ .map((line) => `line ${line + 1}`)
451
+ .join(", ")}. Removed the marker line(s) but preserved surrounding user content — verify the config visually.`);
452
+ }
453
+ if (prunedCount > 0) {
454
+ warnings.push(`Pruned ${prunedCount} orphaned kimi-plugin-cc hook block(s) with no managed marker.`);
455
+ }
456
+ const { blocks: remainingBlocks } = parseManagedBlock(stripped, hostId);
457
+ const remainingHosts = [
458
+ ...new Set(remainingBlocks.map((b) => effectiveHost(b, hostId))),
459
+ ];
460
+ if (!removeAllHosts && remainingHosts.length > 0) {
461
+ warnings.push(`Left ${remainingHosts.length} other host block(s) in place: ${remainingHosts.join(", ")}. ` +
462
+ `Use \`/kimi:setup --uninstall --all\` to remove every host's block.`);
463
+ }
464
+ return {
465
+ action: "uninstall",
466
+ summary: changed
467
+ ? `Removed ${removedBlocks} managed block(s)${prunedCount > 0 ? ` + ${prunedCount} orphan hook block(s)` : ""} from ${configPath}.`
468
+ : `No kimi-plugin-cc managed block to remove from ${configPath}.`,
469
+ configPath,
470
+ hookScriptPath,
471
+ blockWritten: false,
472
+ blockRemoved: changed,
473
+ probe: "skipped",
474
+ warnings,
475
+ reviewGateEnabled,
476
+ nextStep: changed
477
+ ? "Run /kimi:setup again to reinstall the hook, or leave the plugin uninstalled."
478
+ : "Run /kimi:setup to install the hook.",
479
+ details: buildDetails({
480
+ configPath,
481
+ hookScriptPath,
482
+ reviewGateEnabled,
483
+ probe: { ok: true, reason: changed ? `removed ${removedBlocks} block(s)` : "no-op (no markers)" },
484
+ warnings,
485
+ }),
486
+ };
487
+ }
488
+ // ----- Config IO ---------------------------------------------------------
489
+ async function readConfigSafe(configPath) {
490
+ try {
491
+ return await readFile(configPath, "utf8");
492
+ }
493
+ catch (err) {
494
+ if (err.code === "ENOENT")
495
+ return "";
496
+ throw new RuntimeError("SETUP_CONFIG_READ_FAILED", `Failed to read kimi-code config at ${configPath}: ${err.message}`, "setup.read-config", err instanceof Error ? { cause: err, details: { configPath } } : { details: { configPath } });
497
+ }
498
+ }
499
+ async function writeConfigAtomic(configPath, contents) {
500
+ // Two-phase write: write to a sibling temp file, then rename. Each
501
+ // call uses a unique temp path so two concurrent installs/uninstalls
502
+ // can race without clobbering each other's intermediate file (PR 4
503
+ // reviewer finding — fixed-path tmp file was a race surface).
504
+ //
505
+ // Mode: the temp file is created at 0o600 (owner-only read/write) via
506
+ // O_CREAT|O_EXCL with the mode passed directly to open(), mirroring
507
+ // createPrivateLockFile in hooks/config-safety.ts. The kimi-code config
508
+ // file holds API keys and tokens; a writeFile()-then-chmod() sequence
509
+ // would let the temp file sit briefly at the default umask mode
510
+ // (typically 0o644, group/world-readable) in between — audit report 28
511
+ // (Codex M1) tracked the chmod-after-write half of this, but a second
512
+ // pass found the create-then-tighten window itself. Passing the mode to
513
+ // open() means the file never exists on disk at anything wider than
514
+ // 0o600.
515
+ await mkdir(path.dirname(configPath), { recursive: true });
516
+ const suffix = randomBytes(8).toString("hex");
517
+ const tmpPath = `${configPath}.kimi-plugin-cc.${process.pid}.${suffix}.tmp`;
518
+ const openFlags = fsConstants.O_WRONLY |
519
+ fsConstants.O_CREAT |
520
+ fsConstants.O_EXCL |
521
+ (fsConstants.O_NOFOLLOW ?? 0);
522
+ try {
523
+ const handle = await open(tmpPath, openFlags, 0o600);
524
+ try {
525
+ if (process.platform !== "win32") {
526
+ // Windows file modes are emulated; skip the chmod to avoid
527
+ // platform-specific noise. The threat model excludes Windows.
528
+ // Belt-and-suspenders alongside the create-time mode above,
529
+ // matching createPrivateLockFile's pattern.
530
+ await handle.chmod(0o600);
531
+ }
532
+ await handle.writeFile(contents, "utf8");
533
+ }
534
+ finally {
535
+ await handle.close();
536
+ }
537
+ await rename(tmpPath, configPath);
538
+ }
539
+ catch (err) {
540
+ // Best-effort cleanup so stale tmp files don't accumulate.
541
+ try {
542
+ await unlink(tmpPath);
543
+ }
544
+ catch {
545
+ // ignore
546
+ }
547
+ throw err;
548
+ }
549
+ }
550
+ async function assertKimiHookSetValid(contents, configPath, stage, env) {
551
+ const validation = await validateKimiHookSetForEnvironment(contents, env);
552
+ if (validation.valid)
553
+ return;
554
+ throw new RuntimeError("SETUP_INVALID_HOOKS_CONFIG", `${validation.reason ?? "Configured hooks failed validation"} Repair or remove that [[hooks]] entry before rerunning /kimi:setup; the existing config was left unchanged.`, stage, {
555
+ details: {
556
+ configPath,
557
+ hookEntry: validation.entry,
558
+ line: validation.line,
559
+ },
560
+ });
561
+ }
562
+ // ----- Line endings -----------------------------------------------------
563
+ function detectLineEnding(contents) {
564
+ // Heuristic: if the file contains any `\r\n`, treat it as CRLF.
565
+ // Otherwise use LF (also covers the empty-file case where we're
566
+ // writing fresh content). This keeps the file's line-ending shape
567
+ // stable across install/uninstall on Windows users' configs.
568
+ return contents.includes("\r\n") ? "\r\n" : "\n";
569
+ }
570
+ function spliceBlock(contents, beginLine, endLine, replacement, lineEnding) {
571
+ const lines = splitPreservingEnding(contents);
572
+ const before = lines.slice(0, beginLine).join(lineEnding);
573
+ const after = lines.slice(endLine + 1).join(lineEnding);
574
+ const beforeWithSep = before.endsWith(lineEnding) || before.length === 0 ? before : `${before}${lineEnding}`;
575
+ const replacementWithEnding = replacement.endsWith(lineEnding) ? replacement : `${replacement}${lineEnding}`;
576
+ return `${beforeWithSep}${replacementWithEnding}${after}`;
577
+ }
578
+ function appendBlock(contents, block, lineEnding) {
579
+ const base = contents.length === 0 || contents.endsWith(lineEnding) ? contents : `${contents}${lineEnding}`;
580
+ const separator = base.length === 0 ? "" : lineEnding;
581
+ const body = block.endsWith(lineEnding) ? block : `${block}${lineEnding}`;
582
+ return `${base}${separator}${body}`;
583
+ }
584
+ function splitPreservingEnding(contents) {
585
+ // We canonicalize to '\n' for the split, then strip any '\r' that
586
+ // came from CRLF. Rejoining is the caller's responsibility (they
587
+ // pass the desired line ending).
588
+ return contents.split("\n").map((line) => line.replace(/\r$/, ""));
589
+ }
590
+ /**
591
+ * Remove managed BEGIN/END marker pairs that belong to `currentHost` (or every
592
+ * host when `removeAll`). A block's host is its marker suffix, or — for a legacy
593
+ * un-suffixed block — the host derived from its command path (so a Claude
594
+ * uninstall never removes a `~/.codex/…` legacy block another host still relies
595
+ * on; Kimi review). Orphan marker lines are removed individually (no
596
+ * destructive sweep of trailing content) so `--uninstall` on a corrupted config
597
+ * doesn't take user data with it.
598
+ */
599
+ function stripManagedBlocks(contents, currentHost, removeAll) {
600
+ const lineEnding = detectLineEnding(contents);
601
+ const lines = splitPreservingEnding(contents);
602
+ const result = [];
603
+ const orphansLeft = [];
604
+ let removedBlocks = 0;
605
+ // Decide whether a managed block/marker belongs to the host we're uninstalling
606
+ // for. Suffixed blocks: only the matching host. Legacy (un-suffixed) COMPLETE
607
+ // blocks: the host derived from the command path — but if the command can't be
608
+ // attributed (not the canonical two-token shape), leave it (ambiguous
609
+ // ownership; only `--all` removes it), so a scoped uninstall never destroys
610
+ // another host's non-canonical block (Codex review). A bare/orphan marker line
611
+ // has no command and is broken cruft, so a scoped uninstall does sweep it.
612
+ const shouldRemove = (markerHost, commandPath, isOrphanMarker) => {
613
+ if (removeAll)
614
+ return true;
615
+ if (markerHost !== null)
616
+ return markerHost === currentHost;
617
+ if (isOrphanMarker)
618
+ return true;
619
+ const derived = commandPath !== null ? hostIdFromHookCommand(commandPath) : null;
620
+ if (derived === null)
621
+ return false;
622
+ return derived === currentHost;
623
+ };
624
+ let i = 0;
625
+ while (i < lines.length) {
626
+ const line = lines[i];
627
+ const trimmed = line.trim();
628
+ const begin = MARKERS.BEGIN_LINE_RE.exec(trimmed);
629
+ if (begin !== null) {
630
+ const beginHost = begin[1]?.toLowerCase() ?? null;
631
+ // Look ahead for the matching END, capturing the block's command en route.
632
+ let endIdx = -1;
633
+ let blockCommand = null;
634
+ for (let j = i + 1; j < lines.length; j += 1) {
635
+ const nextTrimmed = lines[j].trim();
636
+ if (MARKERS.BEGIN_LINE_RE.test(nextTrimmed)) {
637
+ // Two BEGINs in a row — the outer one is an orphan.
638
+ break;
639
+ }
640
+ if (MARKERS.END_LINE_RE.test(nextTrimmed)) {
641
+ endIdx = j;
642
+ break;
643
+ }
644
+ const decoded = decodeManagedCommandLine(nextTrimmed);
645
+ if (decoded !== null)
646
+ blockCommand = decoded;
647
+ }
648
+ if (endIdx === -1) {
649
+ // Orphan BEGIN: drop only this line (if it's ours to remove).
650
+ if (shouldRemove(beginHost, null, true)) {
651
+ orphansLeft.push(i);
652
+ i += 1;
653
+ continue;
654
+ }
655
+ result.push(line);
656
+ i += 1;
657
+ continue;
658
+ }
659
+ if (shouldRemove(beginHost, blockCommand, false)) {
660
+ // Complete pair we're removing — drop BEGIN..END inclusive.
661
+ removedBlocks += 1;
662
+ i = endIdx + 1;
663
+ continue;
664
+ }
665
+ // Another host's block — keep it verbatim.
666
+ for (let k = i; k <= endIdx; k += 1)
667
+ result.push(lines[k]);
668
+ i = endIdx + 1;
669
+ continue;
670
+ }
671
+ const end = MARKERS.END_LINE_RE.exec(trimmed);
672
+ if (end !== null) {
673
+ const endHost = end[1]?.toLowerCase() ?? null;
674
+ // Orphan END with no preceding BEGIN: drop only this line (if ours).
675
+ if (shouldRemove(endHost, null, true)) {
676
+ orphansLeft.push(i);
677
+ i += 1;
678
+ continue;
679
+ }
680
+ result.push(line);
681
+ i += 1;
682
+ continue;
683
+ }
684
+ result.push(line);
685
+ i += 1;
686
+ }
687
+ // Collapse runs of >= 3 blank lines created by removal back to 2.
688
+ const collapsed = [];
689
+ let blankRun = 0;
690
+ for (const line of result) {
691
+ if (line.length === 0) {
692
+ blankRun += 1;
693
+ if (blankRun <= 2)
694
+ collapsed.push(line);
695
+ }
696
+ else {
697
+ blankRun = 0;
698
+ collapsed.push(line);
699
+ }
700
+ }
701
+ return { stripped: collapsed.join(lineEnding), removedBlocks, orphansLeft };
702
+ }
703
+ /**
704
+ * Remove orphaned, marker-less `[[hooks]]` tables that are unambiguously this
705
+ * plugin's approval hook (see `findUnmanagedApprovalHookBlocks`). Returns the
706
+ * cleaned text, the count removed, and the decoded commands of the removed
707
+ * tables (so install can distinguish "re-adorning this host's live hook after
708
+ * a kimi-code comment strip" from "sweeping stale cruft"). A no-op (returns
709
+ * the input unchanged) when there is nothing to prune, so callers can compare
710
+ * identity cheaply.
711
+ *
712
+ * `ownedBy` restricts the prune to tables whose command path derives to that
713
+ * host — REQUIRED for scoped install/uninstall so one host never disarms the
714
+ * other's live (marker-stripped) hook. Omit only for `--uninstall --all`.
715
+ */
716
+ function pruneOrphanApprovalHooks(contents, lineEnding, ownedBy, alsoMatchCommand) {
717
+ const ranges = findUnmanagedApprovalHookBlocks(contents, ownedBy, alsoMatchCommand);
718
+ if (ranges.length === 0)
719
+ return { pruned: contents, count: 0, commands: [] };
720
+ const remove = new Set();
721
+ for (const { start, end } of ranges) {
722
+ for (let i = start; i < end; i += 1)
723
+ remove.add(i);
724
+ }
725
+ const lines = splitPreservingEnding(contents);
726
+ const kept = lines.filter((_, idx) => !remove.has(idx));
727
+ // Collapse runs of >= 3 blank lines created by removal back to 2.
728
+ const collapsed = [];
729
+ let blankRun = 0;
730
+ for (const line of kept) {
731
+ if (line.length === 0) {
732
+ blankRun += 1;
733
+ if (blankRun <= 2)
734
+ collapsed.push(line);
735
+ }
736
+ else {
737
+ blankRun = 0;
738
+ collapsed.push(line);
739
+ }
740
+ }
741
+ return {
742
+ pruned: collapsed.join(lineEnding),
743
+ count: ranges.length,
744
+ commands: ranges.map((range) => range.command),
745
+ };
746
+ }
747
+ // ----- Block content -----------------------------------------------------
748
+ function buildManagedBlock(hookScriptPath, env, lineEnding = "\n", hostId = "") {
749
+ const shellCommand = buildHookShellCommand(hookScriptPath, env);
750
+ const commandLine = `command = ${tomlBasicString(shellCommand)}`;
751
+ const suffix = hostId.length > 0 ? `:${hostId}` : "";
752
+ return [
753
+ `${BEGIN_MARKER_PREFIX}${suffix} (v${KIMI_PLUGIN_CC_VERSION}) ===`,
754
+ `# DO NOT EDIT — managed by /kimi:setup. Run /kimi:setup --uninstall to remove.`,
755
+ `# Host: ${hostId.length > 0 ? hostId : "(legacy)"} — Claude Code and Codex each own a`,
756
+ `# separate block in this shared ~/.kimi-code/config.toml; setup in one host`,
757
+ `# never touches the other's.`,
758
+ `# Purpose:`,
759
+ `# kimi-code's \`kimi -p\` mode hard-codes permission='auto' and`,
760
+ `# auto-approves every tool call. This hook enforces /kimi:review,`,
761
+ `# /kimi:challenge, /kimi:review_gate, and /kimi:ask as read-only,`,
762
+ `# and applies the workspace-bound rescue allowlist for /kimi:rescue.`,
763
+ `# Without this block the plugin's safety contract collapses.`,
764
+ `# Matcher field is intentionally OMITTED — kimi-code compiles the`,
765
+ `# matcher with \`new RegExp(...)\`. An empty/missing matcher means`,
766
+ `# "fire for every tool". The string "*" would throw and silently`,
767
+ `# disable the hook. Do not "fix" this.`,
768
+ `# The Node binary path is absolute so kimi-code's \`/bin/sh -c\``,
769
+ `# hook spawn doesn't need \`node\` on its PATH (GUI launches,`,
770
+ `# LaunchAgents, etc.).`,
771
+ `[[hooks]]`,
772
+ `event = "PreToolUse"`,
773
+ commandLine,
774
+ `timeout = ${DEFAULT_HOOK_TIMEOUT_S}`,
775
+ `# === END kimi-plugin-cc-managed${suffix} ===`,
776
+ ].join(lineEnding);
777
+ }
778
+ /**
779
+ * Encode a string for a TOML 1.0 basic string. Escapes the six required
780
+ * escape sequences plus quotes/backslashes. Avoids the `\'` shell-quote
781
+ * hazard that broke the v1.0-alpha.1 prototype: TOML basic strings
782
+ * declare `\'` as a reserved escape, and a parser-compliant TOML
783
+ * library (kimi-code uses smol-toml) raises an error on encounter.
784
+ */
785
+ function tomlBasicString(value) {
786
+ const escaped = value
787
+ .replaceAll("\\", "\\\\")
788
+ .replaceAll("\"", "\\\"")
789
+ .replaceAll("\b", "\\b")
790
+ .replaceAll("\t", "\\t")
791
+ .replaceAll("\n", "\\n")
792
+ .replaceAll("\f", "\\f")
793
+ .replaceAll("\r", "\\r");
794
+ return `"${escaped}"`;
795
+ }
796
+ function assertHookPathTomlSafe(hookScriptPath) {
797
+ if (!PATH_FORBIDDEN_RE.test(hookScriptPath))
798
+ return;
799
+ throw new RuntimeError("SETUP_HOOK_PATH_UNSAFE", [
800
+ `Hook script path ${JSON.stringify(hookScriptPath)} contains characters`,
801
+ `(control chars, quotes, backslashes, or newlines) that cannot be safely`,
802
+ `written into kimi-code's TOML config. Set KIMI_PLUGIN_CC_HOOK_SCRIPT`,
803
+ `to an unambiguous absolute path or reinstall the plugin to a location`,
804
+ `without these characters.`,
805
+ ].join(" "), "setup.hook-path", { details: { hookScriptPath } });
806
+ }
807
+ // ----- Path resolution ---------------------------------------------------
808
+ function resolveKimiCodeConfigPath(env) {
809
+ return path.join(resolveKimiHome(env), "config.toml");
810
+ }
811
+ async function assertHookScriptExists(hookScriptPath) {
812
+ try {
813
+ await access(hookScriptPath, fsConstants.R_OK);
814
+ }
815
+ catch (err) {
816
+ throw new RuntimeError("SETUP_HOOK_SCRIPT_MISSING", `Hook script ${hookScriptPath} is missing or unreadable. Reinstall the plugin so dist/hooks/approval-hook.js is present.`, "setup.hook-script", err instanceof Error ? { cause: err, details: { hookScriptPath } } : { details: { hookScriptPath } });
817
+ }
818
+ }
819
+ /**
820
+ * Two-layer probe:
821
+ *
822
+ * 1. **Direct probe.** Spawn the hook with `process.execPath` (the
823
+ * same Node binary running the companion) and assert exit 2 +
824
+ * non-empty stderr. Catches: missing script, broken hook code,
825
+ * hook misrouting a deny as exit 0. This probe is reliable under
826
+ * PATH-sanitized smoke tests because it uses an absolute Node
827
+ * path.
828
+ *
829
+ * 2. **Shell probe.** Run `/bin/sh -c "<nodeBin> <hookScript>"` (the
830
+ * exact shape kimi-code uses via agent-core hooks/runner.ts) with
831
+ * synthetic stdin. Catches: kimi-code's shell can't find the
832
+ * Node binary, shell-quoting in the managed block is wrong, the
833
+ * Node binary on PATH isn't compatible.
834
+ *
835
+ * Both must pass for the install to be considered healthy. The shell
836
+ * probe is skipped on platforms without `/bin/sh` (Windows pure, etc.) —
837
+ * but the rest of the runtime already assumes POSIX.
838
+ */
839
+ async function probeHook(hookScriptPath, env) {
840
+ const directResult = await probeHookDirect(hookScriptPath, env);
841
+ if (!directResult.ok)
842
+ return directResult;
843
+ const shellResult = await probeHookViaShell(hookScriptPath, env);
844
+ if (!shellResult.ok)
845
+ return shellResult;
846
+ return {
847
+ ok: true,
848
+ reason: `${directResult.reason}; shell probe also ok`,
849
+ };
850
+ }
851
+ async function probeHookDirect(hookScriptPath, env) {
852
+ const nodeBin = resolveNodeBinary(env);
853
+ return await spawnProbe(nodeBin, [hookScriptPath], env, `direct probe via ${nodeBin}`);
854
+ }
855
+ async function probeHookViaShell(hookScriptPath, env) {
856
+ if (process.platform === "win32") {
857
+ // The hook runner shells out via `/bin/sh -c` per agent-core; on
858
+ // Windows the entire kimi-code launcher is unsupported. Skip the
859
+ // shell probe so we don't false-fail on a platform we don't run on.
860
+ return { ok: true, reason: "shell probe skipped (Windows)" };
861
+ }
862
+ // Reuse the exact command string the managed block wrote into
863
+ // kimi-code's config so probe and runtime cannot drift.
864
+ const shellCommand = buildHookShellCommand(hookScriptPath, env);
865
+ return await spawnProbe("/bin/sh", ["-c", shellCommand], env, `shell probe via /bin/sh -c`);
866
+ }
867
+ function spawnProbe(command, args, env, label) {
868
+ const payload = JSON.stringify({
869
+ hook_event_name: "PreToolUse",
870
+ session_id: "kimi-plugin-cc-setup-probe",
871
+ cwd: process.cwd(),
872
+ tool_name: "Bash",
873
+ tool_input: { command: "echo probe" },
874
+ tool_call_id: "probe-1",
875
+ });
876
+ return new Promise((resolve) => {
877
+ let child;
878
+ try {
879
+ child = spawn(command, args, {
880
+ env: {
881
+ ...env,
882
+ KIMI_PLUGIN_CC_CMD: "review",
883
+ KIMI_PLUGIN_CC_SKIP_HOOK_CHECK: "1",
884
+ },
885
+ stdio: ["pipe", "pipe", "pipe"],
886
+ });
887
+ }
888
+ catch (err) {
889
+ resolve({ ok: false, reason: `${label}: spawn failed: ${err.message}` });
890
+ return;
891
+ }
892
+ let stdout = "";
893
+ let stderr = "";
894
+ child.stdout?.setEncoding("utf8");
895
+ child.stderr?.setEncoding("utf8");
896
+ child.stdout?.on("data", (chunk) => {
897
+ stdout += chunk;
898
+ });
899
+ child.stderr?.on("data", (chunk) => {
900
+ stderr += chunk;
901
+ });
902
+ const timer = setTimeout(() => {
903
+ try {
904
+ child.kill("SIGKILL");
905
+ }
906
+ catch {
907
+ // best effort
908
+ }
909
+ resolve({
910
+ ok: false,
911
+ reason: `${label}: timed out after ${PROBE_TIMEOUT_MS}ms (no exit). stderr: ${truncate(stderr, 200)}`,
912
+ });
913
+ }, PROBE_TIMEOUT_MS);
914
+ timer.unref();
915
+ child.on("error", (err) => {
916
+ clearTimeout(timer);
917
+ resolve({ ok: false, reason: `${label}: process error: ${err.message}` });
918
+ });
919
+ child.on("close", (code) => {
920
+ clearTimeout(timer);
921
+ const stderrTrimmed = stderr.trim();
922
+ if (code === 2 && stderrTrimmed.length > 0) {
923
+ resolve({ ok: true, reason: `${label}: deny reason captured (${stderrTrimmed.slice(0, 80)}…)` });
924
+ }
925
+ else {
926
+ resolve({
927
+ ok: false,
928
+ reason: `${label}: expected exit 2 with deny reason, got exit ${code ?? "<null>"}. stdout=${truncate(stdout, 120)} stderr=${truncate(stderrTrimmed, 200)}`,
929
+ });
930
+ }
931
+ });
932
+ try {
933
+ child.stdin?.write(payload);
934
+ child.stdin?.end();
935
+ }
936
+ catch (err) {
937
+ clearTimeout(timer);
938
+ resolve({ ok: false, reason: `${label}: failed to write probe stdin: ${err.message}` });
939
+ }
940
+ });
941
+ }
942
+ function truncate(value, max) {
943
+ if (value.length <= max)
944
+ return value;
945
+ return `${value.slice(0, max)}…`;
946
+ }
947
+ // ----- Permission rules scan --------------------------------------------
948
+ /**
949
+ * Best-effort scan for `[[permission.rules]]` entries that would
950
+ * interfere with read-only commands. We don't parse TOML; we look for
951
+ * deny rules against common read tools. This recognizes both
952
+ * double-quoted basic strings and single-quoted literal strings, but
953
+ * still misses inline-table and multi-line forms — those are flagged
954
+ * as a known limitation in PR 4 docs.
955
+ */
956
+ /**
957
+ * Probe kimi-code's version and append a warning to `warnings` if the
958
+ * installed kimi is outside the range kimi-plugin-cc was tested
959
+ * against (H6, Codex post-hotfix audit Area 8).
960
+ *
961
+ * Setup soft-fail policy: if the probe itself fails (kimi not on PATH, spawn
962
+ * error, unparseable output), we record nothing here — the hook probe
963
+ * will surface a more direct error in that case. We only loud-warn when
964
+ * the version is **demonstrably** out of range. Setup may still complete,
965
+ * but the model execution-plan gate will refuse that binary before spawn.
966
+ *
967
+ * Override: `KIMI_PLUGIN_CC_SKIP_VERSION_PROBE=1` skips the probe
968
+ * entirely for tests and CI environments without kimi installed. It is not a
969
+ * production compatibility override; model jobs persist it as test-bypass.
970
+ */
971
+ async function collectKimiVersionWarnings(env, warnings) {
972
+ if (env.KIMI_PLUGIN_CC_SKIP_VERSION_PROBE === "1")
973
+ return;
974
+ const probe = await probeKimiVersion({
975
+ kimiBin: env.KIMI_PLUGIN_CC_KIMI_BIN || undefined,
976
+ env,
977
+ });
978
+ if (probe.kind === "failed") {
979
+ // Don't warn about probe failure here — if kimi is genuinely
980
+ // missing/broken, the hook probe path will catch it with a much
981
+ // clearer message. Avoid double-noise.
982
+ return;
983
+ }
984
+ // Certified for EITHER engine is "in range": the legacy tested minors, or an
985
+ // exact native-v2 certified version (which is deliberately outside
986
+ // KIMI_TESTED_MINORS — that table never gains 0.42). Without this, setup on a
987
+ // v2-certified binary would wrongly warn "not certified, commands will refuse".
988
+ if (probe.inTestedRange || isNativeV2CertifiedVersion(probe.version))
989
+ return;
990
+ warnings.push(formatVersionOutOfRangeWarning(probe, KIMI_PLUGIN_CC_VERSION));
991
+ }
992
+ /**
993
+ * H8 — non-blocking notice listing kimi-code's OWN installed plugins (the
994
+ * user-global plugin system kimi-code 0.4.0+ added; manifest at
995
+ * `~/.kimi-code/plugins/installed.json`, shape verified through 0.12.0:
996
+ * `{ plugins: [{ id, enabled, ... }] }`). kimi-code registers their tools
997
+ * (incl. MCP) on every session. Under kimi-plugin-cc's read-only commands the
998
+ * PreToolUse hook denies any tool outside Read/Grep/Glob, so calls to these
999
+ * plugins' tools are blocked — safe, but they silently burn model turns. We
1000
+ * surface that expectation at setup time so a confused "kimi did nothing" report
1001
+ * doesn't follow. Best-effort: a missing/unreadable/malformed manifest yields no
1002
+ * notice. Never blocks setup; never mutates the plugin list.
1003
+ */
1004
+ async function collectInstalledKimiPluginsNotice(env, warnings) {
1005
+ const home = resolveKimiHome(env);
1006
+ const installedPath = path.join(home, "plugins", "installed.json");
1007
+ let raw;
1008
+ try {
1009
+ raw = await readFile(installedPath, "utf8");
1010
+ }
1011
+ catch {
1012
+ return; // ENOENT / unreadable — kimi-code's plugin system isn't in use here.
1013
+ }
1014
+ let enabledIds;
1015
+ try {
1016
+ const parsed = JSON.parse(raw);
1017
+ if (parsed === null || typeof parsed !== "object" || !Array.isArray(parsed.plugins)) {
1018
+ return;
1019
+ }
1020
+ enabledIds = parsed.plugins
1021
+ .filter((entry) => entry !== null && typeof entry === "object")
1022
+ // `enabled !== false` so an explicitly-disabled plugin is hidden while an
1023
+ // enabled or format-ambiguous one is surfaced (it can still register tools).
1024
+ .filter((entry) => entry.enabled !== false)
1025
+ .map((entry) => (typeof entry.id === "string" ? entry.id : ""))
1026
+ .filter((id) => id.length > 0);
1027
+ }
1028
+ catch {
1029
+ return; // Malformed JSON — don't guess.
1030
+ }
1031
+ if (enabledIds.length === 0)
1032
+ return;
1033
+ warnings.push([
1034
+ `NOTE: ${enabledIds.length} kimi-code plugin(s) installed and enabled: ${enabledIds.join(", ")}.`,
1035
+ " kimi-code registers their tools (including MCP) on every session. Under kimi-plugin-cc's",
1036
+ " read-only commands (/kimi:review, /kimi:challenge, /kimi:ask, and the review gate) the",
1037
+ " PreToolUse hook denies any tool outside Read/Grep/Glob, so calls to these plugins' tools",
1038
+ " are blocked — safe, but they can waste model turns. This is expected; no action needed.",
1039
+ " (/kimi:rescue and /kimi:swarm apply their own allowlists.) To avoid the turn-waste, disable",
1040
+ " kimi-code plugins you don't need for delegated work in your kimi-code config.",
1041
+ ].join("\n"));
1042
+ }
1043
+ function collectPermissionRuleWarnings(contents, warnings) {
1044
+ const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
1045
+ let inRule = false;
1046
+ let ruleStartLine = -1;
1047
+ let ruleDecision = "";
1048
+ let rulePattern = "";
1049
+ const flushRule = () => {
1050
+ if (!inRule)
1051
+ return;
1052
+ if (ruleDecision === "deny" && rulePattern.length > 0) {
1053
+ if (rulePattern === "*" ||
1054
+ /^\s*Read\b/.test(rulePattern) ||
1055
+ /^\s*Grep\b/.test(rulePattern) ||
1056
+ /^\s*Glob\b/.test(rulePattern)) {
1057
+ warnings.push(`permission.rules at line ${ruleStartLine + 1}: deny pattern "${rulePattern}" may block read-only commands; consider scoping the deny narrower.`);
1058
+ }
1059
+ }
1060
+ inRule = false;
1061
+ ruleStartLine = -1;
1062
+ ruleDecision = "";
1063
+ rulePattern = "";
1064
+ };
1065
+ // Accept either double-quoted basic strings or single-quoted literal
1066
+ // strings for `decision` and `pattern`.
1067
+ const decisionRE = /^\s*decision\s*=\s*(?:"([^"]*)"|'([^']*)')/;
1068
+ const patternRE = /^\s*pattern\s*=\s*(?:"([^"]*)"|'([^']*)')/;
1069
+ for (let i = 0; i < lines.length; i += 1) {
1070
+ const line = lines[i];
1071
+ const trimmed = line.trim();
1072
+ if (trimmed === "[[permission.rules]]") {
1073
+ flushRule();
1074
+ inRule = true;
1075
+ ruleStartLine = i;
1076
+ continue;
1077
+ }
1078
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
1079
+ flushRule();
1080
+ continue;
1081
+ }
1082
+ if (!inRule)
1083
+ continue;
1084
+ const decisionMatch = decisionRE.exec(line);
1085
+ if (decisionMatch) {
1086
+ ruleDecision = decisionMatch[1] ?? decisionMatch[2] ?? "";
1087
+ continue;
1088
+ }
1089
+ const patternMatch = patternRE.exec(line);
1090
+ if (patternMatch) {
1091
+ rulePattern = patternMatch[1] ?? patternMatch[2] ?? "";
1092
+ continue;
1093
+ }
1094
+ }
1095
+ flushRule();
1096
+ }
1097
+ // ----- Detail rendering --------------------------------------------------
1098
+ function buildDetails(args) {
1099
+ const details = [
1100
+ `Companion runtime: Node ${process.version}`,
1101
+ `Plugin version: ${KIMI_PLUGIN_CC_VERSION}`,
1102
+ ...(args.hostId ? [`Host id: ${args.hostId}`] : []),
1103
+ `Config file: ${args.configPath}`,
1104
+ `Hook script: ${args.hookScriptPath}`,
1105
+ `Review gate: ${args.reviewGateEnabled ? "enabled" : "disabled"}`,
1106
+ `Probe: ${args.probe.ok ? "ok" : "failed"} — ${args.probe.reason}`,
1107
+ ];
1108
+ if (args.warnings.length > 0) {
1109
+ details.push("Warnings:");
1110
+ for (const warning of args.warnings) {
1111
+ details.push(` - ${warning}`);
1112
+ }
1113
+ }
1114
+ return details;
1115
+ }
1116
+ export function renderSetupResult(result) {
1117
+ return [
1118
+ result.summary,
1119
+ "",
1120
+ `Action: ${result.action}`,
1121
+ `Block written: ${result.blockWritten ? "yes" : "no"}`,
1122
+ `Block removed: ${result.blockRemoved ? "yes" : "no"}`,
1123
+ `Probe: ${result.probe}${result.probeError ? ` (${result.probeError})` : ""}`,
1124
+ `Review gate: ${result.reviewGateEnabled ? "enabled" : "disabled"}`,
1125
+ "",
1126
+ "Details:",
1127
+ ...result.details.map((detail) => `- ${detail}`),
1128
+ "",
1129
+ `Next step: ${result.nextStep}`,
1130
+ "Models: run setup --models to list configured aliases and provider setup guidance without making a model call.",
1131
+ ].join("\n");
1132
+ }