chels-code 0.0.0-stage → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from "ink";
3
+ import { ASSISTANT_NAME, breakLongWords, limitLines, plural, sanitize, shortenPath, TAGLINE, theme, } from "./theme.js";
4
+ function TextBlock({ text, railColor, color, limit = 160, width, }) {
5
+ const content = limitLines(text, limit);
6
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderTop: false, borderBottom: false, borderRight: false, borderColor: railColor, paddingLeft: 1, flexShrink: 1, minWidth: 0, children: [_jsx(Text, { color: color, children: breakLongWords(content.text || " ", width) }), content.omitted > 0 ? (_jsxs(Text, { color: theme.faint, children: ["\u2026 ", content.omitted, " more ", plural(content.omitted, "line")] })) : null] }));
7
+ }
8
+ const LOGO = [
9
+ " ██████╗██╗ ██╗███████╗██╗ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗",
10
+ "██╔════╝██║ ██║██╔════╝██║ ██╔════╝██╔════╝██╔═══██╗██╔══██╗██╔════╝",
11
+ "██║ ███████║█████╗ ██║ ███████╗██║ ██║ ██║██║ ██║█████╗ ",
12
+ "██║ ██╔══██║██╔══╝ ██║ ╚════██║██║ ██║ ██║██║ ██║██╔══╝ ",
13
+ "╚██████╗██║ ██║███████╗███████╗███████║╚██████╗╚██████╔╝██████╔╝███████╗",
14
+ " ╚═════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝",
15
+ ];
16
+ const LOGO_WIDTH = 73;
17
+ const LOGO_COLORS = [
18
+ theme.bright,
19
+ theme.bright,
20
+ theme.primary,
21
+ theme.primary,
22
+ theme.deep,
23
+ theme.deep,
24
+ ];
25
+ function HeaderEntry({ entry, columns }) {
26
+ const wide = columns >= LOGO_WIDTH + 4;
27
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginBottom: 1, children: [wide ? (_jsxs(_Fragment, { children: [LOGO.map((line, index) => (_jsx(Text, { color: LOGO_COLORS[index], children: line }, index))), _jsxs(Box, { width: LOGO_WIDTH, justifyContent: "space-between", children: [_jsx(Text, { color: theme.dim, children: TAGLINE }), _jsx(Text, { color: theme.faint, children: entry.version })] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: "\u25C6" }), _jsx(Text, { bold: true, color: theme.bright, children: " ChelsCode" }), _jsxs(Text, { color: theme.faint, children: [" ", entry.version] })] }), _jsx(Text, { color: theme.dim, children: TAGLINE })] })), _jsx(Text, { color: theme.faint, children: shortenPath(entry.workspace, Math.max(18, columns - 6)) }), entry.resumedFrom ? (_jsxs(Text, { color: theme.faint, children: ["Resumed session ", entry.resumedFrom] })) : null] }));
28
+ }
29
+ function UserEntry({ entry, columns }) {
30
+ return (_jsxs(Box, { paddingX: 2, marginTop: 1, children: [_jsx(Text, { color: theme.primary, children: "> " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, minWidth: 0, children: _jsx(Text, { color: theme.white, children: breakLongWords(sanitize(entry.text), Math.max(8, columns - 6)) }) })] }));
31
+ }
32
+ function PlanEntry({ entry, columns }) {
33
+ return (_jsx(Box, { paddingX: 2, marginTop: 1, width: columns, children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.primary, paddingX: 1, width: Math.max(16, columns - 4), children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "Combined plan" }), _jsxs(Text, { color: theme.faint, children: [" · ", entry.tasks.length, " ", plural(entry.tasks.length, "task"), " · "] }), _jsx(Text, { color: theme.primary, children: ASSISTANT_NAME })] }), entry.tasks.map((task, index) => {
34
+ const instructions = limitLines(task.instructions, 40);
35
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: theme.primary, children: [index + 1, ". "] }), _jsx(Text, { bold: true, children: sanitize(task.title) })] }), _jsx(Text, { color: theme.dim, children: breakLongWords(instructions.text, Math.max(8, columns - 8)) }), instructions.omitted > 0 ? (_jsxs(Text, { color: theme.faint, children: ["\u2026 ", instructions.omitted, " more ", plural(instructions.omitted, "line")] })) : null] }, `${index}-${task.title}`));
36
+ })] }) }));
37
+ }
38
+ function madeText(item) {
39
+ return item.kind === "file" ? item.path : item.command;
40
+ }
41
+ function DoneEntry({ entry, columns }) {
42
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.green, children: "\u25C6" }), _jsx(Text, { bold: true, color: theme.green, children: " Done" })] }), entry.text ? (_jsx(TextBlock, { text: entry.text, railColor: theme.green, width: columns - 6 })) : null, entry.made.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: theme.dim, children: "Made" }), entry.made.map((item, index) => (_jsxs(Text, { children: [_jsx(Text, { color: item.kind === "file" ? theme.green : theme.yellow, children: item.kind === "file" ? "+ " : "$ " }), _jsx(Text, { color: theme.dim, children: breakLongWords(sanitize(madeText(item)), Math.max(8, columns - 8)) })] }, `${item.kind}-${index}`)))] })) : null] }));
43
+ }
44
+ function NoteEntry({ entry }) {
45
+ return (_jsx(Box, { paddingX: 2, marginTop: 1, children: _jsxs(Text, { children: [_jsx(Text, { color: theme.primary, children: "\u25C6" }), _jsxs(Text, { color: theme.dim, children: [" ", sanitize(entry.text)] })] }) }));
46
+ }
47
+ function ErrorEntry({ entry, columns }) {
48
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 2, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.red, children: "\u2716" }), _jsxs(Text, { bold: true, color: theme.red, children: [" ", sanitize(entry.title)] })] }), _jsx(TextBlock, { text: entry.message, railColor: theme.red, width: columns - 6 })] }));
49
+ }
50
+ export function TranscriptEntryView({ entry, columns }) {
51
+ switch (entry.kind) {
52
+ case "header":
53
+ return _jsx(HeaderEntry, { entry: entry, columns: columns });
54
+ case "user":
55
+ return _jsx(UserEntry, { entry: entry, columns: columns });
56
+ case "plan":
57
+ return _jsx(PlanEntry, { entry: entry, columns: columns });
58
+ case "done":
59
+ return _jsx(DoneEntry, { entry: entry, columns: columns });
60
+ case "note":
61
+ return _jsx(NoteEntry, { entry: entry });
62
+ case "error":
63
+ return _jsx(ErrorEntry, { entry: entry, columns: columns });
64
+ default:
65
+ return null;
66
+ }
67
+ }
@@ -0,0 +1,130 @@
1
+ import os from "node:os";
2
+ export const TAGLINE = "I like cats";
3
+ export const ASSISTANT_NAME = "MystBloom V1.0";
4
+ export const MODE_LABELS = {
5
+ build: "Build",
6
+ plan: "Plan",
7
+ };
8
+ export const PLAN_NAME = "Free ChelsCode";
9
+ export const theme = {
10
+ primary: "#a78bfa",
11
+ bright: "#d8b4fe",
12
+ deep: "#7c3aed",
13
+ dim: "#9ca3af",
14
+ faint: "#6b7280",
15
+ green: "#34d399",
16
+ yellow: "#fbbf24",
17
+ red: "#f87171",
18
+ orange: "#fb923c",
19
+ white: "#f5f3ff",
20
+ blue: "#60a5fa",
21
+ cyan: "#22d3ee",
22
+ pink: "#f472b6",
23
+ };
24
+ export const EFFORT_COLORS = {
25
+ low: theme.green,
26
+ medium: theme.cyan,
27
+ high: theme.yellow,
28
+ max: theme.orange,
29
+ ultra: theme.pink,
30
+ };
31
+ export const PROVIDER_UI = {
32
+ openrouter: { label: "OpenRouter", color: theme.primary },
33
+ nvidia: { label: "NVIDIA", color: theme.green },
34
+ groq: { label: "Groq", color: theme.orange },
35
+ };
36
+ export function providerLabel(provider) {
37
+ return PROVIDER_UI[provider]?.label ?? provider;
38
+ }
39
+ export function providerColor(provider) {
40
+ return PROVIDER_UI[provider]?.color ?? theme.primary;
41
+ }
42
+ const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
43
+ const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
44
+ const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g;
45
+ export function sanitize(text) {
46
+ return String(text)
47
+ .replace(OSC, "")
48
+ .replace(ANSI, "")
49
+ .replace(/\r\n?/g, "\n")
50
+ .replace(/\t/g, " ")
51
+ .replace(CONTROL, "");
52
+ }
53
+ export function truncate(text, max) {
54
+ if (max <= 0)
55
+ return "";
56
+ const plain = sanitize(text);
57
+ const chars = [...plain];
58
+ if (chars.length <= max)
59
+ return plain;
60
+ return `${chars.slice(0, Math.max(0, max - 1)).join("")}…`;
61
+ }
62
+ export function limitLines(text, limit = 160) {
63
+ const lines = sanitize(text).split("\n");
64
+ if (lines.length <= limit)
65
+ return { text: lines.join("\n"), omitted: 0 };
66
+ return { text: lines.slice(0, limit).join("\n"), omitted: lines.length - limit };
67
+ }
68
+ export function breakLongWords(text, width) {
69
+ if (!Number.isFinite(width) || width < 2)
70
+ return text;
71
+ return text
72
+ .split("\n")
73
+ .map((line) => {
74
+ if (line.length <= width)
75
+ return line;
76
+ return line.replace(/\S+/g, (word) => {
77
+ const chars = [...word];
78
+ if (chars.length <= width)
79
+ return word;
80
+ const pieces = [];
81
+ for (let index = 0; index < chars.length; index += width) {
82
+ pieces.push(chars.slice(index, index + width).join(""));
83
+ }
84
+ return pieces.join("\n");
85
+ });
86
+ })
87
+ .join("\n");
88
+ }
89
+ export function formatDuration(ms) {
90
+ if (ms < 1000)
91
+ return `${Math.max(0, Math.round(ms))}ms`;
92
+ const seconds = ms / 1000;
93
+ if (seconds < 60)
94
+ return `${seconds.toFixed(1)}s`;
95
+ const minutes = Math.floor(seconds / 60);
96
+ return `${minutes}m ${Math.round(seconds % 60)}s`;
97
+ }
98
+ export function formatBytes(bytes) {
99
+ if (bytes < 1024)
100
+ return `${bytes} B`;
101
+ if (bytes < 1024 * 1024)
102
+ return `${(bytes / 1024).toFixed(1)} KB`;
103
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
104
+ }
105
+ export function formatTokenCount(tokens) {
106
+ if (!Number.isFinite(tokens) || tokens <= 0)
107
+ return "0";
108
+ const trim = (value) => value.toFixed(1).replace(/\.0$/, "");
109
+ if (tokens >= 1_000_000)
110
+ return `${trim(tokens / 1_000_000)}M`;
111
+ if (tokens >= 1_000)
112
+ return `${trim(tokens / 1_000)}K`;
113
+ return String(Math.round(tokens));
114
+ }
115
+ export function shortenPath(value, max) {
116
+ const home = os.homedir();
117
+ let text = value;
118
+ if (home && (value === home || value.startsWith(`${home}\\`) || value.startsWith(`${home}/`))) {
119
+ text = `~${value.slice(home.length)}`;
120
+ }
121
+ return truncate(text, max);
122
+ }
123
+ export function shortModelName(model, max = 36) {
124
+ const value = sanitize(model);
125
+ const tail = value.split("/").pop() ?? value;
126
+ return truncate(tail.length >= 8 ? tail : value, max);
127
+ }
128
+ export function plural(count, singular, pluralForm = `${singular}s`) {
129
+ return count === 1 ? singular : pluralForm;
130
+ }
@@ -0,0 +1,22 @@
1
+ import { useEffect, useState } from "react";
2
+ import { useStdout } from "ink";
3
+ export function useTerminalSize() {
4
+ const { stdout } = useStdout();
5
+ const stream = stdout;
6
+ const [size, setSize] = useState(() => ({
7
+ columns: stream.columns ?? 80,
8
+ rows: stream.rows ?? 24,
9
+ }));
10
+ useEffect(() => {
11
+ const update = () => setSize({
12
+ columns: stream.columns ?? 80,
13
+ rows: stream.rows ?? 24,
14
+ });
15
+ update();
16
+ stream.on("resize", update);
17
+ return () => {
18
+ stream.off("resize", update);
19
+ };
20
+ }, [stream]);
21
+ return size;
22
+ }
@@ -0,0 +1 @@
1
+ export const VERSION = "0.5.0";
@@ -0,0 +1,405 @@
1
+ import { spawn } from "node:child_process";
2
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { resolveWorkspacePath } from "./safety.js";
5
+ const MAX_FILE_BYTES = 200_000;
6
+ const MAX_COMMAND_OUTPUT = 20_000;
7
+ const PREVIEW_LINES = 12;
8
+ const PREVIEW_CHARS = 1_200;
9
+ const IGNORED_ENTRIES = new Set([".git", "node_modules"]);
10
+ const WEB_TIMEOUT_MS = 10_000;
11
+ const MAX_WEB_RESULTS = 8;
12
+ const NEWS_ITEM_LIMIT = 10;
13
+ export const TOOL_DEFINITIONS = [
14
+ {
15
+ type: "function",
16
+ function: {
17
+ name: "list_files",
18
+ description: "List files and folders in a workspace directory. Use '.' for the workspace root.",
19
+ parameters: {
20
+ type: "object",
21
+ properties: { path: { type: "string" } },
22
+ required: ["path"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ },
27
+ {
28
+ type: "function",
29
+ function: {
30
+ name: "read_file",
31
+ description: "Read a UTF-8 text file inside the workspace. Secret-like files are blocked.",
32
+ parameters: {
33
+ type: "object",
34
+ properties: { path: { type: "string" } },
35
+ required: ["path"],
36
+ additionalProperties: false,
37
+ },
38
+ },
39
+ },
40
+ {
41
+ type: "function",
42
+ function: {
43
+ name: "write_file",
44
+ description: "Create or replace a UTF-8 text file inside the workspace. Requires user confirmation.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: { path: { type: "string" }, content: { type: "string" } },
48
+ required: ["path", "content"],
49
+ additionalProperties: false,
50
+ },
51
+ },
52
+ },
53
+ {
54
+ type: "function",
55
+ function: {
56
+ name: "run_command",
57
+ description: "Run a PowerShell command in the workspace. Requires user confirmation.",
58
+ parameters: {
59
+ type: "object",
60
+ properties: { command: { type: "string" } },
61
+ required: ["command"],
62
+ additionalProperties: false,
63
+ },
64
+ },
65
+ },
66
+ {
67
+ type: "function",
68
+ function: {
69
+ name: "get_current_datetime",
70
+ description: "Get the current date and time. Use this when the user asks what the date, day of the week, or current time is.",
71
+ parameters: {
72
+ type: "object",
73
+ properties: {},
74
+ required: [],
75
+ additionalProperties: false,
76
+ },
77
+ },
78
+ },
79
+ {
80
+ type: "function",
81
+ function: {
82
+ name: "get_weather",
83
+ description: "Get the current weather for a location. Use it when the user asks about the weather, temperature, or conditions for a place.",
84
+ parameters: {
85
+ type: "object",
86
+ properties: { location: { type: "string" } },
87
+ required: ["location"],
88
+ additionalProperties: false,
89
+ },
90
+ },
91
+ },
92
+ {
93
+ type: "function",
94
+ function: {
95
+ name: "web_search",
96
+ description: "Search the web for current information. Use it when the user asks about recent events, news, facts that change over time, or anything that needs up-to-date search results.",
97
+ parameters: {
98
+ type: "object",
99
+ properties: { query: { type: "string" } },
100
+ required: ["query"],
101
+ additionalProperties: false,
102
+ },
103
+ },
104
+ },
105
+ {
106
+ type: "function",
107
+ function: {
108
+ name: "get_news",
109
+ description: "Get the latest news headlines for a topic or general headlines. Use it when the user asks for the news or current events.",
110
+ parameters: {
111
+ type: "object",
112
+ properties: { query: { type: "string" } },
113
+ required: [],
114
+ additionalProperties: false,
115
+ },
116
+ },
117
+ },
118
+ ];
119
+ const READ_ONLY_TOOL_NAMES = new Set([
120
+ "list_files",
121
+ "read_file",
122
+ "get_current_datetime",
123
+ "get_weather",
124
+ "web_search",
125
+ "get_news",
126
+ ]);
127
+ export const READ_ONLY_TOOL_DEFINITIONS = TOOL_DEFINITIONS
128
+ .filter((tool) => READ_ONLY_TOOL_NAMES.has(tool.function.name));
129
+ function requireString(value, name) {
130
+ if (typeof value !== "string")
131
+ throw new Error(`${name} must be a string.`);
132
+ return value;
133
+ }
134
+ function toPosix(value) {
135
+ return value.replaceAll(path.sep, "/");
136
+ }
137
+ function previewContent(content) {
138
+ const lines = content.split("\n");
139
+ let preview = lines.slice(0, PREVIEW_LINES).join("\n");
140
+ let truncated = lines.length > PREVIEW_LINES;
141
+ if (preview.length > PREVIEW_CHARS) {
142
+ preview = preview.slice(0, PREVIEW_CHARS);
143
+ truncated = true;
144
+ }
145
+ return { preview, truncated };
146
+ }
147
+ function runPowerShell(command, workspace) {
148
+ return new Promise((resolve, reject) => {
149
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], {
150
+ cwd: workspace,
151
+ windowsHide: true,
152
+ stdio: ["ignore", "pipe", "pipe"],
153
+ });
154
+ let stdout = "";
155
+ let stderr = "";
156
+ const append = (current, chunk) => (`${current}${chunk.toString()}`.slice(-MAX_COMMAND_OUTPUT));
157
+ const timeout = setTimeout(() => child.kill(), 30_000);
158
+ child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
159
+ child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
160
+ child.on("error", (error) => {
161
+ clearTimeout(timeout);
162
+ reject(error);
163
+ });
164
+ child.on("close", (code) => {
165
+ clearTimeout(timeout);
166
+ resolve(JSON.stringify({
167
+ exitCode: code,
168
+ stdout,
169
+ stderr,
170
+ truncated: stdout.length >= MAX_COMMAND_OUTPUT || stderr.length >= MAX_COMMAND_OUTPUT,
171
+ }));
172
+ });
173
+ });
174
+ }
175
+ async function fetchJson(url) {
176
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEB_TIMEOUT_MS) });
177
+ if (!response.ok) {
178
+ throw new Error(`The web service returned HTTP ${response.status} for ${url}.`);
179
+ }
180
+ return response.json();
181
+ }
182
+ function stripHtml(value) {
183
+ return value
184
+ .replace(/<[^>]*>/g, " ")
185
+ .replace(/&amp;/g, "&")
186
+ .replace(/&lt;/g, "<")
187
+ .replace(/&gt;/g, ">")
188
+ .replace(/&quot;/g, '"')
189
+ .replace(/&#39;/g, "'")
190
+ .replace(/&nbsp;/g, " ")
191
+ .replace(/\s+/g, " ")
192
+ .trim();
193
+ }
194
+ function decodeText(value) {
195
+ return value
196
+ .replace(/&lt;/g, "<")
197
+ .replace(/&gt;/g, ">")
198
+ .replace(/&quot;/g, '"')
199
+ .replace(/&#39;/g, "'")
200
+ .replace(/&amp;/g, "&")
201
+ .trim();
202
+ }
203
+ const WEATHER_DESCRIPTIONS = {
204
+ 0: "Clear sky",
205
+ 1: "Mainly clear sky",
206
+ 2: "Partly cloudy",
207
+ 3: "Overcast",
208
+ 45: "Fog",
209
+ 48: "Depositing rime fog",
210
+ 51: "Light drizzle",
211
+ 53: "Drizzle",
212
+ 55: "Dense drizzle",
213
+ 56: "Freezing drizzle",
214
+ 57: "Freezing drizzle",
215
+ 61: "Light rain",
216
+ 63: "Rain",
217
+ 65: "Heavy rain",
218
+ 66: "Freezing rain",
219
+ 67: "Heavy freezing rain",
220
+ 71: "Light snow",
221
+ 73: "Snow",
222
+ 75: "Heavy snow",
223
+ 77: "Snow grains",
224
+ 80: "Light showers",
225
+ 81: "Rain showers",
226
+ 82: "Violent showers",
227
+ 85: "Snow showers",
228
+ 86: "Snow showers",
229
+ 95: "Thunderstorm",
230
+ 96: "Thunderstorm with hail",
231
+ 99: "Thunderstorm with hail",
232
+ };
233
+ function describeWeatherCode(code) {
234
+ if (Number.isFinite(code) && code in WEATHER_DESCRIPTIONS) {
235
+ return WEATHER_DESCRIPTIONS[code];
236
+ }
237
+ return `Unknown weather condition (${code}).`;
238
+ }
239
+ function parseWebResults(html) {
240
+ const pattern = /<a\b[^>]*class="result__a"[^>]*>([\s\S]*?)<\/a>/gi;
241
+ const matches = [...html.matchAll(pattern)];
242
+ const results = [];
243
+ for (let index = 0; index < matches.length && results.length < MAX_WEB_RESULTS; index += 1) {
244
+ const match = matches[index];
245
+ const hrefMatch = /href="([^"]*)"/i.exec(match[0]);
246
+ const title = decodeText(stripHtml(match[1] ?? ""));
247
+ if (!hrefMatch || !title)
248
+ continue;
249
+ const start = (match.index ?? 0) + match[0].length;
250
+ const end = index + 1 < matches.length ? (matches[index + 1].index ?? html.length) : html.length;
251
+ const chunk = html.slice(start, end);
252
+ const snippetMatch = /class="result__snippet"[^>]*>([\s\S]*?)<\/(?:a|div)>/i.exec(chunk);
253
+ const snippet = snippetMatch ? decodeText(stripHtml(snippetMatch[1] ?? "")) : "";
254
+ results.push({ title, url: hrefMatch[1], snippet });
255
+ }
256
+ return results;
257
+ }
258
+ function parseNewsItems(xml) {
259
+ const items = [];
260
+ const pattern = /<item>([\s\S]*?)<\/item>/gi;
261
+ for (const match of xml.matchAll(pattern)) {
262
+ const block = match[1] ?? "";
263
+ const title = decodeText(/<title>([\s\S]*?)<\/title>/i.exec(block)?.[1] ?? "");
264
+ const url = /<link>([\s\S]*?)<\/link>/i.exec(block)?.[1] ?? "";
265
+ const publishedAt = /<pubDate>([\s\S]*?)<\/pubDate>/i.exec(block)?.[1] ?? "";
266
+ const source = decodeText(/<source[^>]*>([\s\S]*?)<\/source>/i.exec(block)?.[1] ?? "");
267
+ if (!title)
268
+ continue;
269
+ items.push({ title, url, publishedAt, source });
270
+ if (items.length >= NEWS_ITEM_LIMIT)
271
+ break;
272
+ }
273
+ return items;
274
+ }
275
+ export async function executeTool(name, rawArguments, { workspace, confirm, onMade }) {
276
+ const args = typeof rawArguments === "string" ? JSON.parse(rawArguments) : rawArguments;
277
+ if (!args || typeof args !== "object" || Array.isArray(args)) {
278
+ throw new Error("Tool arguments must be a JSON object.");
279
+ }
280
+ const values = args;
281
+ if (name === "list_files") {
282
+ const requested = requireString(values.path, "path");
283
+ const directory = await resolveWorkspacePath(workspace, requested, { mustExist: true });
284
+ const entries = await readdir(directory, { withFileTypes: true });
285
+ return JSON.stringify(entries
286
+ .filter((entry) => !IGNORED_ENTRIES.has(entry.name))
287
+ .map((entry) => ({ name: entry.name, type: entry.isDirectory() ? "directory" : "file" })));
288
+ }
289
+ if (name === "read_file") {
290
+ const requested = requireString(values.path, "path");
291
+ const file = await resolveWorkspacePath(workspace, requested, { mustExist: true });
292
+ const content = await readFile(file);
293
+ if (content.byteLength > MAX_FILE_BYTES) {
294
+ throw new Error(`File exceeds the ${MAX_FILE_BYTES}-byte read limit.`);
295
+ }
296
+ return content.toString("utf8");
297
+ }
298
+ if (name === "write_file") {
299
+ const requested = requireString(values.path, "path");
300
+ const content = requireString(values.content, "content");
301
+ const file = await resolveWorkspacePath(workspace, requested);
302
+ const relative = toPosix(path.relative(workspace, file));
303
+ const { preview, truncated } = previewContent(content);
304
+ const approved = await confirm({
305
+ kind: "write",
306
+ path: relative,
307
+ lines: content.split("\n").length,
308
+ bytes: Buffer.byteLength(content, "utf8"),
309
+ preview,
310
+ truncated,
311
+ });
312
+ if (!approved) {
313
+ return "User declined the file write.";
314
+ }
315
+ await mkdir(path.dirname(file), { recursive: true });
316
+ await writeFile(file, content, { encoding: "utf8", flag: "w" });
317
+ onMade({ kind: "file", path: relative });
318
+ return `Wrote ${relative}.`;
319
+ }
320
+ if (name === "run_command") {
321
+ const command = requireString(values.command, "command").trim();
322
+ if (!command)
323
+ throw new Error("Command cannot be empty.");
324
+ const approved = await confirm({ kind: "command", command });
325
+ if (!approved) {
326
+ return "User declined the command.";
327
+ }
328
+ const result = await runPowerShell(command, workspace);
329
+ onMade({ kind: "command", command });
330
+ return result;
331
+ }
332
+ if (name === "get_current_datetime") {
333
+ const now = new Date();
334
+ return JSON.stringify({
335
+ iso: now.toISOString(),
336
+ epochMs: now.getTime(),
337
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
338
+ dayOfWeek: new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(now),
339
+ date: new Intl.DateTimeFormat("en-US", { dateStyle: "full" }).format(now),
340
+ time: new Intl.DateTimeFormat("en-US", { timeStyle: "long" }).format(now),
341
+ });
342
+ }
343
+ if (name === "get_weather") {
344
+ const location = requireString(values.location, "location").trim();
345
+ if (!location)
346
+ throw new Error("location cannot be empty.");
347
+ const geoUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(location)}&count=1&language=en&format=json`;
348
+ const geo = (await fetchJson(geoUrl));
349
+ const place = Array.isArray(geo?.results) ? geo.results[0] : undefined;
350
+ if (!place || typeof place.latitude !== "number" || typeof place.longitude !== "number") {
351
+ throw new Error(`No weather location was found for "${location}".`);
352
+ }
353
+ const forecastUrl = `https://api.open-meteo.com/v1/forecast?latitude=${place.latitude}&longitude=${place.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,weather_code,wind_speed_50m&timezone=auto`;
354
+ const forecast = (await fetchJson(forecastUrl));
355
+ const current = forecast?.current ?? {};
356
+ const rawWeatherCode = typeof current?.weather_code === "number"
357
+ ? current.weather_code
358
+ : Number(current?.weather_code ?? -1);
359
+ const weatherCode = Number.isFinite(rawWeatherCode) ? rawWeatherCode : -1;
360
+ return JSON.stringify({
361
+ location: typeof place.name === "string" && place.name ? place.name : location,
362
+ region: typeof place.admin1 === "string" ? place.admin1 : null,
363
+ country: typeof place.country === "string" ? place.country : null,
364
+ temperatureC: typeof current?.temperature_2m === "number" ? current.temperature_2m : null,
365
+ apparentTemperatureC: typeof current?.apparent_temperature === "number" ? current.apparent_temperature : null,
366
+ humidityPercent: typeof current?.relative_humidity_2m === "number" ? current.relative_humidity_2m : null,
367
+ windKph: typeof current?.wind_speed_50m === "number" ? current.wind_speed_50m : null,
368
+ precipitationMm: typeof current?.precipitation === "number" ? current.precipitation : null,
369
+ isDay: typeof current?.is_day === "number" ? current.is_day : null,
370
+ conditionCode: weatherCode,
371
+ condition: describeWeatherCode(weatherCode),
372
+ });
373
+ }
374
+ if (name === "web_search") {
375
+ const query = requireString(values.query, "query").trim();
376
+ if (!query)
377
+ throw new Error("query cannot be empty.");
378
+ const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
379
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEB_TIMEOUT_MS) });
380
+ if (!response.ok) {
381
+ throw new Error(`The web search service returned HTTP ${response.status} for ${url}.`);
382
+ }
383
+ const html = await response.text();
384
+ const results = parseWebResults(html);
385
+ if (results.length === 0) {
386
+ throw new Error(`The web search returned no results for "${query}".`);
387
+ }
388
+ return JSON.stringify({ query, results });
389
+ }
390
+ if (name === "get_news") {
391
+ const query = typeof values.query === "string" && values.query.trim() ? values.query.trim() : "top news";
392
+ const url = `https://news.google.com/rss/search?q=${encodeURIComponent(query)}&hl=en-US&gl=US&ceid=US:en`;
393
+ const response = await fetch(url, { signal: AbortSignal.timeout(WEB_TIMEOUT_MS) });
394
+ if (!response.ok) {
395
+ throw new Error(`The news service returned HTTP ${response.status} for ${url}.`);
396
+ }
397
+ const xml = await response.text();
398
+ const items = parseNewsItems(xml);
399
+ if (items.length === 0) {
400
+ throw new Error(`The news service returned no headlines for "${query}".`);
401
+ }
402
+ return JSON.stringify({ query, items });
403
+ }
404
+ throw new Error(`Unknown tool: ${name}`);
405
+ }
@@ -0,0 +1,22 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { parseOptions } from "../src/cli-options.js";
4
+ test("parses help and version flags", () => {
5
+ assert.deepEqual(parseOptions([]), {});
6
+ assert.deepEqual(parseOptions(["--help"]), { help: true });
7
+ assert.deepEqual(parseOptions(["-h"]), { help: true });
8
+ assert.deepEqual(parseOptions(["--version"]), { version: true });
9
+ assert.deepEqual(parseOptions(["-v"]), { version: true });
10
+ });
11
+ test("parses an explicit session id", () => {
12
+ assert.deepEqual(parseOptions(["--id", "abc12345"]), { id: "abc12345" });
13
+ assert.deepEqual(parseOptions(["--id=abc12345"]), { id: "abc12345" });
14
+ });
15
+ test("allows --id without a value to resume the latest session", () => {
16
+ assert.deepEqual(parseOptions(["--id"]), { id: true });
17
+ assert.deepEqual(parseOptions(["--id="]), { id: true });
18
+ assert.deepEqual(parseOptions(["--id", "--help"]), { id: true, help: true });
19
+ });
20
+ test("rejects unknown options", () => {
21
+ assert.throws(() => parseOptions(["--wat"]), /Unknown option: --wat/);
22
+ });