atris 3.58.5 → 3.58.7
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/README.md +8 -0
- package/atris/policies/engineering-principles.md +129 -0
- package/atris/policies/genesis.md +112 -0
- package/atris/policies/product-design-principles.md +100 -0
- package/atris/skills/design/SKILL.md +3 -1
- package/atris/skills/engines/SKILL.md +3 -3
- package/atris/skills/x-search/SKILL.md +2 -2
- package/atris/skills/youtube/SKILL.md +44 -28
- package/bin/atris.js +56 -3
- package/commands/auth.js +58 -24
- package/commands/brain.js +1 -0
- package/commands/design.js +362 -0
- package/commands/doc-health.js +329 -0
- package/commands/drive.js +32 -0
- package/commands/improve.js +67 -1
- package/commands/land.js +144 -4
- package/commands/learn.js +211 -40
- package/commands/member.js +65 -11
- package/commands/mission.js +37 -7
- package/commands/pulse.js +38 -0
- package/commands/rsi.js +156 -0
- package/commands/task.js +41 -1
- package/commands/workflow.js +15 -14
- package/commands/x-search.js +9 -10
- package/commands/youtube.js +518 -107
- package/lib/apply-gate.js +22 -4
- package/lib/daily-log.js +88 -0
- package/lib/design-api.js +130 -0
- package/lib/engine-ask.js +1 -1
- package/lib/first-minute.js +1 -6
- package/lib/known-commands.js +3 -3
- package/lib/member-context.js +42 -0
- package/lib/rsi-record.js +335 -0
- package/lib/state-detection.js +8 -8
- package/lib/task-db.js +71 -51
- package/lib/task-list-keeper.js +192 -0
- package/lib/todo-fallback.js +9 -3
- package/lib/todo.js +22 -10
- package/mcp/atris-mcp/index.mjs +174 -0
- package/package.json +8 -3
- package/scripts/det/ytnotes +122 -10
- package/utils/auth.js +109 -13
package/bin/atris.js
CHANGED
|
@@ -120,7 +120,7 @@ const helpRequested = updateCommand === 'help'
|
|
|
120
120
|
const jsonRequested = process.argv.slice(2).includes('--json');
|
|
121
121
|
const dryRunRequested = updateArgs.includes('--dry-run');
|
|
122
122
|
const skipUpdateCheck = Boolean(process.env.ATRIS_SKIP_UPDATE_CHECK || process.env.NO_UPDATE_NOTIFIER || helpRequested || jsonRequested);
|
|
123
|
-
if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update'].includes(updateCommand)))) {
|
|
123
|
+
if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'update', 'mcp'].includes(updateCommand)))) {
|
|
124
124
|
updateCheckPromise = checkForUpdates()
|
|
125
125
|
.then((updateInfo) => {
|
|
126
126
|
if (updateInfo) {
|
|
@@ -577,6 +577,7 @@ function showHelpAll() {
|
|
|
577
577
|
console.log(' watch - Turn one sentence into an always-on background watcher');
|
|
578
578
|
console.log(' ctop - Show a process-first live agent CPU/memory view');
|
|
579
579
|
console.log(' doctor - Node/task/auth/workspace readiness (--json for agents)');
|
|
580
|
+
console.log(' doc-health - workspace document size, navigation, and freshness (--json)');
|
|
580
581
|
console.log(' launchpad - Show the next action from local brain, task, mission, and proof state');
|
|
581
582
|
console.log(' brief - Show the one-glance operator brief');
|
|
582
583
|
console.log(' status - See local work and completions (`atris status <business>` for remote)');
|
|
@@ -627,6 +628,7 @@ function showHelpAll() {
|
|
|
627
628
|
console.log(' land - The landing: what is actually done vs still in the air; --reap backs up + clears overdue');
|
|
628
629
|
console.log(' caretaker - Classify open pull requests on origin (scan only; no fix, comment, or merge)');
|
|
629
630
|
console.log(' drive - One self-driving tick: mission doctor -> auto-fix -> count disengagements');
|
|
631
|
+
console.log(' rsi - Read the Dream-RSI attempt ledger (trees, attempts, policy, dreams)');
|
|
630
632
|
console.log(` autoland - Approve the policy once; ${require('../lib/autoland').certifiedWorkLandsPhrase(process.cwd())}, you keep irreversible calls`);
|
|
631
633
|
console.log(' engine - engine registry, answer validation, dispatch flights, and live progress');
|
|
632
634
|
console.log(' ci - run github actions jobs locally with runs-on: atris');
|
|
@@ -714,6 +716,8 @@ function showHelpAll() {
|
|
|
714
716
|
console.log(' usage - Show developer API usage');
|
|
715
717
|
console.log(' api-key - Create, list, rotate, or revoke a developer API key');
|
|
716
718
|
console.log(' topup - Buy credits and print a Stripe checkout URL');
|
|
719
|
+
console.log(' design - Extract a site design system, check brand adherence, search brands');
|
|
720
|
+
console.log(' mcp - Run the atris MCP server (stdio) for Claude Desktop and Cursor');
|
|
717
721
|
console.log('');
|
|
718
722
|
console.log('Integrations:');
|
|
719
723
|
console.log(' github - github cli wrapper (doctor, auth, pr list/create/checks/view)');
|
|
@@ -1633,7 +1637,15 @@ function showWelcomeVisualization() {
|
|
|
1633
1637
|
const isInitialized = fs.existsSync(atrisDir);
|
|
1634
1638
|
let endgameState = { slug: 'unset', horizon: '' };
|
|
1635
1639
|
|
|
1640
|
+
let keptCount = 0;
|
|
1636
1641
|
if (isInitialized) {
|
|
1642
|
+
try {
|
|
1643
|
+
const kept = require('../lib/task-list-keeper').keepWorkspaceTaskList(cwd);
|
|
1644
|
+
keptCount = (kept.put_away || []).length + (kept.reaped || []).length;
|
|
1645
|
+
if (keptCount > 0) require('../commands/task').refreshKeptTaskList(cwd);
|
|
1646
|
+
} catch {
|
|
1647
|
+
keptCount = 0;
|
|
1648
|
+
}
|
|
1637
1649
|
try {
|
|
1638
1650
|
glance = getTaskGlance(atrisDir);
|
|
1639
1651
|
} catch {
|
|
@@ -1716,6 +1728,10 @@ function showWelcomeVisualization() {
|
|
|
1716
1728
|
// Show the work itself, not counts. A newcomer in any domain (code, docs,
|
|
1717
1729
|
// a travel plan) should read actual task names and know what's happening.
|
|
1718
1730
|
// Waiting-on-you comes first: the one thing only a human can do.
|
|
1731
|
+
if (keptCount > 0) {
|
|
1732
|
+
const noun = keptCount === 1 ? 'item' : 'items';
|
|
1733
|
+
console.log(row('kept', `put away ${keptCount} ${noun} that were finished or sitting still`));
|
|
1734
|
+
}
|
|
1719
1735
|
if (glance.reviewCertified > 0) {
|
|
1720
1736
|
console.log(row('you', `${glance.reviewCertified} done, waiting for your ok:`));
|
|
1721
1737
|
glance.certifiedTitles.forEach((t) => console.log(sub(trimTitle(t))));
|
|
@@ -1739,6 +1755,18 @@ function showWelcomeVisualization() {
|
|
|
1739
1755
|
console.log(row('now', 'nothing on the list yet'));
|
|
1740
1756
|
}
|
|
1741
1757
|
|
|
1758
|
+
try {
|
|
1759
|
+
const health = require('../commands/doc-health').computeDocHealth(cwd);
|
|
1760
|
+
if (health.ok) {
|
|
1761
|
+
const detail = health.lookup_hops.missing
|
|
1762
|
+
? 'add atris/doc-health/questions.jsonl'
|
|
1763
|
+
: `${Math.round((health.lookup_hops.score || 0) * 100)}% one hop · boot ${(health.boot_load.approximate_tokens / 1000).toFixed(1)}k tokens`;
|
|
1764
|
+
console.log(row('docs', `${health.overall.total}/100 · ${detail}`));
|
|
1765
|
+
}
|
|
1766
|
+
} catch {
|
|
1767
|
+
// Document health is advisory and must never prevent startup.
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1742
1770
|
// landSummary is expensive (git board classification) - compute once per boot.
|
|
1743
1771
|
let landInfo = null;
|
|
1744
1772
|
try { landInfo = require('../commands/land').landSummary(cwd); } catch (err) { landInfo = null; }
|
|
@@ -1999,6 +2027,11 @@ if (command === 'guide') {
|
|
|
1999
2027
|
Promise.resolve(require('../commands/drive').driveCommand(process.argv.slice(3)))
|
|
2000
2028
|
.then((code) => process.exit(code || 0))
|
|
2001
2029
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2030
|
+
} else if (command === 'rsi') {
|
|
2031
|
+
// RSI: read the Dream-RSI attempt ledger (trees, attempts, policy, dreams).
|
|
2032
|
+
Promise.resolve(require('../commands/rsi').run(process.argv.slice(3)))
|
|
2033
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
2034
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2002
2035
|
} else if (command === 'orb') {
|
|
2003
2036
|
Promise.resolve(orbCmd(process.argv.slice(3)))
|
|
2004
2037
|
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
@@ -2026,6 +2059,22 @@ if (command === 'guide') {
|
|
|
2026
2059
|
Promise.resolve(require('../commands/aeo').run(process.argv.slice(3)))
|
|
2027
2060
|
.then(() => process.exit(0))
|
|
2028
2061
|
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2062
|
+
} else if (command === 'design') {
|
|
2063
|
+
// Design: extract a site's design system, check brand adherence, search brands.
|
|
2064
|
+
Promise.resolve(require('../commands/design').run(process.argv.slice(3)))
|
|
2065
|
+
.then((code) => process.exit(typeof code === 'number' ? code : 0))
|
|
2066
|
+
.catch((err) => { console.error(String(err.message || err).replace(/\s+/g, ' ')); process.exit(1); });
|
|
2067
|
+
} else if (command === 'mcp') {
|
|
2068
|
+
// MCP: stdio Model Context Protocol server exposing the design tools.
|
|
2069
|
+
{
|
|
2070
|
+
const serverPath = require('path').join(__dirname, '..', 'mcp', 'atris-mcp', 'index.mjs');
|
|
2071
|
+
const child = require('child_process').spawn(process.execPath, [serverPath, ...process.argv.slice(3)], { stdio: 'inherit' });
|
|
2072
|
+
for (const signal of ['SIGTERM', 'SIGINT']) {
|
|
2073
|
+
process.on(signal, () => { try { child.kill(signal); } catch { /* child already gone */ } });
|
|
2074
|
+
}
|
|
2075
|
+
child.on('error', (err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
|
|
2076
|
+
child.on('exit', (code) => process.exit(code == null ? 1 : code));
|
|
2077
|
+
}
|
|
2029
2078
|
} else if (command === 'improve') {
|
|
2030
2079
|
// Improve: one paid RL tick via POST /api/improve (deducts credits), local autopilot fallback.
|
|
2031
2080
|
Promise.resolve(require('../commands/improve').run(process.argv.slice(3)))
|
|
@@ -2696,8 +2745,12 @@ if (command === 'guide') {
|
|
|
2696
2745
|
});
|
|
2697
2746
|
} else if (command === 'doctor') {
|
|
2698
2747
|
Promise.resolve(require('../commands/doctor').doctorCommand(process.argv.slice(3)))
|
|
2699
|
-
.then((code) => process.
|
|
2700
|
-
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.
|
|
2748
|
+
.then((code) => { process.exitCode = typeof code === 'number' ? code : 0; })
|
|
2749
|
+
.catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exitCode = 1; });
|
|
2750
|
+
} else if (command === 'doc-health') {
|
|
2751
|
+
Promise.resolve(require('../commands/doc-health').docHealthCommand(process.argv.slice(3)))
|
|
2752
|
+
.then((code) => { process.exitCode = typeof code === 'number' ? code : 0; })
|
|
2753
|
+
.catch((err) => { console.error(err.message || err); process.exitCode = 1; });
|
|
2701
2754
|
} else if (command === 'verify') {
|
|
2702
2755
|
const args = process.argv.slice(3);
|
|
2703
2756
|
if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
|
package/commands/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { AGENT_TOKEN_EXPIRED_DETAIL, loadCredentials, saveCredentials, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
|
|
1
|
+
const { AGENT_TOKEN_EXPIRED_DETAIL, decodeJwtClaims, loadCredentials, saveCredentials, deleteCredentials, getCredentialsPath, openBrowser, promptUser, displayAccountSummary, ensureValidCredentials, loadProfile, listProfiles, profileNameFromEmail, deleteProfile, saveProfile, getTokenExpiryEpochSeconds, getTerminalSessionId, setSessionProfile, getSessionProfile, clearSessionProfile, cleanStaleSessions, getSessionsDir } = require('../utils/auth');
|
|
2
2
|
const { getAppBaseUrl, apiRequestJson } = require('../utils/api');
|
|
3
3
|
const { isNonInteractive, wantsJson } = require('../lib/noninteractive');
|
|
4
4
|
const { hasFlag, readFlag } = require('../lib/arg-parser');
|
|
@@ -85,20 +85,49 @@ function extractAgentTokenMeta(data, requested, token) {
|
|
|
85
85
|
return { scopes, dailyCreditCap, expiresAt };
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
function isAgentAccessToken(token) {
|
|
89
|
+
return decodeJwtClaims(token)?.type === 'agent_access';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function scopedTokenCandidate(credentials = {}) {
|
|
93
|
+
if (credentials.agent_token) return credentials.agent_token;
|
|
94
|
+
if (
|
|
95
|
+
credentials.source === 'env'
|
|
96
|
+
|| credentials.source === 'agent_token_file'
|
|
97
|
+
|| isAgentAccessToken(credentials.token)
|
|
98
|
+
) {
|
|
99
|
+
return firstNonEmptyString(credentials.token);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function canMintFromLogin(credentials = {}) {
|
|
105
|
+
if (credentials.source === 'agent_token_file') return false;
|
|
106
|
+
if (credentials.source === 'env' && Array.isArray(credentials.scopes) && credentials.scopes.length) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const login = firstNonEmptyString(credentials.token);
|
|
110
|
+
const refresh = firstNonEmptyString(credentials.refresh_token);
|
|
111
|
+
if (isAgentAccessToken(login) && !refresh) return false;
|
|
112
|
+
return Boolean(login || refresh);
|
|
113
|
+
}
|
|
114
|
+
|
|
88
115
|
function persistMintedAgentToken(credentials, token, extras = {}) {
|
|
116
|
+
if (isAgentAccessToken(credentials.token)) {
|
|
117
|
+
throw new Error('Refusing to save a scoped agent token as the login token; keep it under agent_token');
|
|
118
|
+
}
|
|
89
119
|
const next = {
|
|
90
|
-
|
|
120
|
+
...credentials,
|
|
91
121
|
refresh_token: extras.refresh_token || credentials.refresh_token || null,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
saved_at: extras.saved_at || new Date().toISOString(),
|
|
122
|
+
agent_token: token,
|
|
123
|
+
agent_token_scopes: extras.scopes || [],
|
|
124
|
+
agent_token_expires_at: extras.expiresAt || null,
|
|
96
125
|
};
|
|
97
126
|
if (credentials.source_profile) {
|
|
98
127
|
saveProfile(credentials.source_profile, next);
|
|
99
128
|
return next;
|
|
100
129
|
}
|
|
101
|
-
saveCredentials(next.token, next.refresh_token, next.email, next.user_id, next.provider);
|
|
130
|
+
saveCredentials(next.token, next.refresh_token, next.email, next.user_id, next.provider, next);
|
|
102
131
|
return next;
|
|
103
132
|
}
|
|
104
133
|
|
|
@@ -139,10 +168,10 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
|
|
|
139
168
|
? requested.dailyCreditCap
|
|
140
169
|
: DEFAULT_DAILY_CREDIT_CAP;
|
|
141
170
|
|
|
142
|
-
const credentials = load() || {};
|
|
171
|
+
const credentials = await load(api) || {};
|
|
143
172
|
const accessToken = firstNonEmptyString(credentials.token);
|
|
144
173
|
const refreshToken = firstNonEmptyString(credentials.refresh_token);
|
|
145
|
-
if (!
|
|
174
|
+
if (!canMintFromLogin(credentials)) {
|
|
146
175
|
return { ok: false, code: 'not_logged_in', error: NO_STORED_JWT_MESSAGE };
|
|
147
176
|
}
|
|
148
177
|
|
|
@@ -150,7 +179,7 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
|
|
|
150
179
|
scopes,
|
|
151
180
|
daily_credit_cap: dailyCreditCap,
|
|
152
181
|
};
|
|
153
|
-
let authToken = accessToken || refreshToken;
|
|
182
|
+
let authToken = isAgentAccessToken(accessToken) ? refreshToken : (accessToken || refreshToken);
|
|
154
183
|
let result = await postAgentToken(api, authToken, body);
|
|
155
184
|
if (!result.ok && result.status === 401 && refreshToken && authToken !== refreshToken) {
|
|
156
185
|
authToken = refreshToken;
|
|
@@ -172,7 +201,9 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
|
|
|
172
201
|
return { ok: false, code: 'missing_token', error: 'backend did not return an agent token' };
|
|
173
202
|
}
|
|
174
203
|
|
|
204
|
+
const meta = extractAgentTokenMeta(result.data, { scopes, dailyCreditCap }, minted);
|
|
175
205
|
persist(credentials, minted, {
|
|
206
|
+
...meta,
|
|
176
207
|
refresh_token: firstNonEmptyString(result.data && result.data.refresh_token) || refreshToken,
|
|
177
208
|
saved_at: now(),
|
|
178
209
|
});
|
|
@@ -180,7 +211,8 @@ async function mintScopedAgentToken(requested = {}, deps = {}) {
|
|
|
180
211
|
return {
|
|
181
212
|
ok: true,
|
|
182
213
|
token: minted,
|
|
183
|
-
meta
|
|
214
|
+
meta,
|
|
215
|
+
storedIn: credentials.source_profile ? `profile ${credentials.source_profile}, agent_token` : '~/.atris/credentials.json, agent_token',
|
|
184
216
|
};
|
|
185
217
|
}
|
|
186
218
|
|
|
@@ -191,24 +223,23 @@ async function ensureBilledCommandAuth(scope, deps = {}) {
|
|
|
191
223
|
}
|
|
192
224
|
|
|
193
225
|
const api = deps.apiRequestJson || apiRequestJson;
|
|
194
|
-
const ensureFn = deps.ensureValidCredentials || ensureValidCredentials;
|
|
195
226
|
const load = deps.loadCredentials || loadCredentials;
|
|
196
227
|
const mint = deps.mintScopedAgentToken || mintScopedAgentToken;
|
|
197
228
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
229
|
+
const ensured = !deps.forceMint && deps.ensureValidCredentials
|
|
230
|
+
? await deps.ensureValidCredentials(api) : null;
|
|
231
|
+
const credentials = ensured?.credentials || await load(api) || {};
|
|
232
|
+
const candidate = scopedTokenCandidate(credentials);
|
|
233
|
+
const claims = decodeJwtClaims(candidate);
|
|
234
|
+
const scopes = claims?.scopes || credentials.agent_token_scopes || credentials.scopes || [];
|
|
235
|
+
const expiry = claims?.exp
|
|
236
|
+
? claims.exp * 1000
|
|
237
|
+
: Date.parse(credentials.agent_token_expires_at || credentials.expires_at);
|
|
238
|
+
if (!deps.forceMint && candidate && Array.isArray(scopes) && scopes.includes(wanted) && Number.isFinite(expiry) && expiry > Date.now()) {
|
|
239
|
+
return { ok: true, token: candidate, minted: false, credentials };
|
|
208
240
|
}
|
|
209
241
|
|
|
210
|
-
|
|
211
|
-
if (!firstNonEmptyString(credentials.token, credentials.refresh_token)) {
|
|
242
|
+
if (!canMintFromLogin(credentials)) {
|
|
212
243
|
return { ok: false, error: NO_STORED_JWT_MESSAGE };
|
|
213
244
|
}
|
|
214
245
|
|
|
@@ -267,6 +298,7 @@ async function mintAgentToken(args = [], deps = {}) {
|
|
|
267
298
|
output(JSON.stringify({
|
|
268
299
|
ok: true,
|
|
269
300
|
minted: true,
|
|
301
|
+
stored_in: minted.storedIn,
|
|
270
302
|
scopes: minted.meta.scopes,
|
|
271
303
|
daily_credit_cap: minted.meta.dailyCreditCap,
|
|
272
304
|
expires_at: minted.meta.expiresAt,
|
|
@@ -275,6 +307,7 @@ async function mintAgentToken(args = [], deps = {}) {
|
|
|
275
307
|
}
|
|
276
308
|
|
|
277
309
|
printAgentTokenMint(minted.meta, output);
|
|
310
|
+
output(`stored in ${minted.storedIn}`);
|
|
278
311
|
return 0;
|
|
279
312
|
}
|
|
280
313
|
|
|
@@ -982,6 +1015,7 @@ module.exports = {
|
|
|
982
1015
|
shellInit,
|
|
983
1016
|
parseAgentTokenArgs,
|
|
984
1017
|
mintAgentToken,
|
|
1018
|
+
persistMintedAgentToken,
|
|
985
1019
|
ensureBilledCommandAuth,
|
|
986
1020
|
wantsAgentToken,
|
|
987
1021
|
};
|
package/commands/brain.js
CHANGED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atris design, brand design-system commands against api.atris.ai.
|
|
5
|
+
*
|
|
6
|
+
* atris design extract <url> [--json] [--sections colors,typography]
|
|
7
|
+
* atris design check <url> --against <brand-url> [--json]
|
|
8
|
+
* atris design search "<words>" [--limit n] [--json]
|
|
9
|
+
*
|
|
10
|
+
* Auth: developer key as `Authorization: Bearer atris_...`, resolved by
|
|
11
|
+
* lib/design-api.js (ATRIS_API_KEY, then the atris login token, then
|
|
12
|
+
* ~/.atris/design-api-key).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const {
|
|
16
|
+
resolveDesignKey,
|
|
17
|
+
designRequest,
|
|
18
|
+
pollDesignJob,
|
|
19
|
+
billingOf,
|
|
20
|
+
creditLine,
|
|
21
|
+
} = require('../lib/design-api');
|
|
22
|
+
|
|
23
|
+
const NO_KEY = 'no api key found. set ATRIS_API_KEY or run: atris login (or save a key in ~/.atris/design-api-key)';
|
|
24
|
+
|
|
25
|
+
function showDesignHelp() {
|
|
26
|
+
console.log('usage: atris design extract <url> [--json] [--sections colors,typography]');
|
|
27
|
+
console.log(' atris design check <url> --against <brand-url> [--json]');
|
|
28
|
+
console.log(' atris design search "<words>" [--limit n] [--json]');
|
|
29
|
+
console.log('pull a site\'s design system, score a page against a brand, or search extracted brands.');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function wantsHelp(args) {
|
|
33
|
+
return args.includes('--help') || args.includes('-h') || args[0] === 'help';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readFlag(args, name) {
|
|
37
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
38
|
+
const a = String(args[i]);
|
|
39
|
+
if (a === name) {
|
|
40
|
+
const v = args[i + 1];
|
|
41
|
+
if (v === undefined || String(v).startsWith('--')) return { error: `${name} needs a value` };
|
|
42
|
+
args.splice(i, 2);
|
|
43
|
+
return { value: String(v) };
|
|
44
|
+
}
|
|
45
|
+
if (a.startsWith(`${name}=`)) {
|
|
46
|
+
args.splice(i, 1);
|
|
47
|
+
return { value: a.slice(name.length + 1) };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function positionals(args) {
|
|
54
|
+
return args.filter((a) => !String(a).startsWith('--'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unknownFlags(args, allowed) {
|
|
58
|
+
return args.filter((a) => String(a).startsWith('--') && !allowed.includes(a));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeUrl(url) {
|
|
62
|
+
return /^https?:\/\//i.test(url) ? url : `https://${url}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isTerminal(job) {
|
|
66
|
+
const s = String(job && job.status || '').toLowerCase();
|
|
67
|
+
return s === 'completed' || s === 'failed' || s === 'error' || s === 'succeeded';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One-line spinner on TTY only; off TTY stays silent so --json and pipes stay clean.
|
|
71
|
+
function makeSpinner(label, io) {
|
|
72
|
+
const write = io.write;
|
|
73
|
+
if (!io.tty || typeof write !== 'function') return { stop() {} };
|
|
74
|
+
const start = Date.now();
|
|
75
|
+
const frames = ['-', '\\', '|', '/'];
|
|
76
|
+
let n = 0;
|
|
77
|
+
const timer = setInterval(() => {
|
|
78
|
+
const secs = Math.floor((Date.now() - start) / 1000);
|
|
79
|
+
write(`\r ${frames[n++ % frames.length]} ${label} ${secs}s`);
|
|
80
|
+
}, 250);
|
|
81
|
+
if (timer.unref) timer.unref();
|
|
82
|
+
return {
|
|
83
|
+
stop() {
|
|
84
|
+
clearInterval(timer);
|
|
85
|
+
write('\r\x1b[2K');
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function uniqueHexes(colors = {}, palette = []) {
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const key of ['primary', 'secondary', 'accent']) {
|
|
93
|
+
const hex = colors[key];
|
|
94
|
+
if (hex && !out.includes(hex)) out.push(hex);
|
|
95
|
+
}
|
|
96
|
+
for (const p of Array.isArray(palette) ? palette : []) {
|
|
97
|
+
const hex = p && typeof p === 'object' ? p.hex : p;
|
|
98
|
+
if (hex && !out.includes(hex)) out.push(hex);
|
|
99
|
+
if (out.length >= 6) break;
|
|
100
|
+
}
|
|
101
|
+
return out.slice(0, 6);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function fontFamily(value) {
|
|
105
|
+
if (!value) return null;
|
|
106
|
+
if (typeof value === 'string') return value;
|
|
107
|
+
if (typeof value === 'object') return value.family || null;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Short readable card for an extraction job.
|
|
112
|
+
function extractionCard(job = {}) {
|
|
113
|
+
const ds = job.result && job.result.design_system ? job.result.design_system : {};
|
|
114
|
+
const profile = ds.profile || {};
|
|
115
|
+
const colors = ds.colors || {};
|
|
116
|
+
const typo = ds.typography || {};
|
|
117
|
+
const lines = [];
|
|
118
|
+
lines.push('');
|
|
119
|
+
lines.push(` ${profile.brand_name || job.source_url || 'unknown brand'}`);
|
|
120
|
+
if (job.source_url) lines.push(` ${job.source_url}`);
|
|
121
|
+
lines.push('');
|
|
122
|
+
const hexes = uniqueHexes(colors, colors.palette);
|
|
123
|
+
if (hexes.length) lines.push(` colors ${hexes.join(' ')}`);
|
|
124
|
+
const heading = fontFamily(typo.heading);
|
|
125
|
+
const body = fontFamily(typo.body);
|
|
126
|
+
if (heading) lines.push(` heading ${heading}`);
|
|
127
|
+
if (body) lines.push(` body ${body}`);
|
|
128
|
+
if (profile.one_line_positioning) lines.push(` line ${profile.one_line_positioning}`);
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(` ${creditLine(job)}`);
|
|
131
|
+
lines.push('');
|
|
132
|
+
return lines.join('\n');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Short readable card for an adherence job.
|
|
136
|
+
function adherenceCard(job = {}) {
|
|
137
|
+
const result = job.result && typeof job.result === 'object' ? job.result : {};
|
|
138
|
+
const lines = [];
|
|
139
|
+
lines.push('');
|
|
140
|
+
const score = Number(result.score);
|
|
141
|
+
lines.push(` score ${Number.isFinite(score) ? score.toFixed(2) : '?'}`);
|
|
142
|
+
if (job.source_url) lines.push(` source ${job.source_url}`);
|
|
143
|
+
if (job.reference_url) lines.push(` against ${job.reference_url}`);
|
|
144
|
+
lines.push('');
|
|
145
|
+
for (const key of ['fixes', 'recommendations']) {
|
|
146
|
+
const items = Array.isArray(result[key]) ? result[key] : [];
|
|
147
|
+
if (!items.length) continue;
|
|
148
|
+
lines.push(` ${key}:`);
|
|
149
|
+
for (const item of items.slice(0, 5)) {
|
|
150
|
+
const text = typeof item === 'string' ? item : (item && (item.title || item.detail || item.message)) || JSON.stringify(item);
|
|
151
|
+
lines.push(` - ${String(text).replace(/\s+/g, ' ').trim()}`);
|
|
152
|
+
}
|
|
153
|
+
lines.push('');
|
|
154
|
+
}
|
|
155
|
+
lines.push(` ${creditLine(job)}`);
|
|
156
|
+
lines.push('');
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Short readable card for a search response.
|
|
161
|
+
function searchCard(data = {}, query = '') {
|
|
162
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
163
|
+
const lines = [];
|
|
164
|
+
lines.push('');
|
|
165
|
+
lines.push(` ${results.length} result${results.length === 1 ? '' : 's'} for "${query}"`);
|
|
166
|
+
lines.push('');
|
|
167
|
+
for (const r of results) {
|
|
168
|
+
const name = r.brand_name || r.source_url || 'unknown';
|
|
169
|
+
lines.push(` ${name}${r.source_url ? ` ${r.source_url}` : ''}`);
|
|
170
|
+
const palette = Array.isArray(r.palette) ? r.palette.slice(0, 6).join(' ') : '';
|
|
171
|
+
if (palette) lines.push(` ${palette}`);
|
|
172
|
+
}
|
|
173
|
+
lines.push('');
|
|
174
|
+
lines.push(` ${creditLine(data)}`);
|
|
175
|
+
lines.push('');
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function designExtract(args, ctx) {
|
|
180
|
+
const json = args.includes('--json');
|
|
181
|
+
const sections = readFlag(args, '--sections');
|
|
182
|
+
if (sections.error) { ctx.err(sections.error); return 1; }
|
|
183
|
+
const bad = unknownFlags(args, ['--json']);
|
|
184
|
+
if (bad.length) { ctx.err(`unknown flag for design extract: ${bad.join(' ')}`); return 1; }
|
|
185
|
+
const url = positionals(args)[0];
|
|
186
|
+
if (!url) { ctx.err('usage: atris design extract <url> [--json] [--sections colors,typography]'); return 1; }
|
|
187
|
+
const target = normalizeUrl(url);
|
|
188
|
+
|
|
189
|
+
const body = { url: target };
|
|
190
|
+
if (sections.value) body.sections = sections.value;
|
|
191
|
+
const first = await ctx.request('/design/extractions', { method: 'POST', body, key: ctx.key });
|
|
192
|
+
if (!first.ok) {
|
|
193
|
+
ctx.err(`design extract failed (${first.status}): ${first.error}`);
|
|
194
|
+
return 1;
|
|
195
|
+
}
|
|
196
|
+
let job = first.data || {};
|
|
197
|
+
const pollMs = ctx.pollMs;
|
|
198
|
+
if (!isTerminal(job) && job.id) {
|
|
199
|
+
const qs = sections.value ? `?sections=${encodeURIComponent(sections.value)}` : '';
|
|
200
|
+
const spinner = makeSpinner(`extracting ${target}`, ctx.io);
|
|
201
|
+
let failures = 0;
|
|
202
|
+
const polled = await pollDesignJob(async () => {
|
|
203
|
+
const res = await ctx.request(`/design/extractions/${job.id}${qs}`, { key: ctx.key });
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
failures += 1;
|
|
206
|
+
if (failures >= 3) return { status: 'failed', error: res.error };
|
|
207
|
+
return { status: 'polling' };
|
|
208
|
+
}
|
|
209
|
+
failures = 0;
|
|
210
|
+
return res.data;
|
|
211
|
+
}, { intervalMs: pollMs, timeoutMs: ctx.maxWaitMs, sleep: ctx.sleep });
|
|
212
|
+
spinner.stop();
|
|
213
|
+
if (polled.timedOut) {
|
|
214
|
+
ctx.err(`still running after ${Math.round(ctx.maxWaitMs / 1000)}s. job id: ${job.id}`);
|
|
215
|
+
return 1;
|
|
216
|
+
}
|
|
217
|
+
job = polled.job || job;
|
|
218
|
+
}
|
|
219
|
+
if (json) { ctx.out(JSON.stringify(job, null, 2)); return job.status === 'completed' ? 0 : 1; }
|
|
220
|
+
if (!isTerminal(job) || job.status !== 'completed') {
|
|
221
|
+
ctx.err(`extraction did not finish: ${job.error || job.status || 'unknown'}`);
|
|
222
|
+
return 1;
|
|
223
|
+
}
|
|
224
|
+
ctx.out(extractionCard(job));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function designCheck(args, ctx) {
|
|
229
|
+
const json = args.includes('--json');
|
|
230
|
+
const against = readFlag(args, '--against');
|
|
231
|
+
if (against.error) { ctx.err(against.error); return 1; }
|
|
232
|
+
const bad = unknownFlags(args, ['--json']);
|
|
233
|
+
if (bad.length) { ctx.err(`unknown flag for design check: ${bad.join(' ')}`); return 1; }
|
|
234
|
+
const url = positionals(args)[0];
|
|
235
|
+
if (!url || !against.value) {
|
|
236
|
+
ctx.err('usage: atris design check <url> --against <brand-url> [--json]');
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const source = normalizeUrl(url);
|
|
240
|
+
const reference = normalizeUrl(against.value);
|
|
241
|
+
|
|
242
|
+
const first = await ctx.request('/design/adherence', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
body: { source_url: source, reference_url: reference },
|
|
245
|
+
key: ctx.key,
|
|
246
|
+
});
|
|
247
|
+
if (!first.ok) {
|
|
248
|
+
ctx.err(`design check failed (${first.status}): ${first.error}`);
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
let job = first.data || {};
|
|
252
|
+
if (!isTerminal(job) && job.id) {
|
|
253
|
+
const spinner = makeSpinner(`checking ${source}`, ctx.io);
|
|
254
|
+
let failures = 0;
|
|
255
|
+
const polled = await pollDesignJob(async () => {
|
|
256
|
+
const res = await ctx.request(`/design/adherence/${job.id}`, { key: ctx.key });
|
|
257
|
+
if (!res.ok) {
|
|
258
|
+
failures += 1;
|
|
259
|
+
if (failures >= 3) return { status: 'failed', error: res.error };
|
|
260
|
+
return { status: 'polling' };
|
|
261
|
+
}
|
|
262
|
+
failures = 0;
|
|
263
|
+
return res.data;
|
|
264
|
+
}, { intervalMs: ctx.pollMs, timeoutMs: ctx.maxWaitMs, sleep: ctx.sleep });
|
|
265
|
+
spinner.stop();
|
|
266
|
+
if (polled.timedOut) {
|
|
267
|
+
ctx.err(`still running after ${Math.round(ctx.maxWaitMs / 1000)}s. job id: ${job.id}`);
|
|
268
|
+
return 1;
|
|
269
|
+
}
|
|
270
|
+
job = polled.job || job;
|
|
271
|
+
}
|
|
272
|
+
if (json) { ctx.out(JSON.stringify(job, null, 2)); return job.status === 'completed' ? 0 : 1; }
|
|
273
|
+
if (job.status !== 'completed') {
|
|
274
|
+
ctx.err(`check did not finish: ${job.error || job.status || 'unknown'}`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
ctx.out(adherenceCard(job));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function designSearch(args, ctx) {
|
|
282
|
+
const json = args.includes('--json');
|
|
283
|
+
const limitFlag = readFlag(args, '--limit');
|
|
284
|
+
if (limitFlag.error) { ctx.err(limitFlag.error); return 1; }
|
|
285
|
+
const bad = unknownFlags(args, ['--json']);
|
|
286
|
+
if (bad.length) { ctx.err(`unknown flag for design search: ${bad.join(' ')}`); return 1; }
|
|
287
|
+
const query = positionals(args).join(' ').trim();
|
|
288
|
+
if (!query) { ctx.err('usage: atris design search "<words>" [--limit n] [--json]'); return 1; }
|
|
289
|
+
let limit;
|
|
290
|
+
if (limitFlag.value != null) {
|
|
291
|
+
limit = parseInt(limitFlag.value, 10);
|
|
292
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
293
|
+
ctx.err(`invalid --limit value: "${limitFlag.value}". expected a positive integer.`);
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const body = { query };
|
|
299
|
+
if (limit != null) body.limit = limit;
|
|
300
|
+
const res = await ctx.request('/design/search', { method: 'POST', body, key: ctx.key });
|
|
301
|
+
if (!res.ok) {
|
|
302
|
+
ctx.err(`design search failed (${res.status}): ${res.error}`);
|
|
303
|
+
return 1;
|
|
304
|
+
}
|
|
305
|
+
const data = res.data || {};
|
|
306
|
+
if (json) { ctx.out(JSON.stringify(data, null, 2)); return 0; }
|
|
307
|
+
ctx.out(searchCard(data, query));
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function run(args = [], deps = {}) {
|
|
312
|
+
const rest = args.slice();
|
|
313
|
+
if (rest.length === 0 || wantsHelp(rest)) {
|
|
314
|
+
showDesignHelp();
|
|
315
|
+
return rest.length === 0 ? 1 : 0;
|
|
316
|
+
}
|
|
317
|
+
const sub = rest.shift();
|
|
318
|
+
|
|
319
|
+
const io = deps.io || {
|
|
320
|
+
out: (s) => process.stdout.write(`${s}\n`),
|
|
321
|
+
err: (s) => process.stderr.write(`${s}\n`),
|
|
322
|
+
write: (s) => process.stdout.write(s),
|
|
323
|
+
tty: Boolean(process.stdout.isTTY),
|
|
324
|
+
};
|
|
325
|
+
const ctx = {
|
|
326
|
+
out: deps.out || io.out,
|
|
327
|
+
err: deps.err || io.err,
|
|
328
|
+
io,
|
|
329
|
+
key: deps.key !== undefined ? deps.key : resolveDesignKey(process.env, deps),
|
|
330
|
+
request: deps.request || designRequest,
|
|
331
|
+
sleep: deps.sleep,
|
|
332
|
+
pollMs: deps.pollMs != null
|
|
333
|
+
? deps.pollMs
|
|
334
|
+
: Number(process.env.ATRIS_DESIGN_POLL_MS) || undefined,
|
|
335
|
+
maxWaitMs: deps.maxWaitMs != null
|
|
336
|
+
? deps.maxWaitMs
|
|
337
|
+
: Number(process.env.ATRIS_DESIGN_TIMEOUT_MS) || 3 * 60 * 1000,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
if (!['extract', 'check', 'search'].includes(sub)) {
|
|
341
|
+
ctx.err(`unknown design subcommand: ${sub}`);
|
|
342
|
+
showDesignHelp();
|
|
343
|
+
return 1;
|
|
344
|
+
}
|
|
345
|
+
if (!ctx.key) {
|
|
346
|
+
ctx.err(NO_KEY);
|
|
347
|
+
return 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
if (sub === 'extract') return await designExtract(rest, ctx);
|
|
352
|
+
if (sub === 'check') return await designCheck(rest, ctx);
|
|
353
|
+
return await designSearch(rest, ctx);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
ctx.err(`design ${sub} failed: ${(error && error.message) || error}`);
|
|
356
|
+
return 1;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
module.exports = {
|
|
361
|
+
run,
|
|
362
|
+
};
|