spl-lang 0.0.0-stage → 0.1.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.
@@ -0,0 +1,27 @@
1
+ import type { RestrictKind } from './ast.js';
2
+ export interface Restriction {
3
+ kind: RestrictKind;
4
+ /** fs: canonical absolute directories; net: lowercase hosts (may be `*.x`); exec: executable names. */
5
+ values: string[];
6
+ }
7
+ /** Resolves symlinks of the nearest existing ancestor so `..` and links cannot escape a folder. */
8
+ export declare function canonicalPath(p: string, cwd: string, depth?: number): string;
9
+ export declare function normalizeHost(entry: string): string;
10
+ export declare function normalizeExec(name: string): string;
11
+ /** Immutable snapshot of the active `restrict` scopes. Every kind present narrows (intersects). */
12
+ export declare class Restrictions {
13
+ private readonly cwd;
14
+ readonly list: readonly Restriction[];
15
+ constructor(cwd: string, list?: readonly Restriction[]);
16
+ with(r: Restriction): Restrictions;
17
+ get active(): boolean;
18
+ has(kind: RestrictKind): boolean;
19
+ checkPath(p: string, action?: 'read' | 'write'): string;
20
+ checkUrl(url: string): URL;
21
+ checkHost(host: string): void;
22
+ checkExec(name: string): void;
23
+ /** Human-readable guidance for the LLM (the first enforcement layer). */
24
+ guidance(): string[];
25
+ }
26
+ /** Builds a normalized restriction from the script-provided string ("a,b") or list of strings. */
27
+ export declare function makeRestriction(kind: RestrictKind, raw: unknown, cwd: string): Restriction;
@@ -0,0 +1,158 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { Failure } from './errors.js';
4
+ const IS_WINDOWS = process.platform === 'win32';
5
+ const violation = (msg) => new Failure('restriction_violation', msg);
6
+ /** Resolves symlinks of the nearest existing ancestor so `..` and links cannot escape a folder. */
7
+ export function canonicalPath(p, cwd, depth = 0) {
8
+ if (depth > 32)
9
+ throw violation(`too many symbolic links while resolving '${p}'`);
10
+ const abs = path.resolve(cwd, p);
11
+ const rest = [];
12
+ let cur = abs;
13
+ for (;;) {
14
+ try {
15
+ const real = fs.realpathSync.native(cur);
16
+ return path.join(real, ...rest.reverse());
17
+ }
18
+ catch (e) {
19
+ const code = e.code;
20
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') {
21
+ throw violation(`cannot resolve path '${p}': ${e.message}`);
22
+ }
23
+ // A dangling symlink must be followed too: writing through it would create its target.
24
+ let link;
25
+ try {
26
+ if (fs.lstatSync(cur).isSymbolicLink())
27
+ link = fs.readlinkSync(cur);
28
+ }
29
+ catch {
30
+ link = undefined;
31
+ }
32
+ if (link !== undefined) {
33
+ const target = canonicalPath(path.resolve(path.dirname(cur), link), cwd, depth + 1);
34
+ return path.join(target, ...rest.reverse());
35
+ }
36
+ const parent = path.dirname(cur);
37
+ if (parent === cur)
38
+ return abs;
39
+ rest.push(path.basename(cur));
40
+ cur = parent;
41
+ }
42
+ }
43
+ }
44
+ const fold = (p) => (IS_WINDOWS ? p.toLowerCase() : p);
45
+ function isInside(target, root) {
46
+ const rel = path.relative(fold(root), fold(target));
47
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
48
+ }
49
+ export function normalizeHost(entry) {
50
+ let e = entry.trim().toLowerCase();
51
+ if (e.includes('://')) {
52
+ try {
53
+ e = new URL(e).hostname;
54
+ }
55
+ catch {
56
+ throw violation(`invalid host '${entry}' in restrict net`);
57
+ }
58
+ }
59
+ return e.replace(/:\d+$/, '');
60
+ }
61
+ export function normalizeExec(name) {
62
+ let n = path.basename(name.trim().replace(/\\/g, '/'));
63
+ if (IS_WINDOWS)
64
+ n = n.toLowerCase().replace(/\.(exe|cmd|bat|com)$/, '');
65
+ return n;
66
+ }
67
+ function hostAllowed(host, allowed) {
68
+ return allowed.some((a) => host === a || (a.startsWith('*.') && host.endsWith(a.slice(1))));
69
+ }
70
+ /** Immutable snapshot of the active `restrict` scopes. Every kind present narrows (intersects). */
71
+ export class Restrictions {
72
+ cwd;
73
+ list;
74
+ constructor(cwd, list = []) {
75
+ this.cwd = cwd;
76
+ this.list = list;
77
+ }
78
+ with(r) {
79
+ return new Restrictions(this.cwd, [...this.list, r]);
80
+ }
81
+ get active() {
82
+ return this.list.length > 0;
83
+ }
84
+ has(kind) {
85
+ return this.list.some((r) => r.kind === kind);
86
+ }
87
+ checkPath(p, action = 'read') {
88
+ const canonical = canonicalPath(p, this.cwd);
89
+ for (const r of this.list) {
90
+ if (r.kind === 'fs' && !r.values.some((root) => isInside(canonical, root))) {
91
+ throw violation(`${action} of '${canonical}' blocked: outside allowed folder(s) ${r.values.join(', ')}`);
92
+ }
93
+ }
94
+ return canonical;
95
+ }
96
+ checkUrl(url) {
97
+ let u;
98
+ try {
99
+ u = new URL(url);
100
+ }
101
+ catch {
102
+ throw new Failure('tool_error', `invalid URL '${url}'`);
103
+ }
104
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') {
105
+ throw new Failure('tool_error', `unsupported URL scheme '${u.protocol}'`);
106
+ }
107
+ this.checkHost(u.hostname.toLowerCase());
108
+ return u;
109
+ }
110
+ checkHost(host) {
111
+ for (const r of this.list) {
112
+ if (r.kind === 'net' && !hostAllowed(host, r.values)) {
113
+ throw violation(`network access to '${host}' blocked: allowed hosts are ${r.values.join(', ')}`);
114
+ }
115
+ }
116
+ }
117
+ checkExec(name) {
118
+ const n = normalizeExec(name);
119
+ for (const r of this.list) {
120
+ if (r.kind !== 'exec')
121
+ continue;
122
+ if (/[\\/]/.test(name.trim())) {
123
+ throw violation(`executable '${name}' blocked: inside a restrict exec block executables must be named without a path`);
124
+ }
125
+ if (!r.values.includes(n)) {
126
+ throw violation(`executable '${n}' blocked: allowed executables are ${r.values.join(', ')}`);
127
+ }
128
+ }
129
+ }
130
+ /** Human-readable guidance for the LLM (the first enforcement layer). */
131
+ guidance() {
132
+ return this.list.map((r) => {
133
+ if (r.kind === 'fs')
134
+ return `File system: you may read and write only inside: ${r.values.join(', ')}. Any other path will be blocked.`;
135
+ if (r.kind === 'net')
136
+ return `Network: you may only access these hosts: ${r.values.join(', ')}. Any other host will be blocked.`;
137
+ return `Shell: you may only run these executables: ${r.values.join(', ')}, one simple command at a time (no pipes, redirects or chaining). Anything else will be blocked.`;
138
+ });
139
+ }
140
+ }
141
+ /** Builds a normalized restriction from the script-provided string ("a,b") or list of strings. */
142
+ export function makeRestriction(kind, raw, cwd) {
143
+ let items;
144
+ if (typeof raw === 'string')
145
+ items = raw.split(',');
146
+ else if (Array.isArray(raw) && raw.every((x) => typeof x === 'string'))
147
+ items = raw.flatMap((x) => x.split(','));
148
+ else
149
+ throw new Failure('runtime_error', `restrict ${kind} expects a string (comma-separated) or a list of strings`);
150
+ items = items.map((s) => s.trim()).filter((s) => s.length > 0);
151
+ if (items.length === 0)
152
+ throw new Failure('runtime_error', `restrict ${kind} needs at least one entry`);
153
+ if (kind === 'fs')
154
+ return { kind, values: [...new Set(items.map((i) => canonicalPath(i, cwd)))] };
155
+ if (kind === 'net')
156
+ return { kind, values: [...new Set(items.map(normalizeHost))] };
157
+ return { kind, values: [...new Set(items.map(normalizeExec))] };
158
+ }
@@ -0,0 +1,20 @@
1
+ import { type Value } from './values.js';
2
+ /** Pluggable persistence for top-level variables. */
3
+ export interface VariableStore {
4
+ /** Variables persisted by a previous run; they seed the global scope. */
5
+ load(): Record<string, Value>;
6
+ /** Persists a snapshot of all top-level variables. */
7
+ save(vars: Record<string, Value>): void;
8
+ }
9
+ export declare class MemoryStore implements VariableStore {
10
+ load(): Record<string, Value>;
11
+ save(): void;
12
+ }
13
+ export declare class JsonFileStore implements VariableStore {
14
+ private readonly file;
15
+ constructor(file: string);
16
+ load(): Record<string, Value>;
17
+ save(vars: Record<string, Value>): void;
18
+ }
19
+ /** Loads a custom store from a module exporting `createStore(): VariableStore` (or a default export of the same shape). */
20
+ export declare function loadStoreModule(spec: string): Promise<VariableStore>;
package/dist/store.js ADDED
@@ -0,0 +1,52 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { fromJson, isFunction, toJson } from './values.js';
5
+ export class MemoryStore {
6
+ load() {
7
+ return {};
8
+ }
9
+ save() { }
10
+ }
11
+ export class JsonFileStore {
12
+ file;
13
+ constructor(file) {
14
+ this.file = file;
15
+ }
16
+ load() {
17
+ if (!fs.existsSync(this.file))
18
+ return {};
19
+ const raw = fs.readFileSync(this.file, 'utf8');
20
+ if (raw.trim() === '')
21
+ return {};
22
+ const parsed = JSON.parse(raw);
23
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
24
+ throw new Error(`variable store '${this.file}' must contain a JSON object`);
25
+ }
26
+ const out = {};
27
+ for (const [k, v] of Object.entries(parsed))
28
+ out[k] = fromJson(v);
29
+ return out;
30
+ }
31
+ save(vars) {
32
+ const data = {};
33
+ for (const [k, v] of Object.entries(vars))
34
+ if (!isFunction(v))
35
+ data[k] = toJson(v);
36
+ fs.mkdirSync(path.dirname(path.resolve(this.file)), { recursive: true });
37
+ const tmp = `${this.file}.${process.pid}.tmp`;
38
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
39
+ fs.renameSync(tmp, this.file);
40
+ }
41
+ }
42
+ /** Loads a custom store from a module exporting `createStore(): VariableStore` (or a default export of the same shape). */
43
+ export async function loadStoreModule(spec) {
44
+ const url = spec.startsWith('.') || path.isAbsolute(spec) ? pathToFileURL(path.resolve(spec)).href : spec;
45
+ const mod = (await import(url));
46
+ const factory = mod.createStore ?? (typeof mod.default === 'function' ? mod.default : undefined);
47
+ const store = factory ? await factory() : mod.default;
48
+ if (!store || typeof store.load !== 'function' || typeof store.save !== 'function') {
49
+ throw new Error(`store module '${spec}' must export createStore() returning { load(), save() }`);
50
+ }
51
+ return store;
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { ToolName } from './ast.js';
2
+ import type { Restrictions } from './restrict.js';
3
+ export interface ToolSpec {
4
+ name: ToolName;
5
+ description: string;
6
+ properties: Record<string, {
7
+ type: 'string';
8
+ description: string;
9
+ }>;
10
+ required: string[];
11
+ }
12
+ export declare const TOOL_SPECS: Record<ToolName, ToolSpec>;
13
+ /** Splits a simple command line, honoring '...' and "..." quoting. No escapes, no expansion. */
14
+ export declare function splitCommand(command: string): string[];
15
+ export interface SearchResult {
16
+ title: string;
17
+ url: string;
18
+ snippet: string;
19
+ }
20
+ /** Extracts result links from DuckDuckGo's HTML results page; result links are redirects carrying the target in `uddg`. */
21
+ export declare function parseSearchResults(html: string): SearchResult[];
22
+ /**
23
+ * Executes the built-in tools. Every call is checked against the active restrictions,
24
+ * whether it comes from the script itself or from the LLM.
25
+ */
26
+ export declare class ToolHost {
27
+ private readonly restrictions;
28
+ private readonly cwd;
29
+ constructor(restrictions: Restrictions, cwd: string);
30
+ invoke(name: ToolName, params: Record<string, unknown>): Promise<string>;
31
+ private readFile;
32
+ private writeFile;
33
+ private webFetch;
34
+ /** Searches DuckDuckGo's HTML endpoint (no API key); it is a network call, so `restrict net` applies to it. */
35
+ private webSearch;
36
+ private shell;
37
+ private run;
38
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,266 @@
1
+ import { spawn } from 'node:child_process';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { Failure } from './errors.js';
5
+ const MAX_TEXT = 200_000;
6
+ const SHELL_TIMEOUT_MS = 120_000;
7
+ const FETCH_TIMEOUT_MS = 30_000;
8
+ const MAX_REDIRECTS = 5;
9
+ /** Characters that let a command chain, redirect or substitute; rejected while any restriction is active. */
10
+ const SHELL_META = /[&|;<>`$()\r\n^%!]/;
11
+ export const TOOL_SPECS = {
12
+ read_file: {
13
+ name: 'read_file',
14
+ description: 'Read a UTF-8 text file and return its content.',
15
+ properties: { path: { type: 'string', description: 'Path of the file to read' } },
16
+ required: ['path'],
17
+ },
18
+ write_file: {
19
+ name: 'write_file',
20
+ description: 'Create or overwrite a UTF-8 text file (parent folders are created).',
21
+ properties: {
22
+ path: { type: 'string', description: 'Path of the file to write' },
23
+ content: { type: 'string', description: 'Complete new content of the file' },
24
+ },
25
+ required: ['path', 'content'],
26
+ },
27
+ web_fetch: {
28
+ name: 'web_fetch',
29
+ description: 'Fetch an http(s) URL with GET and return the response body as text.',
30
+ properties: { url: { type: 'string', description: 'URL to fetch' } },
31
+ required: ['url'],
32
+ },
33
+ web_search: {
34
+ name: 'web_search',
35
+ description: 'Search the web and return the top results (title, URL and snippet).',
36
+ properties: { query: { type: 'string', description: 'Search query' } },
37
+ required: ['query'],
38
+ },
39
+ shell: {
40
+ name: 'shell',
41
+ description: 'Run a shell command and return its combined output.',
42
+ properties: { command: { type: 'string', description: 'Command line to run' } },
43
+ required: ['command'],
44
+ },
45
+ };
46
+ function needString(tool, params, name) {
47
+ const v = params[name];
48
+ if (typeof v !== 'string' || v === '')
49
+ throw new Failure('tool_error', `${tool}: parameter '${name}' must be a non-empty string`);
50
+ return v;
51
+ }
52
+ function truncate(text) {
53
+ return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT)}\n...[truncated ${text.length - MAX_TEXT} characters]` : text;
54
+ }
55
+ /** Splits a simple command line, honoring '...' and "..." quoting. No escapes, no expansion. */
56
+ export function splitCommand(command) {
57
+ const args = [];
58
+ let cur = '';
59
+ let inToken = false;
60
+ let quote;
61
+ for (const ch of command) {
62
+ if (quote) {
63
+ if (ch === quote)
64
+ quote = undefined;
65
+ else
66
+ cur += ch;
67
+ }
68
+ else if (ch === '"' || ch === "'") {
69
+ quote = ch;
70
+ inToken = true;
71
+ }
72
+ else if (/\s/.test(ch)) {
73
+ if (inToken)
74
+ args.push(cur);
75
+ cur = '';
76
+ inToken = false;
77
+ }
78
+ else {
79
+ cur += ch;
80
+ inToken = true;
81
+ }
82
+ }
83
+ if (quote)
84
+ throw new Failure('restriction_violation', 'shell command rejected: unterminated quote');
85
+ if (inToken)
86
+ args.push(cur);
87
+ return args;
88
+ }
89
+ const SEARCH_ENDPOINT = 'https://html.duckduckgo.com/html/';
90
+ const MAX_SEARCH_RESULTS = 10;
91
+ const decodeEntities = (s) => s
92
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
93
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)))
94
+ .replace(/&quot;/g, '"')
95
+ .replace(/&#39;|&apos;/g, "'")
96
+ .replace(/&lt;/g, '<')
97
+ .replace(/&gt;/g, '>')
98
+ .replace(/&amp;/g, '&');
99
+ const plainText = (html) => decodeEntities(html.replace(/<[^>]*>/g, '')).replace(/\s+/g, ' ').trim();
100
+ /** Extracts result links from DuckDuckGo's HTML results page; result links are redirects carrying the target in `uddg`. */
101
+ export function parseSearchResults(html) {
102
+ const results = [];
103
+ const link = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
104
+ const snippets = [...html.matchAll(/<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g)];
105
+ let m;
106
+ let i = 0;
107
+ while ((m = link.exec(html)) && results.length < MAX_SEARCH_RESULTS) {
108
+ const href = decodeEntities(m[1]);
109
+ let url = href;
110
+ try {
111
+ const u = new URL(href, 'https://duckduckgo.com');
112
+ url = u.searchParams.get('uddg') ?? u.href;
113
+ }
114
+ catch {
115
+ // keep the raw href
116
+ }
117
+ results.push({ title: plainText(m[2]), url, snippet: snippets[i] ? plainText(snippets[i][1]) : '' });
118
+ i++;
119
+ }
120
+ return results;
121
+ }
122
+ const looksLikeUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s);
123
+ const looksLikePath = (s) => /[\\/]/.test(s) || s.startsWith('.') || s.startsWith('~') || /^[a-z]:/i.test(s);
124
+ /**
125
+ * Executes the built-in tools. Every call is checked against the active restrictions,
126
+ * whether it comes from the script itself or from the LLM.
127
+ */
128
+ export class ToolHost {
129
+ restrictions;
130
+ cwd;
131
+ constructor(restrictions, cwd) {
132
+ this.restrictions = restrictions;
133
+ this.cwd = cwd;
134
+ }
135
+ async invoke(name, params) {
136
+ switch (name) {
137
+ case 'read_file': return this.readFile(needString(name, params, 'path'));
138
+ case 'write_file': {
139
+ const content = params.content;
140
+ if (typeof content !== 'string')
141
+ throw new Failure('tool_error', "write_file: parameter 'content' must be a string");
142
+ return this.writeFile(needString(name, params, 'path'), content);
143
+ }
144
+ case 'web_fetch': return this.webFetch(needString(name, params, 'url'));
145
+ case 'web_search': return this.webSearch(needString(name, params, 'query'));
146
+ case 'shell': return this.shell(needString(name, params, 'command'));
147
+ }
148
+ }
149
+ async readFile(p) {
150
+ const canonical = this.restrictions.checkPath(p, 'read');
151
+ try {
152
+ return truncate(await fs.readFile(canonical, 'utf8'));
153
+ }
154
+ catch (e) {
155
+ throw new Failure('tool_error', `read_file '${p}': ${e.message}`);
156
+ }
157
+ }
158
+ async writeFile(p, content) {
159
+ const canonical = this.restrictions.checkPath(p, 'write');
160
+ try {
161
+ await fs.mkdir(path.dirname(canonical), { recursive: true });
162
+ await fs.writeFile(canonical, content, 'utf8');
163
+ }
164
+ catch (e) {
165
+ throw new Failure('tool_error', `write_file '${p}': ${e.message}`);
166
+ }
167
+ return `Wrote ${Buffer.byteLength(content)} bytes to ${canonical}`;
168
+ }
169
+ async webFetch(url) {
170
+ let current = this.restrictions.checkUrl(url);
171
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
172
+ let res;
173
+ try {
174
+ res = await fetch(current, { redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
175
+ }
176
+ catch (e) {
177
+ throw new Failure('tool_error', `web_fetch '${current.href}': ${e.message}`);
178
+ }
179
+ if (res.status >= 300 && res.status < 400 && res.headers.get('location')) {
180
+ current = this.restrictions.checkUrl(new URL(res.headers.get('location'), current).href);
181
+ continue;
182
+ }
183
+ const body = truncate(await res.text());
184
+ if (!res.ok)
185
+ throw new Failure('tool_error', `web_fetch '${current.href}': HTTP ${res.status}\n${body}`);
186
+ return body;
187
+ }
188
+ throw new Failure('tool_error', `web_fetch '${url}': too many redirects`);
189
+ }
190
+ /** Searches DuckDuckGo's HTML endpoint (no API key); it is a network call, so `restrict net` applies to it. */
191
+ async webSearch(query) {
192
+ const url = this.restrictions.checkUrl(`${SEARCH_ENDPOINT}?q=${encodeURIComponent(query)}`);
193
+ let html;
194
+ try {
195
+ const res = await fetch(url, {
196
+ headers: { 'user-agent': 'Mozilla/5.0 (compatible; spl-lang)' },
197
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
198
+ });
199
+ if (!res.ok)
200
+ throw new Error(`HTTP ${res.status}`);
201
+ html = await res.text();
202
+ }
203
+ catch (e) {
204
+ throw new Failure('tool_error', `web_search '${query}': ${e.message}`);
205
+ }
206
+ const results = parseSearchResults(html);
207
+ if (results.length === 0)
208
+ return `No results for "${query}".`;
209
+ return results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`).join('\n');
210
+ }
211
+ async shell(command) {
212
+ if (!this.restrictions.active)
213
+ return this.run(command, { shell: true });
214
+ // With any restriction active only plain commands are accepted, so the checks below are exact.
215
+ if (SHELL_META.test(command)) {
216
+ throw new Failure('restriction_violation', 'shell command rejected: chaining, redirection and substitution are not allowed inside a restrict block');
217
+ }
218
+ const argv = splitCommand(command);
219
+ if (argv.length === 0)
220
+ throw new Failure('tool_error', 'shell: empty command');
221
+ this.restrictions.checkExec(argv[0]);
222
+ for (const arg of argv.slice(1)) {
223
+ const value = arg.startsWith('-') && arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : arg;
224
+ if (looksLikeUrl(value)) {
225
+ if (this.restrictions.has('net'))
226
+ this.restrictions.checkHost(new URL(value).hostname.toLowerCase());
227
+ }
228
+ else if (this.restrictions.has('fs') && value && !value.startsWith('-') && looksLikePath(value)) {
229
+ this.restrictions.checkPath(value, 'read');
230
+ }
231
+ }
232
+ if (argv.some((a) => a.includes('"'))) {
233
+ throw new Failure('restriction_violation', 'shell command rejected: embedded double quotes are not allowed inside a restrict block');
234
+ }
235
+ if (process.platform === 'win32') {
236
+ // .cmd shims (npm, ...) need a shell on Windows; metacharacters were already rejected.
237
+ return this.run(argv.map((a) => (/\s/.test(a) || a === '' ? `"${a}"` : a)).join(' '), { shell: true });
238
+ }
239
+ return this.run(argv[0], { shell: false, args: argv.slice(1) });
240
+ }
241
+ run(command, opts) {
242
+ return new Promise((resolve, reject) => {
243
+ const child = spawn(command, opts.args ?? [], { cwd: this.cwd, shell: opts.shell, windowsHide: true });
244
+ let out = '';
245
+ const collect = (d) => {
246
+ if (out.length < MAX_TEXT * 2)
247
+ out += d.toString('utf8');
248
+ };
249
+ child.stdout.on('data', collect);
250
+ child.stderr.on('data', collect);
251
+ const timer = setTimeout(() => child.kill(), SHELL_TIMEOUT_MS);
252
+ child.on('error', (e) => {
253
+ clearTimeout(timer);
254
+ reject(new Failure('tool_error', `shell: ${e.message}`));
255
+ });
256
+ child.on('close', (code, signal) => {
257
+ clearTimeout(timer);
258
+ const text = truncate(out);
259
+ if (code === 0)
260
+ resolve(text);
261
+ else
262
+ reject(new Failure('tool_error', `shell: ${signal ? `terminated by ${signal}` : `exit code ${code}`}\n${text}`));
263
+ });
264
+ });
265
+ }
266
+ }
@@ -0,0 +1,49 @@
1
+ import type { Stmt } from './ast.js';
2
+ import { Failure } from './errors.js';
3
+ export type Value = null | boolean | number | string | Value[] | {
4
+ [key: string]: Value;
5
+ } | FunctionValue;
6
+ export interface Env {
7
+ vars: Map<string, Value>;
8
+ parent?: Env;
9
+ }
10
+ export interface UserFunction {
11
+ __fn: 'user';
12
+ name: string;
13
+ params: string[];
14
+ body: Stmt[];
15
+ closure: Env;
16
+ }
17
+ export interface BuiltinFunction {
18
+ __fn: 'builtin';
19
+ name: string;
20
+ call: (args: Value[]) => Value;
21
+ }
22
+ export type FunctionValue = UserFunction | BuiltinFunction;
23
+ export declare const runtimeError: (msg: string) => Failure;
24
+ export declare function isFunction(v: unknown): v is FunctionValue;
25
+ export declare function isList(v: unknown): v is Value[];
26
+ export declare function isDict(v: unknown): v is {
27
+ [key: string]: Value;
28
+ };
29
+ export declare function typeName(v: Value): string;
30
+ export declare function truthy(v: Value): boolean;
31
+ export declare function hasKey(o: {
32
+ [key: string]: Value;
33
+ }, k: string): boolean;
34
+ /** Assigns a dict key without ever touching the prototype chain. */
35
+ export declare function setKey(o: {
36
+ [key: string]: Value;
37
+ }, k: string, v: Value): void;
38
+ export declare function newDict(entries?: Iterable<[string, Value]>): {
39
+ [key: string]: Value;
40
+ };
41
+ /** Display form used for string interpolation and str(). */
42
+ export declare function str(v: Value): string;
43
+ /** Converts to a plain JSON-compatible value (functions become descriptive strings). */
44
+ export declare function toJson(v: Value): unknown;
45
+ /** Converts parsed JSON into a Value (JSON objects are already safe dicts). */
46
+ export declare function fromJson(j: unknown): Value;
47
+ export declare function equals(a: Value, b: Value): boolean;
48
+ /** Ordering for numbers, strings and (lexicographically) lists. */
49
+ export declare function compareValues(a: Value, b: Value): number;