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