atris 3.82.11 → 3.82.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -17,6 +17,14 @@ process.on('unhandledRejection', (err) => {
17
17
  }
18
18
  }
19
19
 
20
+ // Agents call atris memory show, search, remember and the rest many times a
21
+ // session. Answer them before the rest of the CLI loads: half the start time.
22
+ if (process.argv[2] === 'memory'
23
+ && require('../lib/memory-live').isLiveCommand(process.argv.slice(3).find((arg) => !arg.startsWith('-')))) {
24
+ require('../commands/memory-live').runFromCli(process.argv.slice(3));
25
+ return;
26
+ }
27
+
20
28
  const fs = require('fs');
21
29
  const path = require('path');
22
30
  const { exec, spawnSync } = require('child_process');
@@ -658,6 +666,7 @@ function showHelpAll() {
658
666
  console.log(' meet - onboard a stranger in one sitting and print their /book link');
659
667
  console.log(' avail - Booking availability (/book/{username} weekly windows)');
660
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 "...")');
661
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)');
662
671
  console.log(' rating - The Atris Rating, 25 to 99: how much real work each engine and member finishes (atris rating opus)');
663
672
  console.log(' brain - Compile MAP/TODO/wiki/state into a loadable agent brain');
@@ -712,6 +721,7 @@ function showHelpAll() {
712
721
  console.log('Sync:');
713
722
  console.log(' pull - Pull journals + member data from cloud');
714
723
  console.log(' push - Push workspace files to cloud');
724
+ console.log(' memory - Your Atris memory for any agent: show, search, remember, notebook; pull/push a laptop copy');
715
725
  console.log(' sync-checkout - fetch and fast-forward a clean current branch');
716
726
  console.log(' zen - make this repo calm and current, keeping everything (--check to only look)');
717
727
  console.log(' cloud - Delete cloud files not present locally (cloud clean --dry-run|--yes)');
@@ -2244,6 +2254,10 @@ if (command === 'guide') {
2244
2254
  Promise.resolve().then(() => require('../commands/scoreboard').scoreboardCommand(process.argv.slice(3)))
2245
2255
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2246
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); });
2247
2261
  } else if (command === 'scorecard') {
2248
2262
  Promise.resolve().then(() => require('../commands/scorecard').scorecardCommand(process.argv.slice(3)))
2249
2263
  .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
@@ -2509,6 +2523,10 @@ if (command === 'guide') {
2509
2523
  process.exit(0);
2510
2524
  }
2511
2525
  require('../commands/auth').accountsCmd(args);
2526
+ } else if (command === 'memory') {
2527
+ Promise.resolve().then(() => require('../commands/memory').memoryCommand(process.argv.slice(3)))
2528
+ .then((code) => exitAfterStdoutDrain(typeof code === 'number' ? code : 0))
2529
+ .catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
2512
2530
  } else if (command === 'balance') {
2513
2531
  Promise.resolve().then(() => require('../commands/balance').balanceCommand(process.argv.slice(3)))
2514
2532
  .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 };