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.
Files changed (42) hide show
  1. package/README.md +8 -0
  2. package/atris/policies/engineering-principles.md +129 -0
  3. package/atris/policies/genesis.md +112 -0
  4. package/atris/policies/product-design-principles.md +100 -0
  5. package/atris/skills/design/SKILL.md +3 -1
  6. package/atris/skills/engines/SKILL.md +3 -3
  7. package/atris/skills/x-search/SKILL.md +2 -2
  8. package/atris/skills/youtube/SKILL.md +44 -28
  9. package/bin/atris.js +56 -3
  10. package/commands/auth.js +58 -24
  11. package/commands/brain.js +1 -0
  12. package/commands/design.js +362 -0
  13. package/commands/doc-health.js +329 -0
  14. package/commands/drive.js +32 -0
  15. package/commands/improve.js +67 -1
  16. package/commands/land.js +144 -4
  17. package/commands/learn.js +211 -40
  18. package/commands/member.js +65 -11
  19. package/commands/mission.js +37 -7
  20. package/commands/pulse.js +38 -0
  21. package/commands/rsi.js +156 -0
  22. package/commands/task.js +41 -1
  23. package/commands/workflow.js +15 -14
  24. package/commands/x-search.js +9 -10
  25. package/commands/youtube.js +518 -107
  26. package/lib/apply-gate.js +22 -4
  27. package/lib/daily-log.js +88 -0
  28. package/lib/design-api.js +130 -0
  29. package/lib/engine-ask.js +1 -1
  30. package/lib/first-minute.js +1 -6
  31. package/lib/known-commands.js +3 -3
  32. package/lib/member-context.js +42 -0
  33. package/lib/rsi-record.js +335 -0
  34. package/lib/state-detection.js +8 -8
  35. package/lib/task-db.js +71 -51
  36. package/lib/task-list-keeper.js +192 -0
  37. package/lib/todo-fallback.js +9 -3
  38. package/lib/todo.js +22 -10
  39. package/mcp/atris-mcp/index.mjs +174 -0
  40. package/package.json +8 -3
  41. package/scripts/det/ytnotes +122 -10
  42. 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.exit(typeof code === 'number' ? code : 0))
2700
- .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
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
- token,
120
+ ...credentials,
91
121
  refresh_token: extras.refresh_token || credentials.refresh_token || null,
92
- email: credentials.email || null,
93
- user_id: credentials.user_id || null,
94
- provider: credentials.provider || null,
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 (!accessToken && !refreshToken) {
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: extractAgentTokenMeta(result.data, { scopes, dailyCreditCap }, minted),
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
- if (!deps.forceMint) {
199
- const ensured = await ensureFn(api);
200
- if (ensured && ensured.credentials && ensured.credentials.token) {
201
- return {
202
- ok: true,
203
- token: ensured.credentials.token,
204
- minted: false,
205
- credentials: ensured.credentials,
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
- const credentials = load() || {};
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
@@ -1774,6 +1774,7 @@ function brainCommand(args = process.argv.slice(3)) {
1774
1774
  }
1775
1775
 
1776
1776
  module.exports = {
1777
+ readText,
1777
1778
  brainCommand,
1778
1779
  collectState,
1779
1780
  renderStatus,
@@ -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
+ };