portal-agent-cli 3.0.0 → 3.0.2

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.
@@ -36,18 +36,18 @@ export const SAFE_TOOLS = new Set([
36
36
  ]);
37
37
 
38
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"
39
+ "read_file(path, start_line?, end_line?)",
40
+ "write_file(path, content)",
41
+ "edit_file(path, old_string, new_string, replace_all?)",
42
+ "append_file(path, content)",
43
+ "undo_file(path)",
44
+ "list_files(path?)",
45
+ "find_files(pattern)",
46
+ "grep_search(pattern, path?, case_sensitive?)",
47
+ "delete_file(path)",
48
+ "move_file(from, to)",
49
+ "shell(command)",
50
+ "git_status()",
51
+ "git_diff(path?, staged?)",
52
+ "git_log(count?)"
53
53
  ].join("\n");
@@ -7,8 +7,7 @@ export async function list_files(args) {
7
7
  const entries = await readdir(abs, { withFileTypes: true });
8
8
 
9
9
  if (!entries.length) {
10
- return "EMPTY. This directory has no files or subdirectories. " +
11
- "Do not call list_files again on this path.";
10
+ return "EMPTY. Do not call list_files again on this path.";
12
11
  }
13
12
 
14
13
  const rows = [];
@@ -19,9 +18,7 @@ export async function list_files(args) {
19
18
  try {
20
19
  const st = await stat(full);
21
20
  size = formatSize(st.size);
22
- } catch (err) {
23
- size = "";
24
- }
21
+ } catch (err) {}
25
22
  }
26
23
  const tag = e.isDirectory() ? "[dir] " : " ";
27
24
  rows.push(tag + e.name.padEnd(40) + " " + size);
@@ -8,16 +8,14 @@ export async function read_file(args) {
8
8
  const text = await readFile(abs, "utf8");
9
9
 
10
10
  if (text.length > MAX_BYTES) {
11
- return text.slice(0, MAX_BYTES) + "\n[truncated at " + MAX_BYTES + " bytes]";
11
+ return text.slice(0, MAX_BYTES) + "\n[truncated]";
12
12
  }
13
13
 
14
14
  const lines = text.split("\n");
15
15
  const start = Math.max(1, Number(args.start_line) || 1);
16
16
  const end = Math.min(lines.length, Number(args.end_line) || lines.length);
17
17
 
18
- if (start > end) {
19
- return "invalid range: start_line " + start + " is after end_line " + end;
20
- }
18
+ if (start > end) return "invalid range";
21
19
 
22
20
  const width = String(end).length;
23
21
  const out = [];
@@ -26,7 +24,7 @@ export async function read_file(args) {
26
24
  }
27
25
 
28
26
  const header = start === 1 && end === lines.length
29
- ? lines.length + " lines total"
27
+ ? lines.length + " lines"
30
28
  : "lines " + start + "-" + end + " of " + lines.length;
31
29
 
32
30
  return "[" + header + "]\n" + out.join("\n");
@@ -4,9 +4,26 @@ import { getWorkspace } from "./path.mjs";
4
4
  const MAX_OUTPUT = 65000;
5
5
  const TIMEOUT_MS = 60000;
6
6
 
7
+ function normalizeWinCommand(cmd) {
8
+ if (process.platform !== "win32") return cmd;
9
+ return cmd.replace(/(\S+)/g, function (token) {
10
+ if (token.indexOf("://") >= 0) return token;
11
+ if (/^\/[a-zA-Z](\s|$)/.test(token)) return token;
12
+ if (/^[A-Za-z]:\//.test(token) || (token.indexOf("/") > 0 && token.indexOf("://") < 0)) {
13
+ const leading = token.startsWith('"') ? '"' : "";
14
+ const trailing = token.endsWith('"') ? '"' : "";
15
+ const body = token.slice(leading.length, token.length - trailing.length);
16
+ return leading + body.split("/").join("\\") + trailing;
17
+ }
18
+ return token;
19
+ });
20
+ }
21
+
7
22
  export function shell(args) {
8
- const cmd = String(args.command || "");
9
- if (!cmd) return Promise.resolve("shell requires a command");
23
+ const raw = String(args.command || "");
24
+ if (!raw) return Promise.resolve("shell requires a command");
25
+
26
+ const cmd = normalizeWinCommand(raw);
10
27
 
11
28
  return new Promise(function (done) {
12
29
  const ws = getWorkspace();
@@ -26,7 +43,7 @@ export function shell(args) {
26
43
 
27
44
  const timer = setTimeout(function () {
28
45
  child.kill();
29
- done("timed out after " + (TIMEOUT_MS / 1000) + "s\n" + stderr);
46
+ done("timed out\n" + stderr);
30
47
  }, TIMEOUT_MS);
31
48
 
32
49
  child.on("error", function (err) {
@@ -38,6 +55,7 @@ export function shell(args) {
38
55
  clearTimeout(timer);
39
56
  const both = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
40
57
  let s = "exit " + (code === null ? "null" : code) + "\n" + (both || "(no output)");
58
+ if (cmd !== raw) s += "\n[normalized: " + cmd + "]";
41
59
  if (s.length > MAX_OUTPUT) s = s.slice(0, MAX_OUTPUT) + "\n[truncated]";
42
60
  done(s);
43
61
  });
@@ -13,8 +13,6 @@ export function setIgnore(names) {
13
13
  ignore = new Set(names || []);
14
14
  }
15
15
 
16
- // Depth-first file walker. Yields full absolute paths. Skips ignored dirs
17
- // and hidden files (except .env.example which is often relevant).
18
16
  export async function* walk(dir) {
19
17
  let items;
20
18
  try {
@@ -2,40 +2,29 @@ import { writeFile, mkdir, readFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { safePath } from "./path.mjs";
4
4
 
5
- // Write a whole file. Creates parent directories. If the file already
6
- // exists, saves a backup first.
7
5
  export async function write_file(args) {
8
6
  const abs = safePath(args.path);
9
7
  const content = args.content || "";
10
8
  await mkdir(dirname(abs), { recursive: true });
11
9
 
12
- // Backup existing file
13
10
  try {
14
11
  const existing = await readFile(abs, "utf8");
15
12
  if (existing !== content) {
16
- const backup = abs + ".portal-bak";
17
- await writeFile(backup, existing, "utf8");
13
+ await writeFile(abs + ".portal-bak", existing, "utf8");
18
14
  }
19
- } catch (e) {
20
- // new file, no backup needed
21
- }
15
+ } catch (e) {}
22
16
 
23
17
  await writeFile(abs, content, "utf8");
24
18
  const lines = content.length === 0 ? 0 : content.split("\n").length;
25
19
  return "wrote " + content.length + " bytes (" + lines + " lines) to " + args.path;
26
20
  }
27
21
 
28
- // Append to end of a file, creating it if missing.
29
22
  export async function append_file(args) {
30
23
  const abs = safePath(args.path);
31
24
  await mkdir(dirname(abs), { recursive: true });
32
25
 
33
26
  let existing = "";
34
- try {
35
- existing = await readFile(abs, "utf8");
36
- } catch (e) {
37
- existing = "";
38
- }
27
+ try { existing = await readFile(abs, "utf8"); } catch (e) { existing = ""; }
39
28
 
40
29
  const content = args.content || "";
41
30
  const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
package/lib/turn.mjs CHANGED
@@ -16,8 +16,7 @@ export async function runTurn(page, provider, prompt, config, session) {
16
16
 
17
17
  const seen = new Map();
18
18
  let next = sys + "\n\nTask: " + prompt;
19
- let approvedAlways = new Set(session.approvedAlways || []);
20
- const newTools = [];
19
+ const approvedAlways = new Set(session.approvedAlways || []);
21
20
 
22
21
  for (let step = 0; step < config.maxSteps; step++) {
23
22
  status("waiting for " + provider.label + "...");
@@ -44,75 +43,57 @@ export async function runTurn(page, provider, prompt, config, session) {
44
43
 
45
44
  if (n >= REPEAT_STOP) {
46
45
  process.stdout.write(theme.error("x loop detected: " + c.tool +
47
- " called " + n + " times with same args. stopping.") + "\n");
46
+ " repeated. stopping.") + "\n");
48
47
  stopped = true;
49
48
  break;
50
49
  }
51
50
 
52
51
  if (n === REPEAT_WARN) {
53
- process.stdout.write(theme.warn("! " + c.tool + " repeated " + n +
54
- " times. warning the model.") + "\n");
52
+ process.stdout.write(theme.warn("! " + c.tool + " repeated.") + "\n");
55
53
  }
56
54
 
57
55
  logTool(c.tool, c.args);
58
56
 
59
- // Approval gate for sensitive tools
60
- const needsApproval = !config.yolo &&
57
+ const needs = !config.yolo &&
61
58
  !SAFE_TOOLS.has(c.tool) &&
62
59
  !approvedAlways.has(c.tool);
63
60
 
64
- if (needsApproval) {
65
- const decision = await askApproval(c.tool, c.args);
66
- if (decision === "no") {
61
+ if (needs) {
62
+ const d = await askApproval(c.tool, c.args);
63
+ if (d === "no") {
67
64
  const msg = "user denied " + c.tool;
68
65
  toolResult(msg, true);
69
66
  results.push("Tool: " + c.tool + "\nStatus: denied\n" + msg);
70
67
  continue;
71
68
  }
72
- if (decision === "always") {
73
- approvedAlways.add(c.tool);
74
- newTools.push(c.tool);
75
- }
69
+ if (d === "always") approvedAlways.add(c.tool);
76
70
  }
77
71
 
78
72
  let out;
79
- let isError = false;
73
+ let isErr = false;
80
74
  try {
81
75
  const fn = TOOLS[c.tool];
82
- if (!fn) {
83
- out = "unknown tool: " + c.tool;
84
- isError = true;
85
- } else {
86
- out = await fn(c.args || {});
87
- if (typeof out === "string" && out.indexOf("failed") === 0) isError = true;
88
- }
76
+ if (!fn) { out = "unknown tool: " + c.tool; isErr = true; }
77
+ else out = await fn(c.args || {});
89
78
  } catch (e) {
90
79
  out = "error: " + (e.message || e);
91
- isError = true;
80
+ isErr = true;
92
81
  }
93
82
 
94
- toolResult(out, isError);
83
+ toolResult(out, isErr);
95
84
  results.push("Tool: " + c.tool + "\nResult:\n" + out);
96
85
  }
97
86
 
98
87
  if (stopped) {
99
88
  session.turns.push({ role: "user", text: prompt });
100
- session.turns.push({ role: "assistant", text: "(stopped: loop detected)" });
89
+ session.turns.push({ role: "assistant", text: "(stopped: loop)" });
101
90
  session.updatedAt = Date.now();
102
91
  return;
103
92
  }
104
93
 
105
- const footer = [
106
- "Results above.",
107
- "Do not repeat any call with the same arguments.",
108
- "If the task is complete, reply with plain prose and no tool calls."
109
- ].join("\n");
110
-
111
- next = "Tool results:\n\n" + results.join("\n\n") + "\n\n" + footer;
94
+ next = "Tool results:\n\n" + results.join("\n\n") +
95
+ "\n\nContinue. Do not repeat any call.";
112
96
  }
113
97
 
114
- process.stdout.write(theme.warn("max steps reached for this turn.") + "\n");
115
- session.turns.push({ role: "user", text: prompt });
116
- session.turns.push({ role: "assistant", text: "(max steps reached)" });
117
- session.updatedAt = Date.now();
98
+ process.stdout.write(theme.warn("max steps reached.") + "\n");
118
99
  }
package/lib/wait.mjs CHANGED
@@ -13,9 +13,7 @@ export async function findFirstVisible(page, selectors, ms) {
13
13
  const loc = page.locator(sel).last();
14
14
  try {
15
15
  if (await loc.isVisible({ timeout: 200 })) return loc;
16
- } catch (e) {
17
- // try next selector
18
- }
16
+ } catch (e) {}
19
17
  }
20
18
  await sleep(200);
21
19
  }
@@ -24,29 +22,26 @@ export async function findFirstVisible(page, selectors, ms) {
24
22
 
25
23
  export async function waitForComposer(page, provider, ms) {
26
24
  const end = Date.now() + ms;
27
- let lastHint = Date.now();
25
+ let hint = Date.now();
28
26
  while (Date.now() < end) {
29
27
  for (const sel of provider.input) {
30
28
  const loc = page.locator(sel).last();
31
29
  let ok = false;
32
30
  try {
33
31
  ok = await loc.isVisible({ timeout: 200 });
34
- } catch (e) {
35
- ok = false;
36
- }
32
+ } catch (e) {}
37
33
  if (ok) return loc;
38
34
  }
39
- if (Date.now() - lastHint > 15000) {
35
+ if (Date.now() - hint > 15000) {
40
36
  const left = Math.round((end - Date.now()) / 1000);
41
37
  process.stdout.write(" waiting for login (" + left + "s left)\n");
42
- lastHint = Date.now();
38
+ hint = Date.now();
43
39
  }
44
40
  await sleep(1000);
45
41
  }
46
42
  throw new Error(
47
- "Timed out waiting for the composer.\n" +
48
- "Log in to the provider in the Chrome window and try again, " +
49
- "or raise --login-timeout."
43
+ "Timed out waiting for the composer. Log in to the provider " +
44
+ "and try again, or raise --login-timeout."
50
45
  );
51
46
  }
52
47
 
@@ -55,9 +50,7 @@ export async function countMatching(page, selectors) {
55
50
  try {
56
51
  const n = await page.locator(sel).count();
57
52
  if (n > 0) return n;
58
- } catch (e) {
59
- // try next
60
- }
53
+ } catch (e) {}
61
54
  }
62
55
  return 0;
63
56
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "portal-agent-cli",
3
- "version": "3.0.0",
4
- "description": "Drive web AI products from the terminal with local tools. Multi-provider, session persistence, approval gate, undo.",
3
+ "version": "3.0.2",
4
+ "description": "Drive web AI products from the terminal with local tools.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "portal-agent": "portal.mjs"
@@ -29,9 +29,7 @@
29
29
  "ai",
30
30
  "agent",
31
31
  "deepseek",
32
- "chatgpt",
33
- "gemini",
34
- "playwright"
32
+ "chatgpt"
35
33
  ],
36
34
  "license": "MIT"
37
35
  }
package/portal.mjs CHANGED
@@ -1,10 +1,4 @@
1
1
  #!/usr/bin/env node
2
- //
3
- // portal-agent -- drive web AI products from the terminal with local tools.
4
- //
5
- // Entry point. Loads config, parses arguments, and dispatches to either the
6
- // interactive REPL or the one-shot exec mode.
7
- //
8
2
  import { parseArgs } from "./lib/args.mjs";
9
3
  import { loadConfig } from "./lib/config.mjs";
10
4
  import { runRepl } from "./lib/repl.mjs";
@@ -14,23 +8,16 @@ import { theme } from "./lib/theme.mjs";
14
8
  async function main() {
15
9
  const opts = parseArgs();
16
10
  const config = await loadConfig(opts);
17
-
18
11
  if (config.verbose) {
19
12
  process.stdout.write(theme.dim("config: " + JSON.stringify(config, null, 2)) + "\n");
20
13
  }
21
-
22
- if (opts.exec) {
23
- await runExec(config);
24
- } else {
25
- await runRepl(config);
26
- }
14
+ if (opts.exec) await runExec(config);
15
+ else await runRepl(config);
27
16
  }
28
17
 
29
18
  main().catch(function (e) {
30
19
  const msg = e && e.message ? e.message : String(e);
31
20
  process.stderr.write(theme.error("fatal: " + msg) + "\n");
32
- if (process.env.PORTAL_DEBUG) {
33
- process.stderr.write(e.stack + "\n");
34
- }
21
+ if (process.env.PORTAL_DEBUG) process.stderr.write(e.stack + "\n");
35
22
  process.exit(1);
36
23
  });