kimi-plugin-cc 2.4.5 → 2.5.2
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 +22 -0
- package/README.md +103 -29
- package/dist/commands/setup.js +78 -51
- package/dist/dsh/companion.js +177 -0
- package/dist/dsh/index.js +169 -0
- package/dist/hooks/install-paths.js +2 -2
- package/dist/hooks/managed-block.js +86 -20
- package/dist/version.js +1 -1
- package/docs/migration.md +4 -2
- package/docs/models.md +5 -1
- package/docs/registry-releases.md +84 -20
- package/docs/safety.md +25 -10
- package/docs/upstream-2.4.1-certification.md +6 -0
- package/package.json +75 -8
- package/plugins/kimi-codex/.codex-plugin/plugin.json +1 -1
- package/plugins/kimi-codex/dist/commands/setup.js +78 -51
- package/plugins/kimi-codex/dist/dsh/companion.js +177 -0
- package/plugins/kimi-codex/dist/dsh/index.js +169 -0
- package/plugins/kimi-codex/dist/hooks/install-paths.js +2 -2
- package/plugins/kimi-codex/dist/hooks/managed-block.js +86 -20
- package/plugins/kimi-codex/dist/version.js +1 -1
- package/plugins/kimi-dsh/LICENSE +201 -0
- package/plugins/kimi-dsh/README.md +83 -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 +1074 -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 +177 -0
- package/plugins/kimi-dsh/dist/dsh/index.js +169 -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 +685 -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 +78 -0
- package/plugins/kimi-dsh/scripts/companion.sh +37 -0
- package/plugins/kimi-dsh/scripts/review-gate-hook.sh +32 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
// Shared parser for the kimi-plugin-cc managed block in
|
|
2
|
+
// ~/.kimi-code/config.toml. Setup and the verifier use this one grammar;
|
|
3
|
+
// install-paths.ts separately owns the canonical command bytes and drift
|
|
4
|
+
// classification used here after a mismatch.
|
|
5
|
+
//
|
|
6
|
+
// Claude Code and Codex share the config but install to different paths. The
|
|
7
|
+
// marker's `:<host-id>` suffix makes each host own and verify only its block.
|
|
8
|
+
// An un-suffixed legacy block can be adopted by the current host on install.
|
|
9
|
+
//
|
|
10
|
+
// A valid block has this line-oriented shape:
|
|
11
|
+
//
|
|
12
|
+
// # === BEGIN kimi-plugin-cc-managed:<host-id> (vX.Y.Z) ===
|
|
13
|
+
// ... optional comments ...
|
|
14
|
+
// [[hooks]]
|
|
15
|
+
// event = "PreToolUse"
|
|
16
|
+
// command = "'/abs/path/to/node' '/abs/path/to/approval-hook.js'"
|
|
17
|
+
// timeout = <integer>
|
|
18
|
+
// # === END kimi-plugin-cc-managed:<host-id> ===
|
|
19
|
+
//
|
|
20
|
+
// Critical rules:
|
|
21
|
+
//
|
|
22
|
+
// - No `matcher` in any TOML spelling: it can disable or narrow the hook.
|
|
23
|
+
// - `event = "PreToolUse"` is required.
|
|
24
|
+
// - Exactly one `[[hooks]]` table appears inside the markers.
|
|
25
|
+
// - The decoded command must equal the canonical expected command byte-for-byte.
|
|
26
|
+
import { parse as parseToml } from "../vendor/smol-toml/parse.js";
|
|
27
|
+
import { classifyHookCommandDrift, describeHookCommandDrift, hostIdFromHookCommand, isOurApprovalHookCommand, } from "./install-paths.js";
|
|
28
|
+
const MARKER_TAG = "kimi-plugin-cc-managed";
|
|
29
|
+
/**
|
|
30
|
+
* Strict BEGIN matcher. Accepts `# === BEGIN kimi-plugin-cc-managed`
|
|
31
|
+
* with an optional `:<host-id>` suffix, an optional ` (vX.Y.Z)` suffix,
|
|
32
|
+
* and trailing ` ===`. Group 1 captures the host id (undefined = legacy).
|
|
33
|
+
* Anything else (random comment containing the tag, mid-line embedded
|
|
34
|
+
* marker) is not a managed-block marker.
|
|
35
|
+
*/
|
|
36
|
+
const BEGIN_LINE_RE = /^#\s*===\s*BEGIN\s+kimi-plugin-cc-managed(?::([A-Za-z0-9._-]+))?(?:\s+\([^)]+\))?\s*===\s*$/;
|
|
37
|
+
const END_LINE_RE = /^#\s*===\s*END\s+kimi-plugin-cc-managed(?::([A-Za-z0-9._-]+))?\s*===\s*$/;
|
|
38
|
+
/** TOML basic string for `command = "..."`. Captures the inner value. */
|
|
39
|
+
const COMMAND_LINE_RE = /^command\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/;
|
|
40
|
+
/** TOML literal string for `command = '...'` (no escapes inside). */
|
|
41
|
+
const COMMAND_LITERAL_LINE_RE = /^command\s*=\s*'([^']*)'\s*$/;
|
|
42
|
+
const EVENT_LINE_RE = /^event\s*=\s*"PreToolUse"\s*$/;
|
|
43
|
+
/** Anything matcher-shaped is a critical safety failure — block disabled. */
|
|
44
|
+
const MATCHER_LINE_RE = /^matcher\s*=/;
|
|
45
|
+
/** `timeout = <int>` — the only other key our managed [[hooks]] table emits. */
|
|
46
|
+
const TIMEOUT_LINE_RE = /^timeout\s*=\s*\d+\s*$/;
|
|
47
|
+
const HOOKS_TABLE_RE = /^\[\[hooks\]\]\s*$/;
|
|
48
|
+
// Recognize comment lines lexically, not text inside TOML string values.
|
|
49
|
+
// Whole-document parsing remains the authority for TOML validity.
|
|
50
|
+
function scanTomlLines(contents) {
|
|
51
|
+
const markers = new Set();
|
|
52
|
+
const tables = new Set();
|
|
53
|
+
let state = "plain";
|
|
54
|
+
let line = 0;
|
|
55
|
+
let blankPrefix = true;
|
|
56
|
+
for (let i = 0; i < contents.length; i += 1) {
|
|
57
|
+
const c = contents[i];
|
|
58
|
+
if (c === "\n") {
|
|
59
|
+
line += 1;
|
|
60
|
+
blankPrefix = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const onlyWhitespaceBefore = blankPrefix;
|
|
64
|
+
if (c !== " " && c !== "\t" && c !== "\r")
|
|
65
|
+
blankPrefix = false;
|
|
66
|
+
if (state === "plain") {
|
|
67
|
+
if (c === "[" && onlyWhitespaceBefore)
|
|
68
|
+
tables.add(line);
|
|
69
|
+
if (c === "#") {
|
|
70
|
+
if (onlyWhitespaceBefore) {
|
|
71
|
+
const end = contents.indexOf("\n", i);
|
|
72
|
+
const comment = contents.slice(i, end < 0 ? contents.length : end).trim();
|
|
73
|
+
if (BEGIN_LINE_RE.test(comment) || END_LINE_RE.test(comment))
|
|
74
|
+
markers.add(line);
|
|
75
|
+
}
|
|
76
|
+
const end = contents.indexOf("\n", i);
|
|
77
|
+
if (end < 0)
|
|
78
|
+
break;
|
|
79
|
+
i = end - 1;
|
|
80
|
+
}
|
|
81
|
+
else if (c === '"' || c === "'") {
|
|
82
|
+
const triple = contents.slice(i, i + 3) === c.repeat(3);
|
|
83
|
+
state = c === '"' ? (triple ? "multibasic" : "basic") : (triple ? "multiliteral" : "literal");
|
|
84
|
+
if (triple)
|
|
85
|
+
i += 2;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const basic = state === "basic" || state === "multibasic";
|
|
90
|
+
const quote = basic ? '"' : "'";
|
|
91
|
+
if (basic && c === "\\") {
|
|
92
|
+
if (contents[i + 1] === "\n") {
|
|
93
|
+
line += 1;
|
|
94
|
+
blankPrefix = true;
|
|
95
|
+
}
|
|
96
|
+
i += 1;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (c !== quote)
|
|
100
|
+
continue;
|
|
101
|
+
if (state === "basic" || state === "literal")
|
|
102
|
+
state = "plain";
|
|
103
|
+
else if (contents.slice(i, i + 3) === quote.repeat(3)) {
|
|
104
|
+
while (contents[i + 1] === quote)
|
|
105
|
+
i += 1;
|
|
106
|
+
state = "plain";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { markers, tables };
|
|
111
|
+
}
|
|
112
|
+
export function markerCommentLines(contents) {
|
|
113
|
+
return scanTomlLines(contents).markers;
|
|
114
|
+
}
|
|
115
|
+
/** Locate every BEGIN/END marker line, in order, with its parsed host id. */
|
|
116
|
+
function findMarkers(lines) {
|
|
117
|
+
const hits = [];
|
|
118
|
+
const comments = markerCommentLines(lines.join("\n"));
|
|
119
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
120
|
+
if (!comments.has(i))
|
|
121
|
+
continue;
|
|
122
|
+
const trimmed = lines[i].trim();
|
|
123
|
+
const begin = BEGIN_LINE_RE.exec(trimmed);
|
|
124
|
+
if (begin !== null) {
|
|
125
|
+
// Normalize marker host ids so a hand-edited casing difference cannot
|
|
126
|
+
// create a distinct host and confusing duplicate blocks.
|
|
127
|
+
hits.push({ index: i, kind: "begin", hostId: begin[1]?.toLowerCase() ?? null });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const end = END_LINE_RE.exec(trimmed);
|
|
131
|
+
if (end !== null) {
|
|
132
|
+
hits.push({ index: i, kind: "end", hostId: end[1]?.toLowerCase() ?? null });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return hits;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Pair BEGIN/END markers into well-formed blocks. Return the first orphan
|
|
139
|
+
* (BEGIN-without-END or END-without-BEGIN); verification fails closed on either.
|
|
140
|
+
*/
|
|
141
|
+
function pairBlocks(lines) {
|
|
142
|
+
const markers = findMarkers(lines);
|
|
143
|
+
const blocks = [];
|
|
144
|
+
let open = null;
|
|
145
|
+
for (const marker of markers) {
|
|
146
|
+
if (marker.kind === "begin") {
|
|
147
|
+
// A second BEGIN before the previous one closed → the previous is orphaned.
|
|
148
|
+
if (open !== null)
|
|
149
|
+
return { orphan: "BEGIN-without-END" };
|
|
150
|
+
open = marker;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// end
|
|
154
|
+
if (open === null)
|
|
155
|
+
return { orphan: "END-without-BEGIN" };
|
|
156
|
+
if (open.hostId !== marker.hostId)
|
|
157
|
+
return { orphan: "BEGIN-without-END" };
|
|
158
|
+
const body = validateBlockBody(lines.slice(open.index + 1, marker.index));
|
|
159
|
+
blocks.push({
|
|
160
|
+
hostId: open.hostId,
|
|
161
|
+
beginLine: open.index,
|
|
162
|
+
endLine: marker.index,
|
|
163
|
+
commandPath: body.commandPath,
|
|
164
|
+
valid: body.valid,
|
|
165
|
+
invalidReason: body.invalidReason,
|
|
166
|
+
});
|
|
167
|
+
open = null;
|
|
168
|
+
}
|
|
169
|
+
if (open !== null)
|
|
170
|
+
return { orphan: "BEGIN-without-END" };
|
|
171
|
+
return { blocks };
|
|
172
|
+
}
|
|
173
|
+
/** Validate the lines BETWEEN a BEGIN/END pair against the safety grammar. */
|
|
174
|
+
function validateBlockBody(rawBody) {
|
|
175
|
+
const blockLines = rawBody.map((line) => line.trim());
|
|
176
|
+
let foundHooksTable = false;
|
|
177
|
+
let foundEvent = false;
|
|
178
|
+
let commandPath = "";
|
|
179
|
+
let invalidReason;
|
|
180
|
+
for (const line of blockLines) {
|
|
181
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
182
|
+
continue;
|
|
183
|
+
if (HOOKS_TABLE_RE.test(line)) {
|
|
184
|
+
if (foundHooksTable) {
|
|
185
|
+
invalidReason = "block contains more than one [[hooks]] table";
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
foundHooksTable = true;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (line.startsWith("[")) {
|
|
192
|
+
// Any TOML table header other than the single [[hooks]] table means the
|
|
193
|
+
// `event`/`command` lines below could belong to a DIFFERENT table (e.g.
|
|
194
|
+
// `[[hooks]]` with only an event, then `[not_hooks]` carrying the
|
|
195
|
+
// canonical command). Binding the block to exactly one [[hooks]] table
|
|
196
|
+
// keeps the verifier from blessing a command-less hook.
|
|
197
|
+
invalidReason =
|
|
198
|
+
"block contains an unexpected TOML table — only a single [[hooks]] table is allowed. Reinstall with /kimi:setup to repair.";
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
if (MATCHER_LINE_RE.test(line)) {
|
|
202
|
+
invalidReason =
|
|
203
|
+
'block contains a `matcher = ...` line — kimi-code compiles matchers as JS regex (`new RegExp("*")` throws and disables the hook). Reinstall with /kimi:setup to repair.';
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
if (EVENT_LINE_RE.test(line)) {
|
|
207
|
+
foundEvent = true;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
const basic = COMMAND_LINE_RE.exec(line);
|
|
211
|
+
const literal = COMMAND_LITERAL_LINE_RE.exec(line);
|
|
212
|
+
if (basic !== null || literal !== null) {
|
|
213
|
+
// Decode TOML basic-string escapes before comparing with the canonical JS
|
|
214
|
+
// string; literal strings have no escapes. Otherwise shell quoting and
|
|
215
|
+
// TOML escaping can make an equivalent command compare unequal.
|
|
216
|
+
commandPath = basic !== null ? decodeTomlBasicString(basic[1]) : literal[1];
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Other TOML lines are ignored only by this local grammar. Whole-file schema
|
|
220
|
+
// validation and foundHookEntryIsClean reject foreign keys before an
|
|
221
|
+
// installed verdict; the shell probe is not the validation backstop.
|
|
222
|
+
}
|
|
223
|
+
if (invalidReason === undefined) {
|
|
224
|
+
if (!foundHooksTable) {
|
|
225
|
+
invalidReason = "block is missing a `[[hooks]]` table";
|
|
226
|
+
}
|
|
227
|
+
else if (!foundEvent) {
|
|
228
|
+
invalidReason = 'block is missing `event = "PreToolUse"`';
|
|
229
|
+
}
|
|
230
|
+
else if (commandPath.length === 0) {
|
|
231
|
+
invalidReason = "block is missing a `command = \"...\"` line";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Parser-based matcher rejection backs up the bare-line regex: smol-toml
|
|
235
|
+
// normalizes quoted, literal, and dotted matcher keys that the regex misses,
|
|
236
|
+
// while kimi-code honors them and can disable or narrow the hook. Both this
|
|
237
|
+
// marked-block path and the absent-state fallback must parse the real TOML.
|
|
238
|
+
// Fail closed when the body cannot be parsed in isolation.
|
|
239
|
+
if (invalidReason === undefined) {
|
|
240
|
+
try {
|
|
241
|
+
const parsed = parseToml(rawBody.join("\n"));
|
|
242
|
+
const hooks = parsed !== null && typeof parsed === "object"
|
|
243
|
+
? parsed.hooks
|
|
244
|
+
: undefined;
|
|
245
|
+
const hasMatcher = Array.isArray(hooks) &&
|
|
246
|
+
hooks.some((entry) => entry !== null &&
|
|
247
|
+
typeof entry === "object" &&
|
|
248
|
+
Object.prototype.hasOwnProperty.call(entry, "matcher"));
|
|
249
|
+
if (hasMatcher) {
|
|
250
|
+
invalidReason =
|
|
251
|
+
"block contains a `matcher` key (in a quoted or dotted spelling the line scanner misses) — kimi-code compiles matchers as JS regex and disables the hook. Reinstall with /kimi:setup to repair.";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
invalidReason =
|
|
256
|
+
"block body is not valid TOML in isolation — cannot confirm it carries no hook-disabling matcher. Reinstall with /kimi:setup to repair.";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { valid: invalidReason === undefined, invalidReason, commandPath };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Parse the supplied kimi-code config text and resolve the managed-block
|
|
263
|
+
* state FOR A SPECIFIC HOST.
|
|
264
|
+
*
|
|
265
|
+
* Host resolution: the current host's own suffixed block is authoritative if
|
|
266
|
+
* present; otherwise a lone LEGACY (un-suffixed) block is adoptable and is
|
|
267
|
+
* returned as the found block (the current host will convert it on install).
|
|
268
|
+
* Other hosts' suffixed blocks are ignored for this host's state (that is the
|
|
269
|
+
* whole point of host scoping — they coexist).
|
|
270
|
+
*
|
|
271
|
+
* Only a trailing `\r` is removed from each line for CRLF safety; other line
|
|
272
|
+
* whitespace is preserved here and trimmed only by the relevant matchers.
|
|
273
|
+
*/
|
|
274
|
+
export function parseManagedBlock(contents, hostId) {
|
|
275
|
+
const rawLines = contents.split("\n");
|
|
276
|
+
const lines = rawLines.map((line) => line.replace(/\r$/, ""));
|
|
277
|
+
const paired = pairBlocks(lines);
|
|
278
|
+
if ("orphan" in paired) {
|
|
279
|
+
return { state: { kind: "orphan", detail: paired.orphan }, blocks: [], lines };
|
|
280
|
+
}
|
|
281
|
+
const blocks = paired.blocks;
|
|
282
|
+
// A block belongs to this host when its marker suffix matches or, for a
|
|
283
|
+
// legacy un-suffixed block, its command derives to this host. An unattributable
|
|
284
|
+
// legacy command is claimable by the next installer; a command attributed to
|
|
285
|
+
// another host is ignored so its block remains untouched.
|
|
286
|
+
const relevant = blocks.filter((b) => effectiveHost(b, hostId) === hostId);
|
|
287
|
+
if (relevant.length === 0) {
|
|
288
|
+
return { state: { kind: "absent" }, blocks, lines };
|
|
289
|
+
}
|
|
290
|
+
if (relevant.length > 1) {
|
|
291
|
+
return {
|
|
292
|
+
state: { kind: "duplicate", beginLines: relevant.map((b) => b.beginLine) },
|
|
293
|
+
blocks,
|
|
294
|
+
lines,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const block = relevant[0];
|
|
298
|
+
return {
|
|
299
|
+
state: {
|
|
300
|
+
kind: "found",
|
|
301
|
+
beginLine: block.beginLine,
|
|
302
|
+
endLine: block.endLine,
|
|
303
|
+
hostId: block.hostId,
|
|
304
|
+
commandPath: block.commandPath,
|
|
305
|
+
valid: block.valid,
|
|
306
|
+
invalidReason: block.invalidReason,
|
|
307
|
+
},
|
|
308
|
+
blocks,
|
|
309
|
+
lines,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* The host a managed block belongs to: its marker suffix when present,
|
|
314
|
+
* otherwise the host derived from its command path (a legacy un-suffixed
|
|
315
|
+
* block), falling back to `currentHost` when the command can't be attributed
|
|
316
|
+
* (a stale/bare legacy command — claimable by whoever installs next).
|
|
317
|
+
*/
|
|
318
|
+
export function effectiveHost(block, currentHost) {
|
|
319
|
+
if (block.hostId !== null)
|
|
320
|
+
return block.hostId;
|
|
321
|
+
return hostIdFromHookCommand(block.commandPath) ?? currentHost;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Decode the `command = "..."` (or `'...'`) value from a single trimmed TOML
|
|
325
|
+
* line, or `null` if the line isn't a command assignment. Exported so the
|
|
326
|
+
* uninstall path can attribute a legacy block to its host without re-deriving
|
|
327
|
+
* the TOML decode.
|
|
328
|
+
*/
|
|
329
|
+
export function decodeManagedCommandLine(trimmedLine) {
|
|
330
|
+
const basic = COMMAND_LINE_RE.exec(trimmedLine);
|
|
331
|
+
if (basic !== null)
|
|
332
|
+
return decodeTomlBasicString(basic[1]);
|
|
333
|
+
const literal = COMMAND_LITERAL_LINE_RE.exec(trimmedLine);
|
|
334
|
+
if (literal !== null)
|
|
335
|
+
return literal[1];
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Return `contents` with every well-formed managed BEGIN/END block removed
|
|
340
|
+
* (inclusive), so what remains is only the bare/root TOML. Used by
|
|
341
|
+
* `hasCleanEnforcingHookEntry` to enforce host isolation. If the markers are
|
|
342
|
+
* unbalanced (orphan), returns the input unchanged — the caller is only reached
|
|
343
|
+
* in the `absent` state, where a well-formed foreign block is the norm.
|
|
344
|
+
*/
|
|
345
|
+
function stripManagedBlockLines(contents) {
|
|
346
|
+
const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
|
|
347
|
+
const paired = pairBlocks(lines);
|
|
348
|
+
if (!("blocks" in paired))
|
|
349
|
+
return contents;
|
|
350
|
+
const inBlock = new Set();
|
|
351
|
+
for (const block of paired.blocks) {
|
|
352
|
+
for (let i = block.beginLine; i <= block.endLine; i += 1)
|
|
353
|
+
inBlock.add(i);
|
|
354
|
+
}
|
|
355
|
+
if (inBlock.size === 0)
|
|
356
|
+
return contents;
|
|
357
|
+
return lines.filter((_, i) => !inBlock.has(i)).join("\n");
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* True if a parsed `hooks[]` array contains a clean enforcing entry for
|
|
361
|
+
* `expectedCommand`: `event="PreToolUse"`, byte-exact `command`, NO `matcher`
|
|
362
|
+
* (any spelling the parser normalized to the `matcher` key can disable/narrow
|
|
363
|
+
* the hook), and no keys beyond {event,command,timeout} (a stray key is fatal to
|
|
364
|
+
* kimi-code's strict schema). Each host's hook path is distinct, so the command
|
|
365
|
+
* uniquely identifies this host's entry.
|
|
366
|
+
*/
|
|
367
|
+
function hooksArrayHasCleanEntry(hooks, expectedCommand) {
|
|
368
|
+
if (!Array.isArray(hooks))
|
|
369
|
+
return false;
|
|
370
|
+
const allowedKeys = new Set(["event", "command", "timeout"]);
|
|
371
|
+
for (const entry of hooks) {
|
|
372
|
+
if (entry === null || typeof entry !== "object")
|
|
373
|
+
continue;
|
|
374
|
+
const rec = entry;
|
|
375
|
+
if (rec.event !== "PreToolUse")
|
|
376
|
+
continue;
|
|
377
|
+
if (rec.command !== expectedCommand)
|
|
378
|
+
continue;
|
|
379
|
+
if (Object.prototype.hasOwnProperty.call(rec, "matcher"))
|
|
380
|
+
continue;
|
|
381
|
+
if (!Object.keys(rec).every((k) => allowedKeys.has(k)))
|
|
382
|
+
continue;
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Parser-based fallback used only in the absent state. Strip every managed
|
|
389
|
+
* block first so another host's live hook cannot count for this host, then parse
|
|
390
|
+
* the remaining bare/root TOML with the real parser rather than the prune line
|
|
391
|
+
* scanner. `hooksArrayHasCleanEntry` requires the exact event and command, no
|
|
392
|
+
* matcher in any spelling, and no foreign keys. Parse errors fail closed.
|
|
393
|
+
* Callers run whole-file schema validation first, including timeout ranges.
|
|
394
|
+
*/
|
|
395
|
+
export function hasCleanEnforcingHookEntry(contents, expectedCommand) {
|
|
396
|
+
let parsed;
|
|
397
|
+
try {
|
|
398
|
+
parsed = parseToml(stripManagedBlockLines(contents));
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
if (parsed === null || typeof parsed !== "object")
|
|
404
|
+
return false;
|
|
405
|
+
return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Whole-file parser check for the FOUND (marked-block present) path. The line
|
|
409
|
+
* grammar in `validateBlockBody` only inspects the lines BETWEEN the BEGIN/END
|
|
410
|
+
* markers — but a `matcher = "*"` (or a stray key) placed AFTER the END comment
|
|
411
|
+
* is still part of the SAME `[[hooks]]` TOML table (comments don't terminate a
|
|
412
|
+
* table), so kimi-code loads it and DISABLES the hook (`new RegExp("*")` throws)
|
|
413
|
+
* while the marked body looks clean → a fail-open `installed:true`. Parsing the
|
|
414
|
+
* whole file exactly as kimi-code does attributes that trailing key to our entry
|
|
415
|
+
* and rejects it. Fail-closed on any parse error.
|
|
416
|
+
*/
|
|
417
|
+
function foundHookEntryIsClean(contents, expectedCommand) {
|
|
418
|
+
let parsed;
|
|
419
|
+
try {
|
|
420
|
+
parsed = parseToml(contents);
|
|
421
|
+
}
|
|
422
|
+
catch {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (parsed === null || typeof parsed !== "object")
|
|
426
|
+
return false;
|
|
427
|
+
return hooksArrayHasCleanEntry(parsed.hooks, expectedCommand);
|
|
428
|
+
}
|
|
429
|
+
export function evaluateInstalled(contents, expectedCommand, opts) {
|
|
430
|
+
const { state } = parseManagedBlock(contents, opts.hostId);
|
|
431
|
+
if (state.kind === "absent") {
|
|
432
|
+
// kimi-code config rewrites drop comments but leave hook tables intact. In
|
|
433
|
+
// the absent state only, accept a marker-less table that is exactly this
|
|
434
|
+
// host's clean enforcing hook: markers are not the security boundary, the
|
|
435
|
+
// byte-exact command is. The real TOML parser sees matchers and foreign keys
|
|
436
|
+
// across blank lines, quoted spellings, and multiline values.
|
|
437
|
+
if (hasCleanEnforcingHookEntry(contents, expectedCommand)) {
|
|
438
|
+
return {
|
|
439
|
+
installed: true,
|
|
440
|
+
via: "bare-table",
|
|
441
|
+
note: "this host's managed-block markers are missing (kimi-code rewrites its config on login/settings changes and strips all comments), but the hook table with the exact canonical command is present and enforcing. Run /kimi:setup to re-adorn the markers.",
|
|
442
|
+
state,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
// If no exact bare table remains, classify drift only from this host's own
|
|
446
|
+
// stale table so the caller gets an actionable axis.
|
|
447
|
+
const staleOwn = findBareApprovalHookTables(contents).find((table) => table.command !== expectedCommand &&
|
|
448
|
+
isOurApprovalHookCommand(table.command) &&
|
|
449
|
+
hostIdFromHookCommand(table.command) === opts.hostId);
|
|
450
|
+
return {
|
|
451
|
+
installed: false,
|
|
452
|
+
reason: "managed block is not present",
|
|
453
|
+
state,
|
|
454
|
+
...(staleOwn !== undefined
|
|
455
|
+
? { drift: classifyHookCommandDrift(staleOwn.command, expectedCommand) }
|
|
456
|
+
: {}),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (state.kind === "duplicate") {
|
|
460
|
+
return {
|
|
461
|
+
installed: false,
|
|
462
|
+
reason: `duplicate managed blocks detected at lines ${state.beginLines
|
|
463
|
+
.map((line) => line + 1)
|
|
464
|
+
.join(", ")}. Run /kimi:setup --uninstall, then /kimi:setup.`,
|
|
465
|
+
state,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
if (state.kind === "orphan") {
|
|
469
|
+
return {
|
|
470
|
+
installed: false,
|
|
471
|
+
reason: `${state.detail} marker. Run /kimi:setup --uninstall to clear.`,
|
|
472
|
+
state,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (!state.valid) {
|
|
476
|
+
return {
|
|
477
|
+
installed: false,
|
|
478
|
+
reason: state.invalidReason ?? "managed block failed validation",
|
|
479
|
+
state,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (state.commandPath !== expectedCommand) {
|
|
483
|
+
const classified = opts.nodeExists !== undefined
|
|
484
|
+
? describeHookCommandDrift(state.commandPath, expectedCommand, opts.nodeExists)
|
|
485
|
+
: undefined;
|
|
486
|
+
return {
|
|
487
|
+
installed: false,
|
|
488
|
+
reason: classified ??
|
|
489
|
+
`installed block's command does not match the canonical command this companion would write. Run /kimi:setup to refresh. expected ${expectedCommand}; got ${state.commandPath}.`,
|
|
490
|
+
state,
|
|
491
|
+
drift: classifyHookCommandDrift(state.commandPath, expectedCommand),
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (!foundHookEntryIsClean(contents, expectedCommand)) {
|
|
495
|
+
return {
|
|
496
|
+
installed: false,
|
|
497
|
+
reason: "the managed block's hook table carries a matcher or an unexpected key OUTSIDE the marked body (e.g. after the END marker) — kimi-code would load it and disable the hook. Run /kimi:setup --uninstall, then /kimi:setup to repair.",
|
|
498
|
+
state,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
return { installed: true, via: "managed-block", state };
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Line-oriented PRUNE scanner, never the installed decision; that decision uses
|
|
505
|
+
* the real TOML parser through `hasCleanEnforcingHookEntry` and
|
|
506
|
+
* `foundHookEntryIsClean`.
|
|
507
|
+
*
|
|
508
|
+
* The deletion range `[start, end)` is conservative: only the contiguous
|
|
509
|
+
* non-blank body after `[[hooks]]`, stopping at a blank line, table header, or
|
|
510
|
+
* managed marker so adjacent user content is preserved. A separate full-span
|
|
511
|
+
* scan refuses any further TOML field before the next table, including quoted
|
|
512
|
+
* and dotted keys, so pruning cannot attach those fields to another table.
|
|
513
|
+
* `command` is decoded for the caller's ownership checks.
|
|
514
|
+
*/
|
|
515
|
+
export function findBareApprovalHookTables(contents) {
|
|
516
|
+
const lines = contents.split("\n").map((line) => line.replace(/\r$/, ""));
|
|
517
|
+
const tableLines = scanTomlLines(contents).tables;
|
|
518
|
+
const managed = new Set();
|
|
519
|
+
const paired = pairBlocks(lines);
|
|
520
|
+
if ("blocks" in paired) {
|
|
521
|
+
for (const block of paired.blocks) {
|
|
522
|
+
for (let i = block.beginLine; i <= block.endLine; i += 1)
|
|
523
|
+
managed.add(i);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
const ranges = [];
|
|
527
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
528
|
+
if (managed.has(i))
|
|
529
|
+
continue;
|
|
530
|
+
if (!tableLines.has(i) || !HOOKS_TABLE_RE.test(lines[i].trim()))
|
|
531
|
+
continue;
|
|
532
|
+
// Collect the contiguous table body.
|
|
533
|
+
let j = i + 1;
|
|
534
|
+
let commandDecoded = "";
|
|
535
|
+
let hasPreToolUseEvent = false;
|
|
536
|
+
let hasMatcher = false;
|
|
537
|
+
// Our tables contain only event/command/timeout and comments. Any other key,
|
|
538
|
+
// including a multiline array, makes the shape unrecognized so pruning never
|
|
539
|
+
// partially cuts a structure and leaves dangling TOML.
|
|
540
|
+
let simpleGrammar = true;
|
|
541
|
+
while (j < lines.length) {
|
|
542
|
+
const trimmed = lines[j].trim();
|
|
543
|
+
if (trimmed.length === 0)
|
|
544
|
+
break;
|
|
545
|
+
// Any TOML table header bounds the table — including one with a trailing
|
|
546
|
+
// comment (`[[permission.rules]] # note`). A bare leading `[` is a table
|
|
547
|
+
// header in TOML (value arrays are `key = [...]`), so stop there and
|
|
548
|
+
// never swallow the next table.
|
|
549
|
+
if (trimmed.startsWith("["))
|
|
550
|
+
break;
|
|
551
|
+
if (BEGIN_LINE_RE.test(trimmed) || END_LINE_RE.test(trimmed))
|
|
552
|
+
break;
|
|
553
|
+
if (trimmed.startsWith("#")) {
|
|
554
|
+
j += 1;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (EVENT_LINE_RE.test(trimmed)) {
|
|
558
|
+
hasPreToolUseEvent = true;
|
|
559
|
+
}
|
|
560
|
+
else if (MATCHER_LINE_RE.test(trimmed)) {
|
|
561
|
+
hasMatcher = true;
|
|
562
|
+
}
|
|
563
|
+
else {
|
|
564
|
+
const decoded = decodeManagedCommandLine(trimmed);
|
|
565
|
+
if (decoded !== null) {
|
|
566
|
+
commandDecoded = decoded;
|
|
567
|
+
}
|
|
568
|
+
else if (!TIMEOUT_LINE_RE.test(trimmed)) {
|
|
569
|
+
// Some key other than event/command/timeout/matcher — not our shape.
|
|
570
|
+
simpleGrammar = false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
j += 1;
|
|
574
|
+
}
|
|
575
|
+
// Blank lines and marker comments do not end a TOML table. Never leave any
|
|
576
|
+
// field behind to be rebound to a preceding table after pruning, regardless
|
|
577
|
+
// of the field's spelling or whether it is valid for a hook.
|
|
578
|
+
let fieldBeyondBody = false;
|
|
579
|
+
for (let k = j; k < lines.length; k += 1) {
|
|
580
|
+
const t = lines[k].trim();
|
|
581
|
+
if (tableLines.has(k))
|
|
582
|
+
break;
|
|
583
|
+
if (t.length > 0 && !t.startsWith("#")) {
|
|
584
|
+
fieldBeyondBody = true;
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
// Report only tables matching this conservative line grammar: PreToolUse,
|
|
589
|
+
// a decoded command, no recognized matcher in the TOML span, and no foreign
|
|
590
|
+
// keys in the contiguous body. This is not a full TOML-semantic acceptance
|
|
591
|
+
// check; command and host ownership are the caller's concern.
|
|
592
|
+
if (simpleGrammar &&
|
|
593
|
+
commandDecoded.length > 0 &&
|
|
594
|
+
hasPreToolUseEvent &&
|
|
595
|
+
!hasMatcher &&
|
|
596
|
+
!fieldBeyondBody) {
|
|
597
|
+
ranges.push({ start: i, end: j, command: commandDecoded });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return ranges;
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Find marker-less canonical approval-hook tables outside managed blocks.
|
|
604
|
+
* `ownedBy` restricts results to the host derived from each command and is
|
|
605
|
+
* required for scoped pruning: another host's marker-less table may be its live
|
|
606
|
+
* hook after a config rewrite. Omit `ownedBy` only for the explicit every-host
|
|
607
|
+
* `/kimi:setup --uninstall --all` sweep. Non-canonical and hand-rolled hooks are
|
|
608
|
+
* not claimed.
|
|
609
|
+
*/
|
|
610
|
+
export function findUnmanagedApprovalHookBlocks(contents, ownedBy, alsoMatchCommand) {
|
|
611
|
+
return findBareApprovalHookTables(contents).filter((table) => {
|
|
612
|
+
// Exact current command bytes are the strongest ownership signal and win
|
|
613
|
+
// even when a host-id override disagrees with path derivation. This does
|
|
614
|
+
// not widen the prune: another host's or a hand-rolled hook has different
|
|
615
|
+
// command bytes.
|
|
616
|
+
if (alsoMatchCommand !== undefined && table.command === alsoMatchCommand)
|
|
617
|
+
return true;
|
|
618
|
+
if (!isOurApprovalHookCommand(table.command))
|
|
619
|
+
return false;
|
|
620
|
+
if (ownedBy === undefined)
|
|
621
|
+
return true;
|
|
622
|
+
return hostIdFromHookCommand(table.command) === ownedBy;
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
export const MARKERS = {
|
|
626
|
+
TAG: MARKER_TAG,
|
|
627
|
+
BEGIN_LINE_RE,
|
|
628
|
+
END_LINE_RE,
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* Decode a TOML 1.0 basic-string body (the bytes BETWEEN the quotes —
|
|
632
|
+
* the surrounding `"..."` is stripped by the capture group). Handles
|
|
633
|
+
* the six standard escapes plus `\\` and `\"`. Unknown escapes fall
|
|
634
|
+
* through as-is, mirroring permissive parsers; the canonical command
|
|
635
|
+
* we compare against never contains them, so a mismatch surfaces as
|
|
636
|
+
* "installed: false" rather than a parse error.
|
|
637
|
+
*
|
|
638
|
+
* Inverse of `tomlBasicString` in `runtime/commands/setup.ts`; decoding before
|
|
639
|
+
* comparison prevents shell/TOML escaping from causing a false mismatch.
|
|
640
|
+
*/
|
|
641
|
+
function decodeTomlBasicString(raw) {
|
|
642
|
+
let out = "";
|
|
643
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
644
|
+
const ch = raw[i];
|
|
645
|
+
if (ch !== "\\") {
|
|
646
|
+
out += ch;
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const next = raw[i + 1];
|
|
650
|
+
if (next === undefined) {
|
|
651
|
+
out += ch;
|
|
652
|
+
continue;
|
|
653
|
+
}
|
|
654
|
+
i += 1;
|
|
655
|
+
switch (next) {
|
|
656
|
+
case "\\":
|
|
657
|
+
out += "\\";
|
|
658
|
+
break;
|
|
659
|
+
case "\"":
|
|
660
|
+
out += "\"";
|
|
661
|
+
break;
|
|
662
|
+
case "b":
|
|
663
|
+
out += "\b";
|
|
664
|
+
break;
|
|
665
|
+
case "t":
|
|
666
|
+
out += "\t";
|
|
667
|
+
break;
|
|
668
|
+
case "n":
|
|
669
|
+
out += "\n";
|
|
670
|
+
break;
|
|
671
|
+
case "f":
|
|
672
|
+
out += "\f";
|
|
673
|
+
break;
|
|
674
|
+
case "r":
|
|
675
|
+
out += "\r";
|
|
676
|
+
break;
|
|
677
|
+
default:
|
|
678
|
+
// Pass unknown escapes through verbatim. The canonical command
|
|
679
|
+
// never produces them, so an inequality surfaces as a benign
|
|
680
|
+
// "installed: false" rather than a parse exception.
|
|
681
|
+
out += `\\${next}`;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|