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/lib/safety.mjs CHANGED
@@ -1,64 +1 @@
1
- import { createInterface } from "node:readline";
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, countMatching } from "./wait.mjs";
1
+ import { sleep, norm, findFirstVisible } from "./wait.mjs";
2
2
  import { dismissPaste, composerEmpty } from "./paste.mjs";
3
-
4
- const SETTLE_MS = 2000;
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 the Chrome window. If a Cancel/Send bar is showing, click Send " +
44
- "once manually, then resend."
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
- let lastCount = 0;
72
+ const stream = makeStreamer();
57
73
 
58
- while (Date.now() < end) {
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(300);
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 { mkdir, readFile, readdir, writeFile, unlink } from "node:fs/promises";
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("\\").join("/");
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
  }
@@ -1,30 +1,2 @@
1
- import { unlink, stat, rename } from "node:fs/promises";
2
- import { safePath } from "./path.mjs";
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
@@ -1,51 +1 @@
1
- import { readFile, writeFile } from "node:fs/promises";
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
@@ -1,42 +1 @@
1
- import { relative, sep } from "node:path";
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 Promise(function (done) {
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
@@ -1,44 +1,2 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import { relative, sep } from "node:path";
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 }
@@ -1,53 +1,2 @@
1
1
  import { read_file } from "./read.mjs";
2
- import { write_file, append_file } from "./write.mjs";
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 }
@@ -1,38 +1 @@
1
- import { readdir, stat } from "node:fs/promises";
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
@@ -1,22 +1 @@
1
- import { resolve, relative, sep } from "node:path";
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
@@ -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 = 500000;
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 =