mindvest-atlas 0.23.2 → 0.26.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.23.2",
3
+ "version": "0.26.0",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,6 +22,7 @@ import {
22
22
  beatRunner,
23
23
  setPaused, writeHelpers, askModel, listModels, chatModel, answerPermission,
24
24
  createProject, ensureProjectFiles, listChats, readChat, deleteChat, appendChat,
25
+ computerUseAllowed,
25
26
  readSchedule, cronMatches, dueMinutes, scheduleMatches, scheduleDueMinutes, writeNext,
26
27
  SCRIPTS_DIR, projectsForAlert, readMode,
27
28
  } from '../remote-control.js';
@@ -381,6 +382,17 @@ async function runJob(job, root, timeoutMs) {
381
382
  ensureProjectFiles(dir);
382
383
 
383
384
  eprintln(color.dim(`▶ ${name}`));
385
+ // MAY THIS RUN USE THE COMPUTER? Read per run, from this machine's own
386
+ // settings, so turning the switch off takes effect at the next job rather
387
+ // than at the next restart. The helper refuses without it and writes the
388
+ // refusal down; nothing here pretends that is a sandbox, only that there is
389
+ // one path that asks first and keeps a record.
390
+ const computerEnv = computerUseAllowed(root)
391
+ ? {
392
+ ATLAS_COMPUTER_USE: '1',
393
+ ATLAS_COMPUTER_SESSION: new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19),
394
+ }
395
+ : {};
384
396
  const env = {
385
397
  ...process.env,
386
398
  // Secrets first (shared .env, then the job's own), so a job's own env can
@@ -398,6 +410,8 @@ async function runJob(job, root, timeoutMs) {
398
410
  // one job's questions stay one conversation and do not blend into another's.
399
411
  ATLAS_JOB: path.basename(dir),
400
412
  ATLAS_JOB_PAYLOAD: JSON.stringify(job.payload || {}),
413
+ // Last, so a job's own env cannot switch this on for itself.
414
+ ...computerEnv,
401
415
  };
402
416
  const r = await runOnce(argv, dir, env, timeoutMs);
403
417
  const finished = Date.now();
@@ -11,6 +11,30 @@ function firstLine(desc) {
11
11
  return '';
12
12
  }
13
13
 
14
+ // Commands this CLI has of its OWN. They are not in the account's tool catalog
15
+ // and never will be, so a search that finds nothing there should still say when
16
+ // one of these is what the person meant (owner, 2026-09-05: an agent concluded
17
+ // there was no screenshot tool while `atlas screenshot` was right there).
18
+ const LOCAL_COMMANDS = {
19
+ screenshot: "atlas screenshot --job <project> [--all] a picture of a project's dashboard, reviewed in words",
20
+ share: 'atlas share <state|publish|off|peek|install|updates> hand a project to someone, or take one in',
21
+ alerts: 'atlas alerts watch your alerts as they fire',
22
+ flow: 'atlas flow options flow, live',
23
+ 'remote-control': "atlas remote-control run this computer's projects",
24
+ 'historical-contract': "atlas historical-contract ... an option's past greeks",
25
+ 'update-cli': 'atlas update-cli fetch the newest Atlas',
26
+ whoami: 'atlas whoami who this computer is signed in as',
27
+ };
28
+
29
+ /** Commands of this CLI whose name or line contains the search term. */
30
+ function localMatches(term) {
31
+ if (!term) return [];
32
+ return Object.entries(LOCAL_COMMANDS)
33
+ .filter(([name, line]) => name.includes(term) || line.toLowerCase().includes(term))
34
+ .sort(([a], [b]) => a.localeCompare(b))
35
+ .map(([, line]) => line);
36
+ }
37
+
14
38
  export async function toolsCommand(args) {
15
39
  const flags = args.flags;
16
40
  const catalog = await fetchCatalog({ force: !!flags.refresh });
@@ -38,7 +62,17 @@ export async function toolsCommand(args) {
38
62
  for (const r of rows) {
39
63
  println(' ' + color.cyan(r.slug.padEnd(width)) + color.dim(r.desc));
40
64
  }
41
- if (!rows.length) println(color.dim(' (no tools match "' + filter + '")'));
65
+ if (!rows.length) {
66
+ println(color.dim(' (no tools match "' + filter + '")'));
67
+ // A COMMAND is not a tool, and looking for one in the catalog is an easy
68
+ // mistake to make: they are both things you type after `atlas`.
69
+ const local = localMatches(filter);
70
+ if (local.length) {
71
+ println('');
72
+ println(color.dim(' Not a tool, but this CLI can do it:'));
73
+ for (const line of local) println(' ' + line);
74
+ }
75
+ }
42
76
  println();
43
77
  println(color.dim('Run ') + color.cyan('atlas <tool> --help') + color.dim(' for parameters.'));
44
78
  return 0;
package/src/config.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
 
5
- export const VERSION = '0.23.2';
5
+ export const VERSION = '0.26.0';
6
6
  export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
7
7
  export const DEFAULT_SCOPE = 'atlas broker';
8
8
  export const CLIENT_NAME = 'Atlas CLI';
@@ -1,7 +1,7 @@
1
1
  // Secrets for this job. Written by Atlas — edits are overwritten.
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
- import { fileURLToPath } from "node:url";
4
+ import url_ from "node:url";
5
5
  //
6
6
  // Values come from the environment Atlas set up before starting this script. Do
7
7
  // NOT open .env: this module is the supported way to reach a secret, and the
@@ -58,7 +58,7 @@ export async function atlas(tool, params = {}) {
58
58
  * starts up once, later questions can refer to earlier ones); fresh:true is for
59
59
  * independent questions that should not pile into one context.
60
60
  */
61
- export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000, model = "" } = {}) {
61
+ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600000 } = {}) {
62
62
  const url = process.env.ATLAS_ASK_URL;
63
63
  const token = process.env.ATLAS_ASK_TOKEN;
64
64
  if (!url || !token) {
@@ -70,12 +70,7 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
70
70
  const res = await fetch(url, {
71
71
  method: "POST",
72
72
  headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
73
- // `model` is sent only when named, so a script that does not care keeps
74
- // whatever is connected rather than pinning itself to today's default.
75
- body: JSON.stringify({
76
- job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh,
77
- ...(String(model || "").trim() ? { model: String(model).trim() } : {}),
78
- }),
73
+ body: JSON.stringify({ job: process.env.ATLAS_JOB || "", prompt: String(prompt), tools, fresh }),
79
74
  signal: AbortSignal.timeout(timeoutMs),
80
75
  });
81
76
  const out = await res.json().catch(() => ({}));
@@ -83,77 +78,6 @@ export async function ask(prompt, { tools = true, fresh = false, timeoutMs = 600
83
78
  return out.answer || "";
84
79
  }
85
80
 
86
- /**
87
- * A PICTURE OF THE DASHBOARD, AND WHAT IS WRONG WITH IT.
88
- *
89
- * You build a page you never see rendered: the thing that draws it is the Atlas
90
- * app, not the browser the file opens in, and that gap is where the UI bugs
91
- * live. Atlas MEASURES the render as well as photographing it and hands back
92
- * what it found in words, because you cannot see a PNG (owner, 2026-09-04:
93
- * "can the AI take a screenshot and review it — right now the UI is just
94
- * guessing").
95
- *
96
- * await screenshot(); // computer, dark
97
- * await screenshot({ phone: true }); // phone, dark
98
- * await screenshot({ theme: "light" }); // computer, light
99
- * await screenshot({ phone: true, theme: "light" });
100
- *
101
- * All four. The phone is where this is most likely to be broken and the view
102
- * you are least able to picture.
103
- *
104
- * Returns { path, summary, findings, facts }. `summary` is the line to read;
105
- * `broken` findings are faults, `style` findings are design rules this page is
106
- * not following. PRINT THE SUMMARY so it lands in the run log, fix what it
107
- * names, and take another. {} means the app was not reachable.
108
- */
109
- export async function screenshot({ theme = "dark", phone = false, width, height, timeoutMs = 60000 } = {}) {
110
- const url = process.env.ATLAS_ASK_URL;
111
- const token = process.env.ATLAS_ASK_TOKEN;
112
- if (!url || !token) return {};
113
- const job = path.basename(path.dirname(fileURLToPath(import.meta.url)));
114
- const body = { job, theme, phone: !!phone };
115
- if (width) body.width = Number(width);
116
- if (height) body.height = Number(height);
117
- try {
118
- const res = await fetch(url.replace("/ask", "/screenshot"), {
119
- method: "POST",
120
- headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
121
- body: JSON.stringify(body),
122
- signal: AbortSignal.timeout(timeoutMs),
123
- });
124
- const out = await res.json().catch(() => ({}));
125
- if (!res.ok || !out.ok) return {};
126
- return {
127
- path: out.path || "", summary: out.summary || "",
128
- findings: out.findings || [], facts: out.facts || {},
129
- };
130
- } catch {
131
- return {};
132
- }
133
- }
134
-
135
- /**
136
- * Every model this computer can reach.
137
- *
138
- * Returns [{ id, label }]. Pass an `id` to ask(..., { model }). An empty list
139
- * means no model is connected, in which case ask() says so too.
140
- */
141
- export async function models() {
142
- const url = process.env.ATLAS_ASK_URL;
143
- const token = process.env.ATLAS_ASK_TOKEN;
144
- if (!url || !token) return [];
145
- try {
146
- const res = await fetch(url.replace("/ask", "/models"), {
147
- headers: { "X-Atlas-Token": token },
148
- signal: AbortSignal.timeout(20000),
149
- });
150
- const out = await res.json().catch(() => ({}));
151
- return (out.models || []).filter((m) => m && m.id);
152
- } catch {
153
- return [];
154
- }
155
- }
156
-
157
81
  /**
158
82
  * Call a tool on one of YOUR connected MCP servers.
159
83
  *
@@ -178,6 +102,156 @@ export async function mcpTools({ server = "", search = "" } = {}) {
178
102
  return atlas("mcp-tools", { server, search });
179
103
  }
180
104
 
105
+ /**
106
+ * Use this project's dashboard the way a person would.
107
+ *
108
+ * screenshot() showed you what it LOOKS like. This lets you find out whether it
109
+ * WORKS: press the button, type in the box, look again at what happened. A
110
+ * layout fault is visible in a picture; a filter that returns nothing and a form
111
+ * that will not submit are not, and those are most of what is actually broken.
112
+ *
113
+ * await dashboard.open();
114
+ * for (const thing of await dashboard.find("button")) console.log(thing);
115
+ * await dashboard.click(x, y);
116
+ * await dashboard.type("AAPL");
117
+ * console.log((await dashboard.shot()).summary);
118
+ * await dashboard.close();
119
+ *
120
+ * It drives the dashboard and nothing else, so the person's real pointer never
121
+ * moves and they can carry on working while you click. Coordinates are inside
122
+ * the dashboard, from its top left, and they are not the same between shapes:
123
+ * reopening at { phone: true } draws a different layout.
124
+ */
125
+ async function driveDashboard(op, body = {}) {
126
+ const url = process.env.ATLAS_ASK_URL || "";
127
+ const token = process.env.ATLAS_ASK_TOKEN || "";
128
+ if (!url || !token) {
129
+ throw new Error(
130
+ "The dashboard can only be driven from Atlas Remote Control, which is "
131
+ + "what draws it. Run this project from the app.",
132
+ );
133
+ }
134
+ const job = path.basename(path.dirname(url_.fileURLToPath(import.meta.url)));
135
+ const res = await fetch(url.replace("/ask", "/dashboard-drive"), {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json", "X-Atlas-Token": token },
138
+ body: JSON.stringify({ ...body, job, op }),
139
+ });
140
+ const out = await res.json().catch(() => ({}));
141
+ if (!res.ok || out.ok === false) {
142
+ throw new Error(out.error || "The dashboard would not do that.");
143
+ }
144
+ return out;
145
+ }
146
+
147
+ export const dashboard = {
148
+ open: (opts = {}) => driveDashboard("open", {
149
+ phone: !!opts.phone,
150
+ theme: opts.theme || "dark",
151
+ width: opts.width,
152
+ height: opts.height,
153
+ }),
154
+ /** Where things are, without reading pixels. This is your own page: ask it. */
155
+ find: async (selector = "button, a, input, select, [role=button]") =>
156
+ (await driveDashboard("read", { selector })).found || [],
157
+ click: (x, y, opts = {}) => driveDashboard("click", {
158
+ x, y, button: opts.button || "left", count: opts.count || 1,
159
+ }),
160
+ doubleClick: (x, y) => driveDashboard("click", { x, y, count: 2 }),
161
+ move: (x, y) => driveDashboard("move", { x, y }),
162
+ scroll: (x, y, dx = 0, dy = -300) => driveDashboard("scroll", { x, y, dx, dy }),
163
+ type: (text) => driveDashboard("type", { text: String(text) }),
164
+ key: (...keys) => driveDashboard("key", { keys: keys.map(String) }),
165
+ /** A picture AND what is wrong with it, because you cannot see a PNG. */
166
+ shot: () => driveDashboard("shot"),
167
+ /** All four views, each measured: computer and phone, dark and light. */
168
+ check: async () => (await driveDashboard("check")).views || [],
169
+ close: () => driveDashboard("close"),
170
+ };
171
+
172
+ /**
173
+ * See the screen, move the mouse, type: the whole computer, not just your page.
174
+ *
175
+ * Runs through the project's Python, which is where the machinery lives
176
+ * (pyautogui and the recording), so the switch, the one-run-at-a-time claim, the
177
+ * action log and standing back when somebody picks up the mouse are all exactly
178
+ * the same rules whichever language the script is written in. There is one
179
+ * implementation, not two that can disagree about what is allowed.
180
+ *
181
+ * await computer.screen(); // look FIRST, always
182
+ * await computer.click(x, y);
183
+ * await computer.type("hello");
184
+ * await computer.note("signing in");
185
+ *
186
+ * Off until the person turns it on, under This Computer in Atlas Remote
187
+ * Control. Read skills/computer-use before using any of it.
188
+ */
189
+ async function useComputer(op, args = []) {
190
+ const { spawn } = await import("node:child_process");
191
+ const dir = path.dirname(url_.fileURLToPath(import.meta.url));
192
+ const py = process.env.ATLAS_PYTHON || (process.platform === "win32" ? "python" : "python3");
193
+ const code =
194
+ "import json,sys,atlas_env\n"
195
+ + "op=sys.argv[1]; args=json.loads(sys.argv[2])\n"
196
+ + "out=getattr(atlas_env,op)(*args)\n"
197
+ + "sys.stdout.write('\\n__ATLAS__'+json.dumps(out,default=str))\n";
198
+ return await new Promise((done, fail) => {
199
+ const p = spawn(py, ["-c", code, op, JSON.stringify(args)], { cwd: dir });
200
+ let out = "", err = "";
201
+ p.stdout.on("data", (d) => { out += d; });
202
+ p.stderr.on("data", (d) => { err += d; });
203
+ p.on("error", () => fail(new Error(
204
+ "No Python on this computer, and using the computer runs through it. "
205
+ + "Install Python 3, or use dashboard for work on your own page.",
206
+ )));
207
+ p.on("close", (codeOut) => {
208
+ if (codeOut !== 0) {
209
+ // The last line of a Python traceback is the message the helper wrote,
210
+ // and that message is the one worth showing: it says which switch to
211
+ // turn on or who is holding the mouse.
212
+ const lines = err.trim().split("\n").filter(Boolean);
213
+ const last = lines[lines.length - 1] || "That did not work.";
214
+ return fail(new Error(last.replace(/^\w*Error:\s*/, "")));
215
+ }
216
+ const mark = out.lastIndexOf("__ATLAS__");
217
+ if (mark < 0) return done(null);
218
+ try {
219
+ done(JSON.parse(out.slice(mark + 9)));
220
+ } catch {
221
+ done(null);
222
+ }
223
+ });
224
+ });
225
+ }
226
+
227
+ export const computer = {
228
+ ready: () => useComputer("computer_ready"),
229
+ screen: () => useComputer("screen"),
230
+ region: (x, y, w, h) => useComputer("region", [x, y, w, h]),
231
+ screens: () => useComputer("screens"),
232
+ useScreen: (n) => useComputer("use_screen", [n]),
233
+ click: (x, y) => useComputer("click", [x, y]),
234
+ doubleClick: (x, y) => useComputer("double_click", [x, y]),
235
+ rightClick: (x, y) => useComputer("right_click", [x, y]),
236
+ move: (x, y) => useComputer("move", [x, y]),
237
+ drag: (x1, y1, x2, y2) => useComputer("drag", [x1, y1, x2, y2]),
238
+ scroll: (x, y, dx, dy) => useComputer("scroll", [x, y, dx, dy]),
239
+ type: (text) => useComputer("type", [String(text)]),
240
+ key: (...keys) => useComputer("key", keys.map(String)),
241
+ copy: (text) => useComputer("copy", [String(text)]),
242
+ focusWindow: (title) => useComputer("focus_window", [String(title)]),
243
+ cursor: () => useComputer("get_cursor"),
244
+ /** Say what you are doing. Somebody is watching a strip that shows this. */
245
+ note: (text) => useComputer("note", [String(text)]),
246
+ pauseRecording: (why = "") => useComputer("pause_recording", [String(why)]),
247
+ resumeRecording: () => useComputer("resume_recording"),
248
+ /** After a person used it by hand. Look again before you call this. */
249
+ takeOver: () => useComputer("take_over"),
250
+ release: () => useComputer("release_computer"),
251
+ /** Only when the person asked you to work inside Atlas itself. */
252
+ useAtlasApp: () => useComputer("use_atlas_app"),
253
+ };
254
+
181
255
  /**
182
256
  * Stop cleanly when a secret has not been filled in yet.
183
257
  *