atris 3.82.11 → 3.82.14
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/atris/skills/README.md +1 -0
- package/atris/skills/clean-copy/SKILL.md +74 -0
- package/bin/atris.js +18 -0
- package/commands/autoland.js +25 -1
- package/commands/business-connect.js +148 -0
- package/commands/business-scoreboard.js +216 -0
- package/commands/business.js +10 -89
- package/commands/computer-login.js +327 -0
- package/commands/computer.js +13 -0
- package/commands/growth.js +151 -0
- package/commands/memory-live.js +373 -0
- package/commands/memory.js +205 -0
- package/lib/auto-accept-certified.js +15 -6
- package/lib/known-commands.js +2 -2
- package/lib/memory-files.js +226 -0
- package/lib/memory-folder.js +602 -0
- package/lib/memory-live.js +356 -0
- package/lib/skill-eval-gate.js +38 -3
- package/lib/workspace-safety.js +1 -0
- package/package.json +1 -1
package/commands/business.js
CHANGED
|
@@ -1158,93 +1158,6 @@ async function businessTeam(slug) {
|
|
|
1158
1158
|
console.log('');
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
-
async function connectService(connector, ...flags) {
|
|
1162
|
-
if (!connector) {
|
|
1163
|
-
console.log('Usage: atris business connect <service> [--business <slug>]');
|
|
1164
|
-
console.log('');
|
|
1165
|
-
console.log('Available connectors:');
|
|
1166
|
-
// List skills that look like integrations
|
|
1167
|
-
const skillDirs = [
|
|
1168
|
-
path.join(__dirname, '..', '..', '.claude', 'skills'),
|
|
1169
|
-
path.join(require('os').homedir(), '.claude', 'skills'),
|
|
1170
|
-
];
|
|
1171
|
-
const seen = new Set();
|
|
1172
|
-
for (const dir of skillDirs) {
|
|
1173
|
-
if (!fs.existsSync(dir)) continue;
|
|
1174
|
-
for (const name of fs.readdirSync(dir)) {
|
|
1175
|
-
const skillFile = path.join(dir, name, 'SKILL.md');
|
|
1176
|
-
if (fs.existsSync(skillFile) && !seen.has(name)) {
|
|
1177
|
-
seen.add(name);
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
const integrations = [...seen].filter(s =>
|
|
1182
|
-
['slack', 'hubspot', 'linear', 'notion', 'google-drive', 'github',
|
|
1183
|
-
'calendar', 'email-agent', 'x-search', 'youtube', 'ramp'].includes(s)
|
|
1184
|
-
).sort();
|
|
1185
|
-
for (const s of integrations) {
|
|
1186
|
-
console.log(` ${s}`);
|
|
1187
|
-
}
|
|
1188
|
-
if (integrations.length === 0) console.log(' (none found, install skills first)');
|
|
1189
|
-
return;
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// Parse --business flag
|
|
1193
|
-
let bizSlug = null;
|
|
1194
|
-
for (let i = 0; i < flags.length; i++) {
|
|
1195
|
-
if ((flags[i] === '--business' || flags[i] === '-b') && flags[i + 1]) {
|
|
1196
|
-
bizSlug = flags[i + 1];
|
|
1197
|
-
i++;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
// Find the skill
|
|
1202
|
-
const skillDirs = [
|
|
1203
|
-
path.join(__dirname, '..', '..', '.claude', 'skills', connector),
|
|
1204
|
-
path.join(require('os').homedir(), '.claude', 'skills', connector),
|
|
1205
|
-
];
|
|
1206
|
-
let skillPath = null;
|
|
1207
|
-
for (const dir of skillDirs) {
|
|
1208
|
-
const p = path.join(dir, 'SKILL.md');
|
|
1209
|
-
if (fs.existsSync(p)) { skillPath = p; break; }
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
if (!skillPath) {
|
|
1213
|
-
console.error(`Skill "${connector}" not found.`);
|
|
1214
|
-
console.error('Check: .claude/skills/ or ~/.claude/skills/');
|
|
1215
|
-
process.exit(1);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
console.log(`\n Connecting: ${connector}`);
|
|
1219
|
-
console.log(` Skill: ${skillPath}`);
|
|
1220
|
-
if (bizSlug) console.log(` Business: ${bizSlug}`);
|
|
1221
|
-
|
|
1222
|
-
// Read skill to check for required secrets
|
|
1223
|
-
const skillContent = fs.readFileSync(skillPath, 'utf8');
|
|
1224
|
-
const secretMatches = skillContent.match(/[A-Z][A-Z0-9_]*_(?:KEY|TOKEN|SECRET|PASSWORD|API_KEY)/g) || [];
|
|
1225
|
-
const uniqueSecrets = [...new Set(secretMatches)];
|
|
1226
|
-
|
|
1227
|
-
if (uniqueSecrets.length > 0) {
|
|
1228
|
-
console.log(`\n Required secrets:`);
|
|
1229
|
-
for (const secret of uniqueSecrets) {
|
|
1230
|
-
console.log(` ${secret}`);
|
|
1231
|
-
}
|
|
1232
|
-
console.log(`\n Store secrets with: atris computer run "echo $${uniqueSecrets[0]}"`);
|
|
1233
|
-
console.log(` Or set in: ~/.atris/secrets/${connector}/`);
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// Create local secrets directory
|
|
1237
|
-
const secretsDir = path.join(stateHome(), '.atris', 'secrets', connector);
|
|
1238
|
-
if (!fs.existsSync(secretsDir)) {
|
|
1239
|
-
fs.mkdirSync(secretsDir, { recursive: true });
|
|
1240
|
-
console.log(`\n Created secrets dir: ${secretsDir}/`);
|
|
1241
|
-
}
|
|
1242
|
-
|
|
1243
|
-
console.log(`\n Connected "${connector}" skill.`);
|
|
1244
|
-
console.log(` Agent can now use ${connector} capabilities.`);
|
|
1245
|
-
console.log('');
|
|
1246
|
-
}
|
|
1247
|
-
|
|
1248
1161
|
async function setNotificationMode(mode, ...flags) {
|
|
1249
1162
|
const validModes = ['digest', 'silent', 'push'];
|
|
1250
1163
|
if (!mode || !validModes.includes(mode)) {
|
|
@@ -1588,6 +1501,7 @@ function printBusinessHelp() {
|
|
|
1588
1501
|
console.log(' add <slug> Register an existing cloud business');
|
|
1589
1502
|
console.log(' list Show registered businesses');
|
|
1590
1503
|
console.log(' room [slug] Show wallet, members, apps, store, and recent orders');
|
|
1504
|
+
console.log(' scoreboard [slug] Revenue, orders, visitors, credits and your own numbers as one board (--doc, --json, add, set, layout)');
|
|
1591
1505
|
console.log(' products [slug] List store products');
|
|
1592
1506
|
console.log(' orders [slug] List store orders');
|
|
1593
1507
|
console.log(' store on [slug] Enable the store with its current catalog');
|
|
@@ -1596,7 +1510,7 @@ function printBusinessHelp() {
|
|
|
1596
1510
|
console.log(' health [slug] Full health dashboard');
|
|
1597
1511
|
console.log(' audit Audit all businesses');
|
|
1598
1512
|
console.log(' doctor [--fix] Find stale business cache, alias, and folder bindings');
|
|
1599
|
-
console.log(' connect
|
|
1513
|
+
console.log(' connect [service] See or make a business connection (slack, telegram, discord, whatsapp, google-drive, hubspot)');
|
|
1600
1514
|
console.log(' notify <mode> Set notification mode (digest/silent/push)');
|
|
1601
1515
|
console.log(' deploy <slug> Push local business to cloud');
|
|
1602
1516
|
console.log(' onboard Seed brief, person, first loop, safe next action, and one-pager from sparse input');
|
|
@@ -1678,6 +1592,13 @@ async function businessCommand(subcommand, ...args) {
|
|
|
1678
1592
|
case 'room':
|
|
1679
1593
|
await businessRoom(args[0]);
|
|
1680
1594
|
break;
|
|
1595
|
+
case 'scoreboard':
|
|
1596
|
+
case 'board':
|
|
1597
|
+
case 'numbers': {
|
|
1598
|
+
const code = await require('./business-scoreboard').scoreboardCommand(args);
|
|
1599
|
+
if (code) process.exitCode = code;
|
|
1600
|
+
break;
|
|
1601
|
+
}
|
|
1681
1602
|
case 'products':
|
|
1682
1603
|
case 'product':
|
|
1683
1604
|
await businessProducts(args[0]);
|
|
@@ -1705,7 +1626,7 @@ async function businessCommand(subcommand, ...args) {
|
|
|
1705
1626
|
await businessDoctor(...args);
|
|
1706
1627
|
break;
|
|
1707
1628
|
case 'connect':
|
|
1708
|
-
await
|
|
1629
|
+
process.exitCode = await require('./business-connect').connectCommand(args);
|
|
1709
1630
|
break;
|
|
1710
1631
|
case 'notify':
|
|
1711
1632
|
case 'notification':
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// `atris computer login`: put your own Claude or Codex subscription on the
|
|
4
|
+
// cloud computers you own, with one browser approval and nothing to answer.
|
|
5
|
+
//
|
|
6
|
+
// The sign-in happens on this laptop, where the browser is. The result is sent
|
|
7
|
+
// straight to Atris, stored encrypted, and used only on runs you start. It is
|
|
8
|
+
// never printed and never written to a file here.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { spawnSync } = require('node:child_process');
|
|
14
|
+
|
|
15
|
+
const { loadCredentials } = require('../utils/auth');
|
|
16
|
+
const { apiRequestJson } = require('../utils/api');
|
|
17
|
+
const engineLogin = require('../lib/engine-login');
|
|
18
|
+
|
|
19
|
+
const ENGINES = ['claude', 'codex'];
|
|
20
|
+
const WORKER_FOR = { claude: 'claude', codex: 'openai' };
|
|
21
|
+
const TEST_MESSAGE = 'Reply with only the word OK.';
|
|
22
|
+
|
|
23
|
+
function parseLoginArgs(args = []) {
|
|
24
|
+
const options = { engine: 'claude', business: '', status: false, unlink: false, check: false, test: true, help: false, error: '' };
|
|
25
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
26
|
+
const arg = args[i];
|
|
27
|
+
if (ENGINES.includes(arg)) options.engine = arg;
|
|
28
|
+
else if (arg === '--status' || arg === 'status') options.status = true;
|
|
29
|
+
else if (arg === '--unlink' || arg === 'unlink' || arg === 'logout') options.unlink = true;
|
|
30
|
+
else if (arg === '--check' || arg === 'check') options.check = true;
|
|
31
|
+
else if (arg === '--no-test') options.test = false;
|
|
32
|
+
else if (arg === '--help' || arg === '-h' || arg === 'help') options.help = true;
|
|
33
|
+
else if (arg === '--business') {
|
|
34
|
+
options.business = String(args[i + 1] || '').trim();
|
|
35
|
+
i += 1;
|
|
36
|
+
if (!options.business) options.error = '--business needs a computer name';
|
|
37
|
+
} else if (arg.startsWith('--business=')) options.business = arg.slice('--business='.length).trim();
|
|
38
|
+
else options.error = `unknown option: ${arg}`;
|
|
39
|
+
}
|
|
40
|
+
return options;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printHelp(log) {
|
|
44
|
+
log('Usage: atris computer login [claude|codex] [--business <name>]');
|
|
45
|
+
log('');
|
|
46
|
+
log('Puts your own subscription on the cloud computers you own.');
|
|
47
|
+
log('One browser approval. It is used only on runs you start.');
|
|
48
|
+
log('');
|
|
49
|
+
log(' atris computer login Claude, on every computer you own');
|
|
50
|
+
log(' atris computer login codex Codex instead (a computer holds one at a time)');
|
|
51
|
+
log(' atris computer login --business x only the computer named x');
|
|
52
|
+
log(' atris computer login --status which computers have a subscription');
|
|
53
|
+
log(' atris computer login --check run one small job and show how it was paid');
|
|
54
|
+
log(' atris computer login --unlink remove it; add --business x for one');
|
|
55
|
+
log(' --no-test skip the test run at the end');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findComputer(computers, name) {
|
|
59
|
+
const wanted = String(name || '').trim().toLowerCase();
|
|
60
|
+
if (!wanted) return null;
|
|
61
|
+
return computers.find((item) => [item.slug, item.name, item.business_id]
|
|
62
|
+
.some((value) => String(value || '').toLowerCase() === wanted)) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function printStatus(computers, log) {
|
|
66
|
+
if (!computers.length) {
|
|
67
|
+
log('You do not own any computers on this account.');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const width = Math.max(...computers.map((item) => String(item.name || item.slug || '').length));
|
|
71
|
+
for (const item of computers) {
|
|
72
|
+
const name = String(item.name || item.slug || item.business_id).padEnd(width);
|
|
73
|
+
log(` ${name} ${item.linked ? `${item.engine} subscription` : 'no subscription'}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// What the server said, in plain words. Never includes the sign-in.
|
|
78
|
+
function explainFailure(result) {
|
|
79
|
+
if (result.status === 404) return 'The server does not have this yet. It needs the latest deploy.';
|
|
80
|
+
if (result.status === 403) return String(result.error || 'This is not switched on for your account.');
|
|
81
|
+
if (result.status === 401) return 'Your Atris sign-in ran out. Run: atris login';
|
|
82
|
+
if (result.status === 400) return String(result.error || 'The server did not accept that sign-in.');
|
|
83
|
+
return 'The server did not answer. Try again in a minute.';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Claude: its own long-lived sign-in command, approved in the browser.
|
|
87
|
+
function claudeSignIn(deps) {
|
|
88
|
+
const lib = deps.engineLogin || engineLogin;
|
|
89
|
+
const recorded = lib.recordSetupToken();
|
|
90
|
+
let token = lib.findToken(recorded.text);
|
|
91
|
+
if (!token) token = lib.askToken();
|
|
92
|
+
if (!/^sk-ant-oat/.test(token)) return { error: 'No sign-in came back from Claude.' };
|
|
93
|
+
// Claude prints the sign-in on screen. Wipe it, scrollback included.
|
|
94
|
+
(deps.clearScreen || (() => process.stdout.write('\x1b[2J\x1b[3J\x1b[H')))();
|
|
95
|
+
const check = lib.checkToken(token);
|
|
96
|
+
if (!check.ok) return { error: `The new sign-in did not work on this laptop (${check.detail}).` };
|
|
97
|
+
return { credential: token };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Codex: a fresh sign-in in a throwaway folder, so the cloud gets its own
|
|
101
|
+
// session and this laptop's Codex sign-in is left alone.
|
|
102
|
+
function codexSignIn(deps) {
|
|
103
|
+
const spawn = deps.spawnSync || spawnSync;
|
|
104
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-codex-login-'));
|
|
105
|
+
fs.chmodSync(home, 0o700);
|
|
106
|
+
try {
|
|
107
|
+
const result = spawn('codex', ['login', '-c', 'cli_auth_credentials_store="file"'], {
|
|
108
|
+
stdio: 'inherit',
|
|
109
|
+
env: { ...process.env, CODEX_HOME: home },
|
|
110
|
+
});
|
|
111
|
+
if (result.error && result.error.code === 'ENOENT') return { error: 'Codex is not installed on this laptop.' };
|
|
112
|
+
const file = path.join(home, 'auth.json');
|
|
113
|
+
if (!fs.existsSync(file)) return { error: 'No sign-in came back from Codex.' };
|
|
114
|
+
const content = fs.readFileSync(file, 'utf8').trim();
|
|
115
|
+
try {
|
|
116
|
+
if (typeof JSON.parse(content) !== 'object') throw new Error('not an object');
|
|
117
|
+
} catch {
|
|
118
|
+
return { error: 'Codex wrote a sign-in this tool could not read.' };
|
|
119
|
+
}
|
|
120
|
+
return { credential: content };
|
|
121
|
+
} finally {
|
|
122
|
+
fs.rmSync(home, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultRunTest(computer, engine) {
|
|
127
|
+
const result = spawnSync(process.execPath, [
|
|
128
|
+
path.join(__dirname, '..', 'bin', 'atris.js'), 'computer', 'chat',
|
|
129
|
+
'--business', String(computer.slug || computer.business_id),
|
|
130
|
+
'--worker', WORKER_FOR[engine], '--message', TEST_MESSAGE,
|
|
131
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 300000 });
|
|
132
|
+
const lines = `${result.stdout || ''}`.trim().split('\n').filter(Boolean);
|
|
133
|
+
return { ok: result.status === 0, reply: lines[lines.length - 1] || '' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function lastRun(api, token, computer) {
|
|
137
|
+
const result = await api(`/ai-computer/subscription/last-run?business_id=${encodeURIComponent(computer.business_id)}`, {
|
|
138
|
+
method: 'GET', token, timeoutMs: 15000, retries: 1,
|
|
139
|
+
});
|
|
140
|
+
return result.ok && result.data ? { ok: true, run: result.data.run || null } : { ok: false, run: null };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Runs one tiny job as you, then asks the server how that run was paid for.
|
|
144
|
+
// "New" means a different run than the newest one before the test, so no
|
|
145
|
+
// clocks are compared.
|
|
146
|
+
async function proveSubscription({ api, token, computer, engine, deps, log }) {
|
|
147
|
+
const name = computer.name || computer.slug;
|
|
148
|
+
const before = await lastRun(api, token, computer);
|
|
149
|
+
if (!before.ok) {
|
|
150
|
+
log('Could not read this computer\'s run records, so this is not proven yet.');
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
log(`Running one small job on ${name}. It may take a minute to wake.`);
|
|
154
|
+
const test = (deps.runTest || defaultRunTest)(computer, engine);
|
|
155
|
+
const after = await lastRun(api, token, computer);
|
|
156
|
+
const record = after.run;
|
|
157
|
+
const isNew = record && record.execution_id && (!before.run || record.execution_id !== before.run.execution_id);
|
|
158
|
+
if (!isNew) {
|
|
159
|
+
log('The test job left no new record, so this is not proven yet.');
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (!record.on_subscription) {
|
|
163
|
+
log(`Not working: that job was paid as "${record.billing_mode || 'unknown'}", not your subscription.`);
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
if (record.status !== 'completed' || !/\bok\b/i.test(test.reply || '')) {
|
|
167
|
+
log(`Not proven: the job was sent to your ${engine} subscription but did not finish cleanly (${record.status || 'unknown'}).`);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (computer.shared_sign_in) {
|
|
171
|
+
log(`That job ran on a ${engine} subscription. ${name} also has the older shared sign-in,`);
|
|
172
|
+
log('so this result cannot tell the two apart.');
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
log(`Proven: that job ran on your ${engine} subscription.`);
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Ctrl-C during a sign-in goes to the sign-in program. Holding the signal here
|
|
180
|
+
// lets cleanup of its screen recording run instead of dying mid-way.
|
|
181
|
+
function holdingInterrupt(work) {
|
|
182
|
+
const hold = () => {};
|
|
183
|
+
process.on('SIGINT', hold);
|
|
184
|
+
try {
|
|
185
|
+
return work();
|
|
186
|
+
} finally {
|
|
187
|
+
process.removeListener('SIGINT', hold);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function computerLogin(args = [], deps = {}) {
|
|
192
|
+
const log = deps.log || console.log;
|
|
193
|
+
const fail = deps.error || console.error;
|
|
194
|
+
const options = parseLoginArgs(args);
|
|
195
|
+
if (!options.business && deps.business) options.business = String(deps.business).trim();
|
|
196
|
+
if (options.help) {
|
|
197
|
+
printHelp(log);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
if (options.error) {
|
|
201
|
+
fail(options.error);
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const credentials = await (deps.loadCredentials || loadCredentials)();
|
|
206
|
+
if (!credentials || !credentials.token) {
|
|
207
|
+
fail('Not signed in to Atris. Run: atris login');
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const token = credentials.token;
|
|
211
|
+
const api = deps.apiRequestJson || apiRequestJson;
|
|
212
|
+
|
|
213
|
+
const listed = await api('/ai-computer/subscription', { method: 'GET', token, timeoutMs: 15000, retries: 1 });
|
|
214
|
+
if (!listed.ok) {
|
|
215
|
+
fail(explainFailure(listed));
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
218
|
+
const computers = Array.isArray(listed.data && listed.data.computers) ? listed.data.computers : [];
|
|
219
|
+
|
|
220
|
+
if (options.status) {
|
|
221
|
+
printStatus(computers, log);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let target = null;
|
|
226
|
+
if (options.business) {
|
|
227
|
+
target = findComputer(computers, options.business);
|
|
228
|
+
if (!target) {
|
|
229
|
+
fail(`You do not own a computer named "${options.business}".`);
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (!computers.length) {
|
|
234
|
+
fail('You do not own any computers on this account, so there is nothing to sign in to.');
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (options.unlink) {
|
|
239
|
+
const removed = await api('/ai-computer/subscription/unlink', {
|
|
240
|
+
method: 'POST', token, body: target ? { business_id: target.business_id } : {}, timeoutMs: 30000, retries: 0,
|
|
241
|
+
});
|
|
242
|
+
if (!removed.ok) {
|
|
243
|
+
fail(explainFailure(removed));
|
|
244
|
+
return 1;
|
|
245
|
+
}
|
|
246
|
+
const count = (removed.data && removed.data.unlinked ? removed.data.unlinked : []).length;
|
|
247
|
+
const stuck = (removed.data && removed.data.failed ? removed.data.failed : []).length;
|
|
248
|
+
log(`Removed your subscription from ${count} computer${count === 1 ? '' : 's'}.`);
|
|
249
|
+
if (stuck) {
|
|
250
|
+
fail(`${stuck} could not be removed. See which: atris computer login --status`);
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const binding = (deps.readBusinessBinding || (() => null))();
|
|
257
|
+
const bound = binding ? findComputer(computers, binding.business_id || binding.id || binding.slug) : null;
|
|
258
|
+
|
|
259
|
+
if (options.check) {
|
|
260
|
+
const checked = target || bound;
|
|
261
|
+
if (!checked) {
|
|
262
|
+
fail('Name the computer to check: atris computer login --check --business <name>');
|
|
263
|
+
return 1;
|
|
264
|
+
}
|
|
265
|
+
if (!checked.linked) {
|
|
266
|
+
fail(`${checked.name || checked.slug} has no subscription yet. Run: atris computer login`);
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
const proven = await proveSubscription({ api, token, computer: checked, engine: checked.engine, deps, log });
|
|
270
|
+
return proven ? 0 : 2;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const engine = options.engine;
|
|
274
|
+
log(`Signing in to ${engine}. A browser page opens. Approve it there.`);
|
|
275
|
+
const signIn = holdingInterrupt(() => (engine === 'claude'
|
|
276
|
+
? (deps.claudeSignIn || claudeSignIn)(deps)
|
|
277
|
+
: (deps.codexSignIn || codexSignIn)(deps)));
|
|
278
|
+
if (signIn.error) {
|
|
279
|
+
fail(`${signIn.error} Nothing was changed.`);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const linked = await api('/ai-computer/subscription/link', {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
token,
|
|
286
|
+
body: { engine, credential: signIn.credential, ...(target ? { business_id: target.business_id } : {}) },
|
|
287
|
+
timeoutMs: 60000,
|
|
288
|
+
retries: 0,
|
|
289
|
+
});
|
|
290
|
+
if (!linked.ok) {
|
|
291
|
+
// A refusal means nothing was stored. Anything else (a timeout, a server
|
|
292
|
+
// error) may have stored it on some computers, so do not claim otherwise.
|
|
293
|
+
const refused = [400, 401, 403, 404].includes(linked.status);
|
|
294
|
+
fail(refused
|
|
295
|
+
? `${explainFailure(linked)} Nothing was changed.`
|
|
296
|
+
: 'The server did not confirm. Some computers may have it. See which: atris computer login --status');
|
|
297
|
+
return 1;
|
|
298
|
+
}
|
|
299
|
+
const done = linked.data && Array.isArray(linked.data.linked) ? linked.data.linked : [];
|
|
300
|
+
const missed = linked.data && Array.isArray(linked.data.failed) ? linked.data.failed : [];
|
|
301
|
+
const names = done
|
|
302
|
+
.map((item) => computers.find((computer) => computer.business_id === item.business_id))
|
|
303
|
+
.filter(Boolean)
|
|
304
|
+
.map((computer) => computer.name || computer.slug);
|
|
305
|
+
log(`Your ${engine} subscription is on ${done.length} computer${done.length === 1 ? '' : 's'}${names.length ? `: ${names.join(', ')}` : ''}.`);
|
|
306
|
+
log('It is used only on runs you start.');
|
|
307
|
+
if (missed.length) {
|
|
308
|
+
const missedNames = missed.map((id) => {
|
|
309
|
+
const computer = computers.find((item) => item.business_id === id);
|
|
310
|
+
return computer ? (computer.name || computer.slug) : id;
|
|
311
|
+
});
|
|
312
|
+
fail(`It could not be added to ${missed.length}: ${missedNames.join(', ')}. Run the command again to retry.`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (!done.length) return 1;
|
|
316
|
+
if (!options.test) return missed.length ? 1 : 0;
|
|
317
|
+
const proofTarget = target || (bound && done.some((item) => item.business_id === bound.business_id) ? bound : null);
|
|
318
|
+
if (!proofTarget) {
|
|
319
|
+
log('To prove it on one computer: atris computer login --check --business <name>');
|
|
320
|
+
return missed.length ? 1 : 0;
|
|
321
|
+
}
|
|
322
|
+
const proven = await proveSubscription({ api, token, computer: proofTarget, engine, deps, log });
|
|
323
|
+
if (!proven) return 2;
|
|
324
|
+
return missed.length ? 1 : 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
module.exports = { computerLogin, parseLoginArgs, findComputer, explainFailure, claudeSignIn, codexSignIn };
|
package/commands/computer.js
CHANGED
|
@@ -1943,6 +1943,19 @@ async function runComputer(argv = process.argv.slice(3), deps = {}) {
|
|
|
1943
1943
|
const cloudOptions = parsed.options;
|
|
1944
1944
|
const sub = args[0];
|
|
1945
1945
|
|
|
1946
|
+
if (sub === 'login') {
|
|
1947
|
+
const { computerLogin } = require('./computer-login');
|
|
1948
|
+
// The shared option reader takes --business wherever it sits, so hand it
|
|
1949
|
+
// on; otherwise a flag typed before `login` would be dropped silently.
|
|
1950
|
+
const code = await computerLogin(argv.slice(argv.indexOf('login') + 1), {
|
|
1951
|
+
readBusinessBinding,
|
|
1952
|
+
business: cloudOptions.businessSlug || '',
|
|
1953
|
+
...deps,
|
|
1954
|
+
});
|
|
1955
|
+
if (code !== 0) process.exitCode = code;
|
|
1956
|
+
return code;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1946
1959
|
if (sub === 'setup') {
|
|
1947
1960
|
const code = await computerSetup(deps);
|
|
1948
1961
|
if (code !== 0) process.exitCode = code;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { spawn } = require('node:child_process');
|
|
4
|
+
const { apiRequestJson } = require('../utils/api');
|
|
5
|
+
const { loadCredentials } = require('../utils/auth');
|
|
6
|
+
const { NOT_LOGGED_IN, wantsHelp, wantsJson, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
|
|
7
|
+
|
|
8
|
+
const PAGE_URL = 'https://atris.ai/dashboard/growth';
|
|
9
|
+
const NOT_FOUNDER = 'the growth room is only for the Atris team. sign in with a team account: atris login';
|
|
10
|
+
|
|
11
|
+
function showGrowthHelp() {
|
|
12
|
+
console.log('usage: atris growth [--json | --open]');
|
|
13
|
+
console.log(' atris growth note "what you did" [--day YYYY-MM-DD]');
|
|
14
|
+
console.log('signups, people, paying, revenue, cli installs and countries, the same numbers as the growth page.');
|
|
15
|
+
console.log(' --open open the growth page in your browser');
|
|
16
|
+
console.log(' --json print the full numbers once as one json object');
|
|
17
|
+
console.log(' note save a dated note that shows on the growth charts');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const count = (value) => Number(value || 0).toLocaleString('en-US');
|
|
21
|
+
|
|
22
|
+
/** "up 50%" against the week before; empty when there is nothing to compare with. */
|
|
23
|
+
function change(now, before) {
|
|
24
|
+
const a = Number(now || 0);
|
|
25
|
+
const b = Number(before || 0);
|
|
26
|
+
if (!b) return '';
|
|
27
|
+
const percent = Math.round(((a - b) / b) * 100);
|
|
28
|
+
if (percent === 0) return ', flat';
|
|
29
|
+
return `, ${percent > 0 ? 'up' : 'down'} ${Math.abs(percent)}%`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function shortDay(day) {
|
|
33
|
+
const date = new Date(`${day}T12:00:00Z`);
|
|
34
|
+
if (Number.isNaN(date.getTime())) return String(day || '');
|
|
35
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The growth numbers as a few short lines. */
|
|
39
|
+
function growthText(data = {}) {
|
|
40
|
+
const totals = data.totals || {};
|
|
41
|
+
const lines = [
|
|
42
|
+
`signups ${count(totals.signups_today)} today, ${count(totals.signups_7d)} last 7 days${change(totals.signups_7d, totals.signups_prev_7d)}, ${count(totals.signups_30d)} last 30`,
|
|
43
|
+
`people ${count(totals.people)} total, ${count(totals.paying)} paying`,
|
|
44
|
+
`revenue $${count(Math.round(Number(totals.mrr_usd || 0)))} a month`,
|
|
45
|
+
];
|
|
46
|
+
const cli = data.cli_installs;
|
|
47
|
+
if (cli) lines.push(`installs ${count(cli.last_7d)} last 7 days${change(cli.last_7d, cli.prev_7d)}`);
|
|
48
|
+
|
|
49
|
+
const countries = Array.isArray(data.countries) ? data.countries.slice(0, 5) : [];
|
|
50
|
+
lines.push(countries.length
|
|
51
|
+
? `places ${countries.map((row) => `${row.country} ${count(row.people)}`).join(', ')}`
|
|
52
|
+
: 'places none recorded yet');
|
|
53
|
+
|
|
54
|
+
const marks = Array.isArray(data.marks) ? data.marks.slice(0, 5) : [];
|
|
55
|
+
if (marks.length) {
|
|
56
|
+
lines.push(`marked ${marks.map((mark) => (mark.people ? `${mark.label} ${count(mark.people)}` : mark.label)).join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
const notes = Array.isArray(data.notes) ? data.notes.slice(0, 3) : [];
|
|
59
|
+
notes.forEach((note, index) => {
|
|
60
|
+
lines.push(`${index === 0 ? 'notes ' : ' '} ${shortDay(note.day)} ${oneLine(note.text)}`);
|
|
61
|
+
});
|
|
62
|
+
lines.push(`page ${PAGE_URL}`);
|
|
63
|
+
return lines.join('\n');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openPage(url) {
|
|
67
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
68
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
69
|
+
try {
|
|
70
|
+
const child = spawn(opener, args, { stdio: 'ignore', detached: true });
|
|
71
|
+
child.on('error', () => {});
|
|
72
|
+
child.unref();
|
|
73
|
+
} catch {
|
|
74
|
+
// The address is printed either way.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Pull `note "text" [--day YYYY-MM-DD]` apart; null text means the note is missing. */
|
|
79
|
+
function readNote(args) {
|
|
80
|
+
const rest = args.slice(1).filter((arg) => arg !== '--json');
|
|
81
|
+
const at = rest.indexOf('--day');
|
|
82
|
+
let day;
|
|
83
|
+
if (at !== -1) {
|
|
84
|
+
day = rest[at + 1];
|
|
85
|
+
rest.splice(at, 2);
|
|
86
|
+
}
|
|
87
|
+
return { text: rest.join(' ').trim(), day };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function failure(result) {
|
|
91
|
+
const status = Number(result && result.status);
|
|
92
|
+
if (status === 401 || status === 403) return NOT_FOUNDER;
|
|
93
|
+
return oneLine(result && result.error);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function growthCommand(args = [], deps = {}) {
|
|
97
|
+
if (wantsHelp(args)) {
|
|
98
|
+
showGrowthHelp();
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
const json = wantsJson(args);
|
|
102
|
+
const io = { log: deps.log || console.log, err: deps.err || console.error };
|
|
103
|
+
const fail = (error, extra = {}) => {
|
|
104
|
+
printResult({ json, ok: false, error, payload: { ok: false, error, ...extra } }, io);
|
|
105
|
+
return 1;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
if (args.includes('--open')) {
|
|
109
|
+
(deps.openPage || openPage)(PAGE_URL);
|
|
110
|
+
printResult({ json, ok: true, text: PAGE_URL, payload: { ok: true, url: PAGE_URL } }, io);
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const token = tokenFrom((deps.loadCredentials || loadCredentials)());
|
|
115
|
+
if (!token) return fail(NOT_LOGGED_IN);
|
|
116
|
+
const request = deps.apiRequestJson || apiRequestJson;
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (args[0] === 'note') {
|
|
120
|
+
const { text, day } = readNote(args);
|
|
121
|
+
if (!text) return fail('say what you did: atris growth note "posted the launch video"');
|
|
122
|
+
if (text.length > 280) return fail('keep a note under 280 characters');
|
|
123
|
+
if (day !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(day))) return fail('write the day as YYYY-MM-DD');
|
|
124
|
+
const result = await request('/admin/growth/notes', {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
token,
|
|
127
|
+
retries: 0, // a retry could save the same note twice
|
|
128
|
+
body: day ? { text, day } : { text },
|
|
129
|
+
});
|
|
130
|
+
if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
|
|
131
|
+
const saved = result.data || {};
|
|
132
|
+
printResult({
|
|
133
|
+
json,
|
|
134
|
+
ok: true,
|
|
135
|
+
text: `noted for ${shortDay(saved.day || day)}: ${oneLine(saved.text || text)}`,
|
|
136
|
+
payload: { ok: true, note: saved },
|
|
137
|
+
}, io);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const result = await request('/admin/growth/overview?days=28', { method: 'GET', token });
|
|
142
|
+
if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
|
|
143
|
+
const data = result.data || {};
|
|
144
|
+
printResult({ json, ok: true, text: growthText(data), payload: { ok: true, ...data } }, io);
|
|
145
|
+
return 0;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return fail(oneLine(error && error.message));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { growthCommand, growthText, showGrowthHelp };
|