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 CHANGED
@@ -1,3 +1,5 @@
1
1
  MIT License
2
2
 
3
3
  Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any
package/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # portal-agent
2
2
 
3
- Drive
3
+ Drive web AI products from the terminal with local tools. Uses a real
4
+ Chromium browser against the provider's own website. No
4
5
  ```
5
6
 
6
7
  npm install -g portal-agent-cli
@@ -15,14 +16,15 @@ portal-agent --workspace F:/projects/my-app
15
16
 
16
17
  ```
17
18
 
18
- Or without installing
19
+ Or without installing:
20
+
19
21
  ```
20
22
 
21
23
  npx portal-agent-cli --workspace F:/projects/my-app
22
24
 
23
25
  ```
24
26
 
25
- On first run, Chrome opens at the provider site. Log in there. The session
27
+ On first run, Chrome
26
28
  ```
27
29
 
28
30
  portal-agent --workspace F:/proj exec "list the files here"
@@ -32,7 +34,10 @@ portal-agent --workspace F:/proj exec "list the files here"
32
34
  ## Tools
33
35
 
34
36
  | Tool | Description |
35
- |-|-|
37
+ |------|-------------|
38
+ | `read_file(path, start_line?, end_line?)` | read with line numbers |
39
+ | `write_file(path, content)` | write a whole file (backs up existing) |
40
+ | `edit_file(path, old_string, new_string, replace_all?)`
36
41
  ```
37
42
 
38
43
  - approval required
@@ -44,12 +49,15 @@ allow? yes / always / no:
44
49
 
45
50
  ```
46
51
 
47
- Answer
52
+ Answering `a` approves that tool for the rest of the session.
53
+
54
+ ## Safety
55
+
56
+ -
48
57
  ```
49
58
 
50
59
  portal-tool: {"tool":"read_file","args":{"path":"notes.txt"}}
51
60
 
52
61
  ```
53
62
 
54
- Portal parses that line, runs the tool locally, and sends the result back
55
- into the same conversation. This
63
+ Portal parses that line, runs the tool locally, and sends
package/lib/args.mjs CHANGED
@@ -35,7 +35,7 @@ export function parseArgs() {
35
35
  else if (t === "--yolo") o.yolo = true;
36
36
  else if (t === "exec") { o.exec = a.slice(i + 1).join(" "); break; }
37
37
  else if (t === "--help" || t === "-h") { help(); process.exit(0); }
38
- else if (t === "--version" || t === "-v") { console.log("2.0.0"); process.exit(0); }
38
+ else if (t === "--version" || t === "-v") { console.log("3.0.1"); process.exit(0); }
39
39
  i++;
40
40
  }
41
41
 
@@ -78,8 +78,10 @@ function help() {
78
78
  console.log(" PORTAL_WORKSPACE same as --workspace");
79
79
  console.log(" PORTAL_PROVIDER default provider");
80
80
  console.log(" PORTAL_BROWSER_PATH explicit browser executable");
81
+ console.log(" PORTAL_DEEPTHINK set to off to skip DeepThink toggle");
81
82
  console.log(" PORTAL_DEBUG print stack traces on error");
82
83
  console.log(" PORTAL_YOLO same as --yolo");
84
+ console.log(" NO_COLOR disable color output");
83
85
  console.log("");
84
86
  console.log(t.bold("EXAMPLES"));
85
87
  console.log(" portal-agent --workspace F:/projects/app");
package/lib/browser.mjs CHANGED
@@ -38,7 +38,7 @@ export function listBrowsers() {
38
38
  return found;
39
39
  }
40
40
 
41
- export async function launch(opts) {
41
+ export async function launch() {
42
42
  const exe = findBrowser();
43
43
  if (!exe) {
44
44
  throw new Error(
@@ -58,8 +58,6 @@ export async function launch(opts) {
58
58
  args: ["--disable-blink-features=AutomationControlled"]
59
59
  });
60
60
 
61
- // Auto-dismiss dialogs (alert, confirm, prompt) so a stray modal cannot
62
- // freeze the session.
63
61
  ctx.on("page", function (page) {
64
62
  page.on("dialog", function (d) {
65
63
  d.dismiss().catch(function () {});
package/lib/config.mjs CHANGED
@@ -63,7 +63,6 @@ export async function loadConfig(args) {
63
63
  throw new Error("workspace does not exist: " + config.ws);
64
64
  }
65
65
 
66
- // Per-project config file
67
66
  const localPath = join(config.ws, "portal.config.json");
68
67
  if (existsSync(localPath)) {
69
68
  try {
@@ -0,0 +1,55 @@
1
+ // Toggle DeepSeek's DeepThink reasoning mode. Without this, DeepSeek answers
2
+ // with the fast model and the output is shorter and less thorough.
3
+ //
4
+ // The button sits in the composer row with a label like "DeepThink" or
5
+ // "Deep Think". After clicking, it gains an active class or aria-pressed=true.
6
+
7
+ export async function enableDeepThink(page) {
8
+ if (process.env.PORTAL_DEEPTHINK === "off") return false;
9
+
10
+ const candidates = page.locator("button, [role=button], div[role=button]");
11
+ let total = 0;
12
+ try {
13
+ total = await candidates.count();
14
+ } catch (e) {
15
+ return false;
16
+ }
17
+ if (total === 0 || total > 400) return false;
18
+
19
+ for (let i = 0; i < total; i++) {
20
+ const btn = candidates.nth(i);
21
+ let text = "";
22
+ try {
23
+ text = ((await btn.textContent()) || "").trim();
24
+ } catch (e) {
25
+ continue;
26
+ }
27
+ if (!text) continue;
28
+
29
+ const isDeepthink = text === "DeepThink" ||
30
+ text === "Deep Think" ||
31
+ text.indexOf("DeepThink") >= 0 ||
32
+ text.indexOf("Deep Think") >= 0;
33
+
34
+ if (!isDeepthink) continue;
35
+
36
+ let pressed = "";
37
+ let cls = "";
38
+ try { pressed = (await btn.getAttribute("aria-pressed")) || ""; } catch (e) {}
39
+ try { cls = (await btn.getAttribute("class")) || ""; } catch (e) {}
40
+
41
+ const alreadyOn = pressed === "true" ||
42
+ /active|selected|on\b|enabled/i.test(cls);
43
+
44
+ if (alreadyOn) return true;
45
+
46
+ try {
47
+ await btn.click({ timeout: 2000 });
48
+ await new Promise(function (r) { setTimeout(r, 400); });
49
+ return true;
50
+ } catch (e) {
51
+ return false;
52
+ }
53
+ }
54
+ return false;
55
+ }
package/lib/exec.mjs CHANGED
@@ -1,32 +1,3 @@
1
1
  import { launch } from "./browser.mjs";
2
2
  import { getProvider } from "./providers.mjs";
3
- import { waitForComposer } from "./wait.mjs";
4
- import { runTurn } from "./turn.mjs";
5
- import { setWorkspace } from "./tools/path.mjs";
6
- import { setIgnore } from "./tools/walk.mjs";
7
- import { loadInstructions } from "./instructions.mjs";
8
- import { info, error } from "./log.mjs";
9
-
10
- export async function runExec(config) {
11
- const provider = getProvider(config.provider);
12
- setWorkspace(config.ws);
13
- setIgnore(config.ignored);
14
-
15
- config.instructionsText = await loadInstructions(config.ws);
16
-
17
- const ctx = await launch();
18
- const page = await ctx.newPage();
19
- page.setDefaultTimeout(30000);
20
-
21
- info("opening " + provider.label + "...");
22
- await page.goto(provider.url);
23
- await waitForComposer(page, provider, config.loginTimeout);
24
-
25
- const session = {
26
- id: "exec-" + Date.now(),
27
- ws: config.ws,
28
- provider: config.provider,
29
- turns: [],
30
- approvedAlways: [],
31
- startedAt: Date.now(),
32
-
3
+ import { waitForComposer } from "./wait
@@ -1,44 +1 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { join } from "node:path";
3
-
4
- const FILENAMES = ["PORTAL.md", "AGENTS.md", ".portal.md", "CLAUDE.md"];
5
-
6
- let cache = null;
7
- let cacheKey = "";
8
-
9
- export async function loadInstructions(ws) {
10
- for (const name of FILENAMES) {
11
- const path = join(ws, name);
12
- let info;
13
- try {
14
- info = await stat(path);
15
- } catch (e) {
16
- continue;
17
- }
18
-
19
- const key = path + ":" + info.mtimeMs;
20
- if (cache && cacheKey === key) return cache;
21
-
22
- try {
23
- const content = await readFile(path, "utf8");
24
- const trimmed = content.trim();
25
- if (!trimmed) return null;
26
- cache = [
27
- "# Project instructions",
28
- "",
29
- "The user placed these instructions in " + name + ". Follow them.",
30
- "",
31
- trimmed
32
- ].join("\n");
33
- cacheKey = key;
34
- return cache;
35
- } catch (e) {
36
- continue;
37
- }
38
- }
39
- return null;
40
- }
41
-
42
- export function activeInstructionFile() {
43
- return cacheKey ? cacheKey.split(":")[0] : null;
44
- }
1
+ import {
package/lib/parse.mjs CHANGED
@@ -17,53 +17,4 @@ function findJsonEnd(s, i) {
17
17
  if (esc) { esc = false; continue; }
18
18
  if (c === "\\") { esc = true; continue; }
19
19
  if (c === String.fromCharCode(34)) { inStr = !inStr; continue; }
20
- if (inStr) continue;
21
- if (c === "{") depth++;
22
- else if (c === "}") {
23
- depth--;
24
- if (!depth) return j + 1;
25
- }
26
- }
27
- return -1;
28
- }
29
-
30
- function repairBackslashes(raw) {
31
- let s = raw;
32
- const bad = ["\\t", "\\h", "\\w", "\\d", "\\p", "\\v", "\\x", "\\c"];
33
- for (const b of bad) s = s.split(b).join("/");
34
- return s;
35
- }
36
-
37
- export function parseCalls(text) {
38
- const out = [];
39
- const seen = new Set();
40
- const re = /portal-tool[:\s]*/g;
41
- let m;
42
- while ((m = re.exec(text))) {
43
- const b = text.indexOf("{", m.index + m[0].length);
44
- if (b < 0) continue;
45
- const e = findJsonEnd(text, b);
46
- if (e < 0) continue;
47
- const raw = text.slice(b, e);
48
- if (seen.has(raw)) continue;
49
- seen.add(raw);
50
- try {
51
- out.push(JSON.parse(raw));
52
- } catch (x) {
53
- try {
54
- out.push(JSON.parse(repairBackslashes(raw)));
55
- } catch (y) {
56
- // skip malformed
57
- }
58
- }
59
- }
60
- return out;
61
- }
62
-
63
- export function stripCalls(text) {
64
- return text.replace(/portal-tool[\s\S]*?\n\}/g, "").trim();
65
- }
66
-
67
- export function hasToolCall(text) {
68
- return /portal-tool[:\s]*\{/.test(text);
69
- }
20
+ if (inStr
package/lib/paste.mjs CHANGED
@@ -53,8 +53,6 @@ export async function dismissPaste(page) {
53
53
  return false;
54
54
  }
55
55
 
56
- // Poll the composer for emptiness. A thrown inputValue() means the composer
57
- // is a contenteditable with no inputValue method, so treat that as sent.
58
56
  export async function composerEmpty(input, ms) {
59
57
  const end = Date.now() + ms;
60
58
  while (Date.now() < end) {
package/lib/providers.mjs CHANGED
@@ -7,7 +7,7 @@ export const PROVIDERS = {
7
7
  submit: ["button[aria-label=Send]", "button[data-testid=send-button]"],
8
8
  response: ["div.ds-markdown", "div[class*=message]"],
9
9
  paste: true,
10
- notes: "DeepSeek renders the user prompt with the same class as the reply."
10
+ deepthink: true
11
11
  },
12
12
  chatgpt: {
13
13
  id: "chatgpt",
@@ -17,7 +17,7 @@ export const PROVIDERS = {
17
17
  submit: ["button[data-testid=send-button]", "button[aria-label*=Send]"],
18
18
  response: ["[data-message-author-role=assistant]", "article[data-testid^=conversation-turn]"],
19
19
  paste: false,
20
- notes: "ChatGPT does not show a paste dialog."
20
+ deepthink: false
21
21
  },
22
22
  gemini: {
23
23
  id: "gemini",
@@ -27,7 +27,7 @@ export const PROVIDERS = {
27
27
  submit: ["button[aria-label*=Send]", "button[aria-label=Send message]"],
28
28
  response: ["message-content", "div[class*=model-response-text]"],
29
29
  paste: false,
30
- notes: "Gemini uses a rich-textarea web component."
30
+ deepthink: false
31
31
  },
32
32
  grok: {
33
33
  id: "grok",
@@ -37,7 +37,7 @@ export const PROVIDERS = {
37
37
  submit: ["button[type=submit]", "button[aria-label*=Send]"],
38
38
  response: ["div[class*=message]", "div[class*=response]"],
39
39
  paste: false,
40
- notes: "Grok selectors are best-effort and may need updating."
40
+ deepthink: false
41
41
  }
42
42
  };
43
43
 
package/lib/repl.mjs CHANGED
@@ -1,286 +1,4 @@
1
1
  import { createInterface } from "node:readline";
2
2
  import { existsSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
- import { launch } from "./browser.mjs";
5
- import { getProvider, listProviders } from "./providers.mjs";
6
- import { waitForComposer } from "./wait.mjs";
7
- import { runTurn } from "./turn.mjs";
8
- import { setWorkspace, getWorkspace } from "./tools/path.mjs";
9
- import { setIgnore } from "./tools/walk.mjs";
10
- import { loadInstructions, activeInstructionFile } from "./instructions.mjs";
11
- import {
12
- newSessionId,
13
- saveSession,
14
- loadSession,
15
- listSessions
16
- } from "./sessions.mjs";
17
- import { theme } from "./theme.mjs";
18
- import { info, warn, error, ok } from "./log.mjs";
19
-
20
- export async function runRepl(config) {
21
- const provider = getProvider(config.provider);
22
- setWorkspace(config.ws);
23
- setIgnore(config.ignored);
24
-
25
- const instructionsText = await loadInstructions(config.ws);
26
- config.instructionsText = instructionsText;
27
-
28
- // Session handling
29
- let session;
30
- if (config.resume) {
31
- session = await loadSession(config.resume);
32
- if (!session) {
33
- warn("session not found: " + config.resume + ". starting fresh.");
34
- session = newSession(config);
35
- }
36
- } else {
37
- session = newSession(config);
38
- }
39
-
40
- // Banner
41
- process.stdout.write("\n");
42
- process.stdout.write(theme.bold(theme.cyan("Portal")) + theme.dim(" v2.0.0") + "\n");
43
- process.stdout.write(theme.dim("workspace: ") + theme.accent(getWorkspace()) + "\n");
44
- process.stdout.write(theme.dim("provider: ") + theme.accent(provider.label) + "\n");
45
- process.stdout.write(theme.dim("session: ") + theme.accent(session.id) + "\n");
46
- if (instructionsText) {
47
- process.stdout.write(theme.dim("instructions: ") +
48
- theme.accent(activeInstructionFile() || "?") + "\n");
49
- }
50
- if (config.yolo) {
51
- process.stdout.write(theme.warn("yolo mode: all tool calls auto-approved") + "\n");
52
- }
53
- process.stdout.write("\n");
54
-
55
- const ctx = await launch();
56
- const page = await ctx.newPage();
57
- page.setDefaultTimeout(30000);
58
-
59
- info("opening " + provider.label + "...");
60
- await page.goto(provider.url);
61
-
62
- process.stdout.write("\n");
63
- process.stdout.write(theme.yellow("Log in to " + provider.label +
64
- " in the Chrome window.") + "\n");
65
- process.stdout.write(theme.dim("Waiting up to " +
66
- Math.round(config.loginTimeout / 1000) + "s.") + "\n");
67
- process.stdout.write("\n");
68
-
69
- await waitForComposer(page, provider, config.loginTimeout);
70
-
71
- ok(provider.label + " ready");
72
- process.stdout.write(theme.dim("type /help for commands") + "\n");
73
- process.stdout.write("\n");
74
-
75
- // REPL
76
- const rl = createInterface({
77
- input: process.stdin,
78
- output: process.stdout,
79
- prompt: theme.user("> ")
80
- });
81
- rl.prompt();
82
-
83
- let busy = false;
84
- const queue = [];
85
-
86
- async function drain() {
87
- if (busy) return;
88
- const next = queue.shift();
89
- if (!next) return;
90
- busy = true;
91
- try {
92
- await runTurn(page, provider, next, config, session);
93
- await saveSession(session.id, session);
94
- } catch (e) {
95
- error(e.message || String(e));
96
- }
97
- busy = false;
98
- process.stdout.write("\n");
99
- rl.prompt();
100
- if (queue.length > 0) drain();
101
- }
102
-
103
- rl.on("line", async function (line) {
104
- const t = line.trim();
105
- if (!t) { rl.prompt(); return; }
106
-
107
- // Slash commands
108
- if (t === "/quit" || t === "/exit") { rl.close(); return; }
109
- if (t === "/help" || t === "/?") { showHelp(); rl.prompt(); return; }
110
- if (t === "/pwd") { process.stdout.write(getWorkspace() + "\n"); rl.prompt(); return; }
111
- if (t === "/queue") { showQueue(queue); rl.prompt(); return; }
112
- if (t === "/status") { showStatus(config, session, queue, busy); rl.prompt(); return; }
113
- if (t === "/history") { showHistory(session); rl.prompt(); return; }
114
- if (t === "/clear") { clearScreen(); rl.prompt(); return; }
115
- if (t === "/sessions") { await showSessions(); rl.prompt(); return; }
116
- if (t === "/providers") { showProviders(); rl.prompt(); return; }
117
- if (t.slice(0, 4) === "/cd ") { changeDir(t.slice(4).trim()); rl.prompt(); return; }
118
- if (t.slice(0, 10) === "/provider ") {
119
- await changeProvider(t.slice(10).trim(), page, config, provider);
120
- rl.prompt();
121
- return;
122
- }
123
-
124
- // Queue or execute
125
- if (busy) {
126
- queue.push(t);
127
- process.stdout.write(theme.dim("queued (" + queue.length + " waiting)") + "\n");
128
- rl.prompt();
129
- return;
130
- }
131
- queue.push(t);
132
- drain();
133
- });
134
-
135
- rl.on("close", async function () {
136
- process.stdout.write("\n");
137
- process.stdout.write(theme.dim("saving session...") + "\n");
138
- await saveSession(session.id, session).catch(function () {});
139
- process.stdout.write(theme.dim("closing browser...") + "\n");
140
- await ctx.close().catch(function () {});
141
- process.stdout.write(theme.ok("+ done") + "\n");
142
- process.exit(0);
143
- });
144
- }
145
-
146
- function newSession(config) {
147
- return {
148
- id: newSessionId(),
149
- ws: config.ws,
150
- provider: config.provider,
151
- turns: [],
152
- approvedAlways: [],
153
- startedAt: Date.now(),
154
- updatedAt: Date.now()
155
- };
156
- }
157
-
158
- function showHelp() {
159
- const lines = [
160
- "COMMANDS",
161
- " /help show this",
162
- " /cd <folder> change workspace",
163
- " /pwd show current workspace",
164
- " /provider <name> switch provider",
165
- " /providers list providers",
166
- " /sessions list saved sessions",
167
- " /history show recent turns",
168
- " /queue list tasks waiting to run",
169
- " /status show session status",
170
- " /clear clear the screen",
171
- " /quit exit",
172
- "",
173
- "Anything else is sent to the model as a task.",
174
- "Tasks typed while the model is busy are queued."
175
- ];
176
- for (const l of lines) {
177
- process.stdout.write((l.startsWith(" ") ? theme.dim(l) : theme.bold(l)) + "\n");
178
- }
179
- }
180
-
181
- function showQueue(queue) {
182
- if (!queue.length) {
183
- process.stdout.write(theme.dim("(queue empty)") + "\n");
184
- return;
185
- }
186
- process.stdout.write(theme.bold(queue.length + " task(s) waiting:") + "\n");
187
- queue.forEach(function (x, i) {
188
- process.stdout.write(theme.dim(" " + (i + 1) + ". ") + x + "\n");
189
- });
190
- }
191
-
192
- function showStatus(config, session, queue, busy) {
193
- process.stdout.write(theme.bold("session status") + "\n");
194
- process.stdout.write(theme.dim(" id: ") + session.id + "\n");
195
- process.stdout.write(theme.dim(" workspace: ") + getWorkspace() + "\n");
196
- process.stdout.write(theme.dim(" provider: ") + session.provider + "\n");
197
- process.stdout.write(theme.dim(" turns: ") + session.turns.length + "\n");
198
- process.stdout.write(theme.dim(" busy: ") + (busy ? "yes" : "no") + "\n");
199
- process.stdout.write(theme.dim(" queued: ") + queue.length + "\n");
200
- process.stdout.write(theme.dim(" yolo: ") + (config.yolo ? "on" : "off") + "\n");
201
- }
202
-
203
- function showHistory(session) {
204
- if (!session.turns.length) {
205
- process.stdout.write(theme.dim("(no turns yet)") + "\n");
206
- return;
207
- }
208
- const recent = session.turns.slice(-10);
209
- for (const t of recent) {
210
- const tag = t.role === "user" ? theme.user("you") : theme.cyan("ai");
211
- const text = String(t.text || "").slice(0, 200);
212
- process.stdout.write(tag + ": " + text + "\n");
213
- }
214
- }
215
-
216
- async function showSessions() {
217
- const list = await listSessions();
218
- if (!list.length) {
219
- process.stdout.write(theme.dim("(no saved sessions)") + "\n");
220
- return;
221
- }
222
- process.stdout.write(theme.bold("saved sessions:") + "\n");
223
- for (const s of list.slice(0, 15)) {
224
- const when = timeAgo(s.updatedAt);
225
- process.stdout.write(theme.dim(" " + s.id + " " + when + " " +
226
- s.provider + " " + s.turns + " turns") + "\n");
227
- }
228
- }
229
-
230
- function showProviders() {
231
- const list = listProviders();
232
- process.stdout.write(theme.bold("providers:") + "\n");
233
- for (const p of list) {
234
- process.stdout.write(theme.dim(" " + p.id.padEnd(10)) + " " + p.label + "\n");
235
- }
236
- }
237
-
238
- function changeDir(target) {
239
- if (!target) {
240
- process.stdout.write(theme.dim("usage: /cd <folder>") + "\n");
241
- process.stdout.write(theme.dim("current: ") + getWorkspace() + "\n");
242
- return;
243
- }
244
- const clean = target.replace(/^"|"$/g, "");
245
- const abs = resolve(clean);
246
- if (!existsSync(abs)) {
247
- warn("not found: " + abs);
248
- return;
249
- }
250
- setWorkspace(abs);
251
- ok("workspace: " + getWorkspace());
252
- }
253
-
254
- async function changeProvider(name, page, config, current) {
255
- if (name === current.id) {
256
- process.stdout.write(theme.dim("already on " + name) + "\n");
257
- return;
258
- }
259
- let next;
260
- try {
261
- next = getProvider(name);
262
- } catch (e) {
263
- error(e.message);
264
- return;
265
- }
266
- info("switching to " + next.label + "...");
267
- await page.goto(next.url);
268
- await waitForComposer(page, next, 60000);
269
- config.provider = name;
270
- ok("switched to " + next.label);
271
- }
272
-
273
- function clearScreen() {
274
- process.stdout.write(String.fromCharCode(27) + "[2J");
275
- process.stdout.write(String.fromCharCode(27) + "[H");
276
- }
277
-
278
- function timeAgo(ms) {
279
- const s = Math.floor((Date.now() - ms) / 1000);
280
- if (s < 60) return s + "s ago";
281
- const m = Math.floor(s / 60);
282
- if (m < 60) return m + "m ago";
283
- const h = Math.floor(m / 60);
284
- if (h < 24) return h + "h ago";
285
- return Math.floor(h / 24) + "d ago";
286
- }
4
+ import { launch } from "./