@jc20231028/local-code-agent 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,62 @@
1
+ import path from "node:path";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+
5
+ const execFile = promisify(execFileCallback);
6
+ const CHECK_TIMEOUT_MS = 5000;
7
+ const PYTHON_CANDIDATES = ["python", "python3", "py"];
8
+
9
+ export async function checkSyntax(fullPath) {
10
+ const extension = path.extname(fullPath).toLowerCase();
11
+
12
+ if (extension === ".py") {
13
+ return checkWithPython(fullPath);
14
+ }
15
+
16
+ if (extension === ".js" || extension === ".mjs") {
17
+ return checkWithNode(fullPath);
18
+ }
19
+
20
+ return { checked: false, ok: true, message: null };
21
+ }
22
+
23
+ async function checkWithPython(fullPath) {
24
+ for (const command of PYTHON_CANDIDATES) {
25
+ try {
26
+ await execFile(command, ["-m", "py_compile", fullPath], {
27
+ timeout: CHECK_TIMEOUT_MS,
28
+ windowsHide: true
29
+ });
30
+ return { checked: true, ok: true, message: null };
31
+ } catch (error) {
32
+ if (isMissingCommand(error)) {
33
+ continue;
34
+ }
35
+
36
+ return { checked: true, ok: false, message: extractErrorText(error) };
37
+ }
38
+ }
39
+
40
+ return { checked: false, ok: true, message: null };
41
+ }
42
+
43
+ async function checkWithNode(fullPath) {
44
+ try {
45
+ await execFile(process.execPath, ["--check", fullPath], {
46
+ timeout: CHECK_TIMEOUT_MS,
47
+ windowsHide: true
48
+ });
49
+ return { checked: true, ok: true, message: null };
50
+ } catch (error) {
51
+ return { checked: true, ok: false, message: extractErrorText(error) };
52
+ }
53
+ }
54
+
55
+ function isMissingCommand(error) {
56
+ return error && (error.code === "ENOENT" || error.errno === "ENOENT");
57
+ }
58
+
59
+ function extractErrorText(error) {
60
+ const text = [error?.stderr, error?.stdout, error?.message].filter(Boolean).join("\n").trim();
61
+ return text || "Unknown syntax error.";
62
+ }
package/src/tools.js ADDED
@@ -0,0 +1,105 @@
1
+ import { checkSyntax } from "./syntaxCheck.js";
2
+ import { confirmCommand } from "./ui.js";
3
+
4
+ export function createToolset(workspace) {
5
+ const tools = {
6
+ list_files: {
7
+ description: "List files under the workspace or a subdirectory.",
8
+ args: { path: "string?", limit: "number?" },
9
+ run: async ({ path = ".", limit = 200 }) => workspace.listFiles(path, limit)
10
+ },
11
+ read_file: {
12
+ description: "Read a UTF-8 text file from the workspace.",
13
+ args: { path: "string" },
14
+ run: async ({ path }) => workspace.readFile(path)
15
+ },
16
+ write_file: {
17
+ description: [
18
+ "Create or overwrite a UTF-8 file inside the workspace.",
19
+ "For files longer than ~40 lines, write a small skeleton first (imports and function signatures), then use append_file to add the rest in smaller pieces.",
20
+ "Do not use this to add to a file that already has content you want to keep - use append_file instead, so you never have to retype existing content."
21
+ ].join(" "),
22
+ args: { path: "string", content: "string" },
23
+ run: async ({ path, content }) => withSyntaxCheck(workspace, path, () => workspace.writeFile(path, content))
24
+ },
25
+ append_file: {
26
+ description: "Append content to the end of a file, creating it if it does not exist. Prefer this over write_file when a file already has content you want to keep.",
27
+ args: { path: "string", content: "string" },
28
+ run: async ({ path, content }) => withSyntaxCheck(workspace, path, () => workspace.appendFile(path, content))
29
+ },
30
+ replace_in_file: {
31
+ description: "Replace text inside an existing file.",
32
+ args: {
33
+ path: "string",
34
+ findText: "string",
35
+ replaceText: "string",
36
+ replaceAll: "boolean?"
37
+ },
38
+ run: async (args) => withSyntaxCheck(
39
+ workspace,
40
+ args.path,
41
+ () => workspace.replaceInFile(args.path, args.findText, args.replaceText, args.replaceAll)
42
+ )
43
+ },
44
+ search_text: {
45
+ description: "Search for a plain text query across workspace files.",
46
+ args: { query: "string", path: "string?", limit: "number?" },
47
+ run: async ({ query, path = ".", limit = 50 }) => workspace.searchText(query, path, limit)
48
+ },
49
+ make_directory: {
50
+ description: "Create a directory recursively in the workspace.",
51
+ args: { path: "string" },
52
+ run: async ({ path }) => workspace.makeDirectory(path)
53
+ },
54
+ run_command: {
55
+ description: "Run a local shell command inside the workspace (e.g. dotnet run, npm test, python script.py) to build, test, or execute code. Unless the session was started with --allow-commands, the user is asked to approve each command in the terminal before it runs.",
56
+ args: { command: "string", args: "string[]?" },
57
+ run: async ({ command, args = [] }) => {
58
+ if (workspace.allowCommands) {
59
+ return workspace.runCommand(command, args);
60
+ }
61
+
62
+ const approved = await confirmCommand({ command, args, cwd: workspace.rootPath });
63
+ return workspace.runCommand(command, args, { approved });
64
+ }
65
+ }
66
+ };
67
+
68
+ return {
69
+ tools,
70
+ getManifest() {
71
+ return Object.entries(tools).map(([name, value]) => ({
72
+ name,
73
+ description: value.description,
74
+ args: value.args
75
+ }));
76
+ },
77
+ async execute(name, args, allowedTools = null) {
78
+ const tool = tools[name];
79
+ if (!tool) {
80
+ throw new Error(`Unknown tool: ${name}`);
81
+ }
82
+
83
+ if (allowedTools && !allowedTools.includes(name)) {
84
+ throw new Error(`Tool "${name}" is not available for this task. Allowed tools: ${allowedTools.join(", ")}`);
85
+ }
86
+
87
+ return tool.run(args ?? {});
88
+ }
89
+ };
90
+ }
91
+
92
+ async function withSyntaxCheck(workspace, targetPath, writeAction) {
93
+ const message = await writeAction();
94
+ const result = await checkSyntax(workspace.resolvePath(targetPath));
95
+
96
+ if (!result.checked) {
97
+ return message;
98
+ }
99
+
100
+ if (result.ok) {
101
+ return `${message}\nSyntax OK.`;
102
+ }
103
+
104
+ return `${message}\n⚠️ Syntax check failed:\n${result.message}`;
105
+ }
package/src/ui.js ADDED
@@ -0,0 +1,281 @@
1
+ import readline from "node:readline";
2
+ import readlinePromises from "node:readline/promises";
3
+ import { stdin as input, stdout as output } from "node:process";
4
+
5
+ const ANSI = {
6
+ reset: "\u001b[0m",
7
+ bold: "\u001b[1m",
8
+ dim: "\u001b[2m",
9
+ cyan: "\u001b[36m",
10
+ green: "\u001b[32m",
11
+ yellow: "\u001b[33m",
12
+ red: "\u001b[31m",
13
+ gray: "\u001b[90m"
14
+ };
15
+
16
+ export async function selectMenu({ title, subtitle = "", headerLines = [], options, footer = "" }) {
17
+ if (!isInteractive()) {
18
+ throw new Error("Interactive menu requires a TTY.");
19
+ }
20
+
21
+ const enabledIndexes = options
22
+ .map((option, index) => ({ option, index }))
23
+ .filter((entry) => !entry.option.disabled)
24
+ .map((entry) => entry.index);
25
+
26
+ if (enabledIndexes.length === 0) {
27
+ throw new Error("Interactive menu has no selectable options.");
28
+ }
29
+
30
+ let selectedIndex = enabledIndexes[0];
31
+
32
+ return new Promise((resolve, reject) => {
33
+ readline.emitKeypressEvents(input);
34
+ const previousRawMode = typeof input.setRawMode === "function" ? input.isRaw : false;
35
+ if (typeof input.setRawMode === "function") {
36
+ input.setRawMode(true);
37
+ }
38
+
39
+ const cleanup = () => {
40
+ input.removeListener("keypress", onKeypress);
41
+ if (typeof input.setRawMode === "function") {
42
+ input.setRawMode(previousRawMode);
43
+ }
44
+ output.write("\n");
45
+ };
46
+
47
+ const render = () => {
48
+ console.clear();
49
+ const lines = [];
50
+ lines.push(style(title, "bold"));
51
+ if (subtitle) {
52
+ lines.push(style(subtitle, "dim"));
53
+ }
54
+ if (headerLines.length > 0) {
55
+ lines.push("");
56
+ lines.push(...headerLines);
57
+ }
58
+ lines.push("");
59
+
60
+ for (let index = 0; index < options.length; index += 1) {
61
+ const option = options[index];
62
+ const focused = index === selectedIndex;
63
+ const pointer = focused ? color("> ", "cyan") : " ";
64
+ const label = focused ? style(option.label, "bold") : option.label;
65
+ const status = color(
66
+ option.badgeLabel ?? (option.disabled ? "unavailable" : "ready"),
67
+ option.badgeTone ?? (option.disabled ? "red" : "green")
68
+ );
69
+ lines.push(`${pointer}${label} ${style(`[${status}]`, "dim")}`);
70
+
71
+ if (option.description) {
72
+ lines.push(` ${style(option.description, option.disabled ? "gray" : "dim")}`);
73
+ }
74
+
75
+ if (option.hint) {
76
+ lines.push(` ${style(option.hint, option.disabled ? "yellow" : "gray")}`);
77
+ }
78
+
79
+ lines.push("");
80
+ }
81
+
82
+ if (footer) {
83
+ lines.push(style(footer, "dim"));
84
+ }
85
+
86
+ output.write(lines.join("\n"));
87
+ };
88
+
89
+ const onKeypress = (_, key) => {
90
+ if (!key) {
91
+ return;
92
+ }
93
+
94
+ if (key.ctrl && key.name === "c") {
95
+ cleanup();
96
+ reject(new Error("Cancelled by user."));
97
+ return;
98
+ }
99
+
100
+ if (key.name === "up") {
101
+ selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, -1);
102
+ render();
103
+ return;
104
+ }
105
+
106
+ if (key.name === "down") {
107
+ selectedIndex = moveSelection(options, enabledIndexes, selectedIndex, 1);
108
+ render();
109
+ return;
110
+ }
111
+
112
+ if (key.name === "return") {
113
+ const option = options[selectedIndex];
114
+ cleanup();
115
+ resolve(option.value);
116
+ }
117
+ };
118
+
119
+ input.on("keypress", onKeypress);
120
+ render();
121
+ });
122
+ }
123
+
124
+ export function printSplash({ workspace, command }) {
125
+ if (!isInteractive()) {
126
+ return;
127
+ }
128
+
129
+ console.log(renderStartupDashboard({ workspace, command }));
130
+ }
131
+
132
+ export async function withSpinner(message, task) {
133
+ if (!isInteractive()) {
134
+ return task();
135
+ }
136
+
137
+ const frames = ["|", "/", "-", "\\"];
138
+ let frameIndex = 0;
139
+ const render = () => {
140
+ output.write(`\r${color(frames[frameIndex], "cyan")} ${message}`);
141
+ frameIndex = (frameIndex + 1) % frames.length;
142
+ };
143
+
144
+ render();
145
+ const timer = setInterval(render, 90);
146
+
147
+ try {
148
+ const result = await task();
149
+ clearInterval(timer);
150
+ output.write(`\r${color("OK", "green")} ${message}\n`);
151
+ return result;
152
+ } catch (error) {
153
+ clearInterval(timer);
154
+ output.write(`\r${color("ERR", "red")} ${message}\n`);
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ export async function confirmCommand({ command, args = [], cwd }) {
160
+ if (!isInteractive()) {
161
+ return false;
162
+ }
163
+
164
+ const commandLine = [command, ...args].join(" ");
165
+ output.write(`\n${style("Model wants to run:", "yellow")} ${commandLine}\n`);
166
+ output.write(`${style("Working directory:", "dim")} ${cwd}\n`);
167
+
168
+ const rl = readlinePromises.createInterface({ input, output });
169
+ try {
170
+ const answer = (await rl.question("Allow this command? [y/N]: ")).trim().toLowerCase();
171
+ return answer === "y" || answer === "yes";
172
+ } finally {
173
+ rl.close();
174
+ }
175
+ }
176
+
177
+ export function printNote(message) {
178
+ console.log(style(message, "dim"));
179
+ }
180
+
181
+ export function renderStartupDashboard({
182
+ workspace,
183
+ command,
184
+ lastUsedProvider = "",
185
+ lastUsedModel = "",
186
+ lastTaskSummary = "",
187
+ readyCount,
188
+ totalProviders,
189
+ readyProviders = [],
190
+ recentFiles = []
191
+ }) {
192
+ const lines = [
193
+ style("local-code-agent", "bold"),
194
+ style("Local coding CLI for Ollama and LM Studio", "dim"),
195
+ style("--------------------------------------------------", "gray"),
196
+ `${style("Workspace", "cyan")} : ${workspace}`,
197
+ `${style("Command", "cyan")} : ${command}`
198
+ ];
199
+
200
+ if (lastUsedProvider || lastUsedModel) {
201
+ lines.push(`${style("Last used", "cyan")} : ${formatLastUsed(lastUsedProvider, lastUsedModel)}`);
202
+ } else {
203
+ lines.push(`${style("Last used", "cyan")} : none saved yet`);
204
+ }
205
+
206
+ if (lastTaskSummary) {
207
+ lines.push(`${style("Last task", "cyan")} : ${lastTaskSummary}`);
208
+ }
209
+
210
+ if (typeof readyCount === "number" && typeof totalProviders === "number") {
211
+ lines.push(`${style("Ready now", "cyan")} : ${readyCount}/${totalProviders}`);
212
+ }
213
+
214
+ if (readyProviders.length > 0) {
215
+ lines.push(`${style("Online", "cyan")} : ${readyProviders.join(", ")}`);
216
+ }
217
+
218
+ if (recentFiles.length > 0) {
219
+ lines.push(`${style("Recent files", "cyan")} : ${recentFiles.join(", ")}`);
220
+ }
221
+
222
+ lines.push("");
223
+ return lines.join("\n");
224
+ }
225
+
226
+ export function renderDiagnostics(title, sections) {
227
+ const lines = [style(title, "bold"), ""];
228
+
229
+ for (const section of sections) {
230
+ lines.push(section.heading);
231
+ for (const line of section.lines) {
232
+ lines.push(line);
233
+ }
234
+ lines.push("");
235
+ }
236
+
237
+ return lines.join("\n").trimEnd();
238
+ }
239
+
240
+ export function color(text, tone) {
241
+ if (!supportsAnsi()) {
242
+ return text;
243
+ }
244
+
245
+ const code = ANSI[tone];
246
+ if (!code) {
247
+ return text;
248
+ }
249
+
250
+ return `${code}${text}${ANSI.reset}`;
251
+ }
252
+
253
+ export function style(text, tone) {
254
+ return color(text, tone);
255
+ }
256
+
257
+ export function supportsAnsi() {
258
+ return Boolean(output.isTTY && process.env.TERM !== "dumb");
259
+ }
260
+
261
+ export function isInteractive() {
262
+ return Boolean(input.isTTY && output.isTTY);
263
+ }
264
+
265
+ function moveSelection(options, enabledIndexes, currentIndex, direction) {
266
+ const currentPosition = enabledIndexes.indexOf(currentIndex);
267
+ const nextPosition = (currentPosition + direction + enabledIndexes.length) % enabledIndexes.length;
268
+ const nextIndex = enabledIndexes[nextPosition];
269
+
270
+ if (options[nextIndex]?.disabled) {
271
+ return currentIndex;
272
+ }
273
+
274
+ return nextIndex;
275
+ }
276
+
277
+ function formatLastUsed(provider, model) {
278
+ const savedProvider = provider || "unknown provider";
279
+ const savedModel = model || "no model saved";
280
+ return `${savedProvider} / ${savedModel}`;
281
+ }
@@ -0,0 +1,224 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ const DEFAULT_IGNORES = new Set([
6
+ ".git",
7
+ "node_modules",
8
+ ".DS_Store"
9
+ ]);
10
+
11
+ export class Workspace {
12
+ constructor(rootPath, options = {}) {
13
+ this.rootPath = path.resolve(rootPath);
14
+ this.allowCommands = Boolean(options.allowCommands);
15
+ }
16
+
17
+ resolvePath(targetPath = ".") {
18
+ const normalizedTarget = targetPath === "" ? "." : targetPath;
19
+ const fullPath = path.resolve(this.rootPath, normalizedTarget);
20
+ const relative = path.relative(this.rootPath, fullPath);
21
+
22
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
23
+ throw new Error(`Path escapes workspace: ${targetPath}`);
24
+ }
25
+
26
+ return fullPath;
27
+ }
28
+
29
+ async listFiles(targetPath = ".", limit = 200) {
30
+ const startPath = this.resolvePath(targetPath);
31
+ const results = [];
32
+ await walk(startPath, this.rootPath, results, limit);
33
+ return results;
34
+ }
35
+
36
+ async listRecentFiles(limit = 5) {
37
+ const results = [];
38
+ await walkRecent(this.rootPath, this.rootPath, results);
39
+
40
+ return results
41
+ .sort((left, right) => right.modifiedAt.localeCompare(left.modifiedAt))
42
+ .slice(0, limit);
43
+ }
44
+
45
+ async readFile(targetPath) {
46
+ const fullPath = this.resolvePath(targetPath);
47
+ return fs.readFile(fullPath, "utf8");
48
+ }
49
+
50
+ async writeFile(targetPath, content) {
51
+ const fullPath = this.resolvePath(targetPath);
52
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
53
+ await fs.writeFile(fullPath, content, "utf8");
54
+ return `Wrote ${path.relative(this.rootPath, fullPath)}`;
55
+ }
56
+
57
+ async appendFile(targetPath, content) {
58
+ const fullPath = this.resolvePath(targetPath);
59
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
60
+ await fs.appendFile(fullPath, content, "utf8");
61
+ return `Appended to ${path.relative(this.rootPath, fullPath)}`;
62
+ }
63
+
64
+ async replaceInFile(targetPath, findText, replaceText, replaceAll = false) {
65
+ const fullPath = this.resolvePath(targetPath);
66
+ const current = await fs.readFile(fullPath, "utf8");
67
+ if (!current.includes(findText)) {
68
+ throw new Error(`Text not found in ${targetPath}`);
69
+ }
70
+
71
+ const updated = replaceAll
72
+ ? current.split(findText).join(replaceText)
73
+ : current.replace(findText, replaceText);
74
+ await fs.writeFile(fullPath, updated, "utf8");
75
+ return `Updated ${targetPath}`;
76
+ }
77
+
78
+ async searchText(query, targetPath = ".", limit = 50) {
79
+ const files = await this.listFiles(targetPath, 500);
80
+ const matches = [];
81
+ for (const relativePath of files) {
82
+ if (matches.length >= limit) {
83
+ break;
84
+ }
85
+
86
+ const content = await this.readFile(relativePath);
87
+ const lines = content.split(/\r?\n/);
88
+ for (let index = 0; index < lines.length; index += 1) {
89
+ if (lines[index].includes(query)) {
90
+ matches.push({
91
+ path: relativePath,
92
+ line: index + 1,
93
+ text: lines[index].trim()
94
+ });
95
+ if (matches.length >= limit) {
96
+ break;
97
+ }
98
+ }
99
+ }
100
+ }
101
+
102
+ return matches;
103
+ }
104
+
105
+ async makeDirectory(targetPath) {
106
+ const fullPath = this.resolvePath(targetPath);
107
+ await fs.mkdir(fullPath, { recursive: true });
108
+ return `Created ${path.relative(this.rootPath, fullPath)}`;
109
+ }
110
+
111
+ async runCommand(command, args = [], { approved = false } = {}) {
112
+ if (!this.allowCommands && !approved) {
113
+ throw new Error("Command execution was not approved. Re-run with --allow-commands to skip the prompt, or approve it when asked.");
114
+ }
115
+
116
+ return new Promise((resolve, reject) => {
117
+ const child = spawn(command, args, {
118
+ cwd: this.rootPath,
119
+ shell: process.platform === "win32",
120
+ env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }
121
+ });
122
+
123
+ let stdout = "";
124
+ let stderr = "";
125
+
126
+ child.stdout.on("data", (chunk) => {
127
+ stdout += chunk.toString();
128
+ });
129
+
130
+ child.stderr.on("data", (chunk) => {
131
+ stderr += chunk.toString();
132
+ });
133
+
134
+ child.on("error", (error) => {
135
+ reject(error);
136
+ });
137
+
138
+ child.on("close", (code) => {
139
+ resolve({
140
+ code,
141
+ stdout: stdout.trimEnd(),
142
+ stderr: stderr.trimEnd()
143
+ });
144
+ });
145
+ });
146
+ }
147
+ }
148
+
149
+ const MAX_SCANNED_ENTRIES = 5000;
150
+
151
+ async function walk(currentPath, rootPath, results, limit) {
152
+ if (results.length >= limit) {
153
+ return;
154
+ }
155
+
156
+ let entries;
157
+ try {
158
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
159
+ } catch {
160
+ return;
161
+ }
162
+
163
+ for (const entry of entries) {
164
+ if (results.length >= limit) {
165
+ return;
166
+ }
167
+
168
+ if (DEFAULT_IGNORES.has(entry.name)) {
169
+ continue;
170
+ }
171
+
172
+ const fullPath = path.join(currentPath, entry.name);
173
+ const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
174
+
175
+ if (entry.isDirectory()) {
176
+ await walk(fullPath, rootPath, results, limit);
177
+ continue;
178
+ }
179
+
180
+ results.push(relativePath);
181
+ }
182
+ }
183
+
184
+ async function walkRecent(currentPath, rootPath, results, scanned = { count: 0 }) {
185
+ if (scanned.count >= MAX_SCANNED_ENTRIES) {
186
+ return;
187
+ }
188
+
189
+ let entries;
190
+ try {
191
+ entries = await fs.readdir(currentPath, { withFileTypes: true });
192
+ } catch {
193
+ return;
194
+ }
195
+
196
+ for (const entry of entries) {
197
+ if (scanned.count >= MAX_SCANNED_ENTRIES) {
198
+ return;
199
+ }
200
+
201
+ if (DEFAULT_IGNORES.has(entry.name)) {
202
+ continue;
203
+ }
204
+
205
+ scanned.count += 1;
206
+ const fullPath = path.join(currentPath, entry.name);
207
+ const relativePath = path.relative(rootPath, fullPath).replace(/\\/g, "/");
208
+
209
+ if (entry.isDirectory()) {
210
+ await walkRecent(fullPath, rootPath, results, scanned);
211
+ continue;
212
+ }
213
+
214
+ try {
215
+ const stat = await fs.stat(fullPath);
216
+ results.push({
217
+ path: relativePath,
218
+ modifiedAt: stat.mtime.toISOString()
219
+ });
220
+ } catch {
221
+ // Skip files we can't stat (permissions, broken symlinks, etc).
222
+ }
223
+ }
224
+ }