mindvest-atlas 0.1.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/LICENSE +21 -0
- package/README.md +56 -0
- package/bin/atlas.js +16 -0
- package/package.json +45 -0
- package/src/api/catalog.js +62 -0
- package/src/api/client.js +100 -0
- package/src/api/stream.js +47 -0
- package/src/auth/loopback.js +129 -0
- package/src/auth/oauth.js +145 -0
- package/src/auth/pkce.js +22 -0
- package/src/auth/store.js +46 -0
- package/src/cli.js +60 -0
- package/src/commands/alerts.js +60 -0
- package/src/commands/call.js +160 -0
- package/src/commands/flow.js +51 -0
- package/src/commands/login.js +110 -0
- package/src/commands/logout.js +22 -0
- package/src/commands/tools.js +45 -0
- package/src/commands/whoami.js +50 -0
- package/src/config.js +52 -0
- package/src/util/args.js +58 -0
- package/src/util/format.js +71 -0
- package/src/util/help.js +109 -0
- package/src/util/stream-loop.js +86 -0
- package/src/util/ui.js +64 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// On-disk state: OAuth credentials, the registered client_id, and the tool
|
|
2
|
+
// catalog cache. Credentials are persisted 0600 inside a 0700 config dir.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { configDir, FILES } from '../config.js';
|
|
6
|
+
|
|
7
|
+
function ensureDir() {
|
|
8
|
+
const dir = configDir();
|
|
9
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
10
|
+
try { fs.chmodSync(dir, 0o700); } catch { /* best effort (e.g. non-POSIX) */ }
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function filePath(name) {
|
|
15
|
+
return path.join(configDir(), name);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function readJson(name) {
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(fs.readFileSync(filePath(name), 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Write JSON and force the mode even if the file already existed.
|
|
27
|
+
export function writeJson(name, data, mode = 0o600) {
|
|
28
|
+
ensureDir();
|
|
29
|
+
const p = filePath(name);
|
|
30
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2) + '\n', { mode });
|
|
31
|
+
try { fs.chmodSync(p, mode); } catch { /* best effort */ }
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function removeJson(name) {
|
|
36
|
+
try { fs.unlinkSync(filePath(name)); return true; } catch { return false; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- credential blob (0600) ---
|
|
40
|
+
export function loadCredentials() { return readJson(FILES.credentials); }
|
|
41
|
+
export function saveCredentials(creds) { return writeJson(FILES.credentials, creds, 0o600); }
|
|
42
|
+
export function clearCredentials() { return removeJson(FILES.credentials); }
|
|
43
|
+
|
|
44
|
+
// --- non-secret config (client_id, cached discovery) ---
|
|
45
|
+
export function loadConfig() { return readJson(FILES.config) || {}; }
|
|
46
|
+
export function saveConfig(cfg) { return writeJson(FILES.config, cfg, 0o600); }
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Argument parsing and command dispatch. Anything that isn't a built-in command
|
|
2
|
+
// is treated as a tool slug and routed to the dynamic tool caller.
|
|
3
|
+
import { parseArgs, withoutCommand } from './util/args.js';
|
|
4
|
+
import { printRootHelp } from './util/help.js';
|
|
5
|
+
import { VERSION } from './config.js';
|
|
6
|
+
import { println, eprintln, color } from './util/ui.js';
|
|
7
|
+
import { AuthError, ApiError } from './api/client.js';
|
|
8
|
+
|
|
9
|
+
import { loginCommand } from './commands/login.js';
|
|
10
|
+
import { logoutCommand } from './commands/logout.js';
|
|
11
|
+
import { whoamiCommand } from './commands/whoami.js';
|
|
12
|
+
import { toolsCommand } from './commands/tools.js';
|
|
13
|
+
import { alertsCommand } from './commands/alerts.js';
|
|
14
|
+
import { flowCommand } from './commands/flow.js';
|
|
15
|
+
import { callCommand } from './commands/call.js';
|
|
16
|
+
|
|
17
|
+
export async function main(rawArgs) {
|
|
18
|
+
const args = parseArgs(rawArgs);
|
|
19
|
+
|
|
20
|
+
// Global --base-url override, applied before any command resolves endpoints.
|
|
21
|
+
if (typeof args.flags['base-url'] === 'string') {
|
|
22
|
+
process.env.ATLAS_BASE_URL = args.flags['base-url'];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const cmd = args._[0];
|
|
26
|
+
|
|
27
|
+
// Version / no-arg / root help.
|
|
28
|
+
if (!cmd && (args.flags.version)) { println(VERSION); return 0; }
|
|
29
|
+
if (!cmd || cmd === 'help') {
|
|
30
|
+
// `atlas help <tool>` shows that tool's help.
|
|
31
|
+
if (cmd === 'help' && args._[1]) {
|
|
32
|
+
return callCommand(args._[1], { _: [], flags: { help: true } });
|
|
33
|
+
}
|
|
34
|
+
printRootHelp();
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (cmd === 'version') { println(VERSION); return 0; }
|
|
38
|
+
// `atlas --help` with no command still shows root help (parseArgs put help in flags).
|
|
39
|
+
if (args.flags.help && !cmd) { printRootHelp(); return 0; }
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
switch (cmd) {
|
|
43
|
+
case 'login': return await loginCommand(withoutCommand(args));
|
|
44
|
+
case 'logout': return await logoutCommand(withoutCommand(args));
|
|
45
|
+
case 'whoami': return await whoamiCommand(withoutCommand(args));
|
|
46
|
+
case 'tools': return await toolsCommand(withoutCommand(args));
|
|
47
|
+
case 'alerts': return await alertsCommand(withoutCommand(args));
|
|
48
|
+
case 'flow': return await flowCommand(withoutCommand(args));
|
|
49
|
+
default:
|
|
50
|
+
// Treat the first positional as a tool slug (e.g. stock-quote).
|
|
51
|
+
return await callCommand(cmd, withoutCommand(args));
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Friendly, prefix-free messages for the common API failures; anything
|
|
55
|
+
// else bubbles to the bin's last-resort handler.
|
|
56
|
+
if (err instanceof AuthError) { eprintln(color.red(err.message)); return 1; }
|
|
57
|
+
if (err instanceof ApiError) { eprintln(color.red(`Error: ${err.message}`)); return 1; }
|
|
58
|
+
throw err;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `atlas alerts` — subscribe to the live alert-fire SSE feed, print each fire,
|
|
2
|
+
// and optionally decode the attached chart PNG to a file.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { API } from '../config.js';
|
|
6
|
+
import { runStream } from '../util/stream-loop.js';
|
|
7
|
+
import { formatAlert } from '../util/format.js';
|
|
8
|
+
import { color, println, eprintln } from '../util/ui.js';
|
|
9
|
+
|
|
10
|
+
function sanitizeFilename(name) {
|
|
11
|
+
return String(name || '').replace(/[^A-Za-z0-9._-]/g, '_') || 'alert.png';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function alertsCommand(args) {
|
|
15
|
+
const flags = args.flags;
|
|
16
|
+
const qs = new URLSearchParams();
|
|
17
|
+
if (flags.symbol) qs.set('symbol', String(flags.symbol).toUpperCase());
|
|
18
|
+
const name = flags.name || flags.type || flags['alert-type'];
|
|
19
|
+
if (typeof name === 'string' && name) qs.set('name', name);
|
|
20
|
+
// --no-chart (flags.chart === false) suppresses chart payloads server-side.
|
|
21
|
+
if (flags.chart === false || flags['no-chart']) qs.set('chart', '0');
|
|
22
|
+
|
|
23
|
+
const saveDir = (typeof flags['save-charts'] === 'string' && flags['save-charts'])
|
|
24
|
+
|| (typeof flags.out === 'string' && flags.out);
|
|
25
|
+
if (saveDir) fs.mkdirSync(saveDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const json = !!flags.json;
|
|
28
|
+
const pathname = API.alerts + (qs.toString() ? '?' + qs.toString() : '');
|
|
29
|
+
|
|
30
|
+
if (!json) {
|
|
31
|
+
eprintln(color.dim('Listening for alert fires' + (flags.symbol ? ` on ${String(flags.symbol).toUpperCase()}` : '') + (name ? ` (${name})` : '') + '…'));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return runStream(pathname, {
|
|
35
|
+
label: 'alerts',
|
|
36
|
+
// `--no-reconnect` parses to flags.reconnect === false (the --no- convention).
|
|
37
|
+
reconnect: flags.reconnect !== false,
|
|
38
|
+
onEvent: ({ event, payload }) => {
|
|
39
|
+
// Decode + save the chart if present and requested.
|
|
40
|
+
if (saveDir && payload.chart && payload.chart.image_base64) {
|
|
41
|
+
const file = path.join(saveDir, sanitizeFilename(payload.chart.filename));
|
|
42
|
+
try {
|
|
43
|
+
fs.writeFileSync(file, Buffer.from(payload.chart.image_base64, 'base64'));
|
|
44
|
+
if (!json) eprintln(color.green(' ↳ chart saved: ') + file);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
eprintln(color.red(' ↳ could not save chart: ') + err.message);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (json) {
|
|
51
|
+
println(JSON.stringify(payload));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
println(formatAlert(payload, event));
|
|
55
|
+
if (Array.isArray(payload.bullets)) {
|
|
56
|
+
for (const b of payload.bullets) println(' ' + color.dim(b));
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// `atlas <tool> [--param value]` — resolve a slug to a server tool, build the
|
|
2
|
+
// JSON body from flags (type-coerced via the schema), POST, and render.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { API } from '../config.js';
|
|
6
|
+
import { fetchCatalog, resolveToolName, suggestTools, slugify } from '../api/catalog.js';
|
|
7
|
+
import { requestJson, ApiError, AuthError } from '../api/client.js';
|
|
8
|
+
import { printToolHelp } from '../util/help.js';
|
|
9
|
+
import { color, println, eprintln, prettyJson, redactBase64 } from '../util/ui.js';
|
|
10
|
+
|
|
11
|
+
const RESERVED = new Set(['help', 'h', 'json', 'out', 'save-charts', 'raw', 'base-url', 'version', 'v']);
|
|
12
|
+
|
|
13
|
+
function coerce(raw, type) {
|
|
14
|
+
if (raw === true || raw === false) return raw;
|
|
15
|
+
const s = String(raw);
|
|
16
|
+
if (type === 'integer') {
|
|
17
|
+
const n = parseInt(s, 10);
|
|
18
|
+
if (Number.isNaN(n)) throw new Error(`expected an integer, got "${s}"`);
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
if (type === 'number') {
|
|
22
|
+
const n = Number(s);
|
|
23
|
+
if (Number.isNaN(n)) throw new Error(`expected a number, got "${s}"`);
|
|
24
|
+
return n;
|
|
25
|
+
}
|
|
26
|
+
if (type === 'boolean') return /^(true|1|yes|y|on)$/i.test(s);
|
|
27
|
+
// string / unknown: accept inline JSON for array/object params.
|
|
28
|
+
if (s.startsWith('[') || s.startsWith('{')) {
|
|
29
|
+
try { return JSON.parse(s); } catch { return s; }
|
|
30
|
+
}
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const norm = (s) => s.replace(/-/g, '_');
|
|
35
|
+
|
|
36
|
+
function buildBody(tool, flags) {
|
|
37
|
+
const params = tool.parameters || {};
|
|
38
|
+
const byNorm = {};
|
|
39
|
+
for (const p of Object.keys(params)) byNorm[norm(p)] = p;
|
|
40
|
+
|
|
41
|
+
const body = {};
|
|
42
|
+
for (const [key, val] of Object.entries(flags)) {
|
|
43
|
+
if (RESERVED.has(key)) continue;
|
|
44
|
+
const pname = params[key] ? key : byNorm[norm(key)];
|
|
45
|
+
if (pname) {
|
|
46
|
+
body[pname] = coerce(val, (params[pname].type) || 'string');
|
|
47
|
+
} else {
|
|
48
|
+
// Unknown to the cached schema — forward it (the server ignores unknown
|
|
49
|
+
// keys), normalising hyphens so `--from-date` maps to `from_date`.
|
|
50
|
+
body[norm(key)] = coerce(val, 'string');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return body;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function requiredMissing(tool, body) {
|
|
57
|
+
const params = tool.parameters || {};
|
|
58
|
+
return Object.entries(params)
|
|
59
|
+
.filter(([p, meta]) => meta.required && (body[p] === undefined || body[p] === ''))
|
|
60
|
+
.map(([p]) => '--' + p.replace(/_/g, '-'));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Decode any base64 images in a tool response and write them to `dir`.
|
|
64
|
+
function saveImages(data, dir, toolName) {
|
|
65
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
66
|
+
const saved = [];
|
|
67
|
+
let i = 0;
|
|
68
|
+
const writeImg = (b64, hint) => {
|
|
69
|
+
if (typeof b64 !== 'string' || b64.length < 64) return;
|
|
70
|
+
const file = path.join(dir, `${slugify(toolName)}${i ? '_' + i : ''}.png`);
|
|
71
|
+
fs.writeFileSync(file, Buffer.from(b64.replace(/^data:image\/\w+;base64,/, ''), 'base64'));
|
|
72
|
+
saved.push(file);
|
|
73
|
+
i++;
|
|
74
|
+
};
|
|
75
|
+
if (Array.isArray(data.images)) for (const img of data.images) writeImg(img && img.image_base64);
|
|
76
|
+
if (data.image_base64) writeImg(data.image_base64);
|
|
77
|
+
return saved;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function callCommand(toolSlug, args) {
|
|
81
|
+
const flags = args.flags;
|
|
82
|
+
|
|
83
|
+
let catalog;
|
|
84
|
+
try {
|
|
85
|
+
catalog = await fetchCatalog({});
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err instanceof AuthError) { eprintln(color.red(err.message)); return 1; }
|
|
88
|
+
eprintln(color.red('Could not load tool catalog: ') + err.message);
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const name = resolveToolName(catalog, toolSlug);
|
|
93
|
+
if (!name) {
|
|
94
|
+
eprintln(color.red(`Unknown command or tool: ${toolSlug}`));
|
|
95
|
+
const hints = suggestTools(catalog, toolSlug);
|
|
96
|
+
if (hints.length) eprintln(color.dim('Did you mean: ') + hints.join(', '));
|
|
97
|
+
eprintln(color.dim('Run `atlas tools` to list everything.'));
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tool = catalog.tools[name];
|
|
102
|
+
|
|
103
|
+
if (flags.help || flags.h) {
|
|
104
|
+
printToolHelp(name, tool);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Build the request body (either an explicit --json or flag-derived).
|
|
109
|
+
let body;
|
|
110
|
+
if (typeof flags.json === 'string') {
|
|
111
|
+
try { body = JSON.parse(flags.json); } catch (err) {
|
|
112
|
+
eprintln(color.red('Invalid --json: ') + err.message);
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
try { body = buildBody(tool, flags); } catch (err) {
|
|
117
|
+
eprintln(color.red('Bad parameter: ') + err.message);
|
|
118
|
+
return 1;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const missing = requiredMissing(tool, body);
|
|
123
|
+
if (missing.length) {
|
|
124
|
+
eprintln(color.red('Missing required parameter(s): ') + missing.join(', '));
|
|
125
|
+
eprintln(color.dim(`See: atlas ${toolSlug} --help`));
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let data;
|
|
130
|
+
try {
|
|
131
|
+
data = await requestJson(API.tool(name), { method: 'POST', body });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (err instanceof AuthError) { eprintln(color.red(err.message)); return 1; }
|
|
134
|
+
if (err instanceof ApiError) {
|
|
135
|
+
eprintln(color.red(`Error (${err.status || '?'}): ${err.message}`));
|
|
136
|
+
if (err.data && err.data.available) eprintln(color.dim('Hint: run `atlas tools` to list valid tools.'));
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
eprintln(color.red('Request failed: ') + err.message);
|
|
140
|
+
return 1;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Save chart images if requested.
|
|
144
|
+
const outDir = (typeof flags.out === 'string' && flags.out) || (typeof flags['save-charts'] === 'string' && flags['save-charts']);
|
|
145
|
+
if (outDir) {
|
|
146
|
+
const saved = saveImages(data, outDir, name);
|
|
147
|
+
if (saved.length) for (const f of saved) eprintln(color.green('Saved: ') + f);
|
|
148
|
+
else eprintln(color.dim('No chart image in the response.'));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The tools API returns 200 with an { error } body on bad input.
|
|
152
|
+
const isError = data && typeof data === 'object' && typeof data.error === 'string';
|
|
153
|
+
|
|
154
|
+
if (flags.json || flags.raw || !process.stdout.isTTY) {
|
|
155
|
+
println(JSON.stringify(data, null, 2));
|
|
156
|
+
} else {
|
|
157
|
+
println(prettyJson(redactBase64(data)));
|
|
158
|
+
}
|
|
159
|
+
return isError ? 1 : 0;
|
|
160
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// `atlas flow stream --symbol NVDA` — subscribe to the live options-flow SSE
|
|
2
|
+
// feed and print each trade. (`flow` is a group so we can add subcommands.)
|
|
3
|
+
import { API } from '../config.js';
|
|
4
|
+
import { runStream } from '../util/stream-loop.js';
|
|
5
|
+
import { formatTrade } from '../util/format.js';
|
|
6
|
+
import { color, println, eprintln } from '../util/ui.js';
|
|
7
|
+
|
|
8
|
+
const PASS_THROUGH = [
|
|
9
|
+
'option_symbol', 'expiration_date', 'strike', 'side',
|
|
10
|
+
'from_date', 'to_date', 'limit', 'min_premium', 'min_tag', 'client_id',
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export async function flowCommand(args) {
|
|
14
|
+
// Accept `flow stream --symbol X`, `flow --symbol X`, and `flow stream NVDA`.
|
|
15
|
+
const rest = args._.slice();
|
|
16
|
+
if (rest[0] === 'stream') rest.shift();
|
|
17
|
+
const flags = args.flags;
|
|
18
|
+
|
|
19
|
+
const symbol = (typeof flags.symbol === 'string' && flags.symbol) || rest[0];
|
|
20
|
+
if (!symbol) {
|
|
21
|
+
eprintln(color.red('`flow stream` requires a symbol, e.g. `atlas flow stream --symbol NVDA`'));
|
|
22
|
+
return 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const qs = new URLSearchParams();
|
|
26
|
+
qs.set('symbol', String(symbol).toUpperCase());
|
|
27
|
+
for (const key of PASS_THROUGH) {
|
|
28
|
+
const v = flags[key] ?? flags[key.replace(/_/g, '-')];
|
|
29
|
+
if (v !== undefined && v !== true) qs.set(key, String(v));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const json = !!flags.json;
|
|
33
|
+
const pathname = API.flowStream + '?' + qs.toString();
|
|
34
|
+
|
|
35
|
+
if (!json) eprintln(color.dim(`Streaming options flow for ${String(symbol).toUpperCase()}…`));
|
|
36
|
+
|
|
37
|
+
return runStream(pathname, {
|
|
38
|
+
label: 'flow',
|
|
39
|
+
// `--no-reconnect` parses to flags.reconnect === false (the --no- convention).
|
|
40
|
+
reconnect: flags.reconnect !== false,
|
|
41
|
+
onEvent: ({ payload }) => {
|
|
42
|
+
const trades = Array.isArray(payload.trades)
|
|
43
|
+
? payload.trades
|
|
44
|
+
: (payload.trade ? [payload.trade] : []);
|
|
45
|
+
for (const t of trades) {
|
|
46
|
+
if (json) println(JSON.stringify(t));
|
|
47
|
+
else println(formatTrade(t));
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `atlas login` — OAuth authorization-code + PKCE loopback flow, or `--token`
|
|
2
|
+
// to store a bearer directly. Mirrors how the GitHub CLI / Claude Code sign in.
|
|
3
|
+
import { baseUrl, DEFAULT_SCOPE } from '../config.js';
|
|
4
|
+
import { LoopbackServer } from '../auth/loopback.js';
|
|
5
|
+
import { createPkce, randomState } from '../auth/pkce.js';
|
|
6
|
+
import {
|
|
7
|
+
getEndpoints, ensureClientId, buildAuthorizeUrl, exchangeCode,
|
|
8
|
+
fetchUserinfo, credentialsFromTokenResponse,
|
|
9
|
+
} from '../auth/oauth.js';
|
|
10
|
+
import { saveCredentials } from '../auth/store.js';
|
|
11
|
+
import { filePath } from '../auth/store.js';
|
|
12
|
+
import { FILES } from '../config.js';
|
|
13
|
+
import { color, println, eprintln, openBrowser } from '../util/ui.js';
|
|
14
|
+
|
|
15
|
+
async function showAccount(accessToken) {
|
|
16
|
+
try {
|
|
17
|
+
const endpoints = await getEndpoints();
|
|
18
|
+
const info = await fetchUserinfo(endpoints, accessToken);
|
|
19
|
+
const who = info.email || info.sub || 'unknown account';
|
|
20
|
+
println(color.green('✓ Signed in as ') + color.bold(who));
|
|
21
|
+
} catch {
|
|
22
|
+
println(color.green('✓ Signed in.'));
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function loginCommand(args) {
|
|
27
|
+
const flags = args.flags;
|
|
28
|
+
|
|
29
|
+
// --- token mode: store a bearer directly (e.g. a dashboard MCP secret) ---
|
|
30
|
+
const token = flags.token || flags.t;
|
|
31
|
+
if (typeof token === 'string' && token.trim()) {
|
|
32
|
+
saveCredentials({
|
|
33
|
+
base_url: baseUrl(),
|
|
34
|
+
access_token: token.trim(),
|
|
35
|
+
refresh_token: null,
|
|
36
|
+
token_type: 'Bearer',
|
|
37
|
+
scope: null,
|
|
38
|
+
expires_at: null,
|
|
39
|
+
obtained_at: Date.now(),
|
|
40
|
+
client_id: null,
|
|
41
|
+
source: 'manual',
|
|
42
|
+
});
|
|
43
|
+
println(color.green('✓ Token saved to ') + color.dim(filePath(FILES.credentials)));
|
|
44
|
+
await showAccount(token.trim());
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (token === true) {
|
|
48
|
+
eprintln(color.red('`--token` needs a value, e.g. `atlas login --token at_...`'));
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// --- OAuth browser flow ---
|
|
53
|
+
const scope = (typeof flags.scope === 'string' && flags.scope) || DEFAULT_SCOPE;
|
|
54
|
+
const endpoints = await getEndpoints({ refresh: true });
|
|
55
|
+
|
|
56
|
+
const loop = new LoopbackServer();
|
|
57
|
+
const { redirectUri } = await loop.start();
|
|
58
|
+
|
|
59
|
+
let clientId;
|
|
60
|
+
try {
|
|
61
|
+
clientId = await ensureClientId(endpoints, redirectUri);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
loop.close();
|
|
64
|
+
eprintln(color.red('Could not register an OAuth client: ') + err.message);
|
|
65
|
+
eprintln(color.dim('If this persists, set ATLAS_CLIENT_ID to a pre-provisioned client.'));
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { verifier, challenge, method } = createPkce();
|
|
70
|
+
const state = randomState();
|
|
71
|
+
const authUrl = buildAuthorizeUrl(endpoints, { clientId, redirectUri, scope, state, challenge, method });
|
|
72
|
+
|
|
73
|
+
println();
|
|
74
|
+
println(color.bold('Sign in to Atlas'));
|
|
75
|
+
println(color.dim('A browser window will open for you to authenticate.'));
|
|
76
|
+
println();
|
|
77
|
+
println(color.dim('If it does not open, visit:'));
|
|
78
|
+
println(' ' + color.cyan(authUrl));
|
|
79
|
+
println();
|
|
80
|
+
|
|
81
|
+
const noBrowser = flags['no-browser'] || process.env.ATLAS_NO_BROWSER;
|
|
82
|
+
if (!noBrowser) openBrowser(authUrl);
|
|
83
|
+
eprintln(color.dim('Waiting for you to finish in the browser…'));
|
|
84
|
+
|
|
85
|
+
let code;
|
|
86
|
+
try {
|
|
87
|
+
({ code } = await loop.waitForCallback({ expectedState: state, timeoutMs: 300_000 }));
|
|
88
|
+
} catch (err) {
|
|
89
|
+
loop.close();
|
|
90
|
+
eprintln(color.red('Login failed: ') + err.message);
|
|
91
|
+
return 1;
|
|
92
|
+
} finally {
|
|
93
|
+
loop.close();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let tok;
|
|
97
|
+
try {
|
|
98
|
+
tok = await exchangeCode(endpoints, { code, redirectUri, clientId, verifier });
|
|
99
|
+
} catch (err) {
|
|
100
|
+
eprintln(color.red('Token exchange failed: ') + err.message);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const creds = credentialsFromTokenResponse(tok, { clientId, base: baseUrl() });
|
|
105
|
+
saveCredentials(creds);
|
|
106
|
+
println();
|
|
107
|
+
await showAccount(creds.access_token);
|
|
108
|
+
println(color.dim('Credentials stored 0600 at ') + color.dim(filePath(FILES.credentials)));
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// `atlas logout` — best-effort token revocation, then wipe local credentials.
|
|
2
|
+
import { loadCredentials, clearCredentials } from '../auth/store.js';
|
|
3
|
+
import { getEndpoints, revokeToken } from '../auth/oauth.js';
|
|
4
|
+
import { color, println } from '../util/ui.js';
|
|
5
|
+
|
|
6
|
+
export async function logoutCommand() {
|
|
7
|
+
const creds = loadCredentials();
|
|
8
|
+
if (!creds) {
|
|
9
|
+
println('Not signed in.');
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
|
12
|
+
if (creds.source !== 'manual') {
|
|
13
|
+
try {
|
|
14
|
+
const endpoints = await getEndpoints();
|
|
15
|
+
if (creds.access_token) await revokeToken(endpoints, { token: creds.access_token });
|
|
16
|
+
if (creds.refresh_token) await revokeToken(endpoints, { token: creds.refresh_token });
|
|
17
|
+
} catch { /* revocation is best-effort */ }
|
|
18
|
+
}
|
|
19
|
+
clearCredentials();
|
|
20
|
+
println(color.green('✓ Signed out.'));
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `atlas tools` — list available tools from the discovery endpoint.
|
|
2
|
+
import { fetchCatalog, listDisplayTools, slugify } from '../api/catalog.js';
|
|
3
|
+
import { color, println } from '../util/ui.js';
|
|
4
|
+
|
|
5
|
+
function firstLine(desc) {
|
|
6
|
+
if (!desc) return '';
|
|
7
|
+
for (const line of String(desc).split('\n')) {
|
|
8
|
+
const t = line.trim();
|
|
9
|
+
if (t) return t;
|
|
10
|
+
}
|
|
11
|
+
return '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function toolsCommand(args) {
|
|
15
|
+
const flags = args.flags;
|
|
16
|
+
const catalog = await fetchCatalog({ force: !!flags.refresh });
|
|
17
|
+
|
|
18
|
+
if (flags.json) {
|
|
19
|
+
println(JSON.stringify(catalog.tools, null, 2));
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const names = listDisplayTools(catalog);
|
|
24
|
+
const filter = String(
|
|
25
|
+
(typeof flags.search === 'string' && flags.search) || args._[0] || '',
|
|
26
|
+
).toLowerCase();
|
|
27
|
+
|
|
28
|
+
const rows = names
|
|
29
|
+
.map((name) => ({ slug: slugify(name), desc: firstLine(catalog.tools[name].description) }))
|
|
30
|
+
.filter((r) => !filter || r.slug.includes(filter) || r.desc.toLowerCase().includes(filter));
|
|
31
|
+
|
|
32
|
+
println(
|
|
33
|
+
color.bold('Atlas tools') +
|
|
34
|
+
color.dim(` (${names.length} tools, ${catalog.count} endpoints incl. snake_case aliases)`),
|
|
35
|
+
);
|
|
36
|
+
println();
|
|
37
|
+
const width = rows.reduce((w, r) => Math.max(w, r.slug.length), 0) + 2;
|
|
38
|
+
for (const r of rows) {
|
|
39
|
+
println(' ' + color.cyan(r.slug.padEnd(width)) + color.dim(r.desc));
|
|
40
|
+
}
|
|
41
|
+
if (!rows.length) println(color.dim(' (no tools match "' + filter + '")'));
|
|
42
|
+
println();
|
|
43
|
+
println(color.dim('Run ') + color.cyan('atlas <tool> --help') + color.dim(' for parameters.'));
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// `atlas whoami` — show the signed-in account via the OAuth userinfo endpoint.
|
|
2
|
+
import { isLoggedIn } from '../api/client.js';
|
|
3
|
+
import { loadCredentials } from '../auth/store.js';
|
|
4
|
+
import { getEndpoints, fetchUserinfo } from '../auth/oauth.js';
|
|
5
|
+
import { request } from '../api/client.js';
|
|
6
|
+
import { color, println, eprintln } from '../util/ui.js';
|
|
7
|
+
|
|
8
|
+
export async function whoamiCommand() {
|
|
9
|
+
if (!isLoggedIn()) {
|
|
10
|
+
eprintln(color.red('Not signed in. Run `atlas login`.'));
|
|
11
|
+
return 1;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Resolve a usable access token via the normal request path (handles env
|
|
15
|
+
// token + refresh) by hitting userinfo. We re-fetch the token implicitly.
|
|
16
|
+
const endpoints = await getEndpoints();
|
|
17
|
+
try {
|
|
18
|
+
// Use the authenticated request helper so env/stored/refresh all work.
|
|
19
|
+
const res = await request('/oauth/userinfo', { accept: 'application/json' });
|
|
20
|
+
if (res.status === 200) {
|
|
21
|
+
const info = await res.json();
|
|
22
|
+
println(color.bold(info.email || info.sub || 'unknown'));
|
|
23
|
+
if (info.sub) println(color.dim(' user id: ') + info.sub);
|
|
24
|
+
if (info.scope) println(color.dim(' scope: ') + info.scope);
|
|
25
|
+
const creds = loadCredentials();
|
|
26
|
+
if (creds && creds.source) println(color.dim(' via: ') + (process.env.ATLAS_TOKEN ? 'ATLAS_TOKEN env' : creds.source));
|
|
27
|
+
else if (process.env.ATLAS_TOKEN) println(color.dim(' via: ') + 'ATLAS_TOKEN env');
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
30
|
+
if (res.status === 401) {
|
|
31
|
+
eprintln(color.red('Token is invalid or expired. Run `atlas login` again.'));
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
eprintln(color.red(`Could not fetch account (HTTP ${res.status}).`));
|
|
35
|
+
return 1;
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Fall back to a direct userinfo call if the helper path failed.
|
|
38
|
+
const creds = loadCredentials();
|
|
39
|
+
const tok = (process.env.ATLAS_TOKEN || (creds && creds.access_token) || '').trim();
|
|
40
|
+
if (tok) {
|
|
41
|
+
try {
|
|
42
|
+
const info = await fetchUserinfo(endpoints, tok);
|
|
43
|
+
println(color.bold(info.email || info.sub || 'unknown'));
|
|
44
|
+
return 0;
|
|
45
|
+
} catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
eprintln(color.red('whoami failed: ') + err.message);
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Centralised constants and path/endpoint resolution.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export const VERSION = '0.1.0';
|
|
6
|
+
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
|
+
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
|
+
export const CLIENT_NAME = 'Atlas CLI';
|
|
9
|
+
export const REDIRECT_PATH = '/callback';
|
|
10
|
+
|
|
11
|
+
// Effective server base URL (env override wins; trailing slashes trimmed).
|
|
12
|
+
export function baseUrl() {
|
|
13
|
+
return (process.env.ATLAS_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Default OAuth endpoints derived from the base URL. These mirror the server's
|
|
17
|
+
// /.well-known/oauth-authorization-server document. At login we fetch that doc
|
|
18
|
+
// and cache any overrides, but these defaults keep the CLI working even if
|
|
19
|
+
// discovery is briefly unavailable.
|
|
20
|
+
export function defaultEndpoints(base = baseUrl()) {
|
|
21
|
+
return {
|
|
22
|
+
issuer: base,
|
|
23
|
+
discovery: `${base}/.well-known/oauth-authorization-server`,
|
|
24
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
25
|
+
token_endpoint: `${base}/oauth/token`,
|
|
26
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
27
|
+
revocation_endpoint: `${base}/oauth/revoke`,
|
|
28
|
+
userinfo_endpoint: `${base}/oauth/userinfo`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// REST API paths (relative to baseUrl()).
|
|
33
|
+
export const API = {
|
|
34
|
+
tools: '/api/v1/tools',
|
|
35
|
+
tool: (name) => `/api/v1/tools/${encodeURIComponent(name)}`,
|
|
36
|
+
alerts: '/api/v1/alerts',
|
|
37
|
+
flowStream: '/api/v1/options-flow/stream',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Config directory: $ATLAS_CONFIG_DIR, else $XDG_CONFIG_HOME/atlas, else
|
|
41
|
+
// ~/.config/atlas. Credentials inside are written 0600 (see auth/store.js).
|
|
42
|
+
export function configDir() {
|
|
43
|
+
if (process.env.ATLAS_CONFIG_DIR) return process.env.ATLAS_CONFIG_DIR;
|
|
44
|
+
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'atlas');
|
|
45
|
+
return path.join(os.homedir(), '.config', 'atlas');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const FILES = {
|
|
49
|
+
credentials: 'credentials.json',
|
|
50
|
+
config: 'config.json',
|
|
51
|
+
catalog: 'tools-cache.json',
|
|
52
|
+
};
|