atris 3.25.2 → 3.27.0

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 CHANGED
@@ -319,6 +319,8 @@ atris upgrade # Install latest from npm
319
319
  atris update # Sync local workspace files to new version
320
320
  ```
321
321
 
322
+ Packaged npm installs check for new versions during normal commands and start a background update automatically. Git checkout installs stay manual so linked development copies are not overwritten unexpectedly.
323
+
322
324
  ---
323
325
 
324
326
  **License:** MIT | **Repo:** [github.com/atrislabs/atris](https://github.com/atrislabs/atris.git)
package/bin/atris.js CHANGED
@@ -92,14 +92,10 @@ if (!skipUpdateCheck && (!updateCommand || (updateCommand && !['version', 'updat
92
92
  .then((updateInfo) => {
93
93
  // Show notification if update available (after command completes)
94
94
  if (updateInfo) {
95
- // Notify only — never auto-update mid-session (opt-in via ATRIS_AUTO_UPDATE=1)
96
- if (process.env.ATRIS_AUTO_UPDATE === '1') {
97
- setTimeout(() => {
98
- if (!autoUpdate(updateInfo)) {
99
- showUpdateNotification(updateInfo);
100
- }
101
- }, 100);
102
- } else {
95
+ const autoUpdateStarted = autoUpdate(updateInfo, {
96
+ packageRoot: path.join(__dirname, '..'),
97
+ });
98
+ if (!autoUpdateStarted) {
103
99
  showUpdateNotification(updateInfo);
104
100
  }
105
101
  }
@@ -644,6 +640,7 @@ function showUpgradeHelp() {
644
640
  console.log('');
645
641
  console.log('Description:');
646
642
  console.log(' Check npm for the latest Atris CLI and install it globally if newer.');
643
+ console.log(' Normal packaged installs also auto-update in the background.');
647
644
  console.log('');
648
645
  console.log('Options:');
649
646
  console.log(' --help, -h Show this help.');
@@ -826,7 +823,7 @@ if (command === '2' && ['fast', 'pro'].includes(String(firstCommandArg || '').to
826
823
  const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
827
824
  'activate', '_activate', 'agent', 'chat', 'fast', 'ax', 'console', 'serve', 'login', 'logout', 'whoami', 'switch', 'use', 'accounts', '_resolve', '_profile-email', '_switch-session', 'shell-init', 'update', 'upgrade', 'version', 'help', 'next', 'atris',
828
825
  'clean', 'verify', 'search', 'skill', 'member', 'codex-goal', 'app', 'apps', 'learn', 'lesson', 'plugin', 'experiments', 'receipt', 'proof', 'openclaw', 'pull', 'push', 'live', 'align', 'terminal', 'computer', 'diff', 'business', 'sync', 'youtube',
829
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
826
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'reel', 'improve', 'xp', 'play', 'gm', 'x', 'recap', 'signup', 'clarity', 'moves',
830
827
  'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
831
828
  'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship'];
832
829
 
@@ -2059,6 +2056,52 @@ if (command === 'init') {
2059
2056
  Promise.resolve(require('../commands/compile').compileCommand(subcommand, ...args))
2060
2057
  .then(() => process.exit(process.exitCode || 0))
2061
2058
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2059
+ } else if (command === 'slop') {
2060
+ // Slop: deterministic frontend-slop detector (no LLM). Exit 1 = slop found, for CI + the autopilot gate.
2061
+ Promise.resolve(require('../commands/slop').slopCommand(process.argv.slice(3)))
2062
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2063
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2064
+ } else if (command === 'deck') {
2065
+ // Deck: premium Google Slides from a plain content spec, via the Atris deck engine (anti-slop design system).
2066
+ Promise.resolve(require('../commands/deck').run(process.argv.slice(3)))
2067
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2068
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2069
+ } else if (command === 'site') {
2070
+ // Site: beautiful static site from a folder of markdown, in the anti-slop design system.
2071
+ Promise.resolve(require('../commands/site').run(process.argv.slice(3)))
2072
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2073
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2074
+ } else if (command === 'theme') {
2075
+ // Theme: brand themes (.atris/theme.json) for the whole design system (deck/html/site).
2076
+ Promise.resolve(require('../commands/theme').run(process.argv.slice(3)))
2077
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2078
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2079
+ } else if (command === 'card') {
2080
+ // Card: one line of text into an on-brand image (uses your theme + the design system).
2081
+ Promise.resolve(require('../commands/card').run(process.argv.slice(3)))
2082
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2083
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2084
+ } else if (command === 'reel') {
2085
+ // Reel: one line of text into a short on-brand video (an animated card; frames via Chrome + ffmpeg).
2086
+ Promise.resolve(require('../commands/reel').run(process.argv.slice(3)))
2087
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2088
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2089
+ } else if (command === 'signup') {
2090
+ // Signup: one-call seedless agent signup (POST /auth/agent/signup) → writes the
2091
+ // active profile so `atris play` works next. The install→signup→play seam.
2092
+ Promise.resolve(require('../commands/signup').signupCommand(process.argv.slice(3)))
2093
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2094
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2095
+ } else if (command === 'moves') {
2096
+ // Moves: your 3 next moves — approve one into the loop, kill, or skip.
2097
+ Promise.resolve(require('../commands/moves').movesCommand(process.argv.slice(3)))
2098
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2099
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2100
+ } else if (command === 'clarity') {
2101
+ // Clarity: interview yourself once; agents read how you work so you stop repeating it.
2102
+ Promise.resolve(require('../commands/clarity').clarityCommand(process.argv.slice(3)))
2103
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
2104
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
2062
2105
  } else if (command === 'receipt' || command === 'proof' || command === 'openclaw') {
2063
2106
  const subcommand = process.argv[3];
2064
2107
  const args = process.argv.slice(4);
@@ -2137,8 +2180,8 @@ async function upgradeAtris() {
2137
2180
  console.log('Installing update...');
2138
2181
  console.log('');
2139
2182
 
2140
- // Run npm update -g atris
2141
- const result = spawnSync('npm', ['update', '-g', 'atris'], {
2183
+ // Run npm install -g atris@latest
2184
+ const result = spawnSync('npm', ['install', '-g', 'atris@latest'], {
2142
2185
  stdio: 'inherit',
2143
2186
  shell: true
2144
2187
  });
@@ -2152,10 +2195,10 @@ async function upgradeAtris() {
2152
2195
  } else {
2153
2196
  console.log('');
2154
2197
  console.log('✗ Upgrade failed. Try running manually:');
2155
- console.log(' npm update -g atris');
2198
+ console.log(' npm install -g atris@latest');
2156
2199
  console.log('');
2157
2200
  console.log('If you see permission errors, try:');
2158
- console.log(' sudo npm update -g atris');
2201
+ console.log(' sudo npm install -g atris@latest');
2159
2202
  console.log('');
2160
2203
  }
2161
2204
  }
@@ -158,6 +158,30 @@ function activateAtris() {
158
158
  wikiStatus.bullets.forEach((line) => console.log(`- ${line.replace(/^- /, '')}`));
159
159
  }
160
160
  console.log('');
161
+ try {
162
+ const { nextMoves } = require('../lib/next-moves');
163
+ const { readProfile, isEmptyProfile } = require('../lib/clarity');
164
+ const root = process.cwd();
165
+ const moves = nextMoves(root, 3);
166
+ console.log('Your next moves:');
167
+ if (moves.length) {
168
+ moves.forEach((m, i) => console.log(` ${i + 1}. ${m.title}`));
169
+ console.log(' steer them: atris moves');
170
+ } else {
171
+ console.log(' none queued. add to ROADMAP.md under "## Open loop items", or jot one with atris log');
172
+ }
173
+ const profile = readProfile(root);
174
+ console.log('');
175
+ if (isEmptyProfile(profile)) {
176
+ console.log('Tip: run atris clarity once so agents learn how you work.');
177
+ } else {
178
+ console.log('How you work (atris clarity):');
179
+ ['focus', 'voice', 'cadence', 'done', 'leash']
180
+ .filter((k) => profile[k])
181
+ .forEach((k) => console.log(` ${k}: ${profile[k]}`));
182
+ }
183
+ console.log('');
184
+ } catch { /* alive onboarding is best-effort; never block activate */ }
161
185
  console.log('Next: atris plan → do → review (or atris log)');
162
186
  console.log('');
163
187
  }
package/commands/brain.js CHANGED
@@ -32,13 +32,15 @@ const CORE_STATE_FILES = [
32
32
  'agents.jsonl',
33
33
  'approvals.jsonl',
34
34
  ];
35
+ // Loop health measures loops that actually emit receipts. Channels with no writer
36
+ // anywhere in the codebase were dropped so the dashboard reports real failures, not
37
+ // never-built aspirations: "Overnight RL" duplicated the active Pulse AGI loop, and
38
+ // "Company YC" had no data source. "Master loop" is now emitted by `atris run`.
35
39
  const LOOP_HEALTH_CHANNELS = [
36
40
  { label: 'Task plane', files: ['task_events.jsonl', 'tasks.projection.json'] },
37
- { label: 'Overnight RL', files: ['overnight_rl_self_heal.jsonl'] },
38
41
  { label: 'Career XP', files: ['career_xp_receipts.jsonl', 'career_xp.projection.json', 'gm_xp.projection.json'] },
39
42
  { label: 'Master loop', files: ['master_loop_events.jsonl'] },
40
43
  { label: 'Missions', files: ['mission_events.jsonl', 'missions.jsonl'] },
41
- { label: 'Company YC', files: ['company_yc_wow_events.jsonl', 'company_yc_wow_latest.json'] },
42
44
  { label: 'Codex goal', files: ['codex_goal.json'] },
43
45
  { label: 'Pulse AGI', files: ['pulse_agi_loop_receipts.jsonl'] },
44
46
  ];
@@ -0,0 +1,121 @@
1
+ // atris card — one line of text -> a beautiful, on-brand image (your theme).
2
+ //
3
+ // atris card "Ship faster" --kind statement --theme brand --size og
4
+ // atris card "It just works" --kind quote --by "a happy user"
5
+ // atris card --kind stat --number "10x" --label "faster reviews"
6
+ //
7
+ // Writes an .html (always) and a .png (when headless Chrome is available).
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const { execFileSync, spawnSync } = require('child_process');
13
+ const { buildCard, SIZES, KINDS } = require('../lib/card');
14
+
15
+ function parseFlags(argv) {
16
+ const flags = {}; const pos = [];
17
+ for (let i = 0; i < argv.length; i++) {
18
+ const a = argv[i];
19
+ if (a === '--html-only') { flags.htmlOnly = true; continue; }
20
+ if (a.startsWith('--')) {
21
+ const key = a.slice(2);
22
+ const next = argv[i + 1];
23
+ if (next != null && !next.startsWith('--')) { flags[key] = next; i++; } else flags[key] = true;
24
+ continue;
25
+ }
26
+ pos.push(a);
27
+ }
28
+ return { flags, pos };
29
+ }
30
+
31
+ // find an installed Chrome/Chromium without adding a dependency
32
+ function findChrome() {
33
+ if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) return process.env.CHROME_PATH;
34
+ const macApps = [
35
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
36
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
37
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
38
+ ];
39
+ for (const p of macApps) if (fs.existsSync(p)) return p;
40
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'chrome']) {
41
+ const r = spawnSync('command', ['-v', name], { shell: true, encoding: 'utf8' });
42
+ if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function renderPng(chrome, html, width, height, outPng) {
48
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-card-'));
49
+ const htmlFile = path.join(tmp, 'card.html');
50
+ fs.writeFileSync(htmlFile, html);
51
+ const args = [
52
+ '--headless=new', '--disable-gpu', '--hide-scrollbars',
53
+ '--force-device-scale-factor=2',
54
+ `--window-size=${width},${height}`,
55
+ '--virtual-time-budget=4000',
56
+ `--screenshot=${outPng}`,
57
+ `file://${htmlFile}`,
58
+ ];
59
+ execFileSync(chrome, args, { stdio: 'ignore', timeout: 60000 });
60
+ return fs.existsSync(outPng);
61
+ }
62
+
63
+ function run(argv) {
64
+ const { flags, pos } = parseFlags(argv);
65
+
66
+ if (pos[0] === 'help' || flags.help) {
67
+ console.log(`\n atris card — one line of text into an on-brand image\n
68
+ atris card "Your headline" [--kind statement|quote|stat] [--theme <name>] [--size og|wide|square|story]
69
+ flags: --sub --kicker --by --number --label --brand --version --out <file.png> --html-only\n
70
+ examples:
71
+ atris card "Design that builds itself" --kicker "Atris v3.23.0" --theme brand
72
+ atris card "It just works." --kind quote --by "a founder" --size square
73
+ atris card --kind stat --number "1260" --label "tests, all green"\n`);
74
+ return 0;
75
+ }
76
+
77
+ const text = pos.join(' ').trim();
78
+ const kind = flags.kind || 'statement';
79
+ if (!KINDS.includes(kind)) { console.error(` unknown kind "${kind}". try: ${KINDS.join(', ')}`); return 2; }
80
+ if (flags.size && !SIZES[flags.size]) { console.error(` unknown size "${flags.size}". try: ${Object.keys(SIZES).join(', ')}`); return 2; }
81
+ if (kind === 'stat' && !flags.number && !text) { console.error(' stat cards need --number (e.g. --number "10x")'); return 2; }
82
+ if (kind !== 'stat' && !text) { console.error(' give the card some text: atris card "Your headline"'); return 2; }
83
+
84
+ const spec = {
85
+ kind, text, headline: text,
86
+ theme: flags.theme, size: flags.size,
87
+ sub: flags.sub, kicker: flags.kicker, by: flags.by,
88
+ number: flags.number, label: flags.label,
89
+ brand: flags.brand, version: flags.version,
90
+ };
91
+
92
+ let card;
93
+ try { card = buildCard(spec); }
94
+ catch (e) { console.error(` could not build card: ${e.message}`); return 1; }
95
+
96
+ const base = (flags.out ? String(flags.out).replace(/\.png$/i, '') : `card-${kind}-${card.theme}-${card.size}`);
97
+ const outPng = path.resolve(`${base}.png`);
98
+ const outHtml = path.resolve(`${base}.html`);
99
+ fs.writeFileSync(outHtml, card.html);
100
+
101
+ if (flags.htmlOnly) {
102
+ console.log(`\n ✓ ${card.kind} card (${card.width}x${card.height}, theme ${card.theme})\n html: ${outHtml}\n open it, or render a png with Chrome installed.\n`);
103
+ return 0;
104
+ }
105
+
106
+ const chrome = findChrome();
107
+ if (!chrome) {
108
+ console.log(`\n ✓ wrote html: ${outHtml}\n no Chrome found for png. open the html, or set CHROME_PATH and re-run.\n`);
109
+ return 0;
110
+ }
111
+ try {
112
+ renderPng(chrome, card.html, card.width, card.height, outPng);
113
+ console.log(`\n ✓ ${card.kind} card, ${card.width}x${card.height}, theme ${card.theme}\n ${outPng}\n`);
114
+ return 0;
115
+ } catch (e) {
116
+ console.log(`\n ! png render failed (${e.message.split('\n')[0]})\n html is ready: ${outHtml}\n`);
117
+ return 0;
118
+ }
119
+ }
120
+
121
+ module.exports = { run };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ // `atris clarity`: interview the operator for how they work, write it down once.
4
+ // The interview moves one question at a time so you stay in flow. The result is
5
+ // a durable profile (.atris/clarity.json + atris/CLARITY.md) that agents read.
6
+
7
+ const readline = require('readline');
8
+ const {
9
+ QUESTIONS,
10
+ KEYS,
11
+ mergeProfile,
12
+ isEmptyProfile,
13
+ renderClarityMd,
14
+ readProfile,
15
+ writeProfile,
16
+ profilePaths,
17
+ } = require('../lib/clarity');
18
+
19
+ function parseSets(args) {
20
+ const answers = {};
21
+ for (let i = 0; i < args.length; i++) {
22
+ if (args[i] === '--set' && args[i + 1]) {
23
+ const eq = args[i + 1].indexOf('=');
24
+ if (eq > 0) {
25
+ const key = args[i + 1].slice(0, eq).trim();
26
+ const val = args[i + 1].slice(eq + 1).trim();
27
+ if (KEYS.includes(key)) answers[key] = val;
28
+ }
29
+ i++;
30
+ }
31
+ }
32
+ return answers;
33
+ }
34
+
35
+ function ask(rl, question) {
36
+ return new Promise((resolve) => rl.question(question, (a) => resolve(a)));
37
+ }
38
+
39
+ async function clarityCommand(args = [], root = process.cwd()) {
40
+ const sub = args[0];
41
+ const stamp = new Date().toISOString();
42
+
43
+ if (args.includes('--help') || args.includes('-h') || sub === 'help') {
44
+ console.log('');
45
+ console.log('Usage: atris clarity [show|--json|--set key=value|--reset]');
46
+ console.log('');
47
+ console.log('Interview the operator for how they work, once. Agents read the result.');
48
+ console.log('');
49
+ console.log(' atris clarity Run the interview (one question at a time)');
50
+ console.log(' atris clarity show Show the current profile');
51
+ console.log(' atris clarity --json Print the profile as JSON');
52
+ console.log(' atris clarity --set voice=plain Set one field without prompting');
53
+ console.log(' atris clarity --reset Clear the profile');
54
+ console.log('');
55
+ console.log(`Fields: ${KEYS.join(', ')}`);
56
+ console.log('');
57
+ return 0;
58
+ }
59
+
60
+ if (args.includes('--reset')) {
61
+ writeProfile(root, { updated_at: stamp });
62
+ console.log('clarity profile reset.');
63
+ return 0;
64
+ }
65
+
66
+ const existing = readProfile(root);
67
+
68
+ if (args.includes('--json')) {
69
+ console.log(JSON.stringify(existing, null, 2));
70
+ return 0;
71
+ }
72
+
73
+ if (sub === 'show') {
74
+ console.log(renderClarityMd(existing));
75
+ return 0;
76
+ }
77
+
78
+ // Non-interactive set: write fields and exit.
79
+ const sets = parseSets(args);
80
+ if (Object.keys(sets).length) {
81
+ const merged = mergeProfile(existing, sets, stamp);
82
+ // Only the keys whose values actually landed (mergeProfile drops empties).
83
+ const changed = Object.keys(sets).filter((k) => sets[k].trim() && merged[k] === sets[k].trim());
84
+ if (!changed.length) {
85
+ console.log('nothing to set (empty value ignored). use --reset to clear all.');
86
+ return 0;
87
+ }
88
+ const { md } = writeProfile(root, merged);
89
+ console.log(`saved ${changed.join(', ')} to ${md.replace(`${root}/`, '')}`);
90
+ return 0;
91
+ }
92
+
93
+ // Interactive interview. Only on a terminal, so spawns never hang.
94
+ if (!process.stdin.isTTY) {
95
+ console.log(renderClarityMd(existing));
96
+ if (isEmptyProfile(existing)) {
97
+ console.log('Run `atris clarity` in a terminal to fill this in, or use --set key=value.');
98
+ }
99
+ return 0;
100
+ }
101
+
102
+ console.log('');
103
+ console.log('clarity interview. plain answers, one at a time. enter to keep the current value.');
104
+ console.log('');
105
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
106
+ const answers = {};
107
+ for (const { key, q } of QUESTIONS) {
108
+ const current = existing[key] ? ` [${existing[key]}]` : '';
109
+ // eslint-disable-next-line no-await-in-loop
110
+ const a = (await ask(rl, `${q}${current}\n> `)).trim();
111
+ if (a) answers[key] = a;
112
+ console.log('');
113
+ }
114
+ rl.close();
115
+
116
+ const merged = mergeProfile(existing, answers, stamp);
117
+ const { md } = writeProfile(root, merged);
118
+ console.log('clarity saved. agents will read it from:');
119
+ console.log(` ${md.replace(`${root}/`, '')}`);
120
+ console.log('');
121
+ console.log(renderClarityMd(merged));
122
+ return 0;
123
+ }
124
+
125
+ module.exports = { clarityCommand, parseSets };
@@ -0,0 +1,184 @@
1
+ // atris deck — generate a premium, on-brand Google Slides deck from a plain
2
+ // content spec, using the Atris deck engine (lib/slides-deck.js). The pitch:
3
+ // describe the deck, get the design system for free. No Arial-on-white slop.
4
+ //
5
+ // Usage:
6
+ // atris deck themes list design themes
7
+ // atris deck build <spec.json> [--title T] [--theme terminal|paper] [--update ID]
8
+ // atris deck sample [--theme paper] print a starter spec to stdout
9
+ //
10
+ // A spec is JSON: { theme, brand:{name,accent}, slides:[ {type,...} ] }.
11
+ // Slide types: title, statement, columns, panel, chips, bignumber, close.
12
+
13
+ const fs = require('fs');
14
+ const https = require('https');
15
+ const os = require('os');
16
+ const { buildDeck, THEMES } = require('../lib/slides-deck');
17
+ const { parseMarkdownToSpec } = require('../lib/deck-from-md');
18
+ const { mergedThemes } = require('../lib/theme');
19
+
20
+ const BASE = 'api.atris.ai';
21
+ const PFX = '/api/integrations/google-slides';
22
+
23
+ function token() {
24
+ try { return require(os.homedir() + '/.atris/credentials.json').token; }
25
+ catch { return null; }
26
+ }
27
+ function api(method, path, body, tok) {
28
+ return new Promise((resolve, reject) => {
29
+ const data = body ? JSON.stringify(body) : null;
30
+ const req = https.request({ host: BASE, path: PFX + path, method,
31
+ headers: { Authorization: 'Bearer ' + tok, 'Content-Type': 'application/json',
32
+ ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } },
33
+ (res) => { let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => {
34
+ let j; try { j = JSON.parse(b); } catch { j = b; }
35
+ if (res.statusCode >= 300) reject(new Error('HTTP ' + res.statusCode + ': ' + (typeof j === 'string' ? j : JSON.stringify(j)).slice(0, 600)));
36
+ else resolve(j); }); });
37
+ req.on('error', reject); if (data) req.write(data); req.end();
38
+ });
39
+ }
40
+
41
+ const SAMPLE = {
42
+ theme: 'terminal',
43
+ brand: { name: 'Sentinel', accent: '.' },
44
+ slides: [
45
+ { type: 'title', headline: 'Read your incidents in the **dark.**',
46
+ sub: "On-call shouldn't mean panic. Every alert ranked by blast radius, in one calm view.",
47
+ panel: { header: { title: 'Active incidents', meta: 'updated 12s ago' },
48
+ rows: [
49
+ { title: 'Checkout latency spike', sub: 'api-gateway · us-east-1', value: '42%', valueSub: 'of traffic', sev: 0, active: true },
50
+ { title: 'Stale read replica', sub: 'orders-db · eu-west-2', value: '8%', valueSub: 'of traffic', sev: 1 },
51
+ { title: 'Elevated 4xx on search', sub: 'search-svc · global', value: '1.2%', valueSub: 'of traffic', sev: 2 },
52
+ ], footer: { left: '3 active, 1 worth a page', right: 'View all' } } },
53
+ { type: 'statement', text: "On-call shouldn't mean **panic.**",
54
+ sub: 'So the console is calm by default. One screen, ranked by real impact.' },
55
+ { type: 'columns', heading: 'What makes it calm', columns: [
56
+ { h: 'Ranked by impact', b: 'Severity comes from real blast radius, so the top of the list is the thing to fix.' },
57
+ { h: 'Quiet by default', b: 'One page-worthy signal per incident. The rest stays in the log until you ask.' },
58
+ { h: 'Built for 3am', b: 'High contrast, keyboard-first, and readable before you are fully awake.' } ] },
59
+ { type: 'bignumber', number: '11 min', label: 'median time to first action', sub: 'down from 47 minutes before Sentinel.' },
60
+ { type: 'close', tagline: 'Read your incidents in the dark.',
61
+ buttons: [{ label: 'Open the console', primary: true }, { label: 'Read the docs' }], footer: 'sentinel.sh · 2026' },
62
+ ],
63
+ };
64
+
65
+ // shared: spec -> live deck. Returns the URL.
66
+ async function publishDeck(spec, { title, updateId, tok }) {
67
+ const { requests } = buildDeck(spec, { themes: mergedThemes(THEMES) });
68
+ let id, firstSlide;
69
+ if (updateId) {
70
+ id = updateId;
71
+ const got = await api('GET', `/presentations/${id}`, null, tok);
72
+ const slides = got.slides || (got.presentation && got.presentation.slides) || [];
73
+ firstSlide = slides[0] && slides[0].objectId;
74
+ } else {
75
+ const pres = await api('POST', '/presentations', { title }, tok);
76
+ id = pres.presentationId || pres.id || (pres.presentation && pres.presentation.presentationId);
77
+ const slides = pres.slides || (pres.presentation && pres.presentation.slides) || [];
78
+ firstSlide = slides[0] && slides[0].objectId;
79
+ }
80
+ const reqs = firstSlide ? [...requests, { deleteObject: { objectId: firstSlide } }] : requests;
81
+ console.log(` building ${spec.slides.length} slides (${spec.theme}) · ${reqs.length} ops...`);
82
+ await api('POST', `/presentations/${id}/batch-update`, { requests: reqs }, tok);
83
+ return `https://docs.google.com/presentation/d/${id}/edit`;
84
+ }
85
+
86
+ // beautiful HTML output (page or AppBlock JSON) from a content spec
87
+ function outputHtml(spec, argv, srcLabel) {
88
+ const { renderHtml, renderBlock, THEMES: HTML_THEMES } = require('../lib/html-render');
89
+ const themes = mergedThemes(HTML_THEMES);
90
+ if (!themes[spec.theme]) spec.theme = 'atris';
91
+ const title = flag(argv, '--title');
92
+ if (hasFlag(argv, '--block')) {
93
+ console.log(JSON.stringify(renderBlock(spec, { title, themes }), null, 2));
94
+ return 0;
95
+ }
96
+ const html = renderHtml(spec, { title, themes });
97
+ const out = flag(argv, '--out');
98
+ if (out) { fs.writeFileSync(out, html); console.log(`\n ✓ html written: ${out}${srcLabel ? ` (from ${srcLabel})` : ''}\n`); }
99
+ else process.stdout.write(html + '\n');
100
+ return 0;
101
+ }
102
+
103
+ async function run(argv) {
104
+ const sub = argv[0];
105
+
106
+ if (sub === 'from') {
107
+ const docPath = argv.slice(1).find((a) => !a.startsWith('-'));
108
+ if (!docPath) { console.error(' usage: atris deck from <doc.md> [--theme x] [--brand Name] [--build] [--title T]'); return 2; }
109
+ let md;
110
+ try { md = fs.readFileSync(docPath, 'utf8'); }
111
+ catch (e) { console.error(` cannot read doc: ${e.message}`); return 2; }
112
+ const spec = parseMarkdownToSpec(md, { theme: flag(argv, '--theme'), brandName: flag(argv, '--brand') });
113
+ if (hasFlag(argv, '--html') || hasFlag(argv, '--block')) return outputHtml(spec, argv, docPath);
114
+ { const dt = mergedThemes(THEMES); if (!dt[spec.theme]) { console.error(` unknown theme "${spec.theme}". try: ${Object.keys(dt).join(', ')}`); return 2; } }
115
+ if (!hasFlag(argv, '--build')) {
116
+ // default: print the spec so the PM can tweak before building
117
+ console.log(JSON.stringify(spec, null, 2));
118
+ return 0;
119
+ }
120
+ const tok = token();
121
+ if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
122
+ const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
123
+ const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
124
+ console.log(`\n ✓ deck from ${docPath} ready: ${url}\n`);
125
+ return 0;
126
+ }
127
+
128
+ if (sub === 'themes') {
129
+ console.log('\n atris deck themes:\n');
130
+ for (const [name, t] of Object.entries(THEMES)) {
131
+ console.log(` ${name.padEnd(10)} ${t.fonts.display} + ${t.fonts.body} · accent ${t.color.accent} bg ${t.color.bg}`);
132
+ }
133
+ console.log('\n slide types: title, statement, columns, panel, chips, bignumber, close\n');
134
+ return 0;
135
+ }
136
+
137
+ if (sub === 'sample') {
138
+ const theme = flag(argv, '--theme') || 'terminal';
139
+ console.log(JSON.stringify({ ...SAMPLE, theme }, null, 2));
140
+ return 0;
141
+ }
142
+
143
+ if (sub === 'build') {
144
+ const specPath = argv.slice(1).find((a) => !a.startsWith('-'));
145
+ if (!specPath) { console.error(' usage: atris deck build <spec.json> [--title T] [--theme x] [--update ID]'); return 2; }
146
+ let spec;
147
+ try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
148
+ catch (e) { console.error(` cannot read spec: ${e.message}`); return 2; }
149
+ const themeOverride = flag(argv, '--theme'); if (themeOverride) spec.theme = themeOverride;
150
+ if (hasFlag(argv, '--html') || hasFlag(argv, '--block')) return outputHtml(spec, argv, specPath);
151
+ { const dt = mergedThemes(THEMES); if (!dt[spec.theme]) { console.error(` unknown theme "${spec.theme}". try: ${Object.keys(dt).join(', ')}`); return 2; } }
152
+ const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
153
+
154
+ const tok = token();
155
+ if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
156
+
157
+ const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
158
+ console.log(`\n ✓ deck ready: ${url}\n`);
159
+ return 0;
160
+ }
161
+
162
+ console.log(`
163
+ atris deck — premium Google Slides from a plain content spec or a markdown doc
164
+
165
+ atris deck from doc.md [--build] [--title T] turn a markdown doc into a deck
166
+ atris deck from doc.md --html --out page.html beautiful HTML page (theme: atris|terminal|paper)
167
+ atris deck from doc.md --block emit the AppBlock JSON for a web app
168
+ atris deck sample [--theme paper] > my.json start from a sample spec
169
+ atris deck build my.json [--title "Q3 review"] create the deck, print the URL
170
+ atris deck build my.json --html --out p.html render the spec as HTML instead of slides
171
+ atris deck themes list design themes
172
+
173
+ 'from' maps headings to slides (## with bullets -> columns, "**X** label" -> a
174
+ big number, Close -> a closing slide). Without --build it prints the spec to tweak.
175
+ Design system is baked in: distinctive fonts, one accent, real data panels, and
176
+ no AI tells (em dashes sanitized, sentence-case labels, no gradient text).
177
+ `);
178
+ return 0;
179
+ }
180
+
181
+ function flag(argv, name) { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; }
182
+ function hasFlag(argv, name) { return argv.includes(name); }
183
+
184
+ module.exports = { run, SAMPLE, publishDeck };