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