draco-cli 0.0.0-stage → 3.0.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.
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ const { webFetch, webSearch } = require('./web-tools.js');
4
+
5
+ const FS_TOOLS = [
6
+ {
7
+ type: 'function',
8
+ function: {
9
+ name: 'readFile',
10
+ description:
11
+ 'Read a text file from the workspace into context. Large files are truncated.',
12
+ parameters: {
13
+ type: 'object',
14
+ properties: {
15
+ path: { type: 'string', description: 'Relative or absolute path inside the workspace.' },
16
+ },
17
+ required: ['path'],
18
+ },
19
+ },
20
+ },
21
+ {
22
+ type: 'function',
23
+ function: {
24
+ name: 'writeFile',
25
+ description:
26
+ 'Create or overwrite a file in the workspace. Parent directories are created automatically.',
27
+ parameters: {
28
+ type: 'object',
29
+ properties: {
30
+ path: { type: 'string', description: 'Relative or absolute path inside the workspace.' },
31
+ content: { type: 'string', description: 'Full file contents to write.' },
32
+ },
33
+ required: ['path', 'content'],
34
+ },
35
+ },
36
+ },
37
+ {
38
+ type: 'function',
39
+ function: {
40
+ name: 'makeDirectory',
41
+ description: 'Create a directory (and any missing parents) inside the workspace.',
42
+ parameters: {
43
+ type: 'object',
44
+ properties: {
45
+ path: { type: 'string', description: 'Directory path to create.' },
46
+ },
47
+ required: ['path'],
48
+ },
49
+ },
50
+ },
51
+ ];
52
+
53
+ const EXEC_TOOLS = [
54
+ {
55
+ type: 'function',
56
+ function: {
57
+ name: 'executeShell',
58
+ description:
59
+ 'Run a shell command in the workspace via the OS default shell. Requires user confirmation unless approval mode allows it. 45s timeout.',
60
+ parameters: {
61
+ type: 'object',
62
+ properties: {
63
+ command: { type: 'string', description: 'The shell command line to run.' },
64
+ cwd: { type: 'string', description: 'Optional working directory inside the workspace.' },
65
+ shell: { type: 'string', description: 'Optional explicit shell binary override.' },
66
+ },
67
+ required: ['command'],
68
+ },
69
+ },
70
+ },
71
+ ];
72
+
73
+ const WEB_TOOLS = [
74
+ {
75
+ type: 'function',
76
+ function: {
77
+ name: 'webFetch',
78
+ description:
79
+ 'Fetch an http/https URL and return its content as cleaned plain text (or raw HTML with format="html"). Internal/private network targets are blocked.',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ url: { type: 'string', description: 'The http(s) URL to fetch.' },
84
+ format: { type: 'string', enum: ['text', 'html'], description: 'Response format. Defaults to text.' },
85
+ },
86
+ required: ['url'],
87
+ },
88
+ },
89
+ },
90
+ {
91
+ type: 'function',
92
+ function: {
93
+ name: 'webSearch',
94
+ description:
95
+ 'Search the public web and return the top results as title/url/snippet triples.',
96
+ parameters: {
97
+ type: 'object',
98
+ properties: {
99
+ query: { type: 'string', description: 'Search query.' },
100
+ },
101
+ required: ['query'],
102
+ },
103
+ },
104
+ },
105
+ ];
106
+
107
+ function buildToolDefs(mcpTools) {
108
+ const defs = [...FS_TOOLS, ...EXEC_TOOLS, ...WEB_TOOLS];
109
+ for (const tool of mcpTools || []) {
110
+ defs.push({
111
+ type: 'function',
112
+ function: {
113
+ name: tool.qualifiedName,
114
+ description:
115
+ (tool.description || `MCP tool from server "${tool.serverName}"`) +
116
+ ` [MCP server: ${tool.serverName}]`,
117
+ parameters: tool.inputSchema || { type: 'object', properties: {} },
118
+ },
119
+ });
120
+ }
121
+ return defs;
122
+ }
123
+
124
+ async function executeTool(name, args, ctx) {
125
+ try {
126
+ if (name.startsWith('mcp_')) {
127
+ if (!ctx.mcp) return 'Error: MCP is not available in this session.';
128
+ const text = await ctx.mcp.call(name, args);
129
+ return text || '(tool returned no output)';
130
+ }
131
+ switch (name) {
132
+ case 'readFile':
133
+ return require('./fs-tools.js').readFile(ctx.workspaceRoot, args.path);
134
+ case 'writeFile':
135
+ return await require('./fs-tools.js').writeFile(ctx.workspaceRoot, args.path, args.content, ctx);
136
+ case 'makeDirectory':
137
+ return await require('./fs-tools.js').makeDirectory(ctx.workspaceRoot, args.path, ctx);
138
+ case 'executeShell':
139
+ return await require('./shell-tool.js').executeShell(args, ctx);
140
+ case 'webFetch':
141
+ return await webFetch(args);
142
+ case 'webSearch':
143
+ return await webSearch(args, ctx.cfg);
144
+ default:
145
+ return `Error: unknown tool "${name}".`;
146
+ }
147
+ } catch (e) {
148
+ return `Error: ${e.message || e}`;
149
+ }
150
+ }
151
+
152
+ module.exports = { buildToolDefs, executeTool };
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ function sandboxPath(workspaceRoot, p) {
6
+ if (typeof p !== 'string' || !p.trim()) {
7
+ throw new Error('A non-empty "path" is required.');
8
+ }
9
+ const rootAbs = path.resolve(workspaceRoot);
10
+ const abs = path.resolve(rootAbs, p);
11
+ const rel = path.relative(rootAbs, abs);
12
+ const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
13
+ if (!inside) {
14
+ throw new Error(
15
+ `Access denied: "${p}" resolves outside the workspace root (${rootAbs}).`
16
+ );
17
+ }
18
+ return abs;
19
+ }
20
+
21
+ module.exports = { sandboxPath };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const { exec } = require('child_process');
4
+ const { SHELL_TIMEOUT_MS, SHELL_MAX_BUFFER } = require('../config.js');
5
+ const { sandboxPath } = require('./sandbox.js');
6
+ const { gate } = require('../core/approvals.js');
7
+
8
+ async function executeShell(args, ctx) {
9
+ const command = String(args.command || '').trim();
10
+ if (!command) return 'Error: "command" is required.';
11
+
12
+ const allowed = await gate(ctx, `Allow shell command: "${command}" ? [y/N] `, ['full-auto']);
13
+ if (!allowed) {
14
+ return 'User declined to run this command. Do not retry it; ask how they want to proceed.';
15
+ }
16
+
17
+ let cwd = ctx.workspaceRoot;
18
+ if (args.cwd) cwd = sandboxPath(ctx.workspaceRoot, args.cwd);
19
+
20
+ return new Promise((resolve) => {
21
+ exec(
22
+ command,
23
+ {
24
+ cwd,
25
+ shell: args.shell || true,
26
+ timeout: SHELL_TIMEOUT_MS,
27
+ maxBuffer: SHELL_MAX_BUFFER,
28
+ },
29
+ (err, stdout, stderr) => {
30
+ if (err && !stdout && !stderr) {
31
+ return resolve(`Error running command: ${err.message}`);
32
+ }
33
+ let out = `${stdout || ''}${stderr ? (stdout ? '\n' : '') + stderr : ''}`.trim();
34
+ if (!out) out = '(no output)';
35
+ if (err && err.killed) out += `\n[command timed out after ${SHELL_TIMEOUT_MS / 1000}s]`;
36
+ else if (err && typeof err.code === 'number') out += `\n[exit code: ${err.code}]`;
37
+ resolve(out);
38
+ }
39
+ );
40
+ });
41
+ }
42
+
43
+ module.exports = { executeShell };
@@ -0,0 +1,322 @@
1
+ 'use strict';
2
+
3
+ const dns = require('dns').promises;
4
+ const { FETCH_TIMEOUT_MS, FETCH_MAX_BYTES } = require('../config.js');
5
+
6
+ function isPrivateIPv4(ip) {
7
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
8
+ if (!m) return false;
9
+ const a = Number(m[1]);
10
+ const b = Number(m[2]);
11
+ if (a === 0 || a === 10 || a === 127) return true;
12
+ if (a === 169 && b === 254) return true;
13
+ if (a === 172 && b >= 16 && b <= 31) return true;
14
+ if (a === 192 && b === 168) return true;
15
+ return false;
16
+ }
17
+
18
+ function isPrivateIPv6(ip) {
19
+ const s = ip.toLowerCase();
20
+ if (s === '::' || s === '::1') return true;
21
+ if (s.startsWith('fe8') || s.startsWith('fe9') || s.startsWith('fea') || s.startsWith('feb')) {
22
+ return true;
23
+ }
24
+ if (/^f[cd]/.test(s)) return true;
25
+ const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(s);
26
+ if (mapped) return isPrivateIPv4(mapped[1]);
27
+ return false;
28
+ }
29
+
30
+ function assertPublicUrl(urlString) {
31
+ let url;
32
+ try {
33
+ url = new URL(urlString);
34
+ } catch {
35
+ throw new Error(`Invalid URL: ${urlString}`);
36
+ }
37
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
38
+ throw new Error(`Only http/https URLs are allowed, got "${url.protocol}"`);
39
+ }
40
+ const host = url.hostname.replace(/^\[/, '').replace(/\]$/, '').toLowerCase();
41
+ if (host === 'localhost' || host.endsWith('.localhost')) {
42
+ throw new Error('Blocked: localhost targets are not allowed.');
43
+ }
44
+ const isLiteralV4 = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
45
+ const isLiteralV6 = host.includes(':');
46
+ const addresses = isLiteralV4 || isLiteralV6 ? [{ address: host }] : null;
47
+ return { url, host, addresses };
48
+ }
49
+
50
+ async function resolveAndGuard(urlString) {
51
+ const { url, host, addresses } = assertPublicUrl(urlString);
52
+ let resolved;
53
+ if (addresses) {
54
+ resolved = addresses;
55
+ if (isPrivateIPv4(host) || isPrivateIPv6(host)) {
56
+ throw new Error(
57
+ `Blocked: ${host} is a loopback/link-local/private address. ` +
58
+ `SSRF protection refuses to fetch internal network targets.`
59
+ );
60
+ }
61
+ } else {
62
+ try {
63
+ resolved = await dns.lookup(host, { all: true, verbatim: true });
64
+ } catch (e) {
65
+ throw new Error(`Could not resolve host "${host}": ${e.message}`);
66
+ }
67
+ for (const entry of resolved) {
68
+ const addr = entry.address;
69
+ if (isPrivateIPv4(addr) || isPrivateIPv6(addr)) {
70
+ throw new Error(
71
+ `Blocked: ${host} resolves to a loopback/link-local/private address (${addr}). ` +
72
+ `SSRF protection refuses to fetch internal network targets.`
73
+ );
74
+ }
75
+ }
76
+ }
77
+ return url;
78
+ }
79
+
80
+ const NAMED_ENTITIES = {
81
+ amp: '&',
82
+ lt: '<',
83
+ gt: '>',
84
+ quot: '"',
85
+ apos: "'",
86
+ nbsp: ' ',
87
+ copy: '\u00A9',
88
+ reg: '\u00AE',
89
+ trade: '\u2122',
90
+ hellip: '\u2026',
91
+ mdash: '\u2014',
92
+ ndash: '\u2013',
93
+ lsquo: '\u2018',
94
+ rsquo: '\u2019',
95
+ ldquo: '\u201C',
96
+ rdquo: '\u201D',
97
+ };
98
+
99
+ function decodeEntities(text) {
100
+ return String(text)
101
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => {
102
+ try {
103
+ return String.fromCodePoint(parseInt(hex, 16));
104
+ } catch {
105
+ return '';
106
+ }
107
+ })
108
+ .replace(/&#(\d+);/g, (_, dec) => {
109
+ try {
110
+ return String.fromCodePoint(Number(dec));
111
+ } catch {
112
+ return '';
113
+ }
114
+ })
115
+ .replace(/&([a-z]+);/gi, (_, name) => NAMED_ENTITIES[name.toLowerCase()] ?? `&${name};`);
116
+ }
117
+
118
+ function htmlToText(html) {
119
+ // Regex-based conversion: good enough for static HTML; JS-rendered pages will
120
+ // yield only their noscript/static content.
121
+ let s = String(html);
122
+ s = s.replace(/<script[\s\S]*?<\/script\s*>/gi, ' ');
123
+ s = s.replace(/<style[\s\S]*?<\/style\s*>/gi, ' ');
124
+ s = s.replace(/<noscript[\s\S]*?<\/noscript\s*>/gi, ' ');
125
+ s = s.replace(/<!--[\s\S]*?-->/g, ' ');
126
+ s = s.replace(/<svg[\s\S]*?<\/svg\s*>/gi, ' ');
127
+ s = s.replace(/<br\s*\/?>/gi, '\n');
128
+ s = s.replace(/<\/(p|div|section|article|header|footer|h[1-6]|li|tr|ul|ol|table|pre|blockquote)>/gi, '\n');
129
+ s = s.replace(/<[^>]+>/g, ' ');
130
+ s = decodeEntities(s);
131
+ s = s.replace(/[ \t\f\v\u00a0]+/g, ' ');
132
+ s = s.replace(/ ?\n ?/g, '\n');
133
+ s = s.replace(/\n{3,}/g, '\n\n');
134
+ return s.trim();
135
+ }
136
+
137
+ async function fetchCapped(urlString, timeoutMs, maxBytes) {
138
+ const controller = new AbortController();
139
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
140
+ try {
141
+ const res = await fetch(urlString, {
142
+ signal: controller.signal,
143
+ redirect: 'follow',
144
+ headers: {
145
+ 'User-Agent': 'DracoCLI/1.0 (+https://github.com/draco-cli)',
146
+ Accept: 'text/html,application/json,text/plain;q=0.9,*/*;q=0.5',
147
+ },
148
+ });
149
+ if (!res.ok) {
150
+ throw new Error(`HTTP ${res.status} ${res.statusText} while fetching ${urlString}`);
151
+ }
152
+ if (!res.body) {
153
+ const text = await res.text();
154
+ return { text, contentType: res.headers.get('content-type') || '' };
155
+ }
156
+ const decoder = new TextDecoder('utf-8', { fatal: false });
157
+ let total = 0;
158
+ let text = '';
159
+ for await (const chunk of res.body) {
160
+ total += chunk.byteLength;
161
+ if (total > maxBytes) {
162
+ controller.abort();
163
+ throw new Error(
164
+ `Response exceeded the ${Math.round(maxBytes / (1024 * 1024))}MB size cap.`
165
+ );
166
+ }
167
+ text += decoder.decode(chunk, { stream: true });
168
+ }
169
+ text += decoder.decode();
170
+ return { text, contentType: res.headers.get('content-type') || '' };
171
+ } catch (e) {
172
+ if (e.name === 'AbortError') {
173
+ const timedOut = e.message === undefined;
174
+ throw new Error(
175
+ `Request timed out or was aborted after ${Math.round(timeoutMs / 1000)}s${timedOut ? '' : ''}`
176
+ );
177
+ }
178
+ throw e;
179
+ } finally {
180
+ clearTimeout(timer);
181
+ }
182
+ }
183
+
184
+ async function webFetch(args) {
185
+ const format = args.format === 'html' ? 'html' : 'text';
186
+ let url;
187
+ try {
188
+ url = await resolveAndGuard(String(args.url || ''));
189
+ } catch (e) {
190
+ return `Error: ${e.message}`;
191
+ }
192
+ try {
193
+ const { text, contentType } = await fetchCapped(
194
+ url.href,
195
+ Number(args.timeoutMs) > 120000 ? 120000 : Number(args.timeoutMs) || FETCH_TIMEOUT_MS,
196
+ FETCH_MAX_BYTES
197
+ );
198
+ if (format === 'html') return text;
199
+ if (/application\/json/i.test(contentType)) return text;
200
+ const cleaned = htmlToText(text);
201
+ return cleaned || '(page contained no readable text)';
202
+ } catch (e) {
203
+ return `Error: ${e.message || e}`;
204
+ }
205
+ }
206
+
207
+ const SEARCH_PROVIDERS = {
208
+ async brave(query, apiKey) {
209
+ const endpoint =
210
+ 'https://api.search.brave.com/res/v1/web/search?' +
211
+ new URLSearchParams({ q: query, count: '5' }).toString();
212
+ const controller = new AbortController();
213
+ const timer = setTimeout(() => controller.abort(), 15000);
214
+ try {
215
+ const res = await fetch(endpoint, {
216
+ signal: controller.signal,
217
+ headers: {
218
+ Accept: 'application/json',
219
+ 'Accept-Encoding': 'gzip',
220
+ 'X-Subscription-Token': apiKey,
221
+ },
222
+ });
223
+ if (!res.ok) {
224
+ const body = await res.text().catch(() => '');
225
+ throw new Error(`Brave search HTTP ${res.status}: ${body.slice(0, 200)}`);
226
+ }
227
+ const data = await res.json();
228
+ const results = ((data.web && data.web.results) || []).slice(0, 5);
229
+ return results.map((r) => ({
230
+ title: r.title,
231
+ url: r.url,
232
+ snippet: r.description || '',
233
+ }));
234
+ } finally {
235
+ clearTimeout(timer);
236
+ }
237
+ },
238
+
239
+ async duckduckgo(query) {
240
+ const endpoint =
241
+ 'https://html.duckduckgo.com/html/?' +
242
+ new URLSearchParams({ q: query }).toString();
243
+ const controller = new AbortController();
244
+ const timer = setTimeout(() => controller.abort(), 15000);
245
+ try {
246
+ const res = await fetch(endpoint, {
247
+ method: 'POST',
248
+ signal: controller.signal,
249
+ headers: {
250
+ 'Content-Type': 'application/x-www-form-urlencoded',
251
+ 'User-Agent':
252
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36',
253
+ Accept: 'text/html,application/xhtml+xml',
254
+ },
255
+ body: new URLSearchParams({ q: query }).toString(),
256
+ });
257
+ if (!res.ok) {
258
+ throw new Error(`DuckDuckGo HTTP ${res.status} (may be rate-limited — retry later or set DRACO_SEARCH_API_KEY to use Brave)`);
259
+ }
260
+ const html = await res.text();
261
+ const clean = (s) =>
262
+ decodeEntities(String(s).replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
263
+ const unwrap = (u) => {
264
+ if (u.startsWith('//')) u = 'https:' + u;
265
+ try {
266
+ const parsed = new URL(u);
267
+ const target = parsed.searchParams.get('uddg');
268
+ return target ? decodeURIComponent(target) : u;
269
+ } catch {
270
+ return u;
271
+ }
272
+ };
273
+ const anchorRe = /<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
274
+ const snippetRe = /<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
275
+ const links = [];
276
+ let m;
277
+ while ((m = anchorRe.exec(html)) !== null && links.length < 8) {
278
+ links.push({ url: unwrap(m[1]), title: clean(m[2]) });
279
+ }
280
+ const snippets = [];
281
+ while ((m = snippetRe.exec(html)) !== null && snippets.length < 8) {
282
+ snippets.push(clean(m[1]));
283
+ }
284
+ const seen = new Set();
285
+ const results = [];
286
+ for (let i = 0; i < links.length && results.length < 5; i++) {
287
+ const r = { title: links[i].title, url: links[i].url, snippet: snippets[i] || '' };
288
+ if (!r.url || seen.has(r.url)) continue;
289
+ seen.add(r.url);
290
+ results.push(r);
291
+ }
292
+ return results;
293
+ } finally {
294
+ clearTimeout(timer);
295
+ }
296
+ },
297
+ };
298
+
299
+ async function webSearch(args, cfg) {
300
+ const query = String(args.query || '').trim();
301
+ if (!query) return 'Error: query is required.';
302
+ const providerName = cfg.searchApiKey ? (cfg.searchProvider || 'brave') : 'duckduckgo';
303
+ const impl = SEARCH_PROVIDERS[providerName];
304
+ if (!impl) return `Error: unknown search provider "${providerName}".`;
305
+ try {
306
+ const results = await impl(query, cfg.searchApiKey);
307
+ if (!results.length) return `No results found for "${query}".`;
308
+ return results
309
+ .map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`)
310
+ .join('\n\n');
311
+ } catch (e) {
312
+ return `Error: search failed — ${e.message || e}`;
313
+ }
314
+ }
315
+
316
+ module.exports = {
317
+ assertPublicUrl,
318
+ resolveAndGuard,
319
+ htmlToText,
320
+ webFetch,
321
+ webSearch,
322
+ };
package/src/ui/diff.js ADDED
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ const tty = process.stdout.isTTY;
4
+
5
+ const col = {
6
+ reset: tty ? '\x1b[0m' : '',
7
+ dim: tty ? '\x1b[2m' : '',
8
+ red: tty ? '\x1b[31m' : '',
9
+ green: tty ? '\x1b[32m' : '',
10
+ cyan: tty ? '\x1b[36m' : '',
11
+ };
12
+
13
+ function splitLines(text) {
14
+ return String(text ?? '').split('\n');
15
+ }
16
+
17
+ function diffLines(oldText, newText) {
18
+ const a = splitLines(oldText);
19
+ const b = splitLines(newText);
20
+ let start = 0;
21
+ while (start < a.length && start < b.length && a[start] === b[start]) start++;
22
+ let endA = a.length;
23
+ let endB = b.length;
24
+ while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
25
+ endA--;
26
+ endB--;
27
+ }
28
+ const rows = [];
29
+ for (let i = Math.max(0, start - 3); i < start; i++) rows.push({ type: ' ', text: a[i] });
30
+ for (let i = start; i < endA; i++) rows.push({ type: '-', text: a[i] });
31
+ for (let i = start; i < endB; i++) rows.push({ type: '+', text: b[i] });
32
+ const tailStart = Math.min(a.length, endA + 3);
33
+ for (let i = endA; i < tailStart; i++) rows.push({ type: ' ', text: a[i] });
34
+ return rows;
35
+ }
36
+
37
+ function renderDiff(rows, maxRows = 120) {
38
+ const shown = rows.slice(0, maxRows);
39
+ const parts = [];
40
+ parts.push(`${col.dim}\u2500\u2500 diff ${'─'.repeat(24)}${col.reset}`);
41
+ for (const row of shown) {
42
+ if (row.type === '+') parts.push(`${col.green}+ ${row.text}${col.reset}`);
43
+ else if (row.type === '-') parts.push(`${col.red}- ${row.text}${col.reset}`);
44
+ else parts.push(`${col.dim} ${row.text}${col.reset}`);
45
+ }
46
+ if (rows.length > shown.length) {
47
+ parts.push(`${col.cyan}\u2026 ${rows.length - shown.length} more lines${col.reset}`);
48
+ }
49
+ parts.push(`${col.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${col.reset}`);
50
+ return parts.join('\n');
51
+ }
52
+
53
+ module.exports = { diffLines, renderDiff };
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const theme = require('./theme.js');
4
+
5
+ const tty = process.stdout.isTTY;
6
+
7
+ const c = theme.c;
8
+
9
+ function inline(line) {
10
+ let out = line;
11
+ out = out.replace(/`([^`]+)`/g, `${c.yellow}$1${c.reset}`);
12
+ out = out.replace(/\*\*([^*]+)\*\*/g, `${c.bold}$1${c.reset}`);
13
+ out = out.replace(/(^|[\s(])\*([^*\s][^*]*)\*/g, `$1${c.italic}$2${c.reset}`);
14
+ return out;
15
+ }
16
+
17
+ function renderLine(line, state) {
18
+ if (state.fence) {
19
+ if (/^\s*```/.test(line)) {
20
+ state.fence = false;
21
+ return `${c.dim}└─ code${c.reset}`;
22
+ }
23
+ return `${c.blue}${line}${c.reset}`;
24
+ }
25
+ const fence = /^\s*```(.*)$/.exec(line);
26
+ if (fence) {
27
+ state.fence = true;
28
+ const lang = (fence[1] || '').trim();
29
+ return `${c.dim}┌─ code${lang ? ` ${lang}` : ''}${c.reset}`;
30
+ }
31
+ const h = /^(#{1,6})\s+(.*)$/.exec(line);
32
+ if (h) {
33
+ const level = h[1].length;
34
+ const color = level <= 2 ? c.bold : c.magenta || c.purple;
35
+ return `${color}${h[2]}${c.reset}`;
36
+ }
37
+ const bullet = /^(\s*)[-*]\s+(.*)$/.exec(line);
38
+ if (bullet) {
39
+ return `${bullet[1]}${c.cyan}•${c.reset} ${inline(bullet[2])}`;
40
+ }
41
+ const numbered = /^(\s*)(\d+\.)\s+(.*)$/.exec(line);
42
+ if (numbered) {
43
+ return `${numbered[1]}${c.cyan}${numbered[2]}${c.reset} ${inline(numbered[3])}`;
44
+ }
45
+ return inline(line);
46
+ }
47
+
48
+ class MarkdownStream {
49
+ constructor() {
50
+ this.buffer = '';
51
+ this.state = { fence: false };
52
+ this.started = false;
53
+ this.blockOpen = false;
54
+ }
55
+
56
+ openBlock() {
57
+ if (!tty || this.blockOpen) return;
58
+ this.blockOpen = true;
59
+ process.stdout.write(theme.responseRule('top') + '\n');
60
+ }
61
+
62
+ closeBlock() {
63
+ if (!tty || !this.blockOpen) return;
64
+ this.blockOpen = false;
65
+ process.stdout.write(theme.responseRule('bottom') + '\n');
66
+ }
67
+
68
+ write(chunk) {
69
+ if (!tty) {
70
+ process.stdout.write(chunk);
71
+ return;
72
+ }
73
+ this.buffer += chunk;
74
+ let nl;
75
+ while ((nl = this.buffer.indexOf('\n')) >= 0) {
76
+ const line = this.buffer.slice(0, nl + 1);
77
+ this.buffer = this.buffer.slice(nl + 1);
78
+ this.flushLine(line);
79
+ }
80
+ }
81
+
82
+ flushLine(lineWithNewline) {
83
+ const hadNewline = lineWithNewline.endsWith('\n');
84
+ const text = hadNewline ? lineWithNewline.slice(0, -1) : lineWithNewline;
85
+ this.openBlock();
86
+ if (text.trim() === '') {
87
+ process.stdout.write(theme.gutter() + (hadNewline ? '\n' : ''));
88
+ return;
89
+ }
90
+ process.stdout.write(
91
+ `${theme.gutter()} ${renderLine(text, this.state)}` + (hadNewline ? '\n' : '')
92
+ );
93
+ }
94
+
95
+ end() {
96
+ if (!tty) return;
97
+ if (this.buffer.length > 0) {
98
+ const rest = this.buffer;
99
+ this.buffer = '';
100
+ this.flushLine(rest.endsWith('\n') ? rest : `${rest}\n`);
101
+ }
102
+ this.closeBlock();
103
+ }
104
+ }
105
+
106
+ module.exports = { MarkdownStream };