atris 3.82.12 → 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.
@@ -55,6 +55,7 @@ Each line says what the skill does and when to open it. A skill with a review po
55
55
  - Backend rules that keep code simple. Open [backend](backend/SKILL.md) when you build an API or service. Policy: `policies/atris-backend.md`.
56
56
  - Essay process with gates. Open [writing](writing/SKILL.md) when you draft an essay or long post. Policy: `policies/writing.md`.
57
57
  - Find and fix generated-sounding writing. Open [copy-editor](copy-editor/SKILL.md) when you edit a draft before it ships.
58
+ - Cut interface text that should not be on the screen. Open [clean-copy](clean-copy/SKILL.md) when you write or review labels, hints, empty states or button text.
58
59
  - Content written to get cited by AI answer engines. Open [aeo](aeo/SKILL.md) when a page should show up in AI answers.
59
60
  - Write a release post. Open [launch](launch/SKILL.md) after a release ships.
60
61
  - Author Atris block documents: docs, decks and reports. Open [blocks](blocks/SKILL.md) when you write a doc for the Atris editor.
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: clean-copy
3
+ description: Cut interface copy that should not exist. Captions that restate a label, hints that answer a worry the user never had, lines that defend a choice or give permission for a button already on screen. Use when writing or reviewing any user-facing string (labels, hints, empty states, toasts, confirmations, button text) or when asked to clean up or tighten copy. Triggers on clean copy, tighten copy, cut copy, review strings, too much text, ui copy.
4
+ version: 1.1.0
5
+ tags:
6
+ - copy
7
+ - interface
8
+ - writing
9
+ - quality
10
+ ---
11
+
12
+ # Cleaning copy
13
+
14
+ Most bad copy is not badly written. It is copy that should not be on the screen. Editing it for tone just makes the wrong thing read better. The first question is never "how should this be phrased". It is **should this exist**.
15
+
16
+ Every example below is a real line from an Atris screen and what happened to it.
17
+
18
+ ## The ladder
19
+
20
+ Walk a string down these in order. Stop at the first one that fires. Cut the string or cut the part that fired.
21
+
22
+ 1. **Does this need to surface at all?**
23
+ Would the user ever have wondered about this, unprompted? If not, you are not answering a question. You are planting one. Under the theme picker sat "Theme changes apply instantly across the entire app". Nobody picks a theme wondering whether it will reach the whole app. The line went and the picker lost nothing.
24
+
25
+ 2. **Does the caption need to exist?**
26
+ A caption earns its place only when the thing beside it is meaningless without it. A checkbox labelled "Require sign-in" had "Users must sign in before accessing this app." beneath it. A heading "Recent payments" had "The latest payments received by this business." Both went. A bare "30" does need "minutes" beside it. That one stays.
27
+
28
+ 3. **Is it answering something the user would never have had to consider?**
29
+ The tell: copy that rules out a failure, defends a choice or hedges about something that simply works.
30
+
31
+ > Bring the existing site into blocks.
32
+ > *Your current index.html will become one custom HTML block, so every byte stays intact.*
33
+
34
+ The first half says what will happen. The second half denies a loss the user had not imagined until we named it. A list of job postings said "Every link is a real posting." An error said "Could not regenerate the scoreboard. Your current one is unchanged." Each one hands the reader a worry they arrived without. The same goes for a line that remembers an old bug. **The fix for a fixed bug is the fix. Not a caption about the fix.**
35
+
36
+ 4. **Rewrite to remove the part that fired. If nothing is left, delete the element.**
37
+ Do not preserve a hint just because there was a hint there. An empty `hint` prop comes off the component. An explanatory `<p>` gets deleted, not shortened to a stub.
38
+
39
+ ## What survives
40
+
41
+ Copy stays when the user cannot act without it. These lines went through the same review and stayed word for word:
42
+
43
+ - A real consequence at the moment of choosing. *"Remove Dana from the team? Their notes are kept."* They need it before they click and they cannot infer it.
44
+ - A fact about scope they cannot see. *"Visitor chat history from the last 90 days."* Nothing else on the page says where the history stops.
45
+ - What a blank or stuck state means and what to do next. *"This computer is asleep. Send it one message to wake it, then come back."*
46
+ - Units, scale or format on a bare value.
47
+
48
+ A line can be half of each. *"Only work you start uses it. Your teammates never do, and you can disconnect any time."* The first part is scope the user cannot see. The last part tells them they may press a Disconnect button that is already on the screen. Keep the scope. Cut the permission slip.
49
+
50
+ ## Working style
51
+
52
+ - Read the string next to what is on screen around it: the label, the value, the button. Redundancy is invisible when you read the string alone.
53
+ - Cut whole clauses, not words. Noticing which half of a two-sentence hint does no work beats trimming three words from both halves.
54
+ - When reviewing a batch, report as `file:line` with the current string and either the rewrite or `cut`. Say in a few words which rung it failed. Do not rewrite copy that passes.
55
+ - If a string matters only in a rare state, consider whether it belongs in that state instead of sitting on the page all the time.
56
+
57
+ ## Get a second reader before you cut
58
+
59
+ Cutting is easy to overdo. On the first real run the first reader marked 40 lines and a second reader kept 15 of them, mostly lines about billing and failed states that told the user something they could not see.
60
+
61
+ - Hand the batch to a second reader on a different model. Give it the strings and this skill, not your verdicts or your reasons.
62
+ - Mix in lines you are sure should stay. A reader that cuts those is cutting too much and its other verdicts are not worth much.
63
+ - Change a line only where both readers agree. When they disagree the line stays.
64
+
65
+ ## After the cut
66
+
67
+ - A removed line takes its spacing with it. Look at the gap it leaves between the heading and the button.
68
+ - Tests often quote the old string. Search for it before you run them.
69
+
70
+ ## Go deeper
71
+
72
+ - [../copy-editor/SKILL.md](../copy-editor/SKILL.md): fixes how a sentence reads. Run this skill first, since a deleted string needs no editing.
73
+ - [../taste/SKILL.md](../taste/SKILL.md): where the taste of the people you work for lives.
74
+ - [../design/SKILL.md](../design/SKILL.md): the visual rules for the screen the copy sits on.
package/bin/atris.js CHANGED
@@ -666,6 +666,7 @@ function showHelpAll() {
666
666
  console.log(' meet - onboard a stranger in one sitting and print their /book link');
667
667
  console.log(' avail - Booking availability (/book/{username} weekly windows)');
668
668
  console.log(' scoreboard - Today\'s github contributions vs your best day, streak, repo commits (--watch, --json)');
669
+ console.log(' growth - Signups, paying, revenue, cli installs and countries, same as the growth page (--open, --json, note "...")');
669
670
  console.log(' scorecard - Which AI can do your team\'s work: past changes graded by their own tests, run on your Claude plan (find, run, show)');
670
671
  console.log(' rating - The Atris Rating, 25 to 99: how much real work each engine and member finishes (atris rating opus)');
671
672
  console.log(' brain - Compile MAP/TODO/wiki/state into a loadable agent brain');
@@ -2253,6 +2254,10 @@ if (command === 'guide') {
2253
2254
  Promise.resolve().then(() => require('../commands/scoreboard').scoreboardCommand(process.argv.slice(3)))
2254
2255
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2255
2256
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2257
+ } else if (command === 'growth') {
2258
+ Promise.resolve().then(() => require('../commands/growth').growthCommand(process.argv.slice(3)))
2259
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2260
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2256
2261
  } else if (command === 'scorecard') {
2257
2262
  Promise.resolve().then(() => require('../commands/scorecard').scorecardCommand(process.argv.slice(3)))
2258
2263
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -830,6 +830,25 @@ function acceptedNotLandedNote(receipt) {
830
830
  return `, ${rows.length} accepted but not proven landed (${rows.map((r) => `${r.ref} ${r.status}`).join(', ')})`;
831
831
  }
832
832
 
833
+ // Every job a run leaves waiting keeps its reason. Counts alone hid one cause
834
+ // holding every finished job for five days: the record said "8 skipped" and
835
+ // nothing on disk said why.
836
+ function waitingReasonRows(results) {
837
+ return (Array.isArray(results) ? results : [])
838
+ .filter((row) => row && row.action === 'skipped' && row.ref)
839
+ .map((row) => ({ ref: String(row.ref), reason: String(row.reason || 'unknown') }));
840
+ }
841
+
842
+ function waitingReasonSummary(receipt) {
843
+ const rows = Array.isArray(receipt.waiting_reasons) ? receipt.waiting_reasons : [];
844
+ const counts = new Map();
845
+ for (const row of rows) counts.set(row.reason, (counts.get(row.reason) || 0) + 1);
846
+ return [...counts.entries()]
847
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
848
+ .map(([reason, count]) => `${count} ${reason}`)
849
+ .join(', ');
850
+ }
851
+
833
852
  function stuckBacklogNote(receipt) {
834
853
  const parts = [];
835
854
  const scanned = Number(receipt.scanned || 0);
@@ -838,7 +857,8 @@ function stuckBacklogNote(receipt) {
838
857
  // separately (accepted_not_landed), not misread as a wedged queue.
839
858
  const accepted = Array.isArray(receipt.accepted) ? receipt.accepted.length : receipt.landed.length;
840
859
  if (scanned > 0 && accepted === 0) {
841
- parts.push(`${certified}/${scanned} certified in review, none landed`);
860
+ const why = waitingReasonSummary(receipt);
861
+ parts.push(`${certified}/${scanned} certified in review, none landed${why ? ` (${why})` : ''}`);
842
862
  }
843
863
  const unrunnable = Array.isArray(receipt.unrunnable_verifiers) ? receipt.unrunnable_verifiers.length : 0;
844
864
  if (unrunnable > 0) {
@@ -1118,6 +1138,8 @@ async function runTickBody(root, { json, policy, receipt, engineValidationDeps =
1118
1138
  receipt.scanned = parsed.scanned ?? null;
1119
1139
  receipt.revised = parsed.revised ?? null;
1120
1140
  receipt.skipped = parsed.skipped ?? null;
1141
+ const waiting = waitingReasonRows(results);
1142
+ if (waiting.length) receipt.waiting_reasons = waiting;
1121
1143
  receipt.undercounted = Boolean(parsed.undercounted);
1122
1144
  } catch (err) {
1123
1145
  receipt.accept_error = describeAcceptKillCause(accept) || accept.stderr.slice(0, 200) || 'auto-accept output unreadable';
@@ -1513,6 +1535,8 @@ module.exports = {
1513
1535
  hasAgentJargon,
1514
1536
  runEngineAnswerValidation,
1515
1537
  runTickBody,
1538
+ stuckBacklogNote,
1539
+ waitingReasonRows,
1516
1540
  acceptCommandArgs,
1517
1541
  runSshAgentSweep,
1518
1542
  sshAgentSweepNote,
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atris business connect [service] [--business slug]
5
+ *
6
+ * Connects a service to a business on the server, the same thing the
7
+ * Integrations tab on the web does. With no service it shows what is
8
+ * connected. Bot services take a token here; OAuth services open the
9
+ * approval page in the browser.
10
+ */
11
+
12
+ const { apiRequestJson } = require('../utils/api');
13
+ const { loadCredentials, openBrowser } = require('../utils/auth');
14
+ const { detectBusinessSlug } = require('../lib/business-core');
15
+ const { NOT_LOGGED_IN, wantsHelp, wantsJson, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
16
+
17
+ /** Each business service: how to connect it, and the words to show for it. */
18
+ const SERVICES = {
19
+ slack: { kind: 'oauth', label: 'Slack', fields: ['team_name'] },
20
+ 'google-drive': { kind: 'oauth', label: 'Google Drive', key: 'google_drive' },
21
+ hubspot: { kind: 'oauth', label: 'HubSpot' },
22
+ telegram: { kind: 'token', label: 'Telegram', body: (f) => ({ bot_token: f.token }), need: '--token <bot token from @BotFather>', fields: ['bot_username'] },
23
+ discord: { kind: 'token', label: 'Discord', body: (f) => ({ bot_token: f.token, guild_id: f.guild || null }), need: '--token <bot token> [--guild <server id>]', fields: ['guild_name'] },
24
+ whatsapp: { kind: 'token', label: 'WhatsApp', body: (f) => ({ access_token: f['access-token'], phone_number_id: f['phone-id'] }), need: '--access-token <token> --phone-id <id>', fields: ['phone_number_id'] },
25
+ };
26
+ const PERSONAL_ONLY = ['linear', 'notion', 'github', 'jira', 'salesforce', 'microsoft', 'gmail', 'calendar', 'mercury', 'brex', 'robinhood', 'x', 'twitter', 'linkedin'];
27
+
28
+ function showConnectHelp(log = console.log) {
29
+ log('usage: atris business connect [business] what is connected');
30
+ log(' atris business connect [business] slack opens the approval page');
31
+ log(' atris business connect [business] google-drive | hubspot');
32
+ log(' atris business connect [business] telegram --token <bot token>');
33
+ log(' atris business connect [business] discord --token <bot token> [--guild <server id>]');
34
+ log(' atris business connect [business] whatsapp --access-token <token> --phone-id <id>');
35
+ log(' atris business connect [business] <service> --off disconnect it');
36
+ log('the business comes from the folder you are in when you leave it out. --no-open prints the url instead of opening it.');
37
+ log('linear, notion, github and the rest connect to your own account, not a business: atris.ai/dashboard/integrations');
38
+ }
39
+
40
+ function readFlags(args, names) {
41
+ const flags = {};
42
+ const rest = [];
43
+ for (let i = 0; i < args.length; i += 1) {
44
+ const arg = args[i];
45
+ if (names.includes(arg)) {
46
+ flags[arg.slice(2)] = args[i + 1];
47
+ i += 1;
48
+ } else if (!['--json', '--off', '--no-open'].includes(arg)) {
49
+ rest.push(arg);
50
+ }
51
+ }
52
+ return { flags, rest };
53
+ }
54
+
55
+ function failure(result) {
56
+ const status = Number(result && result.status);
57
+ if (status === 401) return NOT_LOGGED_IN;
58
+ if (status === 403) return 'only the owner or an admin of this business can change its connections';
59
+ if (status === 404) return 'no business with that name that you can see. try: atris business list';
60
+ return oneLine(result && (result.errorMessage || result.error)) || `HTTP ${status || '?'}`;
61
+ }
62
+
63
+ /** The status as short lines: one per service, connected ones first with their detail. */
64
+ function statusText(business, status = {}) {
65
+ const lines = [`${business}:`];
66
+ for (const [name, svc] of Object.entries(SERVICES)) {
67
+ const entry = status[svc.key || name] || {};
68
+ if (entry.connected) {
69
+ const detail = (svc.fields || []).map((f) => entry[f]).filter(Boolean).join(', ');
70
+ lines.push(` ${svc.label.padEnd(13)} connected${detail ? `, ${detail}` : ''}`);
71
+ } else {
72
+ lines.push(` ${svc.label.padEnd(13)} not connected atris business connect ${business} ${name}${svc.need ? ` ${svc.need}` : ''}`);
73
+ }
74
+ }
75
+ const google = status.google || {};
76
+ if (google.connected || google.account_email) lines.push(` ${'Google'.padEnd(13)} ${google.account_email || 'connected'}`);
77
+ return lines.join('\n');
78
+ }
79
+
80
+ async function connectCommand(args = [], deps = {}) {
81
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
82
+ if (wantsHelp(args)) {
83
+ showConnectHelp(io.log);
84
+ return 0;
85
+ }
86
+ const json = wantsJson(args);
87
+ const fail = (error, extra = {}) => {
88
+ printResult({ json, ok: false, error, payload: { ok: false, error, ...extra } }, io);
89
+ return 1;
90
+ };
91
+ const { flags, rest } = readFlags(args, ['--business', '--token', '--guild', '--access-token', '--phone-id']);
92
+ // The service is whichever word is a known service; the other word, if any, is the business.
93
+ let service = null;
94
+ let business = flags.business || null;
95
+ for (const word of rest) {
96
+ if (!service && (SERVICES[word] || PERSONAL_ONLY.includes(word))) service = word;
97
+ else if (!business) business = word;
98
+ }
99
+ business = (deps.detectBusinessSlug || detectBusinessSlug)(business);
100
+ if (!business) return fail('say which business: atris business connect sunset-ink');
101
+ if (service && PERSONAL_ONLY.includes(service)) {
102
+ return fail(`${service} connects to your own account, not to a business. open atris.ai/dashboard/integrations`);
103
+ }
104
+ const token = tokenFrom((deps.loadCredentials || loadCredentials)());
105
+ if (!token) return fail(NOT_LOGGED_IN);
106
+ const request = deps.apiRequestJson || apiRequestJson;
107
+ const base = `/business/${encodeURIComponent(business)}/integrations`;
108
+
109
+ try {
110
+ if (!service) {
111
+ const result = await request(base, { method: 'GET', token });
112
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
113
+ printResult({ json, ok: true, text: statusText(business, result.data || {}), payload: { ok: true, business, integrations: result.data } }, io);
114
+ return 0;
115
+ }
116
+ const svc = SERVICES[service];
117
+ if (args.includes('--off')) {
118
+ const result = await request(`${base}/${service}`, { method: 'DELETE', token });
119
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
120
+ printResult({ json, ok: true, text: `${svc.label} is off for ${business}`, payload: { ok: true, business, service, connected: false } }, io);
121
+ return 0;
122
+ }
123
+ if (svc.kind === 'token') {
124
+ const body = svc.body(flags);
125
+ if (Object.values(body).some((v) => v === undefined || v === '')) return fail(`${svc.label} needs ${svc.need}`);
126
+ const result = await request(`${base}/${service}`, { method: 'POST', token, retries: 0, body });
127
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
128
+ const data = result.data || {};
129
+ const detail = (svc.fields || []).map((f) => data[f]).filter(Boolean).join(', ');
130
+ printResult({ json, ok: true, text: `${svc.label} is connected to ${business}${detail ? ` as ${detail}` : ''}`, payload: { ok: true, business, service, ...data } }, io);
131
+ return 0;
132
+ }
133
+ const result = await request(`${base}/${service}/start`, { method: 'POST', token, retries: 0, body: {} });
134
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
135
+ const url = (result.data || {}).auth_url || (result.data || {}).url;
136
+ if (!url) return fail(`the server gave no approval page for ${svc.label}`);
137
+ const opened = !args.includes('--no-open') && Boolean((deps.openBrowser || openBrowser)(url));
138
+ const text = opened
139
+ ? `opening ${svc.label}'s approval page. finish there, then check: atris business connect ${business}\n${url}`
140
+ : `open this to approve ${svc.label} for ${business}:\n${url}`;
141
+ printResult({ json, ok: true, text, payload: { ok: true, business, service, auth_url: url, opened } }, io);
142
+ return 0;
143
+ } catch (error) {
144
+ return fail(oneLine(error && error.message) || 'request failed');
145
+ }
146
+ }
147
+
148
+ module.exports = { connectCommand, statusText, showConnectHelp, SERVICES };
@@ -0,0 +1,216 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atris business scoreboard <business>
5
+ *
6
+ * One board of normalized units for any business: revenue, payments, orders,
7
+ * visitors, credits, plus custom numbers the owner records by hand. The same
8
+ * numbers render as text here, as JSON, or as an Atris doc with live blocks.
9
+ */
10
+
11
+ const fs = require('node:fs');
12
+ const { apiRequestJson } = require('../utils/api');
13
+ const { loadCredentials } = require('../utils/auth');
14
+ const { detectBusinessSlug } = require('../lib/business-core');
15
+ const { NOT_LOGGED_IN, wantsHelp, wantsJson, tokenFrom, oneLine, printResult } = require('../lib/developer-api');
16
+
17
+ function showScoreboardHelp(log = console.log) {
18
+ log('usage: atris business scoreboard [business] [--days N] [--json]');
19
+ log(' atris business scoreboard [business] --doc [--out FILE]');
20
+ log(' atris business scoreboard [business] add "Label" [--kind money|count|percent] [--id slug] [--target N]');
21
+ log(' atris business scoreboard [business] add "Label" --event signup [--people]');
22
+ log(' atris business scoreboard [business] add "Label" --formula "revenue / customers"');
23
+ log(' atris business scoreboard [business] set <unit> <value> [--day YYYY-MM-DD]');
24
+ log(' atris business scoreboard [business] target <unit> <N|none>');
25
+ log(' atris business scoreboard [business] layout <unit,unit,...>');
26
+ log(' atris business scoreboard [business] remove <unit>');
27
+ log('revenue, payments, orders, visitors and credits for one business, plus your own numbers.');
28
+ log(' --event count an event your site sends (atris business track); --people counts distinct visitors');
29
+ log(' --formula a number made from other units with + - * / and parentheses');
30
+ log(' --days window for the time-boxed units (default 30)');
31
+ log(' --doc print the board as an Atris doc with metric, gauge and chart blocks');
32
+ log(' --out write the doc to a file instead of printing it');
33
+ log(' --json print the full board once as one json object');
34
+ log('the business comes from the folder you are in when you leave it out.');
35
+ }
36
+
37
+ const money = (value) => (Number(value) >= 100
38
+ ? `$${Math.round(Number(value)).toLocaleString('en-US')}`
39
+ : `$${Number(value).toFixed(2)}`);
40
+
41
+ function formatValue(unit = {}) {
42
+ if (unit.value === null || unit.value === undefined) return 'no data';
43
+ if (unit.kind === 'money') return money(unit.value);
44
+ if (unit.kind === 'percent') return `${Number(unit.value)}%`;
45
+ return Number(unit.value).toLocaleString('en-US');
46
+ }
47
+
48
+ function periodText(unit = {}) {
49
+ const period = String(unit.period || '');
50
+ return period === 'now' ? 'now' : `last ${period.replace(/d$/, '')} days`;
51
+ }
52
+
53
+ /** The board as aligned lines: label, value, period, and a target when one is set. */
54
+ function scoreboardText(board = {}) {
55
+ const units = Array.isArray(board.units) ? board.units : [];
56
+ const name = (board.business && (board.business.name || board.business.slug)) || 'business';
57
+ if (!units.length) return `${name}: no units on the board yet. add one: atris business scoreboard add "Leads"`;
58
+ const width = Math.max(...units.map((u) => String(u.label || u.id).length));
59
+ const lines = [`${name}, ${board.days || 30} day window`];
60
+ for (const unit of units) {
61
+ let line = `${String(unit.label || unit.id).padEnd(width)} ${formatValue(unit).padStart(10)} ${periodText(unit)}`;
62
+ if (unit.target !== null && unit.target !== undefined && unit.value !== null && unit.value !== undefined) {
63
+ line += `, target ${formatValue({ ...unit, value: unit.target })}`;
64
+ }
65
+ if (unit.note) line += ` (${unit.note})`;
66
+ lines.push(line);
67
+ }
68
+ const custom = units.filter((u) => u.builtin === false).map((u) => u.id);
69
+ if (custom.length) lines.push(`record one: atris business scoreboard set ${custom[0]} <value>`);
70
+ return lines.join('\n');
71
+ }
72
+
73
+ /** Pull `--flag value` pairs out of args; returns {flags, rest}. */
74
+ function readFlags(args, names) {
75
+ const flags = {};
76
+ const rest = [];
77
+ for (let i = 0; i < args.length; i += 1) {
78
+ const arg = args[i];
79
+ if (names.includes(arg)) {
80
+ flags[arg.slice(2)] = args[i + 1];
81
+ i += 1;
82
+ } else if (arg !== '--json' && arg !== '--doc') {
83
+ rest.push(arg);
84
+ }
85
+ }
86
+ return { flags, rest };
87
+ }
88
+
89
+ const ACTIONS = new Set(['add', 'set', 'layout', 'remove', 'target']);
90
+
91
+ function failure(result) {
92
+ const status = Number(result && result.status);
93
+ if (status === 401) return NOT_LOGGED_IN;
94
+ if (status === 403) return 'only the owner or an admin of this business can change its scoreboard';
95
+ if (status === 404) return 'no business with that name that you can see. try: atris business list';
96
+ return oneLine(result && (result.errorMessage || result.error)) || `HTTP ${status || '?'}`;
97
+ }
98
+
99
+ async function scoreboardCommand(args = [], deps = {}) {
100
+ const io = { log: deps.log || console.log, err: deps.err || console.error };
101
+ if (wantsHelp(args)) {
102
+ showScoreboardHelp(io.log);
103
+ return 0;
104
+ }
105
+ const json = wantsJson(args);
106
+ const fail = (error, extra = {}) => {
107
+ printResult({ json, ok: false, error, payload: { ok: false, error, ...extra } }, io);
108
+ return 1;
109
+ };
110
+
111
+ const { flags, rest } = readFlags(args, ['--days', '--out', '--kind', '--id', '--target', '--day', '--event', '--formula']);
112
+ // The business is the first word unless that word is an action, then it comes from the folder.
113
+ let business = null;
114
+ if (rest.length && !ACTIONS.has(rest[0])) business = rest.shift();
115
+ business = (deps.detectBusinessSlug || detectBusinessSlug)(business);
116
+ if (!business) return fail('say which business: atris business scoreboard sunset-ink');
117
+
118
+ const token = tokenFrom((deps.loadCredentials || loadCredentials)());
119
+ if (!token) return fail(NOT_LOGGED_IN);
120
+ const request = deps.apiRequestJson || apiRequestJson;
121
+ const base = `/business/${encodeURIComponent(business)}/scoreboard`;
122
+ const days = flags.days ? Number(flags.days) : 30;
123
+ if (!Number.isInteger(days) || days < 1 || days > 365) return fail('--days takes a whole number from 1 to 365');
124
+
125
+ try {
126
+ const action = rest[0];
127
+ if (action === 'add') {
128
+ const label = rest.slice(1).join(' ').trim();
129
+ if (!label) return fail('name the unit: atris business scoreboard add "Shirts printed"');
130
+ const body = { label, kind: flags.kind || 'count' };
131
+ if (flags.id) body.id = flags.id;
132
+ if (flags.target !== undefined) {
133
+ body.target = Number(flags.target);
134
+ if (!Number.isFinite(body.target)) return fail('--target takes a number');
135
+ }
136
+ if (flags.event && flags.formula) return fail('a unit reads an event or a formula, not both');
137
+ if (flags.event) {
138
+ body.event = String(flags.event).trim();
139
+ body.measure = args.includes('--people') ? 'people' : 'count';
140
+ }
141
+ if (flags.formula) body.formula = String(flags.formula).trim();
142
+ const result = await request(`${base}/units`, { method: 'POST', token, retries: 0, body });
143
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
144
+ const unit = result.data || {};
145
+ const next = unit.event
146
+ ? `it counts ${unit.measure === 'people' ? 'people who send' : 'every'} ${unit.event}`
147
+ : unit.formula ? `it is ${unit.formula}` : `record one: atris business scoreboard set ${unit.id} <value>`;
148
+ printResult({ json, ok: true, text: `added ${unit.label} (${unit.id}). ${next}`, payload: { ok: true, unit } }, io);
149
+ return 0;
150
+ }
151
+ if (action === 'target') {
152
+ const [unit, raw] = rest.slice(1);
153
+ if (!unit || raw === undefined) return fail('say the unit and the goal: atris business scoreboard target revenue 5000 (or none)');
154
+ const target = raw === 'none' ? null : Number(raw);
155
+ if (target !== null && !(Number.isFinite(target) && target > 0)) return fail('the target has to be a number above zero, or none');
156
+ const result = await request(`${base}/units/${encodeURIComponent(unit)}/target`, { method: 'PUT', token, retries: 0, body: { target } });
157
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
158
+ printResult({ json, ok: true, text: target === null ? `${unit} has no target now` : `${unit} aims for ${target}`, payload: { ok: true, unit, target } }, io);
159
+ return 0;
160
+ }
161
+ if (action === 'set') {
162
+ const [unit, raw] = rest.slice(1);
163
+ const value = Number(raw);
164
+ if (!unit || raw === undefined) return fail('say the unit and the number: atris business scoreboard set shirts_printed 12');
165
+ if (!Number.isFinite(value)) return fail('the value has to be a number');
166
+ if (flags.day !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(flags.day))) return fail('write the day as YYYY-MM-DD');
167
+ const body = flags.day ? { value, day: flags.day } : { value };
168
+ const result = await request(`${base}/units/${encodeURIComponent(unit)}/values`, { method: 'POST', token, retries: 0, body });
169
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
170
+ const saved = result.data || {};
171
+ printResult({ json, ok: true, text: `${unit} is ${saved.value} for ${saved.day}`, payload: { ok: true, value: saved } }, io);
172
+ return 0;
173
+ }
174
+ if (action === 'layout') {
175
+ const units = rest.slice(1).join(',').split(',').map((s) => s.trim()).filter(Boolean);
176
+ if (!units.length) return fail('list the units in order: atris business scoreboard layout revenue,orders,leads');
177
+ const result = await request(`${base}/layout`, { method: 'PUT', token, body: { units } });
178
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
179
+ const layout = (result.data || {}).layout || units;
180
+ printResult({ json, ok: true, text: `board shows ${layout.join(', ')}`, payload: { ok: true, layout } }, io);
181
+ return 0;
182
+ }
183
+ if (action === 'remove') {
184
+ const unit = rest[1];
185
+ if (!unit) return fail('say which unit: atris business scoreboard remove leads');
186
+ const result = await request(`${base}/units/${encodeURIComponent(unit)}`, { method: 'DELETE', token });
187
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
188
+ printResult({ json, ok: true, text: `removed ${unit}`, payload: { ok: true, removed: unit } }, io);
189
+ return 0;
190
+ }
191
+ if (rest.length) return fail(`unknown action ${rest[0]}. try: atris business scoreboard --help`);
192
+
193
+ if (args.includes('--doc')) {
194
+ const result = await request(`${base}/doc?days=${days}`, { method: 'GET', token });
195
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
196
+ const doc = result.text || (typeof result.data === 'string' ? result.data : '');
197
+ if (flags.out) {
198
+ (deps.writeFile || fs.writeFileSync)(flags.out, doc);
199
+ printResult({ json, ok: true, text: `wrote ${flags.out}`, payload: { ok: true, path: flags.out } }, io);
200
+ } else {
201
+ io.log(doc);
202
+ }
203
+ return 0;
204
+ }
205
+
206
+ const result = await request(`${base}?days=${days}`, { method: 'GET', token });
207
+ if (!result || !result.ok) return fail(failure(result), { status: result && result.status });
208
+ const board = result.data || {};
209
+ printResult({ json, ok: true, text: scoreboardText(board), payload: { ok: true, ...board } }, io);
210
+ return 0;
211
+ } catch (error) {
212
+ return fail(oneLine(error && error.message));
213
+ }
214
+ }
215
+
216
+ module.exports = { scoreboardCommand, scoreboardText, formatValue, showScoreboardHelp };
@@ -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':
@@ -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;
@@ -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 };
@@ -715,12 +715,12 @@ function runVerifyCommandCached(verify, workspaceRoot, cache = null) {
715
715
  // root: one scan covers every task in a sweep.
716
716
  const repoHygieneCache = new Map();
717
717
 
718
- function repoHygieneGate(workspaceRoot) {
718
+ function repoHygieneGate(workspaceRoot, skillScope = null) {
719
719
  const root = path.resolve(workspaceRoot || process.cwd());
720
720
  // Skill receipts are mutable workspace state, so recompute this gate before
721
721
  // consulting the repo-wide dead-code cache. A new receipt can unblock a
722
722
  // landing, while a later SKILL.md edit must make an older receipt stale.
723
- const skillEval = skillEvalGate(root);
723
+ const skillEval = skillEvalGate(root, skillScope || {});
724
724
  if (!skillEval.ok) return skillEval;
725
725
  if (repoHygieneCache.has(root)) return repoHygieneCache.get(root);
726
726
  let result = { ok: true, skipped: true };
@@ -755,8 +755,17 @@ function repoHygieneGate(workspaceRoot) {
755
755
 
756
756
  // Shared by both landing lanes so the check cannot ship in one dispatch branch
757
757
  // and silently skip the sibling (lesson: parallel-paths-drift).
758
- function hygieneBlockResult(task, ref) {
759
- const hygiene = repoHygieneGate(task.workspace_root || process.cwd());
758
+ // A job answers for the skills it changed. When its exact file list is on
759
+ // record (the same list the taste check just read), only those files count,
760
+ // read where the job's branch is checked out. With no list on record the
761
+ // workspace-wide check stays, so an unevaluated skill is never waved through.
762
+ function jobSkillScope(task, candidateGate) {
763
+ if (!candidateGate || candidateGate.advisory_only || !Array.isArray(candidateGate.paths)) return null;
764
+ return { changeRoot: candidateWorkspaceRoot(task), paths: candidateGate.paths };
765
+ }
766
+
767
+ function hygieneBlockResult(task, ref, candidateGate = null) {
768
+ const hygiene = repoHygieneGate(task.workspace_root || process.cwd(), jobSkillScope(task, candidateGate));
760
769
  if (hygiene.ok) return null;
761
770
  return {
762
771
  eligible: false,
@@ -1360,7 +1369,7 @@ function evaluateAutoAccept(task, options = {}) {
1360
1369
  if (executeVerify) {
1361
1370
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1362
1371
  if (!candidateGate.ok) return { ...candidateGate, ref };
1363
- const hygieneBlock = hygieneBlockResult(task, ref);
1372
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1364
1373
  if (hygieneBlock) return hygieneBlock;
1365
1374
  }
1366
1375
  return {
@@ -1444,7 +1453,7 @@ function evaluateAutoAccept(task, options = {}) {
1444
1453
  if (executeVerify) {
1445
1454
  candidateGate = candidatePolicyGate(task, { verifyCache, executeDetectors: true });
1446
1455
  if (!candidateGate.ok) return { ...candidateGate, ref };
1447
- const hygieneBlock = hygieneBlockResult(task, ref);
1456
+ const hygieneBlock = hygieneBlockResult(task, ref, candidateGate.gate);
1448
1457
  if (hygieneBlock) return hygieneBlock;
1449
1458
  }
1450
1459
 
@@ -3,7 +3,7 @@
3
3
  const knownCommands = ['init', 'guide', 'doctor', 'doc-health', 'install', 'workspace', 'log', 'logs', 'later', 'idea', 'done', 'wish', 'ask', 'approve', 'stop', 'ready', 'check', 'drill', 'dream', 'now', '99', 'goal', 'wtf', 'founder', 'orb', 'radar', 'who', 'stream', 'ctop', 'launchpad', 'status', 'analytics', 'revisions', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
4
4
  'activate', '_activate', 'agent', 'team', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
5
5
  'clean', 'close', 'harvest', 'verify', 'recover', 'search', 'scout', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'taste', 'teach', 'plugin', 'experiments', 'bench', 'router', 'tree', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'watch', 'cloud', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube', 'x-search',
6
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'scorecard', 'rating', 'sync-checkout', 'zen',
6
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'decide', 'campaign', 'agents', 'probe', 'worktree', 'land', 'caretaker', 'autoland', 'drive', 'aeo', 'slop', 'voice', 'strings', 'write', 'security-review', 'secure', 'sentinel', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'study', 'rainmaker', 'xp', 'play', 'gm', 'game', 'x', 'recap', 'report', 'signup', 'clarity', 'interview', 'meet', 'moves', 'unknowns', 'avail', 'scoreboard', 'growth', 'scorecard', 'rating', 'sync-checkout', 'zen',
7
7
  'ci', 'github', 'vercel', 'supabase', 'linear', 'stripe', 'memory', 'balance', 'usage', 'spend', 'api-key', 'agent-client', 'topup', 'buy', 'transactions', 'hire', 'hires', 'mail', 'design', 'mcp', 'gmail', 'google', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
8
8
  'fork', 'browse', 'publish', 'pack', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'fleet-report', 'loops', 'self-improve', 'compile', 'spaceship', 'truth', 'sign', 'engine', 'engines', 'link', 'playbook', 'feed', 'brief', 'social', 'host', 'people', 'follow', 'unfollow', 'friends', 'msg', 'inbox', 'invite', 'join', 'rsi', 'pay', 'trust'];
9
9
 
@@ -187,12 +187,47 @@ function validateSkillEvalReceipt(receipt, skill) {
187
187
  return { ok: true, receipt_at_ms: receiptAtMs };
188
188
  }
189
189
 
190
- function skillEvalGate(workspaceRoot = process.cwd()) {
190
+ // The learner writes these notes itself after verified runs (see
191
+ // writeAutoLearnedSkill in lib/mission-core.js). No job authors them, so they
192
+ // never count against a job whose own file list is unknown.
193
+ const LEARNER_NOTE_PATTERN = /(^|\/)skills\/auto-learned\/SKILL\.md$/;
194
+
195
+ function normalizedScopePaths(paths) {
196
+ if (!Array.isArray(paths)) return null;
197
+ return paths
198
+ .map((entry) => String(entry || '').trim().replace(/\\/g, '/').replace(/\/+$/, ''))
199
+ .filter(Boolean);
200
+ }
201
+
202
+ // A job answers for the skills it changed, not for every skill file that
203
+ // happens to sit changed in the workspace. Eight learner notes nobody had
204
+ // evaluated held seven finished jobs for five days (2026-11-04 to 11-09)
205
+ // while none of those jobs touched a skill.
206
+ // scope.paths the job's exact file list, when it is on record. Only
207
+ // skills inside it count, learner notes included.
208
+ // scope.changeRoot where the job's branch is checked out, when that is not
209
+ // the workspace itself.
210
+ // With no file list every changed skill still counts, except learner notes.
211
+ function skillsInScope(root, scope = {}) {
212
+ const changeRoot = path.resolve(scope.changeRoot || root);
213
+ const jobPaths = normalizedScopePaths(scope.paths);
214
+ return changedSkillFiles(changeRoot).filter((skill) => {
215
+ if (!jobPaths) return !LEARNER_NOTE_PATTERN.test(skill.path);
216
+ return jobPaths.some((entry) => skill.path === entry || skill.path.startsWith(`${entry}/`));
217
+ });
218
+ }
219
+
220
+ function skillEvalGate(workspaceRoot = process.cwd(), scope = {}) {
191
221
  const root = path.resolve(workspaceRoot);
192
- const skills = changedSkillFiles(root);
222
+ const changeRoot = path.resolve((scope && scope.changeRoot) || root);
223
+ const skills = skillsInScope(root, scope || {});
193
224
  if (!skills.length) return { ok: true, skipped: true, skills: [] };
194
225
 
195
- const receipts = readSkillEvalReceipts(root);
226
+ // Evaluations are saved in the workspace; one run from the branch's own
227
+ // checkout is saved there instead. Either proves the skill was judged.
228
+ const receipts = changeRoot === root
229
+ ? readSkillEvalReceipts(root)
230
+ : [...readSkillEvalReceipts(root), ...readSkillEvalReceipts(changeRoot)];
196
231
  const missing = [];
197
232
  const accepted = [];
198
233
  for (const skill of skills) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atris",
3
- "version": "3.82.12",
3
+ "version": "3.82.14",
4
4
  "description": "you say what you want in plain words. atris builds it, checks it, and shows you proof. project memory your coding agents share across sessions.",
5
5
  "mcpName": "io.github.atrislabs/atris",
6
6
  "main": "bin/atris.js",