mindvest-atlas 0.23.2 → 0.28.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/package.json +1 -1
- package/src/cli.js +6 -0
- package/src/commands/remote-control.js +14 -0
- package/src/commands/run.js +108 -0
- package/src/commands/tools.js +111 -1
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.js +153 -79
- package/src/helpers/atlas_env.py +1215 -1
- package/src/remote-control.js +39 -0
- package/src/sharing.js +52 -6
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -15,6 +15,7 @@ import { flowCommand } from './commands/flow.js';
|
|
|
15
15
|
import { remoteControlCommand } from './commands/remote-control.js';
|
|
16
16
|
import { updateCliCommand } from './commands/update-cli.js';
|
|
17
17
|
import { screenshotCommand } from './commands/screenshot.js';
|
|
18
|
+
import { runCommand } from './commands/run.js';
|
|
18
19
|
import { remoteCheckCommand } from './commands/remote-check.js';
|
|
19
20
|
import { shareCommand } from './commands/share.js';
|
|
20
21
|
import { historicalContractCommand } from './commands/historical.js';
|
|
@@ -92,6 +93,11 @@ export async function main(rawArgs) {
|
|
|
92
93
|
case 'screenshot':
|
|
93
94
|
case 'shot':
|
|
94
95
|
return await screenshotCommand(withoutCommand(args));
|
|
96
|
+
// RUN SOMETHING INSIDE A PROJECT, which had no entry point at all: an
|
|
97
|
+
// agent that had correctly worked out atlas_env only exists in project
|
|
98
|
+
// code then had nowhere to try it (owner, 2026-09-05).
|
|
99
|
+
case 'run':
|
|
100
|
+
return runCommand(withoutCommand(args));
|
|
95
101
|
// Put the newest CLI on this machine.
|
|
96
102
|
case 'update-cli':
|
|
97
103
|
case 'self-update':
|
|
@@ -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();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// `atlas run` — run something inside a project, the way Atlas runs it.
|
|
2
|
+
//
|
|
3
|
+
// THE GAP THIS FILLS, from a transcript (owner, 2026-09-05). An agent worked
|
|
4
|
+
// out, correctly and after a lot of hunting, that `atlas_env` is not a CLI
|
|
5
|
+
// thing at all but a helper that only exists inside project code. Then it asked
|
|
6
|
+
// exactly the right next question: "is there a run-this-project-now entry point
|
|
7
|
+
// so we can execute a tiny readiness probe through Atlas instead of normal
|
|
8
|
+
// Python?" It searched four times and there was not one.
|
|
9
|
+
//
|
|
10
|
+
// So it had two options, both bad: write the probe into the project's real
|
|
11
|
+
// script and trigger a whole run, or give up. It gave up. Same shape as the bug
|
|
12
|
+
// `atlas screenshot` was written for: an agent at a prompt with no right tool
|
|
13
|
+
// does the wrong available thing.
|
|
14
|
+
//
|
|
15
|
+
// atlas run --job my-project the project's own script
|
|
16
|
+
// atlas run --job my-project --code "..." one snippet, same environment
|
|
17
|
+
// atlas run --job my-project --file probe.py a file, same environment
|
|
18
|
+
//
|
|
19
|
+
// "Same environment" is the whole point: the project's interpreter, its folder
|
|
20
|
+
// as the working directory so `import atlas_env` resolves, its secrets, and the
|
|
21
|
+
// bridge back to Atlas. A snippet run with plain python has none of that, which
|
|
22
|
+
// is why plain python answers ModuleNotFoundError and teaches the wrong lesson.
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import {
|
|
27
|
+
workspace, venvPython, ambientPython, jobEnv, ensureProjectFiles,
|
|
28
|
+
} from '../remote-control.js';
|
|
29
|
+
import { color, println, eprintln } from '../util/ui.js';
|
|
30
|
+
|
|
31
|
+
export function runCommand(args) {
|
|
32
|
+
const flags = args.flags || {};
|
|
33
|
+
const positional = args.positional || args._ || [];
|
|
34
|
+
const job = String(flags.job || positional[0] || '').trim();
|
|
35
|
+
if (!job) {
|
|
36
|
+
eprintln(color.red('Which project? `atlas run --job my-project`'));
|
|
37
|
+
eprintln(color.dim(' --code "..." run one snippet inside it'));
|
|
38
|
+
eprintln(color.dim(' --file x.py run a file inside it'));
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const { dir } = workspace(job);
|
|
43
|
+
if (!fs.existsSync(dir)) {
|
|
44
|
+
eprintln(color.red(`There is no project called ${job} on this computer.`));
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
// The helper has to be there to be imported, and it is written rather than
|
|
48
|
+
// assumed: a project made before this existed, or one whose copy is behind,
|
|
49
|
+
// would fail with ModuleNotFoundError and look like the very confusion this
|
|
50
|
+
// command exists to end.
|
|
51
|
+
try { ensureProjectFiles(dir); } catch { /* it may already be right */ }
|
|
52
|
+
|
|
53
|
+
const code = flags.code;
|
|
54
|
+
const file = flags.file;
|
|
55
|
+
if (code && file) {
|
|
56
|
+
eprintln(color.red('One or the other: --code or --file.'));
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// THE PROJECT'S OWN INTERPRETER FIRST: its venv is where the things the
|
|
61
|
+
// script imports actually live, so the ambient python would answer "not
|
|
62
|
+
// installed" about a machine that has them. venvPython hands back a path and
|
|
63
|
+
// ambientPython hands back an argv (it may need `py -3` on Windows).
|
|
64
|
+
const venv = venvPython(dir);
|
|
65
|
+
const python = venv ? [String(venv)] : [...ambientPython()];
|
|
66
|
+
|
|
67
|
+
let argv;
|
|
68
|
+
let what;
|
|
69
|
+
if (code) {
|
|
70
|
+
argv = [...python, '-c', String(code)];
|
|
71
|
+
what = 'the snippet';
|
|
72
|
+
} else if (file) {
|
|
73
|
+
argv = [...python, String(file)];
|
|
74
|
+
what = String(file);
|
|
75
|
+
} else {
|
|
76
|
+
if (!fs.existsSync(path.join(dir, 'main.py'))) {
|
|
77
|
+
eprintln(color.red(`${job} has no main.py. Use --code or --file.`));
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
argv = [...python, 'main.py'];
|
|
81
|
+
what = 'main.py';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const env = { ...process.env, ...jobEnv(dir) };
|
|
85
|
+
// THE BRIDGE BACK TO ATLAS is inherited, never invented. These are handed to
|
|
86
|
+
// a process Atlas starts, so an agent's own shell has them and a person's
|
|
87
|
+
// ordinary terminal does not. Saying so beats a snippet that half works.
|
|
88
|
+
if (!env.ATLAS_ASK_URL || !env.ATLAS_ASK_TOKEN) {
|
|
89
|
+
println(color.dim(
|
|
90
|
+
' Note: this shell was not started by Atlas, so ask(), dashboard and '
|
|
91
|
+
+ 'screenshot() will not work in it. Everything else will. Ask in a chat '
|
|
92
|
+
+ 'if you need those.'));
|
|
93
|
+
}
|
|
94
|
+
if (!env.ATLAS_JOB) env.ATLAS_JOB = path.basename(dir);
|
|
95
|
+
// PYTHONPATH as well as the working directory. Running with -c does not put
|
|
96
|
+
// the script's folder on the path the way running a file does, and `import
|
|
97
|
+
// atlas_env` is the entire reason somebody is here.
|
|
98
|
+
env.PYTHONPATH = [dir, env.PYTHONPATH].filter(Boolean).join(path.delimiter);
|
|
99
|
+
|
|
100
|
+
println(color.dim(`Running ${what} in ${path.basename(dir)}`));
|
|
101
|
+
println();
|
|
102
|
+
const out = spawnSync(argv[0], argv.slice(1), { cwd: dir, env, stdio: 'inherit' });
|
|
103
|
+
if (out.error) {
|
|
104
|
+
eprintln(color.red(`Could not run it: ${out.error.message}`));
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
return out.status === null ? 1 : out.status;
|
|
108
|
+
}
|
package/src/commands/tools.js
CHANGED
|
@@ -11,6 +11,87 @@ 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
|
+
run: 'atlas run --job <project> [--code "..."] run a project, or one snippet, INSIDE it',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// ── THE THIRD PLACE, which nothing here used to mention ────────────────────
|
|
31
|
+
//
|
|
32
|
+
// An agent searched this catalogue for "computer", found nothing, and concluded
|
|
33
|
+
// Atlas could not drive one (owner, 2026-09-05: "the agents can't find stuff,
|
|
34
|
+
// it struggles"). Right about the catalogue, wrong about Atlas: computer use is
|
|
35
|
+
// not a tool, it is an import inside a project's script, on a surface a tool
|
|
36
|
+
// search cannot see. A search that finds nothing is where that mistake gets
|
|
37
|
+
// made, so that is where the other two surfaces get named.
|
|
38
|
+
const HELPER_FUNCTIONS = {
|
|
39
|
+
secret: "secret(name) / hasSecret(name) this project's keys, for use, never printed",
|
|
40
|
+
atlas: 'atlas(tool, params) any cloud tool, as a call instead of a process',
|
|
41
|
+
ask: 'ask(prompt) the model on this computer, with its own tools',
|
|
42
|
+
mcp: 'mcp(server, tool, args) a connected MCP server',
|
|
43
|
+
dashboard: "dashboard.open/find/click/type/shot work this project's own page",
|
|
44
|
+
computer: 'computer: screen, click, type, key see and use the whole machine',
|
|
45
|
+
screen: 'screen(), click(x,y), type(text) see and use the whole machine',
|
|
46
|
+
click: 'click(x, y), double_click, drag use the mouse on this computer',
|
|
47
|
+
type: 'type(text), key(...keys), copy(text) type on this computer',
|
|
48
|
+
note: 'note(text) say what you are doing, as you do it',
|
|
49
|
+
progress: 'progress(step, done, total) what the dashboard draws while it runs',
|
|
50
|
+
recordTrade: 'recordTrade({...}) a closed trade onto the Performance page',
|
|
51
|
+
};
|
|
52
|
+
// The word somebody searches for is rarely the name of the function.
|
|
53
|
+
const HELPER_ALIASES = {
|
|
54
|
+
navigate: ['click', 'screen'],
|
|
55
|
+
browser: ['click', 'screen'],
|
|
56
|
+
mouse: ['click'],
|
|
57
|
+
keyboard: ['type'],
|
|
58
|
+
keystroke: ['type'],
|
|
59
|
+
screenshot: ['dashboard', 'screen'],
|
|
60
|
+
desktop: ['screen'],
|
|
61
|
+
gui: ['click', 'screen'],
|
|
62
|
+
ui: ['dashboard'],
|
|
63
|
+
automate: ['computer', 'click'],
|
|
64
|
+
control: ['computer'],
|
|
65
|
+
'computer-use': ['computer'],
|
|
66
|
+
key: ['type'],
|
|
67
|
+
password: ['secret'],
|
|
68
|
+
credential: ['secret'],
|
|
69
|
+
llm: ['ask'],
|
|
70
|
+
model: ['ask'],
|
|
71
|
+
prompt: ['ask'],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
function helperMatches(term) {
|
|
75
|
+
if (!term) return [];
|
|
76
|
+
const want = new Set();
|
|
77
|
+
for (const [name, line] of Object.entries(HELPER_FUNCTIONS)) {
|
|
78
|
+
if (name.includes(term) || line.toLowerCase().includes(term)) want.add(name);
|
|
79
|
+
}
|
|
80
|
+
for (const [alias, names] of Object.entries(HELPER_ALIASES)) {
|
|
81
|
+
if (alias.includes(term) || term.includes(alias)) for (const n of names) want.add(n);
|
|
82
|
+
}
|
|
83
|
+
return [...want].sort().map((n) => HELPER_FUNCTIONS[n]).filter(Boolean);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Commands of this CLI whose name or line contains the search term. */
|
|
87
|
+
function localMatches(term) {
|
|
88
|
+
if (!term) return [];
|
|
89
|
+
return Object.entries(LOCAL_COMMANDS)
|
|
90
|
+
.filter(([name, line]) => name.includes(term) || line.toLowerCase().includes(term))
|
|
91
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
92
|
+
.map(([, line]) => line);
|
|
93
|
+
}
|
|
94
|
+
|
|
14
95
|
export async function toolsCommand(args) {
|
|
15
96
|
const flags = args.flags;
|
|
16
97
|
const catalog = await fetchCatalog({ force: !!flags.refresh });
|
|
@@ -38,7 +119,36 @@ export async function toolsCommand(args) {
|
|
|
38
119
|
for (const r of rows) {
|
|
39
120
|
println(' ' + color.cyan(r.slug.padEnd(width)) + color.dim(r.desc));
|
|
40
121
|
}
|
|
41
|
-
if (!rows.length)
|
|
122
|
+
if (!rows.length) {
|
|
123
|
+
println(color.dim(' (no tools match "' + filter + '")'));
|
|
124
|
+
// A COMMAND is not a tool, and looking for one in the catalog is an easy
|
|
125
|
+
// mistake to make: they are both things you type after `atlas`.
|
|
126
|
+
const local = localMatches(filter);
|
|
127
|
+
if (local.length) {
|
|
128
|
+
println('');
|
|
129
|
+
println(color.dim(' Not a tool, but this CLI can do it:'));
|
|
130
|
+
for (const line of local) println(' ' + line);
|
|
131
|
+
}
|
|
132
|
+
const helper = helperMatches(filter);
|
|
133
|
+
if (helper.length) {
|
|
134
|
+
println('');
|
|
135
|
+
println(color.dim(" Not a tool either, but a project's script can:"));
|
|
136
|
+
println(color.dim(' (import these from atlas_env, inside the project)'));
|
|
137
|
+
for (const line of helper) println(' ' + line);
|
|
138
|
+
}
|
|
139
|
+
// NEVER A DEAD END. "No matches" and nothing else is what let an agent
|
|
140
|
+
// decide Atlas could not drive a computer, when it was one import away on a
|
|
141
|
+
// surface this search does not cover.
|
|
142
|
+
println('');
|
|
143
|
+
println(color.dim(' Searched the CLOUD TOOLS only. Two other places exist:'));
|
|
144
|
+
println(color.dim(" this CLI's own commands ") + color.cyan('atlas --help'));
|
|
145
|
+
println(color.dim(" a project's own script ") + color.cyan('from atlas_env import ...'));
|
|
146
|
+
println(color.dim(' To try one without writing a file: ')
|
|
147
|
+
+ color.cyan('atlas run --job <project> --code "..."'));
|
|
148
|
+
println(color.dim(' Computer use, dashboard driving, ask() and secrets all live in the'));
|
|
149
|
+
println(color.dim(' script, not here. Read skills/computer-use and skills/atlas-tools'));
|
|
150
|
+
println(color.dim(' before concluding Atlas cannot do something.'));
|
|
151
|
+
}
|
|
42
152
|
println();
|
|
43
153
|
println(color.dim('Run ') + color.cyan('atlas <tool> --help') + color.dim(' for parameters.'));
|
|
44
154
|
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.
|
|
5
|
+
export const VERSION = '0.28.1';
|
|
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';
|
package/src/helpers/atlas_env.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
* shot() shows you what it LOOKS like. The rest 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
|
*
|