@vincemakes/kiso-code 0.39.1 → 0.40.0
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/README.md +1 -1
- package/dist/attachments.d.ts +1 -1
- package/dist/attachments.js +11 -4
- package/dist/builtin.d.ts +12 -0
- package/dist/builtin.js +22 -1
- package/dist/chat.d.ts +28 -2
- package/dist/chat.js +143 -25
- package/dist/config.d.ts +11 -1
- package/dist/config.js +48 -3
- package/dist/dispatch.d.ts +10 -2
- package/dist/dispatch.js +138 -23
- package/dist/floor.d.ts +80 -0
- package/dist/floor.js +652 -0
- package/dist/index.d.ts +21 -2
- package/dist/index.js +397 -44
- package/dist/mode.d.ts +12 -4
- package/dist/mode.js +32 -10
- package/dist/projects.d.ts +106 -0
- package/dist/projects.js +212 -0
- package/dist/protected-shell.d.ts +88 -0
- package/dist/protected-shell.js +406 -0
- package/dist/protected-writes.d.ts +29 -0
- package/dist/protected-writes.js +54 -0
- package/dist/readonly-shell.d.ts +58 -0
- package/dist/readonly-shell.js +590 -0
- package/dist/replay.d.ts +53 -0
- package/dist/replay.js +158 -0
- package/dist/resume-tail.js +4 -1
- package/dist/resume.js +3 -3
- package/dist/retries.d.ts +9 -0
- package/dist/retries.js +16 -0
- package/dist/session-cards.d.ts +33 -4
- package/dist/session-cards.js +44 -3
- package/dist/session-migration.d.ts +104 -0
- package/dist/session-migration.js +319 -0
- package/dist/shell-words.d.ts +134 -0
- package/dist/shell-words.js +628 -0
- package/dist/skill-invoke.d.ts +58 -0
- package/dist/skill-invoke.js +139 -0
- package/dist/state.d.ts +67 -1
- package/dist/state.js +98 -2
- package/dist/trust-ui.js +29 -5
- package/package.json +15 -15
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shell command line, READ — never run — for the approval chain
|
|
3
|
+
* (0.40.0: the read-only allow now, the catastrophe floor next).
|
|
4
|
+
*
|
|
5
|
+
* This is not a shell. It is a lexer for the subset of POSIX sh whose
|
|
6
|
+
* meaning can be known WITHOUT running anything, and it refuses the rest:
|
|
7
|
+
* a command line that uses expansion (`$`, backticks, globs, a leading
|
|
8
|
+
* `~`, braces), grouping or subshells, background jobs, heredocs, or a
|
|
9
|
+
* leading variable assignment does not parse, and a caller that cannot
|
|
10
|
+
* parse a line must treat it as unknown. The refusals are the design —
|
|
11
|
+
* every construct accepted here is one whose words are exactly the words
|
|
12
|
+
* the shell will pass to the program.
|
|
13
|
+
*
|
|
14
|
+
* What it produces is a LIST of pipelines joined by `;`, `&&`, `||` (a
|
|
15
|
+
* newline counts as `;`), each pipeline a series of simple commands joined
|
|
16
|
+
* by `|`, each command an argv plus its redirections.
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
19
|
+
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
20
|
+
const GLOB = new Set(["*", "?", "["]);
|
|
21
|
+
/**
|
|
22
|
+
* Parse a command line, or say why not. Pure: no filesystem, no
|
|
23
|
+
* environment.
|
|
24
|
+
*/
|
|
25
|
+
export function parseShell(src) {
|
|
26
|
+
const list = [];
|
|
27
|
+
let stages = [];
|
|
28
|
+
let argv = [];
|
|
29
|
+
let redirects = [];
|
|
30
|
+
let joinedBy = null;
|
|
31
|
+
// The word being built, and whether any of it has been seen yet — an
|
|
32
|
+
// empty quoted string `''` is a word, an empty buffer is not.
|
|
33
|
+
let word = "";
|
|
34
|
+
let inWord = false;
|
|
35
|
+
let wordQuoted = false;
|
|
36
|
+
// Where in `word` the first quoted character sits: an assignment is
|
|
37
|
+
// `NAME=` written UNQUOTED, so `FOO="x" cmd` is one and `"FOO=x" cmd`
|
|
38
|
+
// is not.
|
|
39
|
+
let quotedFrom = Number.POSITIVE_INFINITY;
|
|
40
|
+
// A redirection operator waiting for its target word.
|
|
41
|
+
let pending = null;
|
|
42
|
+
const fail = (why) => ({ ok: false, why });
|
|
43
|
+
const endWord = () => {
|
|
44
|
+
if (!inWord)
|
|
45
|
+
return null;
|
|
46
|
+
const w = word;
|
|
47
|
+
if (pending !== null) {
|
|
48
|
+
redirects.push({ fd: pending.fd, op: pending.op, target: w });
|
|
49
|
+
pending = null;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const assign = /^[A-Za-z_][A-Za-z0-9_]*=/.exec(w);
|
|
53
|
+
if (argv.length === 0 && assign !== null && assign[0].length <= quotedFrom)
|
|
54
|
+
return "a leading variable assignment";
|
|
55
|
+
argv.push(w);
|
|
56
|
+
}
|
|
57
|
+
word = "";
|
|
58
|
+
inWord = false;
|
|
59
|
+
wordQuoted = false;
|
|
60
|
+
quotedFrom = Number.POSITIVE_INFINITY;
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
const endCommand = () => {
|
|
64
|
+
if (pending !== null)
|
|
65
|
+
return "a redirection with no target";
|
|
66
|
+
if (argv.length === 0)
|
|
67
|
+
return redirects.length > 0 ? "a redirection with no command" : "an empty command";
|
|
68
|
+
stages.push({ argv, redirects });
|
|
69
|
+
argv = [];
|
|
70
|
+
redirects = [];
|
|
71
|
+
return null;
|
|
72
|
+
};
|
|
73
|
+
const endPipeline = (next) => {
|
|
74
|
+
const e = endCommand();
|
|
75
|
+
if (e !== null)
|
|
76
|
+
return e;
|
|
77
|
+
list.push({ joinedBy, stages });
|
|
78
|
+
stages = [];
|
|
79
|
+
joinedBy = next;
|
|
80
|
+
return null;
|
|
81
|
+
};
|
|
82
|
+
let i = 0;
|
|
83
|
+
const n = src.length;
|
|
84
|
+
while (i < n) {
|
|
85
|
+
const c = src[i];
|
|
86
|
+
// ── quoting ──────────────────────────────────────────────────────
|
|
87
|
+
if (c === "'") {
|
|
88
|
+
const end = src.indexOf("'", i + 1);
|
|
89
|
+
if (end < 0)
|
|
90
|
+
return fail("an unterminated single quote");
|
|
91
|
+
quotedFrom = Math.min(quotedFrom, word.length);
|
|
92
|
+
word += src.slice(i + 1, end);
|
|
93
|
+
inWord = true;
|
|
94
|
+
wordQuoted = true;
|
|
95
|
+
i = end + 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (c === '"') {
|
|
99
|
+
quotedFrom = Math.min(quotedFrom, word.length);
|
|
100
|
+
let j = i + 1;
|
|
101
|
+
let closed = false;
|
|
102
|
+
while (j < n) {
|
|
103
|
+
const d = src[j];
|
|
104
|
+
if (d === '"') {
|
|
105
|
+
closed = true;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
if (d === "$" || d === "`")
|
|
109
|
+
return fail("an expansion inside double quotes");
|
|
110
|
+
if (d === "\\" && j + 1 < n && '$`"\\\n'.includes(src[j + 1])) {
|
|
111
|
+
if (src[j + 1] !== "\n")
|
|
112
|
+
word += src[j + 1];
|
|
113
|
+
j += 2;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
word += d;
|
|
117
|
+
j += 1;
|
|
118
|
+
}
|
|
119
|
+
if (!closed)
|
|
120
|
+
return fail("an unterminated double quote");
|
|
121
|
+
inWord = true;
|
|
122
|
+
wordQuoted = true;
|
|
123
|
+
i = j + 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (c === "\\") {
|
|
127
|
+
if (i + 1 >= n)
|
|
128
|
+
return fail("a trailing backslash");
|
|
129
|
+
if (src[i + 1] !== "\n") {
|
|
130
|
+
quotedFrom = Math.min(quotedFrom, word.length);
|
|
131
|
+
word += src[i + 1];
|
|
132
|
+
inWord = true;
|
|
133
|
+
wordQuoted = true;
|
|
134
|
+
}
|
|
135
|
+
i += 2;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// ── what this reader refuses ─────────────────────────────────────
|
|
139
|
+
if (c === "$" || c === "`")
|
|
140
|
+
return fail("an expansion");
|
|
141
|
+
if (c === "(" || c === ")")
|
|
142
|
+
return fail("a subshell or grouping");
|
|
143
|
+
// A brace EXPANDS only as `{a,b}` or `{a..b}`; anything else is a
|
|
144
|
+
// character (`HEAD@{u}`, `@{1}`). A `{` or `}` standing alone as a
|
|
145
|
+
// word is a group, which this reader refuses.
|
|
146
|
+
if (c === "{") {
|
|
147
|
+
const close = src.indexOf("}", i + 1);
|
|
148
|
+
const body = close < 0 ? "" : src.slice(i + 1, close);
|
|
149
|
+
if (close < 0 || !inWord || /[\s;&|(){}<>]/.test(body) || body.includes(",") || body.includes(".."))
|
|
150
|
+
return fail("a brace");
|
|
151
|
+
word += src.slice(i, close + 1);
|
|
152
|
+
i = close + 1;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (c === "}")
|
|
156
|
+
return fail("a brace");
|
|
157
|
+
if (GLOB.has(c))
|
|
158
|
+
return fail("a glob");
|
|
159
|
+
// A tilde expands at the start of a word and, in bash, after the `=`
|
|
160
|
+
// or `:` of anything shaped like an assignment (`--prefix=~/x`). In
|
|
161
|
+
// the middle of a word it is a character (`HEAD~3`).
|
|
162
|
+
if (c === "~" && (!inWord || word.endsWith("=") || word.endsWith(":")))
|
|
163
|
+
return fail("a tilde");
|
|
164
|
+
if (c === "#" && !inWord)
|
|
165
|
+
return fail("a comment");
|
|
166
|
+
// ── separators and operators ─────────────────────────────────────
|
|
167
|
+
if (c === " " || c === "\t") {
|
|
168
|
+
const e = endWord();
|
|
169
|
+
if (e !== null)
|
|
170
|
+
return fail(e);
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (c === "\n" || c === ";") {
|
|
175
|
+
if (c === ";" && src[i + 1] === ";")
|
|
176
|
+
return fail("a case terminator");
|
|
177
|
+
const e = endWord() ?? endPipeline(";");
|
|
178
|
+
if (e !== null)
|
|
179
|
+
return fail(e);
|
|
180
|
+
i += 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (c === "|") {
|
|
184
|
+
if (src[i + 1] === "&")
|
|
185
|
+
return fail("a |& pipe");
|
|
186
|
+
const e = endWord();
|
|
187
|
+
if (e !== null)
|
|
188
|
+
return fail(e);
|
|
189
|
+
if (src[i + 1] === "|") {
|
|
190
|
+
const f = endPipeline("||");
|
|
191
|
+
if (f !== null)
|
|
192
|
+
return fail(f);
|
|
193
|
+
i += 2;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const f = endCommand();
|
|
197
|
+
if (f !== null)
|
|
198
|
+
return fail(f);
|
|
199
|
+
i += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (c === "&") {
|
|
203
|
+
if (src[i + 1] === "&") {
|
|
204
|
+
const e = endWord() ?? endPipeline("&&");
|
|
205
|
+
if (e !== null)
|
|
206
|
+
return fail(e);
|
|
207
|
+
i += 2;
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
if (src[i + 1] === ">" && !inWord) {
|
|
211
|
+
if (src[i + 2] === ">")
|
|
212
|
+
return fail("an appending &>> redirection");
|
|
213
|
+
pending = { fd: "&", op: ">" };
|
|
214
|
+
i += 2;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
return fail("a background job");
|
|
218
|
+
}
|
|
219
|
+
if (c === ">" || c === "<") {
|
|
220
|
+
// A descriptor is the digits written immediately before, unquoted.
|
|
221
|
+
let fd = null;
|
|
222
|
+
if (inWord) {
|
|
223
|
+
if (wordQuoted || !/^\d+$/.test(word))
|
|
224
|
+
return fail("a redirection glued to a word");
|
|
225
|
+
fd = Number(word);
|
|
226
|
+
word = "";
|
|
227
|
+
inWord = false;
|
|
228
|
+
}
|
|
229
|
+
if (pending !== null)
|
|
230
|
+
return fail("a redirection with no target");
|
|
231
|
+
if (c === "<") {
|
|
232
|
+
if (src[i + 1] === "<" || src[i + 1] === ">" || src[i + 1] === "&")
|
|
233
|
+
return fail("a heredoc or descriptor duplication on input");
|
|
234
|
+
pending = { fd, op: "<" };
|
|
235
|
+
i += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (src[i + 1] === ">") {
|
|
239
|
+
pending = { fd, op: ">>" };
|
|
240
|
+
i += 2;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (src[i + 1] === "&") {
|
|
244
|
+
pending = { fd, op: ">&" };
|
|
245
|
+
i += 2;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (src[i + 1] === "|")
|
|
249
|
+
return fail("a clobbering redirection");
|
|
250
|
+
pending = { fd, op: ">" };
|
|
251
|
+
i += 1;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
word += c;
|
|
255
|
+
inWord = true;
|
|
256
|
+
i += 1;
|
|
257
|
+
}
|
|
258
|
+
const e = endWord();
|
|
259
|
+
if (e !== null)
|
|
260
|
+
return fail(e);
|
|
261
|
+
// A trailing `;` or newline leaves an empty last command, which is not an
|
|
262
|
+
// error in sh — `ls;` is `ls`. A trailing `|`, `&&`, `||` is.
|
|
263
|
+
if (argv.length === 0 && redirects.length === 0 && pending === null && stages.length === 0 && joinedBy === ";" && list.length > 0) {
|
|
264
|
+
return { ok: true, list };
|
|
265
|
+
}
|
|
266
|
+
const f = endPipeline(null);
|
|
267
|
+
if (f !== null)
|
|
268
|
+
return fail(f);
|
|
269
|
+
return { ok: true, list };
|
|
270
|
+
}
|
|
271
|
+
/** The well-known home subtrees that hold credentials or configuration
|
|
272
|
+
* that runs — one list for both chain members: the read-only allow
|
|
273
|
+
* never reads or lists under them, the floor never deletes them. */
|
|
274
|
+
export const HOME_SUBTREES = [".ssh", ".config", ".kiso", ".gnupg", ".aws"];
|
|
275
|
+
/** A real path in the case the DISK holds it. The JS realpath keeps the
|
|
276
|
+
* case it was given, so on a case-insensitive disk `.ENV` stayed `.ENV`
|
|
277
|
+
* and walked past every name-based predicate (the lead's review, B2). */
|
|
278
|
+
export function realCase(p) {
|
|
279
|
+
try {
|
|
280
|
+
return realpathSync.native(p);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return p;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export function resolveShellPath(workspaceRoot, cwd, word) {
|
|
287
|
+
// B1 (the lead's review): `..` is taken on the REAL path, one component
|
|
288
|
+
// at a time — never collapsed as text first. `link-out/..` is the parent
|
|
289
|
+
// of where link-out POINTS, which is where the shell goes; collapsed as
|
|
290
|
+
// text it was the workspace, and `cat link-out/../etc/hosts` printed.
|
|
291
|
+
let current = isAbsolute(word) ? "/" : realCase(cwd);
|
|
292
|
+
// past the last component that exists, the rest is text: nothing on
|
|
293
|
+
// disk can redirect a path that does not exist
|
|
294
|
+
const missing = [];
|
|
295
|
+
for (const part of word.split("/")) {
|
|
296
|
+
if (part === "" || part === ".")
|
|
297
|
+
continue;
|
|
298
|
+
if (missing.length > 0) {
|
|
299
|
+
if (part === "..")
|
|
300
|
+
missing.pop();
|
|
301
|
+
else
|
|
302
|
+
missing.push(part);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (part === "..") {
|
|
306
|
+
current = dirname(current);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const next = join(current, part);
|
|
310
|
+
if (existsSync(next))
|
|
311
|
+
current = realCase(next);
|
|
312
|
+
else
|
|
313
|
+
missing.push(part);
|
|
314
|
+
}
|
|
315
|
+
const canonical = missing.length > 0 ? join(current, ...missing) : current;
|
|
316
|
+
const rel = relative(realCase(workspaceRoot), canonical);
|
|
317
|
+
return { canonical, inside: rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)) };
|
|
318
|
+
}
|
|
319
|
+
/** B6 (the lead's review): how deep the reader follows nested subshells.
|
|
320
|
+
* A 9 KB `$(` nest overflowed the stack; the runtime turned the throw into
|
|
321
|
+
* an ask, bypass's allow won, and /bin/sh ran the line. Past this depth
|
|
322
|
+
* the word is taken as only a variable — which, as a target, is refused —
|
|
323
|
+
* and nothing deeper is read. */
|
|
324
|
+
export const LOOSE_MAX_DEPTH = 64;
|
|
325
|
+
/** Every command the line would run, in order, flattened — for a caller
|
|
326
|
+
* that asks the same question of each one. */
|
|
327
|
+
export function looseCommands(nodes) {
|
|
328
|
+
const out = [];
|
|
329
|
+
const walk = (ns) => {
|
|
330
|
+
for (const n of ns) {
|
|
331
|
+
if (n.kind === "cmd")
|
|
332
|
+
out.push(n);
|
|
333
|
+
else
|
|
334
|
+
walk(n.items);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
walk(nodes);
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
export function parseShellLoose(src) {
|
|
341
|
+
return parseLooseAt(src, 0, 0, false, { truncated: false, keepDropped: false }).items;
|
|
342
|
+
}
|
|
343
|
+
/** The same, saying whether the reader stopped short: a nest past
|
|
344
|
+
* LOOSE_MAX_DEPTH is not read, and a caller that must see every command
|
|
345
|
+
* (the floor) treats an unread line as one it could not read. */
|
|
346
|
+
export function parseShellLooseChecked(src, opts = {}) {
|
|
347
|
+
const flags = { truncated: false, keepDropped: opts.keepDropped === true };
|
|
348
|
+
return { nodes: parseLooseAt(src, 0, 0, false, flags).items, truncated: flags.truncated };
|
|
349
|
+
}
|
|
350
|
+
/** One level of the reader, from `start`. With `closeParen`, it is the
|
|
351
|
+
* inside of `$(`: an unmatched `)` ends it and its index is returned — the
|
|
352
|
+
* recursion finds its own end, so a nest is read ONCE, in linear time,
|
|
353
|
+
* rather than scanned for its matching paren at every level. */
|
|
354
|
+
function parseLooseAt(src, start, depth, closeParen, flags) {
|
|
355
|
+
// the open `( … )` groups at this level: [items so far, the joiner before the group]
|
|
356
|
+
const stack = [{ items: [], joinedBy: ";" }];
|
|
357
|
+
let argv = [];
|
|
358
|
+
let dropped = [];
|
|
359
|
+
let joinedBy = ";";
|
|
360
|
+
let text = "";
|
|
361
|
+
let lit = "";
|
|
362
|
+
let inWord = false;
|
|
363
|
+
let unknownAt = -1;
|
|
364
|
+
let unknownIsGlob = false;
|
|
365
|
+
let tilde = false;
|
|
366
|
+
let startsWithExpansion = false;
|
|
367
|
+
let tooDeep = false; // a nest past LOOSE_MAX_DEPTH: nothing in it is known
|
|
368
|
+
let dropNext = false; // the next word is a redirection target
|
|
369
|
+
let seenCommandWord = false;
|
|
370
|
+
const top = () => stack[stack.length - 1].items;
|
|
371
|
+
const markUnknown = (glob) => {
|
|
372
|
+
if (unknownAt < 0) {
|
|
373
|
+
unknownAt = text.length;
|
|
374
|
+
unknownIsGlob = glob;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
const endWord = () => {
|
|
378
|
+
if (!inWord)
|
|
379
|
+
return;
|
|
380
|
+
const w = { text, unknownAt, unknownIsGlob, tilde, variableOnly: tooDeep || (startsWithExpansion && /^[/*.]*$/.test(lit)) };
|
|
381
|
+
const assign = /^[A-Za-z_][A-Za-z0-9_]*=/.exec(text);
|
|
382
|
+
if (dropNext) {
|
|
383
|
+
dropNext = false;
|
|
384
|
+
if (flags.keepDropped)
|
|
385
|
+
dropped.push(w);
|
|
386
|
+
}
|
|
387
|
+
else if (!seenCommandWord && assign !== null && (unknownAt < 0 || unknownAt >= assign[0].length)) {
|
|
388
|
+
// a leading assignment: environment for the command, not the
|
|
389
|
+
// command — `X="$Y" rm …` included (B10: the value holding an
|
|
390
|
+
// expansion made it the command, and hid the rm)
|
|
391
|
+
if (flags.keepDropped)
|
|
392
|
+
dropped.push(w);
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
argv.push(w);
|
|
396
|
+
seenCommandWord = true;
|
|
397
|
+
}
|
|
398
|
+
text = "";
|
|
399
|
+
lit = "";
|
|
400
|
+
inWord = false;
|
|
401
|
+
unknownAt = -1;
|
|
402
|
+
unknownIsGlob = false;
|
|
403
|
+
tilde = false;
|
|
404
|
+
startsWithExpansion = false;
|
|
405
|
+
tooDeep = false;
|
|
406
|
+
};
|
|
407
|
+
const endCommand = (next) => {
|
|
408
|
+
endWord();
|
|
409
|
+
// without keepDropped, exactly as before: a command with no words is
|
|
410
|
+
// not a command. With it, `$(< file)` — a read with no command — is one
|
|
411
|
+
if (flags.keepDropped) {
|
|
412
|
+
if (argv.length > 0 || dropped.length > 0)
|
|
413
|
+
top().push({ kind: "cmd", argv, joinedBy, dropped });
|
|
414
|
+
}
|
|
415
|
+
else if (argv.length > 0)
|
|
416
|
+
top().push({ kind: "cmd", argv, joinedBy });
|
|
417
|
+
argv = [];
|
|
418
|
+
dropped = [];
|
|
419
|
+
seenCommandWord = false;
|
|
420
|
+
dropNext = false;
|
|
421
|
+
// a separator after an empty command (`&& &&`) keeps the stronger one
|
|
422
|
+
joinedBy = next;
|
|
423
|
+
};
|
|
424
|
+
const openGroup = () => {
|
|
425
|
+
endCommand(joinedBy);
|
|
426
|
+
// the `( )` groups count toward the same depth as `$( )`
|
|
427
|
+
if (depth + stack.length > LOOSE_MAX_DEPTH) {
|
|
428
|
+
flags.truncated = true;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
stack.push({ items: [], joinedBy });
|
|
432
|
+
joinedBy = ";";
|
|
433
|
+
};
|
|
434
|
+
const closeGroup = () => {
|
|
435
|
+
endCommand(";");
|
|
436
|
+
const g = stack.pop();
|
|
437
|
+
top().push({ kind: "group", items: g.items, joinedBy: g.joinedBy });
|
|
438
|
+
};
|
|
439
|
+
/** An expansion at i (`$…` or a backtick): its raw text joins the word,
|
|
440
|
+
* any command inside it is read as a subshell, and the index after it
|
|
441
|
+
* is returned. */
|
|
442
|
+
const expansion = (i) => {
|
|
443
|
+
// "only a variable" means nothing came before it — quotes add nothing
|
|
444
|
+
if (text === "")
|
|
445
|
+
startsWithExpansion = true;
|
|
446
|
+
inWord = true;
|
|
447
|
+
markUnknown(false);
|
|
448
|
+
let j;
|
|
449
|
+
if (src[i] === "`") {
|
|
450
|
+
const close = src.indexOf("`", i + 1);
|
|
451
|
+
j = close < 0 ? src.length : close + 1;
|
|
452
|
+
// the subshell runs as part of the command that holds it, so it
|
|
453
|
+
// takes that command's joiner (`cd x && echo \`…\`` runs it in x)
|
|
454
|
+
if (depth < LOOSE_MAX_DEPTH)
|
|
455
|
+
top().push({ kind: "group", items: parseLooseAt(src.slice(i + 1, close < 0 ? src.length : close), 0, depth + 1, false, flags).items, joinedBy });
|
|
456
|
+
else {
|
|
457
|
+
tooDeep = true;
|
|
458
|
+
flags.truncated = true;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else if (src[i + 1] === "(") {
|
|
462
|
+
if (depth < LOOSE_MAX_DEPTH) {
|
|
463
|
+
const inner = parseLooseAt(src, i + 2, depth + 1, true, flags);
|
|
464
|
+
top().push({ kind: "group", items: inner.items, joinedBy });
|
|
465
|
+
j = Math.min(inner.end + 1, src.length);
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
// too deep to read: consume to the matching paren, unread
|
|
469
|
+
tooDeep = true;
|
|
470
|
+
flags.truncated = true;
|
|
471
|
+
let open = 0;
|
|
472
|
+
for (j = i + 1; j < src.length; j += 1) {
|
|
473
|
+
if (src[j] === "(")
|
|
474
|
+
open += 1;
|
|
475
|
+
else if (src[j] === ")" && --open === 0)
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
j = Math.min(j + 1, src.length);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
else if (src[i + 1] === "{") {
|
|
482
|
+
const close = src.indexOf("}", i + 2);
|
|
483
|
+
j = close < 0 ? src.length : close + 1;
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
j = i + 1;
|
|
487
|
+
if (j < src.length && /[@*#?$!0-9-]/.test(src[j]))
|
|
488
|
+
j += 1;
|
|
489
|
+
else
|
|
490
|
+
while (j < src.length && /[A-Za-z0-9_]/.test(src[j]))
|
|
491
|
+
j += 1;
|
|
492
|
+
}
|
|
493
|
+
// The raw text is kept for the messages only — nothing is judged on
|
|
494
|
+
// what an expansion says, only on where it starts — so a long one is
|
|
495
|
+
// kept short: a nest used to carry its whole remainder at every level.
|
|
496
|
+
const raw = src.slice(i, j);
|
|
497
|
+
text += raw.length > 256 ? `${raw.slice(0, 256)}…` : raw;
|
|
498
|
+
return j;
|
|
499
|
+
};
|
|
500
|
+
let i = start;
|
|
501
|
+
while (i < src.length) {
|
|
502
|
+
const c = src[i];
|
|
503
|
+
if (c === "'") {
|
|
504
|
+
const close = src.indexOf("'", i + 1);
|
|
505
|
+
const body = src.slice(i + 1, close < 0 ? src.length : close);
|
|
506
|
+
text += body;
|
|
507
|
+
lit += body;
|
|
508
|
+
inWord = true;
|
|
509
|
+
i = close < 0 ? src.length : close + 1;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (c === '"') {
|
|
513
|
+
inWord = true;
|
|
514
|
+
let j = i + 1;
|
|
515
|
+
while (j < src.length && src[j] !== '"') {
|
|
516
|
+
if (src[j] === "\\" && j + 1 < src.length) {
|
|
517
|
+
text += src[j + 1];
|
|
518
|
+
lit += src[j + 1];
|
|
519
|
+
j += 2;
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
522
|
+
if (src[j] === "$" || src[j] === "`") {
|
|
523
|
+
j = expansion(j);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
text += src[j];
|
|
527
|
+
lit += src[j];
|
|
528
|
+
j += 1;
|
|
529
|
+
}
|
|
530
|
+
i = j + 1;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (c === "\\") {
|
|
534
|
+
if (i + 1 < src.length && src[i + 1] !== "\n") {
|
|
535
|
+
text += src[i + 1];
|
|
536
|
+
lit += src[i + 1];
|
|
537
|
+
inWord = true;
|
|
538
|
+
}
|
|
539
|
+
i += 2;
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (c === "$" || c === "`") {
|
|
543
|
+
i = expansion(i);
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
if (c === "*" || c === "?" || c === "[") {
|
|
547
|
+
markUnknown(true);
|
|
548
|
+
text += c;
|
|
549
|
+
lit += c;
|
|
550
|
+
inWord = true;
|
|
551
|
+
i += 1;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (c === "~" && !inWord)
|
|
555
|
+
tilde = true;
|
|
556
|
+
if (c === "#" && !inWord) {
|
|
557
|
+
while (i < src.length && src[i] !== "\n")
|
|
558
|
+
i += 1;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
if (c === " " || c === "\t") {
|
|
562
|
+
endWord();
|
|
563
|
+
i += 1;
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (c === "(") {
|
|
567
|
+
openGroup();
|
|
568
|
+
i += 1;
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
if (c === ")") {
|
|
572
|
+
if (stack.length > 1) {
|
|
573
|
+
closeGroup();
|
|
574
|
+
i += 1;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (closeParen) {
|
|
578
|
+
endCommand(";");
|
|
579
|
+
return { items: top(), end: i };
|
|
580
|
+
}
|
|
581
|
+
endCommand(";"); // a stray `)` separates
|
|
582
|
+
i += 1;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (c === ";" || c === "\n" || c === "&" || c === "|") {
|
|
586
|
+
if (c === "&" && src[i + 1] === ">") {
|
|
587
|
+
endWord();
|
|
588
|
+
dropNext = true;
|
|
589
|
+
i += src[i + 2] === ">" ? 3 : 2;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const two = src.slice(i, i + 2);
|
|
593
|
+
if (two === "&&" || two === "||") {
|
|
594
|
+
endCommand(two);
|
|
595
|
+
i += 2;
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
endCommand(c === "|" ? "|" : ";");
|
|
599
|
+
i += 1;
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (c === ">" || c === "<") {
|
|
603
|
+
if (inWord && /^\d+$/.test(text) && unknownAt < 0) {
|
|
604
|
+
text = "";
|
|
605
|
+
lit = "";
|
|
606
|
+
inWord = false;
|
|
607
|
+
}
|
|
608
|
+
else
|
|
609
|
+
endWord();
|
|
610
|
+
let j = i + 1;
|
|
611
|
+
while (j < src.length && (src[j] === ">" || src[j] === "<" || src[j] === "&" || src[j] === "|"))
|
|
612
|
+
j += 1;
|
|
613
|
+
// `>&1` and `2>&-` name a descriptor, not a file; either way the
|
|
614
|
+
// word that follows is the operator's, never an argument.
|
|
615
|
+
dropNext = true;
|
|
616
|
+
i = j;
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
text += c;
|
|
620
|
+
lit += c;
|
|
621
|
+
inWord = true;
|
|
622
|
+
i += 1;
|
|
623
|
+
}
|
|
624
|
+
endCommand(";");
|
|
625
|
+
while (stack.length > 1)
|
|
626
|
+
closeGroup(); // an unclosed `(` closes at the end
|
|
627
|
+
return { items: top(), end: src.length };
|
|
628
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 0.40.0 — a PERSON invokes a skill.
|
|
3
|
+
*
|
|
4
|
+
* `/skill <name> [args…]`, or `/<name> [args…]` when no built-in command
|
|
5
|
+
* has that name — built-ins win, always, so installing a skill can never
|
|
6
|
+
* take a command away. The invocation is a USER TURN: the SKILL.md body,
|
|
7
|
+
* then the person's args after a blank line, submitted through the same
|
|
8
|
+
* path as typed input. The durable record says the turn came from the
|
|
9
|
+
* person (`source` stays "user") and says HOW it was composed in `via`,
|
|
10
|
+
* which carries the line they typed — so the chip, the resume tail and the
|
|
11
|
+
* session's name show what the person wrote, not a body they never saw.
|
|
12
|
+
*
|
|
13
|
+
* Pure: the catalog comes from the skills extension's own scan (one scan,
|
|
14
|
+
* one answer — the model's index and this door are one list), and every
|
|
15
|
+
* outcome is data. The dispatcher does the printing and the submitting.
|
|
16
|
+
*/
|
|
17
|
+
import type { UserInputVia } from "@vincemakes/kiso-core";
|
|
18
|
+
import type { SkillsCatalog } from "@vincemakes/kiso-skills-ext";
|
|
19
|
+
export type SkillOutcome = {
|
|
20
|
+
readonly kind: "submit";
|
|
21
|
+
readonly content: string;
|
|
22
|
+
readonly via: UserInputVia;
|
|
23
|
+
} | {
|
|
24
|
+
readonly kind: "error";
|
|
25
|
+
readonly message: string;
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: "list";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* What a submitted line asks of the skills, if anything. `null` means the
|
|
31
|
+
* line is not a skill request, and the dispatcher goes on as before — which
|
|
32
|
+
* is how a typo stays "unknown command" rather than becoming a skill error.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveSkillLine(trimmed: string, catalog: SkillsCatalog | null, builtins: readonly string[]): SkillOutcome | null;
|
|
35
|
+
/** Up to three installed names close to the one typed: a shared prefix, or
|
|
36
|
+
* within two edits. Closest first; nothing when nothing is close — a
|
|
37
|
+
* suggestion that is not near is noise. */
|
|
38
|
+
export declare function nearest(name: string, names: readonly string[]): string[];
|
|
39
|
+
/** `/skills` — the skills grouped by the directory they really live in,
|
|
40
|
+
* each directory named ONCE as a header: project and user skills are
|
|
41
|
+
* merged into one scan directory by symlink, so the scan root would name a
|
|
42
|
+
* temp directory nobody can find — `sourceOf` resolves an entry's
|
|
43
|
+
* directory to where it came from. Under each: `/name — description`, a
|
|
44
|
+
* `model only` tag for `user-invocable: false`, then the broken entries
|
|
45
|
+
* with the loader's own reason. */
|
|
46
|
+
export declare function skillsRows(catalog: SkillsCatalog | null, sourceOf: (dir: string) => string, userDir: string): string[];
|
|
47
|
+
/**
|
|
48
|
+
* 0.40.1 (owner's ruling, revoking the 0.41.0 deferral) — the installed
|
|
49
|
+
* skills as `/` menu entries. The same rule the dispatcher follows, so the
|
|
50
|
+
* menu never offers what `/<name>` would not run: invocable skills only
|
|
51
|
+
* (`user-invocable: false` stays the model's), a name a person can type
|
|
52
|
+
* after `/`, and never a name a built-in claims — the built-in wins. The
|
|
53
|
+
* description is cut so a long one cannot crowd the band.
|
|
54
|
+
*/
|
|
55
|
+
export declare function skillMenuItems(catalog: SkillsCatalog | null, builtins: readonly string[]): {
|
|
56
|
+
readonly name: string;
|
|
57
|
+
readonly desc: string;
|
|
58
|
+
}[];
|