portal-agent-cli 3.0.0 → 3.0.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.
- package/LICENSE +2 -0
- package/README.md +15 -7
- package/lib/args.mjs +3 -1
- package/lib/browser.mjs +1 -3
- package/lib/config.mjs +0 -1
- package/lib/deepthink.mjs +55 -0
- package/lib/exec.mjs +1 -30
- package/lib/instructions.mjs +1 -44
- package/lib/parse.mjs +1 -50
- package/lib/paste.mjs +0 -2
- package/lib/providers.mjs +4 -4
- package/lib/repl.mjs +1 -283
- package/lib/safety.mjs +1 -64
- package/lib/send.mjs +30 -14
- package/lib/sessions.mjs +1 -75
- package/lib/system.mjs +1 -35
- package/lib/theme.mjs +0 -7
- package/lib/tools/delete.mjs +2 -30
- package/lib/tools/edit.mjs +1 -51
- package/lib/tools/find.mjs +1 -42
- package/lib/tools/git.mjs +1 -48
- package/lib/tools/grep.mjs +1 -43
- package/lib/tools/index.mjs +1 -52
- package/lib/tools/list.mjs +1 -38
- package/lib/tools/path.mjs +1 -22
- package/lib/tools/read.mjs +1 -30
- package/lib/tools/shell.mjs +1 -41
- package/lib/tools/walk.mjs +1 -32
- package/lib/tools/write.mjs +1 -42
- package/lib/turn.mjs +1 -117
- package/package.json +3 -2
- package/portal.mjs +1 -2
package/lib/safety.mjs
CHANGED
|
@@ -1,64 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { theme } from "./theme.mjs";
|
|
3
|
-
|
|
4
|
-
// Tools that touch the filesystem destructively or execute code. These
|
|
5
|
-
// require explicit approval unless --yolo is set.
|
|
6
|
-
export const SENSITIVE = new Set([
|
|
7
|
-
"shell",
|
|
8
|
-
"delete_file",
|
|
9
|
-
"move_file",
|
|
10
|
-
"write_file",
|
|
11
|
-
"edit_file"
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
// Read-only tools. Never prompt.
|
|
15
|
-
export const SAFE = new Set([
|
|
16
|
-
"read_file",
|
|
17
|
-
"list_files",
|
|
18
|
-
"find_files",
|
|
19
|
-
"grep_search",
|
|
20
|
-
"stat_file",
|
|
21
|
-
"git_status",
|
|
22
|
-
"git_diff"
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
export function isSensitive(name) {
|
|
26
|
-
return SENSITIVE.has(name);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function isSafe(name) {
|
|
30
|
-
return SAFE.has(name);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Approval prompt. Reads a single key from stdin.
|
|
34
|
-
export async function askApproval(toolName, args) {
|
|
35
|
-
const preview = JSON.stringify(args || {}, null, 2).slice(0, 500);
|
|
36
|
-
process.stdout.write("\n");
|
|
37
|
-
process.stdout.write(theme.warn("+ approval required") + "\n");
|
|
38
|
-
process.stdout.write(theme.dim(" tool: ") + theme.tool(toolName) + "\n");
|
|
39
|
-
const lines = preview.split("\n").slice(0, 12);
|
|
40
|
-
for (const l of lines) {
|
|
41
|
-
process.stdout.write(theme.dim(" " + l) + "\n");
|
|
42
|
-
}
|
|
43
|
-
if (preview.split("\n").length > 12) {
|
|
44
|
-
process.stdout.write(theme.dim(" ...") + "\n");
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
return new Promise(function (resolve) {
|
|
48
|
-
const rl = createInterface({
|
|
49
|
-
input: process.stdin,
|
|
50
|
-
output: process.stdout
|
|
51
|
-
});
|
|
52
|
-
rl.question(
|
|
53
|
-
theme.bold(" allow? ") + theme.ok("y") + "es / " +
|
|
54
|
-
theme.warn("a") + "lways / " + theme.error("n") + "o: ",
|
|
55
|
-
function (answer) {
|
|
56
|
-
rl.close();
|
|
57
|
-
const a = answer.trim().toLowerCase();
|
|
58
|
-
if (a === "y" || a === "yes") resolve("yes");
|
|
59
|
-
else if (a === "a" || a === "always") resolve("always");
|
|
60
|
-
else resolve("no");
|
|
61
|
-
}
|
|
62
|
-
);
|
|
63
|
-
});
|
|
64
|
-
}
|
|
1
|
+
import
|
package/lib/send.mjs
CHANGED
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
import { sleep, norm, findFirstVisible
|
|
1
|
+
import { sleep, norm, findFirstVisible } from "./wait.mjs";
|
|
2
2
|
import { dismissPaste, composerEmpty } from "./paste.mjs";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { theme } from "./theme.mjs";
|
|
4
|
+
|
|
5
|
+
const SETTLE_MS = 1500;
|
|
6
|
+
const POLL_MS = 150;
|
|
7
|
+
|
|
8
|
+
function makeStreamer() {
|
|
9
|
+
let printed = 0;
|
|
10
|
+
let lastLength = 0;
|
|
11
|
+
return function (text) {
|
|
12
|
+
if (!text) return;
|
|
13
|
+
const t = String(text);
|
|
14
|
+
if (t.length > lastLength) {
|
|
15
|
+
const delta = t.slice(printed);
|
|
16
|
+
process.stdout.write(delta);
|
|
17
|
+
printed = t.length;
|
|
18
|
+
lastLength = t.length;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
5
22
|
|
|
6
23
|
export async function send(page, provider, prompt, timeoutMs) {
|
|
7
24
|
const input = await findFirstVisible(page, provider.input, 30000);
|
|
8
25
|
if (!input) throw new Error("composer not found");
|
|
9
26
|
|
|
10
|
-
const before = await countMatching(page, provider.response);
|
|
11
27
|
const want = norm(prompt);
|
|
12
28
|
|
|
13
29
|
await input.focus();
|
|
@@ -40,8 +56,8 @@ export async function send(page, provider, prompt, timeoutMs) {
|
|
|
40
56
|
if (!ok) {
|
|
41
57
|
throw new Error(
|
|
42
58
|
"Message stayed in composer.\n" +
|
|
43
|
-
"Check
|
|
44
|
-
"
|
|
59
|
+
"Check Chrome: if a Cancel/Send bar is showing, click Send manually, " +
|
|
60
|
+
"then resend."
|
|
45
61
|
);
|
|
46
62
|
}
|
|
47
63
|
|
|
@@ -53,36 +69,36 @@ async function waitForReply(page, provider, want, timeoutMs) {
|
|
|
53
69
|
let last = "";
|
|
54
70
|
let stab = Date.now();
|
|
55
71
|
let sawAnything = false;
|
|
56
|
-
|
|
72
|
+
const stream = makeStreamer();
|
|
57
73
|
|
|
58
|
-
|
|
59
|
-
const count = await countMatching(page, provider.response);
|
|
60
|
-
if (count > lastCount) lastCount = count;
|
|
74
|
+
process.stdout.write("\n" + theme.cyan("* ") + " ");
|
|
61
75
|
|
|
76
|
+
while (Date.now() < end) {
|
|
62
77
|
const node = await findFirstVisible(page, provider.response, 1000);
|
|
63
78
|
if (node) {
|
|
64
79
|
let tx = "";
|
|
65
80
|
try { tx = (await node.textContent()) || ""; } catch (e) { tx = ""; }
|
|
66
81
|
const n = norm(tx);
|
|
67
82
|
|
|
68
|
-
// Skip empty nodes and the echoed prompt.
|
|
69
83
|
if (!n || n === want) {
|
|
70
|
-
await sleep(
|
|
84
|
+
await sleep(POLL_MS);
|
|
71
85
|
continue;
|
|
72
86
|
}
|
|
73
87
|
|
|
74
88
|
if (tx !== last) {
|
|
89
|
+
stream(tx);
|
|
75
90
|
last = tx;
|
|
76
91
|
stab = Date.now();
|
|
77
92
|
sawAnything = true;
|
|
78
93
|
} else if (sawAnything && Date.now() - stab > SETTLE_MS) {
|
|
94
|
+
process.stdout.write("\n\n");
|
|
79
95
|
return tx;
|
|
80
96
|
}
|
|
81
97
|
}
|
|
82
|
-
|
|
83
|
-
await sleep(300);
|
|
98
|
+
await sleep(POLL_MS);
|
|
84
99
|
}
|
|
85
100
|
|
|
101
|
+
process.stdout.write("\n\n");
|
|
86
102
|
if (last) return last;
|
|
87
103
|
throw new Error(
|
|
88
104
|
"Timed out waiting for a reply.\n" +
|
package/lib/sessions.mjs
CHANGED
|
@@ -1,75 +1 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
|
|
5
|
-
function dir() {
|
|
6
|
-
return join(homedir(), ".portal-agent", "sessions");
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export function newSessionId() {
|
|
10
|
-
const d = new Date();
|
|
11
|
-
const p = function (n, w) { return String(n).padStart(w || 2, "0"); };
|
|
12
|
-
return [
|
|
13
|
-
d.getFullYear(),
|
|
14
|
-
p(d.getMonth() + 1),
|
|
15
|
-
p(d.getDate()),
|
|
16
|
-
"-",
|
|
17
|
-
p(d.getHours()),
|
|
18
|
-
p(d.getMinutes()),
|
|
19
|
-
p(d.getSeconds())
|
|
20
|
-
].join("");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function saveSession(id, data) {
|
|
24
|
-
const d = dir();
|
|
25
|
-
await mkdir(d, { recursive: true });
|
|
26
|
-
const path = join(d, id + ".json");
|
|
27
|
-
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function loadSession(id) {
|
|
31
|
-
const path = join(dir(), id + ".json");
|
|
32
|
-
try {
|
|
33
|
-
const raw = await readFile(path, "utf8");
|
|
34
|
-
return JSON.parse(raw);
|
|
35
|
-
} catch (e) {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export async function listSessions() {
|
|
41
|
-
const d = dir();
|
|
42
|
-
let names;
|
|
43
|
-
try {
|
|
44
|
-
names = await readdir(d);
|
|
45
|
-
} catch (e) {
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const metas = [];
|
|
50
|
-
for (const n of names) {
|
|
51
|
-
if (!n.endsWith(".json")) continue;
|
|
52
|
-
const id = n.slice(0, -5);
|
|
53
|
-
const s = await loadSession(id);
|
|
54
|
-
if (!s) continue;
|
|
55
|
-
metas.push({
|
|
56
|
-
id: id,
|
|
57
|
-
ws: s.ws || "",
|
|
58
|
-
provider: s.provider || "",
|
|
59
|
-
turns: (s.turns || []).length,
|
|
60
|
-
updatedAt: s.updatedAt || 0
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
metas.sort(function (a, b) { return b.updatedAt - a.updatedAt; });
|
|
64
|
-
return metas;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export async function deleteSession(id) {
|
|
68
|
-
const path = join(dir(), id + ".json");
|
|
69
|
-
try {
|
|
70
|
-
await unlink(path);
|
|
71
|
-
return true;
|
|
72
|
-
} catch (e) {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
1
|
+
import { mk
|
package/lib/system.mjs
CHANGED
|
@@ -1,38 +1,4 @@
|
|
|
1
1
|
import { CATALOG } from "./tools/index.mjs";
|
|
2
2
|
|
|
3
3
|
export function buildSystem(ws, instructions) {
|
|
4
|
-
const shown = ws.split("\\
|
|
5
|
-
const parts = [];
|
|
6
|
-
|
|
7
|
-
parts.push([
|
|
8
|
-
"You are a coding agent working in a terminal.",
|
|
9
|
-
"Workspace: " + shown,
|
|
10
|
-
"",
|
|
11
|
-
"Rules:",
|
|
12
|
-
"1. Use forward slashes in every path, even on Windows.",
|
|
13
|
-
"2. Do not call the same tool with the same arguments twice.",
|
|
14
|
-
"3. Do not explore. Act. Only call tools you actually need.",
|
|
15
|
-
"4. Read a file before editing it. Never guess contents.",
|
|
16
|
-
"5. When a task is done, stop. Reply with plain prose, no tool calls.",
|
|
17
|
-
"6. If the request is ambiguous, ask one short question. Do not guess.",
|
|
18
|
-
"7. Keep replies short. One sentence is fine. No filler.",
|
|
19
|
-
"8. Prefer edit_file over write_file for changes to existing files."
|
|
20
|
-
].join("\n"));
|
|
21
|
-
|
|
22
|
-
if (instructions) parts.push(instructions);
|
|
23
|
-
|
|
24
|
-
parts.push([
|
|
25
|
-
"Tool call format:",
|
|
26
|
-
"",
|
|
27
|
-
"To call a tool, write on its own line:",
|
|
28
|
-
"portal-tool: {\"tool\":\"name\",\"args\":{\"key\":\"value\"}}",
|
|
29
|
-
"",
|
|
30
|
-
"The JSON must use double quotes. Plain text only, no backticks.",
|
|
31
|
-
"One call per action. Never combine calls inside a code block.",
|
|
32
|
-
"Multiple calls in one reply are allowed, one per line."
|
|
33
|
-
].join("\n"));
|
|
34
|
-
|
|
35
|
-
parts.push("Available tools:\n" + CATALOG);
|
|
36
|
-
|
|
37
|
-
return parts.join("\n\n");
|
|
38
|
-
}
|
|
4
|
+
const shown = ws.split("\\
|
package/lib/theme.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
const E = String.fromCharCode(27);
|
|
2
2
|
|
|
3
3
|
const MODE = process.env.NO_COLOR ? "plain" : "color";
|
|
4
|
-
const FORCE = process.env.PORTAL_THEME || "auto";
|
|
5
4
|
|
|
6
5
|
function c(code) {
|
|
7
6
|
if (MODE === "plain") return "";
|
|
@@ -45,12 +44,6 @@ export const theme = {
|
|
|
45
44
|
underline: function (s) { return c(4) + String(s) + R; }
|
|
46
45
|
};
|
|
47
46
|
|
|
48
|
-
export function isDark() {
|
|
49
|
-
if (FORCE === "dark") return true;
|
|
50
|
-
if (FORCE === "light") return false;
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
47
|
export function colorEnabled() {
|
|
55
48
|
return MODE === "color";
|
|
56
49
|
}
|
package/lib/tools/delete.mjs
CHANGED
|
@@ -1,30 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
// Move a file to a trash directory instead of unlinking. Safer default.
|
|
5
|
-
export async function delete_file(args) {
|
|
6
|
-
const abs = safePath(args.path);
|
|
7
|
-
try {
|
|
8
|
-
const st = await stat(abs);
|
|
9
|
-
if (st.isDirectory()) {
|
|
10
|
-
return "delete_file cannot remove directories: " + args.path;
|
|
11
|
-
}
|
|
12
|
-
// Instead of deleting, rename to .portal-trash
|
|
13
|
-
const trash = abs + ".portal-trash";
|
|
14
|
-
await rename(abs, trash);
|
|
15
|
-
return "moved " + args.path + " to " + args.path + ".portal-trash";
|
|
16
|
-
} catch (e) {
|
|
17
|
-
return "delete_file failed: " + (e.message || e);
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export async function move_file(args) {
|
|
22
|
-
const from = safePath(args.from);
|
|
23
|
-
const to = safePath(args.to);
|
|
24
|
-
try {
|
|
25
|
-
await rename(from, to);
|
|
26
|
-
return "moved " + args.from + " to " + args.to;
|
|
27
|
-
} catch (e) {
|
|
28
|
-
return "move_file failed: " + (e.message || e);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
1
|
+
import { stat, rename } from "node:fs/promises";
|
|
2
|
+
import
|
package/lib/tools/edit.mjs
CHANGED
|
@@ -1,51 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { safePath } from "./path.mjs";
|
|
3
|
-
|
|
4
|
-
// Exact string replace. Refuses ambiguous edits, so the model cannot
|
|
5
|
-
// accidentally replace the wrong occurrence.
|
|
6
|
-
export async function edit_file(args) {
|
|
7
|
-
const abs = safePath(args.path);
|
|
8
|
-
const oldStr = String(args.old_string || "");
|
|
9
|
-
const newStr = String(args.new_string || "");
|
|
10
|
-
|
|
11
|
-
if (!oldStr) return "edit_file requires old_string";
|
|
12
|
-
if (oldStr === newStr) return "old_string and new_string are identical";
|
|
13
|
-
|
|
14
|
-
const orig = await readFile(abs, "utf8");
|
|
15
|
-
const count = orig.split(oldStr).length - 1;
|
|
16
|
-
|
|
17
|
-
if (count === 0) {
|
|
18
|
-
return "old_string not found in " + args.path + "\n" +
|
|
19
|
-
"Read the file first with read_file to see exact contents.";
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
if (count > 1 && !args.replace_all) {
|
|
23
|
-
return "old_string appears " + count + " times in " + args.path + "\n" +
|
|
24
|
-
"Add more surrounding context to make it unique, or set replace_all:true.";
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const next = args.replace_all
|
|
28
|
-
? orig.split(oldStr).join(newStr)
|
|
29
|
-
: orig.replace(oldStr, newStr);
|
|
30
|
-
|
|
31
|
-
await writeFile(abs + ".portal-bak", orig, "utf8");
|
|
32
|
-
await writeFile(abs, next, "utf8");
|
|
33
|
-
|
|
34
|
-
const delta = next.length - orig.length;
|
|
35
|
-
const noun = args.replace_all ? count + " occurrences" : "1 occurrence";
|
|
36
|
-
return "edited " + args.path + ": replaced " + noun +
|
|
37
|
-
" (" + (delta >= 0 ? "+" : "") + delta + " bytes)";
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// Restore the last backup for a file, if one exists.
|
|
41
|
-
export async function undo_file(args) {
|
|
42
|
-
const abs = safePath(args.path);
|
|
43
|
-
const backup = abs + ".portal-bak";
|
|
44
|
-
try {
|
|
45
|
-
const content = await readFile(backup, "utf8");
|
|
46
|
-
await writeFile(abs, content, "utf8");
|
|
47
|
-
return "restored " + args.path + " from backup";
|
|
48
|
-
} catch (e) {
|
|
49
|
-
return "no backup found for " + args.path;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
1
|
+
import
|
package/lib/tools/find.mjs
CHANGED
|
@@ -1,42 +1 @@
|
|
|
1
|
-
import { relative, sep } from "
|
|
2
|
-
import { walk } from "./walk.mjs";
|
|
3
|
-
import { safePath, toPosix, getWorkspace } from "./path.mjs";
|
|
4
|
-
|
|
5
|
-
function globToRegex(glob) {
|
|
6
|
-
let re = "";
|
|
7
|
-
for (let i = 0; i < glob.length; i++) {
|
|
8
|
-
const c = glob[i];
|
|
9
|
-
if (c === "*") {
|
|
10
|
-
if (glob[i + 1] === "*") { re += ".*"; i++; }
|
|
11
|
-
else re += "[^/\\\\]*";
|
|
12
|
-
} else if (c === "?") {
|
|
13
|
-
re += ".";
|
|
14
|
-
} else if (".+^$(){}[]|\\".indexOf(c) >= 0) {
|
|
15
|
-
re += "\\" + c;
|
|
16
|
-
} else {
|
|
17
|
-
re += c;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return new RegExp("^" + re + "$", "i");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function find_files(args) {
|
|
24
|
-
const pattern = args.pattern || "*";
|
|
25
|
-
const re = globToRegex(pattern);
|
|
26
|
-
const ws = getWorkspace();
|
|
27
|
-
const hits = [];
|
|
28
|
-
const MAX = 200;
|
|
29
|
-
|
|
30
|
-
for await (const f of walk(ws)) {
|
|
31
|
-
if (hits.length >= MAX) break;
|
|
32
|
-
const rel = toPosix(relative(ws, f));
|
|
33
|
-
const base = f.split(sep).pop();
|
|
34
|
-
if (re.test(rel) || re.test(base)) {
|
|
35
|
-
hits.push(rel);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!hits.length) return "no files matched: " + pattern;
|
|
40
|
-
hits.sort();
|
|
41
|
-
return hits.length + " file(s):\n" + hits.join("\n");
|
|
42
|
-
}
|
|
1
|
+
import { relative, sep } from "
|
package/lib/tools/git.mjs
CHANGED
|
@@ -2,51 +2,4 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { getWorkspace } from "./path.mjs";
|
|
3
3
|
|
|
4
4
|
function run(args) {
|
|
5
|
-
return new
|
|
6
|
-
const child = spawn("git", args, {
|
|
7
|
-
cwd: getWorkspace(),
|
|
8
|
-
shell: false,
|
|
9
|
-
windowsHide: true
|
|
10
|
-
});
|
|
11
|
-
let out = "";
|
|
12
|
-
let err = "";
|
|
13
|
-
if (child.stdout) child.stdout.on("data", function (d) { out += String(d); });
|
|
14
|
-
if (child.stderr) child.stderr.on("data", function (d) { err += String(d); });
|
|
15
|
-
const timer = setTimeout(function () {
|
|
16
|
-
child.kill();
|
|
17
|
-
done({ code: -1, out: out, err: "timed out" });
|
|
18
|
-
}, 15000);
|
|
19
|
-
child.on("error", function (e) {
|
|
20
|
-
clearTimeout(timer);
|
|
21
|
-
done({ code: -1, out: "", err: e.message });
|
|
22
|
-
});
|
|
23
|
-
child.on("close", function (code) {
|
|
24
|
-
clearTimeout(timer);
|
|
25
|
-
done({ code: code, out: out, err: err });
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export async function git_status() {
|
|
31
|
-
const r = await run(["status", "--short", "--branch"]);
|
|
32
|
-
if (r.code !== 0) return "not a git repository, or git is not installed";
|
|
33
|
-
return r.out.trim() || "working tree clean";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function git_diff(args) {
|
|
37
|
-
const cmd = ["diff"];
|
|
38
|
-
if (args && args.staged) cmd.push("--cached");
|
|
39
|
-
if (args && args.path) cmd.push("--", args.path);
|
|
40
|
-
const r = await run(cmd);
|
|
41
|
-
if (r.code !== 0) return r.err || "git diff failed";
|
|
42
|
-
const out = r.out.trim();
|
|
43
|
-
if (!out) return "no changes";
|
|
44
|
-
return out.length > 30000 ? out.slice(0, 30000) + "\n[truncated]" : out;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function git_log(args) {
|
|
48
|
-
const n = (args && args.count) || 10;
|
|
49
|
-
const r = await run(["log", "--oneline", "-" + n]);
|
|
50
|
-
if (r.code !== 0) return "not a git repository";
|
|
51
|
-
return r.out.trim() || "no commits";
|
|
52
|
-
}
|
|
5
|
+
return new
|
package/lib/tools/grep.mjs
CHANGED
|
@@ -1,44 +1,2 @@
|
|
|
1
1
|
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import { relative
|
|
3
|
-
import { walk } from "./walk.mjs";
|
|
4
|
-
import { safePath, toPosix, getWorkspace } from "./path.mjs";
|
|
5
|
-
|
|
6
|
-
const MAX_RESULTS = 80;
|
|
7
|
-
const MAX_FILE_BYTES = 512000;
|
|
8
|
-
|
|
9
|
-
export async function grep_search(args) {
|
|
10
|
-
const pattern = String(args.pattern || "");
|
|
11
|
-
if (!pattern) return "grep_search requires a pattern";
|
|
12
|
-
|
|
13
|
-
let re;
|
|
14
|
-
try {
|
|
15
|
-
re = new RegExp(pattern, args.case_sensitive ? "" : "i");
|
|
16
|
-
} catch (e) {
|
|
17
|
-
return "bad regex: " + e.message;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const ws = getWorkspace();
|
|
21
|
-
const startPath = args.path ? safePath(args.path) : ws;
|
|
22
|
-
const hits = [];
|
|
23
|
-
|
|
24
|
-
for await (const f of walk(startPath)) {
|
|
25
|
-
if (hits.length >= MAX_RESULTS) break;
|
|
26
|
-
let info;
|
|
27
|
-
try { info = await stat(f); } catch (e) { continue; }
|
|
28
|
-
if (info.size > MAX_FILE_BYTES) continue;
|
|
29
|
-
|
|
30
|
-
let text;
|
|
31
|
-
try { text = await readFile(f, "utf8"); } catch (e) { continue; }
|
|
32
|
-
const lines = text.split("\n");
|
|
33
|
-
|
|
34
|
-
for (let i = 0; i < lines.length && hits.length < MAX_RESULTS; i++) {
|
|
35
|
-
if (re.test(lines[i])) {
|
|
36
|
-
const rel = toPosix(relative(ws, f));
|
|
37
|
-
hits.push(rel + ":" + (i + 1) + ":" + lines[i].trim().slice(0, 200));
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!hits.length) return "no matches for: " + pattern;
|
|
43
|
-
return hits.length + " matches:\n" + hits.join("\n");
|
|
44
|
-
}
|
|
2
|
+
import { relative }
|
package/lib/tools/index.mjs
CHANGED
|
@@ -1,53 +1,2 @@
|
|
|
1
1
|
import { read_file } from "./read.mjs";
|
|
2
|
-
import { write_file, append_file }
|
|
3
|
-
import { edit_file, undo_file } from "./edit.mjs";
|
|
4
|
-
import { list_files } from "./list.mjs";
|
|
5
|
-
import { find_files } from "./find.mjs";
|
|
6
|
-
import { grep_search } from "./grep.mjs";
|
|
7
|
-
import { delete_file, move_file } from "./delete.mjs";
|
|
8
|
-
import { shell } from "./shell.mjs";
|
|
9
|
-
import { git_status, git_diff, git_log } from "./git.mjs";
|
|
10
|
-
|
|
11
|
-
export const TOOLS = {
|
|
12
|
-
read_file: read_file,
|
|
13
|
-
write_file: write_file,
|
|
14
|
-
edit_file: edit_file,
|
|
15
|
-
append_file: append_file,
|
|
16
|
-
undo_file: undo_file,
|
|
17
|
-
list_files: list_files,
|
|
18
|
-
find_files: find_files,
|
|
19
|
-
grep_search: grep_search,
|
|
20
|
-
delete_file: delete_file,
|
|
21
|
-
move_file: move_file,
|
|
22
|
-
shell: shell,
|
|
23
|
-
git_status: git_status,
|
|
24
|
-
git_diff: git_diff,
|
|
25
|
-
git_log: git_log
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
export const SAFE_TOOLS = new Set([
|
|
29
|
-
"read_file",
|
|
30
|
-
"list_files",
|
|
31
|
-
"find_files",
|
|
32
|
-
"grep_search",
|
|
33
|
-
"git_status",
|
|
34
|
-
"git_diff",
|
|
35
|
-
"git_log"
|
|
36
|
-
]);
|
|
37
|
-
|
|
38
|
-
export const CATALOG = [
|
|
39
|
-
"- read_file(path, start_line?, end_line?) read file with line numbers",
|
|
40
|
-
"- write_file(path, content) write or overwrite a whole file",
|
|
41
|
-
"- edit_file(path, old_string, new_string, replace_all?) exact find and replace",
|
|
42
|
-
"- append_file(path, content) add text to the end of a file",
|
|
43
|
-
"- undo_file(path) restore the last backup",
|
|
44
|
-
"- list_files(path?) list a directory with sizes",
|
|
45
|
-
"- find_files(pattern) glob search, e.g. **/*.ts",
|
|
46
|
-
"- grep_search(pattern, path?, case_sensitive?) regex inside files",
|
|
47
|
-
"- delete_file(path) move a file to .portal-trash",
|
|
48
|
-
"- move_file(from, to) rename or relocate a file",
|
|
49
|
-
"- shell(command) run a shell command",
|
|
50
|
-
"- git_status() show git status",
|
|
51
|
-
"- git_diff(path?, staged?) show uncommitted changes",
|
|
52
|
-
"- git_log(count?) recent commits"
|
|
53
|
-
].join("\n");
|
|
2
|
+
import { write_file, append_file }
|
package/lib/tools/list.mjs
CHANGED
|
@@ -1,38 +1 @@
|
|
|
1
|
-
import { readdir, stat } from "node:fs/
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { safePath } from "./path.mjs";
|
|
4
|
-
|
|
5
|
-
export async function list_files(args) {
|
|
6
|
-
const abs = safePath(args.path || ".");
|
|
7
|
-
const entries = await readdir(abs, { withFileTypes: true });
|
|
8
|
-
|
|
9
|
-
if (!entries.length) {
|
|
10
|
-
return "EMPTY. This directory has no files or subdirectories. " +
|
|
11
|
-
"Do not call list_files again on this path.";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const rows = [];
|
|
15
|
-
for (const e of entries) {
|
|
16
|
-
const full = join(abs, e.name);
|
|
17
|
-
let size = "";
|
|
18
|
-
if (e.isFile()) {
|
|
19
|
-
try {
|
|
20
|
-
const st = await stat(full);
|
|
21
|
-
size = formatSize(st.size);
|
|
22
|
-
} catch (err) {
|
|
23
|
-
size = "";
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
const tag = e.isDirectory() ? "[dir] " : " ";
|
|
27
|
-
rows.push(tag + e.name.padEnd(40) + " " + size);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
rows.sort();
|
|
31
|
-
return rows.join("\n");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function formatSize(bytes) {
|
|
35
|
-
if (bytes < 1024) return bytes + " B";
|
|
36
|
-
if (bytes < 1024 * 1024) return Math.round(bytes / 1024) + " KB";
|
|
37
|
-
return (bytes / 1024 / 1024).toFixed(1) + " MB";
|
|
38
|
-
}
|
|
1
|
+
import { readdir, stat } from "node:fs/prom
|
package/lib/tools/path.mjs
CHANGED
|
@@ -1,22 +1 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
let ws = process.cwd();
|
|
4
|
-
|
|
5
|
-
export function setWorkspace(p) { ws = resolve(p); }
|
|
6
|
-
export function getWorkspace() { return ws; }
|
|
7
|
-
|
|
8
|
-
export function safePath(p) {
|
|
9
|
-
if (!p || typeof p !== "string") {
|
|
10
|
-
throw new Error("path must be a non-empty string");
|
|
11
|
-
}
|
|
12
|
-
const abs = resolve(ws, p);
|
|
13
|
-
const rel = relative(ws, abs);
|
|
14
|
-
if (rel === ".." || rel.slice(0, 3) === "..\\" || rel.slice(0, 3) === "../") {
|
|
15
|
-
throw new Error("path escapes workspace: " + p);
|
|
16
|
-
}
|
|
17
|
-
return abs;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function toPosix(p) {
|
|
21
|
-
return p.split(sep).join("/");
|
|
22
|
-
}
|
|
1
|
+
import
|
package/lib/tools/read.mjs
CHANGED
|
@@ -1,33 +1,4 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
2
|
import { safePath } from "./path.mjs";
|
|
3
3
|
|
|
4
|
-
const MAX_BYTES =
|
|
5
|
-
|
|
6
|
-
export async function read_file(args) {
|
|
7
|
-
const abs = safePath(args.path);
|
|
8
|
-
const text = await readFile(abs, "utf8");
|
|
9
|
-
|
|
10
|
-
if (text.length > MAX_BYTES) {
|
|
11
|
-
return text.slice(0, MAX_BYTES) + "\n[truncated at " + MAX_BYTES + " bytes]";
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const lines = text.split("\n");
|
|
15
|
-
const start = Math.max(1, Number(args.start_line) || 1);
|
|
16
|
-
const end = Math.min(lines.length, Number(args.end_line) || lines.length);
|
|
17
|
-
|
|
18
|
-
if (start > end) {
|
|
19
|
-
return "invalid range: start_line " + start + " is after end_line " + end;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const width = String(end).length;
|
|
23
|
-
const out = [];
|
|
24
|
-
for (let i = start - 1; i < end; i++) {
|
|
25
|
-
out.push(String(i + 1).padStart(width) + "| " + lines[i]);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const header = start === 1 && end === lines.length
|
|
29
|
-
? lines.length + " lines total"
|
|
30
|
-
: "lines " + start + "-" + end + " of " + lines.length;
|
|
31
|
-
|
|
32
|
-
return "[" + header + "]\n" + out.join("\n");
|
|
33
|
-
}
|
|
4
|
+
const MAX_BYTES =
|