atris 3.82.12 → 3.82.15

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.
@@ -52,38 +52,38 @@ const {
52
52
  findAtrisDir,
53
53
  } = require('../lib/business-core');
54
54
 
55
- async function findExistingBusinessBySlug(slug, token) {
55
+ async function findExistingBusinessBySlug(slug, token, { api = apiRequestJson, loadLocal = loadBusinesses } = {}) {
56
56
  if (!slug) return null;
57
57
 
58
- // Local cache first, no network round-trip needed.
59
- const local = loadBusinesses();
60
- if (local[slug]) {
61
- return { id: local[slug].business_id, name: local[slug].name, slug, source: 'local' };
62
- }
63
- for (const v of Object.values(local)) {
64
- if (businessMatchesSlug(v, slug)) {
65
- return { id: v.business_id, name: v.name, slug, source: 'local' };
66
- }
67
- }
58
+ const local = loadLocal();
59
+ const localMatch = local[slug]
60
+ ? { id: local[slug].business_id, name: local[slug].name, slug, source: 'local' }
61
+ : (() => {
62
+ const v = Object.values(local).find((b) => businessMatchesSlug(b, slug));
63
+ return v ? { id: v.business_id, name: v.name, slug, source: 'local' } : null;
64
+ })();
68
65
 
69
- if (!token) return null;
66
+ if (!token) return localMatch;
70
67
 
71
- // Cloud lookup, covers businesses the user is a member of but hasn't added.
72
- const direct = await apiRequestJson(`/business/by-slug/${encodeURIComponent(slug)}`, {
73
- method: 'GET',
74
- token,
75
- });
68
+ // The cloud is the truth. The Mac's list can hold a business that was
69
+ // deleted, and trusting it refused to recreate one that no longer existed.
70
+ const direct = await api(`/business/by-slug/${encodeURIComponent(slug)}`, { method: 'GET', token });
76
71
  if (direct.ok && direct.data && direct.data.id) {
77
72
  return { id: direct.data.id, name: direct.data.name, slug: direct.data.slug || slug, source: 'cloud' };
78
73
  }
79
74
 
80
- const list = await apiRequestJson('/business/', { method: 'GET', token });
75
+ const list = await api('/business/', { method: 'GET', token });
81
76
  if (list.ok && Array.isArray(list.data)) {
82
77
  const match = list.data.find(b => businessMatchesSlug(b, slug));
83
78
  if (match) return { id: match.id, name: match.name, slug: match.slug, source: 'cloud' };
79
+ if (localMatch) {
80
+ console.warn(` Note: this Mac still lists "${slug}", but the cloud has no such business. Run: atris business doctor --fix`);
81
+ }
82
+ return null;
84
83
  }
85
84
 
86
- return null;
85
+ // Cloud unreachable: fall back to the Mac's list rather than risk a duplicate.
86
+ return localMatch;
87
87
  }
88
88
 
89
89
  async function addBusiness(slug) {
@@ -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 <service> Connect a skill/integration');
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 connectService(args[0], ...args.slice(1));
1629
+ process.exitCode = await require('./business-connect').connectCommand(args);
1709
1630
  break;
1710
1631
  case 'notify':
1711
1632
  case 'notification':
@@ -1744,6 +1665,7 @@ async function businessCommand(subcommand, ...args) {
1744
1665
  }
1745
1666
 
1746
1667
  module.exports = {
1668
+ findExistingBusinessBySlug,
1747
1669
  businessCommand,
1748
1670
  businessWorkspaceBase,
1749
1671
  loadBusinesses,
@@ -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 };
@@ -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;