@jc20231028/local-code-agent 0.1.0 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +290 -263
- package/bin/local-code.js +8 -8
- package/package.json +34 -32
- package/scripts/postinstall.js +14 -0
- package/src/agent.js +0 -0
- package/src/checkpoint.js +125 -125
- package/src/cli.js +964 -932
- package/src/config.js +108 -106
- package/src/providers/lmstudio.js +49 -49
- package/src/providers/ollama.js +52 -52
- package/src/runtime.js +341 -341
- package/src/skills.js +130 -130
- package/src/syntaxCheck.js +62 -62
- package/src/tools.js +130 -105
- package/src/ui.js +313 -281
- package/src/workspace.js +235 -224
package/src/skills.js
CHANGED
|
@@ -1,130 +1,130 @@
|
|
|
1
|
-
import fs from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
|
|
5
|
-
export const RESERVED_NAMES = new Set(["exit", "provider", "model", "status", "skills", "reset"]);
|
|
6
|
-
|
|
7
|
-
export function parseSkillFile(raw, sourcePath, scope) {
|
|
8
|
-
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
9
|
-
if (!frontmatterMatch) {
|
|
10
|
-
throw new Error(`Missing frontmatter in ${sourcePath}`);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const [, frontmatterBlock, body] = frontmatterMatch;
|
|
14
|
-
const fields = {};
|
|
15
|
-
for (const line of frontmatterBlock.split(/\r?\n/)) {
|
|
16
|
-
if (!line.trim()) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const separatorIndex = line.indexOf(":");
|
|
21
|
-
if (separatorIndex === -1) {
|
|
22
|
-
continue;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const key = line.slice(0, separatorIndex).trim();
|
|
26
|
-
const value = line.slice(separatorIndex + 1).trim();
|
|
27
|
-
fields[key] = value;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
if (!fields.name) {
|
|
31
|
-
throw new Error(`Missing "name" field in ${sourcePath}`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
if (!fields.description) {
|
|
35
|
-
throw new Error(`Missing "description" field in ${sourcePath}`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
name: fields.name,
|
|
40
|
-
description: fields.description,
|
|
41
|
-
keywords: parseList(fields.keywords),
|
|
42
|
-
tools: fields.tools ? parseList(fields.tools) : null,
|
|
43
|
-
body: body.trim(),
|
|
44
|
-
sourcePath,
|
|
45
|
-
scope
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function loadSkills(workspace, options = {}) {
|
|
50
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
51
|
-
const skills = new Map();
|
|
52
|
-
|
|
53
|
-
await loadSkillsFromDir(path.join(homeDir, ".local-code", "skills"), "user", skills);
|
|
54
|
-
await loadSkillsFromDir(path.join(workspace, ".local-code", "skills"), "project", skills);
|
|
55
|
-
|
|
56
|
-
return skills;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function matchSkillInvocation(prompt, skills) {
|
|
60
|
-
const match = prompt.match(/^\/(\S+)\s*([\s\S]*)$/);
|
|
61
|
-
if (!match) {
|
|
62
|
-
return { type: "none" };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const [, token, rest] = match;
|
|
66
|
-
const skill = skills.get(token.toLowerCase());
|
|
67
|
-
if (!skill) {
|
|
68
|
-
return { type: "unknown", token };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return { type: "skill", skill, rest: rest.trim() };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function buildSkillPrompt(skill, rest) {
|
|
75
|
-
return [`[Skill: ${skill.name}]`, skill.body, "", `Task: ${rest}`].join("\n");
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function loadSkillsFromDir(dirPath, scope, skills) {
|
|
79
|
-
let entries;
|
|
80
|
-
try {
|
|
81
|
-
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
82
|
-
} catch {
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
for (const entry of entries) {
|
|
87
|
-
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
88
|
-
continue;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const fullPath = path.join(dirPath, entry.name);
|
|
92
|
-
|
|
93
|
-
let skill;
|
|
94
|
-
try {
|
|
95
|
-
const raw = await fs.readFile(fullPath, "utf8");
|
|
96
|
-
skill = parseSkillFile(raw, fullPath, scope);
|
|
97
|
-
} catch (error) {
|
|
98
|
-
console.error(`Skipping invalid skill file ${fullPath}: ${error.message}`);
|
|
99
|
-
continue;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
const lowerName = skill.name.toLowerCase();
|
|
103
|
-
if (RESERVED_NAMES.has(lowerName)) {
|
|
104
|
-
console.error(`Skipping skill "${skill.name}" in ${fullPath}: name is reserved.`);
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
skills.set(lowerName, skill);
|
|
109
|
-
for (const keyword of skill.keywords) {
|
|
110
|
-
const lowerKeyword = keyword.toLowerCase();
|
|
111
|
-
if (RESERVED_NAMES.has(lowerKeyword)) {
|
|
112
|
-
console.error(`Skipping keyword "${keyword}" for skill "${skill.name}": name is reserved.`);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
skills.set(lowerKeyword, skill);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function parseList(value) {
|
|
122
|
-
if (!value) {
|
|
123
|
-
return [];
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return value
|
|
127
|
-
.split(",")
|
|
128
|
-
.map((item) => item.trim())
|
|
129
|
-
.filter(Boolean);
|
|
130
|
-
}
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const RESERVED_NAMES = new Set(["exit", "provider", "model", "status", "skills", "reset"]);
|
|
6
|
+
|
|
7
|
+
export function parseSkillFile(raw, sourcePath, scope) {
|
|
8
|
+
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
9
|
+
if (!frontmatterMatch) {
|
|
10
|
+
throw new Error(`Missing frontmatter in ${sourcePath}`);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const [, frontmatterBlock, body] = frontmatterMatch;
|
|
14
|
+
const fields = {};
|
|
15
|
+
for (const line of frontmatterBlock.split(/\r?\n/)) {
|
|
16
|
+
if (!line.trim()) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const separatorIndex = line.indexOf(":");
|
|
21
|
+
if (separatorIndex === -1) {
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
26
|
+
const value = line.slice(separatorIndex + 1).trim();
|
|
27
|
+
fields[key] = value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!fields.name) {
|
|
31
|
+
throw new Error(`Missing "name" field in ${sourcePath}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!fields.description) {
|
|
35
|
+
throw new Error(`Missing "description" field in ${sourcePath}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: fields.name,
|
|
40
|
+
description: fields.description,
|
|
41
|
+
keywords: parseList(fields.keywords),
|
|
42
|
+
tools: fields.tools ? parseList(fields.tools) : null,
|
|
43
|
+
body: body.trim(),
|
|
44
|
+
sourcePath,
|
|
45
|
+
scope
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function loadSkills(workspace, options = {}) {
|
|
50
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
51
|
+
const skills = new Map();
|
|
52
|
+
|
|
53
|
+
await loadSkillsFromDir(path.join(homeDir, ".local-code", "skills"), "user", skills);
|
|
54
|
+
await loadSkillsFromDir(path.join(workspace, ".local-code", "skills"), "project", skills);
|
|
55
|
+
|
|
56
|
+
return skills;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function matchSkillInvocation(prompt, skills) {
|
|
60
|
+
const match = prompt.match(/^\/(\S+)\s*([\s\S]*)$/);
|
|
61
|
+
if (!match) {
|
|
62
|
+
return { type: "none" };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const [, token, rest] = match;
|
|
66
|
+
const skill = skills.get(token.toLowerCase());
|
|
67
|
+
if (!skill) {
|
|
68
|
+
return { type: "unknown", token };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return { type: "skill", skill, rest: rest.trim() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function buildSkillPrompt(skill, rest) {
|
|
75
|
+
return [`[Skill: ${skill.name}]`, skill.body, "", `Task: ${rest}`].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function loadSkillsFromDir(dirPath, scope, skills) {
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
92
|
+
|
|
93
|
+
let skill;
|
|
94
|
+
try {
|
|
95
|
+
const raw = await fs.readFile(fullPath, "utf8");
|
|
96
|
+
skill = parseSkillFile(raw, fullPath, scope);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`Skipping invalid skill file ${fullPath}: ${error.message}`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const lowerName = skill.name.toLowerCase();
|
|
103
|
+
if (RESERVED_NAMES.has(lowerName)) {
|
|
104
|
+
console.error(`Skipping skill "${skill.name}" in ${fullPath}: name is reserved.`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
skills.set(lowerName, skill);
|
|
109
|
+
for (const keyword of skill.keywords) {
|
|
110
|
+
const lowerKeyword = keyword.toLowerCase();
|
|
111
|
+
if (RESERVED_NAMES.has(lowerKeyword)) {
|
|
112
|
+
console.error(`Skipping keyword "${keyword}" for skill "${skill.name}": name is reserved.`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
skills.set(lowerKeyword, skill);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseList(value) {
|
|
122
|
+
if (!value) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return value
|
|
127
|
+
.split(",")
|
|
128
|
+
.map((item) => item.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
package/src/syntaxCheck.js
CHANGED
|
@@ -1,62 +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
|
-
}
|
|
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
CHANGED
|
@@ -1,105 +1,130 @@
|
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
1
|
+
import { checkSyntax } from "./syntaxCheck.js";
|
|
2
|
+
import { confirmCommand, confirmWrite } 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
|
+
"Unless the session was started with --allow-writes, the user is asked to approve each file change in the terminal before it is written."
|
|
22
|
+
].join(" "),
|
|
23
|
+
args: { path: "string", content: "string" },
|
|
24
|
+
run: async ({ path, content }) => {
|
|
25
|
+
const approved = await approveWrite(workspace, { action: "write", path, preview: content });
|
|
26
|
+
return withSyntaxCheck(workspace, path, () => workspace.writeFile(path, content, { approved }));
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
append_file: {
|
|
30
|
+
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.",
|
|
31
|
+
args: { path: "string", content: "string" },
|
|
32
|
+
run: async ({ path, content }) => {
|
|
33
|
+
const approved = await approveWrite(workspace, { action: "append", path, preview: content });
|
|
34
|
+
return withSyntaxCheck(workspace, path, () => workspace.appendFile(path, content, { approved }));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
replace_in_file: {
|
|
38
|
+
description: "Replace text inside an existing file.",
|
|
39
|
+
args: {
|
|
40
|
+
path: "string",
|
|
41
|
+
findText: "string",
|
|
42
|
+
replaceText: "string",
|
|
43
|
+
replaceAll: "boolean?"
|
|
44
|
+
},
|
|
45
|
+
run: async (args) => {
|
|
46
|
+
const approved = await approveWrite(workspace, {
|
|
47
|
+
action: "replace",
|
|
48
|
+
path: args.path,
|
|
49
|
+
preview: `Find:\n${args.findText}\nReplace with:\n${args.replaceText}`
|
|
50
|
+
});
|
|
51
|
+
return withSyntaxCheck(
|
|
52
|
+
workspace,
|
|
53
|
+
args.path,
|
|
54
|
+
() => workspace.replaceInFile(args.path, args.findText, args.replaceText, args.replaceAll, { approved })
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
search_text: {
|
|
59
|
+
description: "Search for a plain text query across workspace files.",
|
|
60
|
+
args: { query: "string", path: "string?", limit: "number?" },
|
|
61
|
+
run: async ({ query, path = ".", limit = 50 }) => workspace.searchText(query, path, limit)
|
|
62
|
+
},
|
|
63
|
+
make_directory: {
|
|
64
|
+
description: "Create a directory recursively in the workspace.",
|
|
65
|
+
args: { path: "string" },
|
|
66
|
+
run: async ({ path }) => {
|
|
67
|
+
const approved = await approveWrite(workspace, { action: "mkdir", path });
|
|
68
|
+
return workspace.makeDirectory(path, { approved });
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
run_command: {
|
|
72
|
+
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.",
|
|
73
|
+
args: { command: "string", args: "string[]?" },
|
|
74
|
+
run: async ({ command, args = [] }) => {
|
|
75
|
+
if (workspace.allowCommands) {
|
|
76
|
+
return workspace.runCommand(command, args);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const approved = await confirmCommand({ command, args, cwd: workspace.rootPath });
|
|
80
|
+
return workspace.runCommand(command, args, { approved });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
tools,
|
|
87
|
+
getManifest() {
|
|
88
|
+
return Object.entries(tools).map(([name, value]) => ({
|
|
89
|
+
name,
|
|
90
|
+
description: value.description,
|
|
91
|
+
args: value.args
|
|
92
|
+
}));
|
|
93
|
+
},
|
|
94
|
+
async execute(name, args, allowedTools = null) {
|
|
95
|
+
const tool = tools[name];
|
|
96
|
+
if (!tool) {
|
|
97
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (allowedTools && !allowedTools.includes(name)) {
|
|
101
|
+
throw new Error(`Tool "${name}" is not available for this task. Allowed tools: ${allowedTools.join(", ")}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return tool.run(args ?? {});
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function approveWrite(workspace, { action, path, preview }) {
|
|
110
|
+
if (workspace.allowWrites) {
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return confirmWrite({ action, path, preview, cwd: workspace.rootPath });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function withSyntaxCheck(workspace, targetPath, writeAction) {
|
|
118
|
+
const message = await writeAction();
|
|
119
|
+
const result = await checkSyntax(workspace.resolvePath(targetPath));
|
|
120
|
+
|
|
121
|
+
if (!result.checked) {
|
|
122
|
+
return message;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (result.ok) {
|
|
126
|
+
return `${message}\nSyntax OK.`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return `${message}\n⚠️ Syntax check failed:\n${result.message}`;
|
|
130
|
+
}
|