atris 3.17.0 → 3.23.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/atris.md CHANGED
@@ -62,12 +62,12 @@ Labels used below:
62
62
 
63
63
  What you ship should not read as generated. The test: if someone said "an AI made this," would they believe it instantly? If yes, that is the bug. The model has no words for restraint and it falls into gravity wells. Beat both.
64
64
 
65
- - **Gate it.** `atris slop detect <path>` is deterministic: no model, exit 1 on a tell, built for CI and the review stage. A finding is a fact (file:line + rule), not an opinion. `guarded` once wired into review.
65
+ - **Gate it.** `atris slop detect <path>` is deterministic: no model, exit 1 on a tell, built for CI and the review stage. A finding is a fact (file:line + rule), not an opinion. `--diff`/`--staged` scopes it to changed lines for a commit gate; `--fix` repairs the safe tells. `guarded` once wired into review.
66
66
  - **Name the move.** Vague prompts make vague output. Direct with craft words: vertical rhythm, negative space, hierarchy, contrast, bolder here / quieter there, restraint. Precise language is the lever. Own it.
67
67
  - **Refuse the wells** (named so you can): purple/indigo gradients, gradient-filled text, glassmorphism, Inter/Roboto defaults, claude-beige, neon-on-dark, hero-metric rows, identical card grids, eyebrow/tracked-caps labels, pulsing live-dots, em dashes.
68
68
  - **Commit to constraints.** One distinctive font, one accent hue, a small spacing scale. Taste is subtraction, not addition.
69
- - **Generate it right.** `atris deck` (slides) and the `design` policy apply the system by default: own backgrounds and fonts, never the tool's stock template.
70
- - **Compound it.** A new tell becomes a typed lesson with a `detector:` regex, so the gate grows instead of leaning on memory. Taste lives in code, not vibes.
69
+ - **Generate it right.** `atris deck` (slides), `atris deck from <doc.md> --html` (a web page from a plain doc, in the web app's design tokens), `atris site <dir>` (a whole markdown folder into a navigable site), and `atris recap --html` (a memory-updates page) all apply the system by default: own backgrounds and fonts, never the tool's stock template. Output as an AppBlock with `--block` to drop into a web app.
70
+ - **Compound it.** A new tell becomes a project rule in `.atris/slop.rules.json` (`atris slop rules --add`), and a project's brand lives in `.atris/theme.json` (`atris theme create` builds your own by feel, or `atris theme init` scaffolds one) so every deck, page, and site is on-brand by default, and one line of text becomes an on-brand image with `atris card`. The gate and the look grow per project instead of leaning on memory. Taste lives in code, not vibes.
71
71
 
72
72
  ## voice
73
73
 
package/bin/atris.js CHANGED
@@ -363,6 +363,9 @@ function showHelp() {
363
363
  console.log(' review - Validate work (tests, safety checks, docs)');
364
364
  console.log(' slop - Detect frontend AI-slop tells (deterministic, exit 1 = found)');
365
365
  console.log(' deck - Generate a premium Google Slides deck from a content spec');
366
+ console.log(' site - Build a beautiful static site from a folder of markdown');
367
+ console.log(' theme - Brand themes: "theme create" builds your own by feel (deck/html/site)');
368
+ console.log(' card - One line of text into an on-brand image (uses your theme)');
366
369
  console.log(' run - Auto-chain plan→do→review (autonomous loop, auto-pushes)');
367
370
  console.log(' run logs - Browse glass run logs (phase reasoning persisted to disk)');
368
371
  console.log(' run search - Search phase reasoning across all run logs');
@@ -376,7 +379,7 @@ function showHelp() {
376
379
  console.log(' radar - Show live agents joined with tasks, missions, and worktrees');
377
380
  console.log(' ctop - Show a process-first live agent CPU/memory view');
378
381
  console.log(' status - See local work and completions (`atris status <business>` for remote)');
379
- console.log(' recap - What your AI team did, in plain English (--share for paste-ready)');
382
+ console.log(' recap - What your AI team did, in plain English (--share, or --html for a memory page)');
380
383
  console.log(' xp - Show Career XP and contribution graph');
381
384
  console.log(' analytics - Show recent productivity from journals');
382
385
  console.log(' search - Search journal history (atris search <keyword>)');
@@ -828,7 +831,7 @@ if (command === '2' && ['fast', 'pro'].includes(String(firstCommandArg || '').to
828
831
  const knownCommands = ['init', 'log', 'now', 'radar', 'ctop', 'status', 'analytics', 'visualize', 'brain', 'brainstorm', 'autopilot', 'run', 'plan', 'do', 'review', 'release',
829
832
  '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',
830
833
  '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',
831
- 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
834
+ 'ingest', 'query', 'lint', 'loop', 'pulse', 'task', 'mission', 'probe', 'worktree', 'aeo', 'slop', 'deck', 'site', 'theme', 'card', 'improve', 'xp', 'play', 'gm', 'x', 'recap',
832
835
  'gmail', 'calendar', 'twitter', 'slack', 'imessage', 'integrations', 'setup', 'clean-workspace', 'cw',
833
836
  'fork', 'browse', 'publish', 'sleep', 'wake', 'feedback', 'errors', 'wiki', 'code-review', 'cr', 'soul', 'fleet', 'compile', 'spaceship'];
834
837
 
@@ -1352,6 +1355,21 @@ if (command === 'init') {
1352
1355
  Promise.resolve(require('../commands/deck').run(process.argv.slice(3)))
1353
1356
  .then((code) => process.exit(typeof code === 'number' ? code : 0))
1354
1357
  .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
1358
+ } else if (command === 'site') {
1359
+ // Site: beautiful static site from a folder of markdown, in the anti-slop design system.
1360
+ Promise.resolve(require('../commands/site').run(process.argv.slice(3)))
1361
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
1362
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
1363
+ } else if (command === 'theme') {
1364
+ // Theme: brand themes (.atris/theme.json) for the whole design system (deck/html/site).
1365
+ Promise.resolve(require('../commands/theme').run(process.argv.slice(3)))
1366
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
1367
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
1368
+ } else if (command === 'card') {
1369
+ // Card: one line of text into an on-brand image (uses your theme + the design system).
1370
+ Promise.resolve(require('../commands/card').run(process.argv.slice(3)))
1371
+ .then((code) => process.exit(typeof code === 'number' ? code : 0))
1372
+ .catch((err) => { console.error(`\n✗ Error: ${err.message || err}`); process.exit(1); });
1355
1373
  } else if (command === 'aeo') {
1356
1374
  // AEO: AI Engine Optimization — credit-metered citation drafting against the customer workspace.
1357
1375
  Promise.resolve(require('../commands/aeo').run(process.argv.slice(3)))
@@ -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 };
package/commands/deck.js CHANGED
@@ -14,6 +14,8 @@ const fs = require('fs');
14
14
  const https = require('https');
15
15
  const os = require('os');
16
16
  const { buildDeck, THEMES } = require('../lib/slides-deck');
17
+ const { parseMarkdownToSpec } = require('../lib/deck-from-md');
18
+ const { mergedThemes } = require('../lib/theme');
17
19
 
18
20
  const BASE = 'api.atris.ai';
19
21
  const PFX = '/api/integrations/google-slides';
@@ -60,9 +62,69 @@ const SAMPLE = {
60
62
  ],
61
63
  };
62
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
+
63
103
  async function run(argv) {
64
104
  const sub = argv[0];
65
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
+
66
128
  if (sub === 'themes') {
67
129
  console.log('\n atris deck themes:\n');
68
130
  for (const [name, t] of Object.entries(THEMES)) {
@@ -85,51 +147,38 @@ async function run(argv) {
85
147
  try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); }
86
148
  catch (e) { console.error(` cannot read spec: ${e.message}`); return 2; }
87
149
  const themeOverride = flag(argv, '--theme'); if (themeOverride) spec.theme = themeOverride;
88
- if (!THEMES[spec.theme]) { console.error(` unknown theme "${spec.theme}". try: ${Object.keys(THEMES).join(', ')}`); return 2; }
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; } }
89
152
  const title = flag(argv, '--title') || `${(spec.brand && spec.brand.name) || 'Atris'} deck`;
90
153
 
91
154
  const tok = token();
92
155
  if (!tok) { console.error(' no credentials at ~/.atris/credentials.json — run `atris login` and connect Google Drive.'); return 1; }
93
156
 
94
- const { requests } = buildDeck(spec);
95
-
96
- let id, firstSlide;
97
- const updateId = flag(argv, '--update');
98
- if (updateId) {
99
- id = updateId;
100
- const got = await api('GET', `/presentations/${id}`, null, tok);
101
- const slides = got.slides || (got.presentation && got.presentation.slides) || [];
102
- firstSlide = slides[0] && slides[0].objectId;
103
- } else {
104
- const pres = await api('POST', '/presentations', { title }, tok);
105
- id = pres.presentationId || pres.id || (pres.presentation && pres.presentation.presentationId);
106
- const slides = pres.slides || (pres.presentation && pres.presentation.slides) || [];
107
- firstSlide = slides[0] && slides[0].objectId;
108
- }
109
-
110
- const reqs = firstSlide ? [...requests, { deleteObject: { objectId: firstSlide } }] : requests;
111
- console.log(` building ${spec.slides.length} slides (${spec.theme}) · ${reqs.length} ops...`);
112
- await api('POST', `/presentations/${id}/batch-update`, { requests: reqs }, tok);
113
-
114
- const url = `https://docs.google.com/presentation/d/${id}/edit`;
157
+ const url = await publishDeck(spec, { title, updateId: flag(argv, '--update'), tok });
115
158
  console.log(`\n ✓ deck ready: ${url}\n`);
116
159
  return 0;
117
160
  }
118
161
 
119
162
  console.log(`
120
- atris deck — premium Google Slides from a plain content spec
163
+ atris deck — premium Google Slides from a plain content spec or a markdown doc
121
164
 
122
- atris deck sample [--theme paper] > my.json start from a sample spec
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
123
169
  atris deck build my.json [--title "Q3 review"] create the deck, print the URL
124
- atris deck build my.json --update <id> rebuild into an existing deck
170
+ atris deck build my.json --html --out p.html render the spec as HTML instead of slides
125
171
  atris deck themes list design themes
126
172
 
127
- Design system is baked in: distinctive fonts, one accent, real data panels,
128
- and no AI tells (em dashes sanitized, sentence-case labels, no gradient text).
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).
129
177
  `);
130
178
  return 0;
131
179
  }
132
180
 
133
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); }
134
183
 
135
- module.exports = { run, SAMPLE };
184
+ module.exports = { run, SAMPLE, publishDeck };
package/commands/recap.js CHANGED
@@ -220,6 +220,22 @@ function recapAtris(args = []) {
220
220
  printRecapHelp();
221
221
  return;
222
222
  }
223
+ if (args.includes('--html')) {
224
+ // another way to view memory updates: a beautiful HTML page of what the workspace learned
225
+ const { buildMemorySpec } = require('../lib/memory-view');
226
+ const { renderHtml, renderBlock, THEMES: HTML_THEMES } = require('../lib/html-render');
227
+ const { mergedThemes } = require('../lib/theme');
228
+ const themes = mergedThemes(HTML_THEMES);
229
+ const flagVal = (n) => { const i = args.indexOf(n); return i !== -1 ? args[i + 1] : null; };
230
+ const spec = buildMemorySpec(process.cwd(), { theme: flagVal('--theme'), brand: flagVal('--brand') });
231
+ if (!themes[spec.theme]) spec.theme = 'atris';
232
+ if (args.includes('--block')) { console.log(JSON.stringify(renderBlock(spec, { title: 'Workspace memory', themes }), null, 2)); return; }
233
+ const html = renderHtml(spec, { title: 'Workspace memory', themes });
234
+ const out = flagVal('--out');
235
+ if (out) { fs.writeFileSync(out, html); console.log(`\n ✓ memory view written: ${out}\n`); }
236
+ else process.stdout.write(html + '\n');
237
+ return;
238
+ }
223
239
  const daysIdx = args.indexOf('--days');
224
240
  const days = daysIdx !== -1 ? Number(args[daysIdx + 1]) : DEFAULT_DAYS;
225
241
  const data = buildRecapData(process.cwd(), { days });
@@ -0,0 +1,48 @@
1
+ // atris site — turn a folder of markdown (docs, your wiki, memory) into a
2
+ // beautiful, navigable static site in the design system. Built on lib/site.js.
3
+ //
4
+ // atris site <dir|doc.md> [--out dist] [--theme atris|terminal|paper] [--title T] [--serve]
5
+
6
+ const { buildSite, serveSite } = require('../lib/site');
7
+
8
+ function flag(argv, name) { const i = argv.indexOf(name); return i !== -1 ? argv[i + 1] : null; }
9
+ function hasFlag(argv, name) { return argv.includes(name); }
10
+
11
+ async function run(argv) {
12
+ const input = argv.find((a) => !a.startsWith('-'));
13
+ if (!input || input === 'help' || hasFlag(argv, '--help')) {
14
+ console.log(`
15
+ atris site — a beautiful static site from a folder of markdown
16
+
17
+ atris site <dir|doc.md> [--out dist] [--theme atris|terminal|paper] [--title T]
18
+ atris site atris/wiki --title "Atris Wiki" --serve
19
+
20
+ Each .md becomes a page; an index links them all. Same anti-slop design system,
21
+ semantic data-atris-block sections, ready for the web app. --serve previews it.
22
+ `);
23
+ return input === 'help' || hasFlag(argv, '--help') ? 0 : 2;
24
+ }
25
+
26
+ let res;
27
+ try {
28
+ res = buildSite(input, {
29
+ out: flag(argv, '--out') || 'dist',
30
+ theme: flag(argv, '--theme'),
31
+ title: flag(argv, '--title'),
32
+ brand: flag(argv, '--brand'),
33
+ });
34
+ } catch (e) { console.error(` ${e.message}`); return 2; }
35
+
36
+ console.log(`\n ✓ site built: ${res.pages.length} page${res.pages.length === 1 ? '' : 's'} + index -> ${res.outDir}/`);
37
+ console.log(` open ${res.indexPath}`);
38
+
39
+ if (hasFlag(argv, '--serve')) {
40
+ const port = Number(flag(argv, '--port')) || 4321;
41
+ const { url } = await serveSite(res.outDir, port);
42
+ console.log(`\n serving at ${url} (ctrl-c to stop)\n`);
43
+ await new Promise(() => {}); // keep alive until killed
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ module.exports = { run };
package/commands/slop.js CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  const fs = require('fs');
19
19
  const path = require('path');
20
+ const { execFileSync } = require('child_process');
20
21
 
21
22
  const SCAN_EXTS = new Set(['.css', '.scss', '.sass', '.less', '.tsx', '.jsx', '.ts', '.js', '.mjs', '.html', '.vue', '.svelte', '.astro',
22
23
  '.md', '.mdx', '.txt']); // prose too: the voice doctrine (em-dash, hype-copy) is enforceable, not just advice
@@ -60,6 +61,7 @@ const RULES = [
60
61
  why: 'decorative emoji in UI copy' },
61
62
  { id: 'em-dash', sev: 'warn',
62
63
  re: /—/,
64
+ fix: (s) => s.replace(/\s*—\s*/g, ', '), // safe deterministic repair (prose)
63
65
  why: 'em dash: a top AI-writing tell; use a comma, colon, or period' },
64
66
  { id: 'hype-copy', sev: 'error',
65
67
  re: /\b(boost your productivity|supercharge|unleash|game[- ]?chang(?:er|ing)|seamlessly|effortlessly|revolutioniz(?:e|ing)|take your .{1,30} to the next level|elevate your|cutting[- ]edge|powered by ai|next[- ]generation)\b/i,
@@ -68,6 +70,86 @@ const RULES = [
68
70
 
69
71
  const ICON = { error: '✗', warn: '⚠' }; // ✗ ⚠
70
72
 
73
+ const PROJECT_RULES_FILE = path.join('.atris', 'slop.rules.json');
74
+
75
+ // Compounding: projects grow their own anti-slop ruleset in .atris/slop.rules.json.
76
+ // Each entry: { id, pattern, flags?, why?, sev? }. Loaded on top of the built-ins.
77
+ function loadProjectRules(root = process.cwd()) {
78
+ try {
79
+ const raw = JSON.parse(fs.readFileSync(path.join(root, PROJECT_RULES_FILE), 'utf8'));
80
+ const arr = Array.isArray(raw) ? raw : (raw.rules || []);
81
+ return arr.map((r) => {
82
+ if (!r || !r.id || !r.pattern) return null;
83
+ let re; try { re = new RegExp(r.pattern, r.flags || 'i'); } catch { return null; }
84
+ return { id: r.id, sev: r.sev === 'error' ? 'error' : 'warn', re, why: r.why || r.id, project: true };
85
+ }).filter(Boolean);
86
+ } catch { return []; }
87
+ }
88
+
89
+ function addProjectRule(rule, root = process.cwd()) {
90
+ const file = path.join(root, PROJECT_RULES_FILE);
91
+ let arr = [];
92
+ try { const raw = JSON.parse(fs.readFileSync(file, 'utf8')); arr = Array.isArray(raw) ? raw : (raw.rules || []); } catch {}
93
+ arr = arr.filter((r) => r.id !== rule.id);
94
+ arr.push(rule);
95
+ fs.mkdirSync(path.dirname(file), { recursive: true });
96
+ fs.writeFileSync(file, JSON.stringify(arr, null, 2) + '\n');
97
+ return file;
98
+ }
99
+
100
+ // Map<absFile, Set<changedLineNumber>> from the working-tree (or --cached) git diff.
101
+ function gitChangedLines(staged, cwd = process.cwd()) {
102
+ const map = new Map();
103
+ let out;
104
+ try { out = execFileSync('git', ['diff', '--unified=0', ...(staged ? ['--cached'] : [])], { encoding: 'utf8', cwd }); }
105
+ catch { return map; }
106
+ let cur = null;
107
+ for (const line of out.split('\n')) {
108
+ const f = line.match(/^\+\+\+ b\/(.+)$/);
109
+ if (f) { cur = path.resolve(cwd, f[1]); map.set(cur, map.get(cur) || new Set()); continue; }
110
+ const h = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
111
+ if (h && cur) { const start = +h[1], count = h[2] != null ? +h[2] : 1; for (let i = 0; i < count; i++) map.get(cur).add(start + i); }
112
+ }
113
+ return map;
114
+ }
115
+
116
+ // Install a pre-commit hook that gates staged changes through `atris slop detect --staged`.
117
+ // Idempotent and non-destructive: appends a marked block, skips gracefully if atris is absent.
118
+ function installHook(root = process.cwd()) {
119
+ if (!fs.existsSync(path.join(root, '.git'))) throw new Error('not a git repo (no .git here)');
120
+ const hookDir = path.join(root, '.git', 'hooks');
121
+ fs.mkdirSync(hookDir, { recursive: true });
122
+ const hookPath = path.join(hookDir, 'pre-commit');
123
+ const marker = '# atris slop gate';
124
+ let content = '';
125
+ try { content = fs.readFileSync(hookPath, 'utf8'); } catch {}
126
+ if (content.includes(marker)) return { hookPath, already: true };
127
+ if (!content) content = '#!/bin/sh\n';
128
+ if (!content.endsWith('\n')) content += '\n';
129
+ content += `\n${marker}\nif command -v atris >/dev/null 2>&1; then atris slop detect --staged --quiet || exit 1; fi\n`;
130
+ fs.writeFileSync(hookPath, content);
131
+ fs.chmodSync(hookPath, 0o755);
132
+ return { hookPath, already: false };
133
+ }
134
+
135
+ // Apply every fixable rule's safe transform in place. Returns { fixedCount, fixedFiles }.
136
+ function applyFixes(files, rules) {
137
+ const fixable = rules.filter((r) => typeof r.fix === 'function');
138
+ let fixedCount = 0; const fixedFiles = [];
139
+ for (const file of files) {
140
+ let text; try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
141
+ const lines = text.split('\n');
142
+ let touched = false;
143
+ for (let i = 0; i < lines.length; i++) {
144
+ let line = lines[i];
145
+ for (const r of fixable) { if (r.re.test(line)) { const nl = r.fix(line); if (nl !== line) { line = nl; fixedCount++; } } }
146
+ if (line !== lines[i]) { lines[i] = line; touched = true; }
147
+ }
148
+ if (touched) { fs.writeFileSync(file, lines.join('\n')); fixedFiles.push(file); }
149
+ }
150
+ return { fixedCount, fixedFiles };
151
+ }
152
+
71
153
  function walk(target, out) {
72
154
  let stat;
73
155
  try { stat = fs.statSync(target); } catch { return out; }
@@ -85,14 +167,14 @@ function walk(target, out) {
85
167
  return out;
86
168
  }
87
169
 
88
- function scanFile(file) {
170
+ function scanFile(file, rules = RULES) {
89
171
  const findings = [];
90
172
  let text;
91
173
  try { text = fs.readFileSync(file, 'utf8'); } catch { return findings; }
92
174
  const lines = text.split('\n');
93
175
  for (let i = 0; i < lines.length; i++) {
94
176
  const line = lines[i];
95
- for (const rule of RULES) {
177
+ for (const rule of rules) {
96
178
  const m = rule.re.exec(line);
97
179
  if (m) {
98
180
  findings.push({
@@ -108,15 +190,38 @@ function scanFile(file) {
108
190
  function detect(argv) {
109
191
  const json = argv.includes('--json');
110
192
  const quiet = argv.includes('--quiet');
111
- const target = argv.find((a) => !a.startsWith('-')) || '.';
193
+ const doFix = argv.includes('--fix');
194
+ const staged = argv.includes('--staged');
195
+ const diffMode = staged || argv.includes('--diff');
196
+ const rules = RULES.concat(loadProjectRules());
112
197
 
113
- const files = walk(path.resolve(target), []);
114
- const findings = files.flatMap(scanFile);
198
+ // pick the file set: a git diff (changed files) or a path walk
199
+ let files, changed = null;
200
+ if (diffMode) {
201
+ changed = gitChangedLines(staged);
202
+ files = [...changed.keys()].filter((f) => SCAN_EXTS.has(path.extname(f)) && fs.existsSync(f));
203
+ } else {
204
+ const target = argv.find((a) => !a.startsWith('-')) || '.';
205
+ files = walk(path.resolve(target), []);
206
+ }
207
+
208
+ let fixed = null;
209
+ if (doFix) {
210
+ fixed = applyFixes(files, rules);
211
+ if (!json && fixed.fixedCount) {
212
+ console.log(`\n ✎ fixed ${fixed.fixedCount} tell${fixed.fixedCount === 1 ? '' : 's'} in ${fixed.fixedFiles.length} file${fixed.fixedFiles.length === 1 ? '' : 's'}`);
213
+ }
214
+ }
215
+
216
+ let findings = files.flatMap((f) => scanFile(f, rules));
217
+ if (diffMode && changed) findings = findings.filter((f) => changed.get(f.file) && changed.get(f.file).has(f.line));
115
218
  const errors = findings.filter((f) => f.sev === 'error').length;
116
219
 
117
220
  if (json) {
118
221
  console.log(JSON.stringify({
119
222
  ok: findings.length === 0, scanned: files.length,
223
+ mode: diffMode ? (staged ? 'staged' : 'diff') : 'path',
224
+ fixed: fixed ? fixed.fixedCount : 0,
120
225
  slop: findings.length, errors,
121
226
  findings: findings.map((f) => ({ ...f, file: path.relative(process.cwd(), f.file) })),
122
227
  }, null, 2));
@@ -146,28 +251,57 @@ function detect(argv) {
146
251
 
147
252
  function slopCommand(argv) {
148
253
  const sub = argv[0];
149
- if (!sub || sub === 'detect' || sub.startsWith('-') || !['detect', 'rules', 'help'].includes(sub)) {
254
+ if (!sub || sub === 'detect' || sub.startsWith('-') || !['detect', 'rules', 'help', 'hook', 'install-hook'].includes(sub)) {
150
255
  // default + `detect`: scan. Bare `atris slop` scans cwd too.
151
256
  const rest = sub === 'detect' ? argv.slice(1) : argv;
152
257
  return detect(rest);
153
258
  }
259
+ if (sub === 'hook' || sub === 'install-hook') {
260
+ try {
261
+ const { hookPath, already } = installHook();
262
+ console.log(already
263
+ ? `\n already installed: ${path.relative(process.cwd(), hookPath)}\n`
264
+ : `\n ✓ slop pre-commit gate installed: ${path.relative(process.cwd(), hookPath)}\n every commit now runs: atris slop detect --staged\n`);
265
+ return 0;
266
+ } catch (e) { console.error(` ${e.message}`); return 2; }
267
+ }
268
+
154
269
  if (sub === 'rules') {
270
+ if (argv.includes('--add')) {
271
+ const rest = argv.slice(argv.indexOf('--add') + 1).filter((a) => !a.startsWith('-'));
272
+ const [id, pattern, ...whyParts] = rest;
273
+ if (!id || !pattern) { console.error(' usage: atris slop rules --add <id> <regex-pattern> <why...> [--sev error|warn]'); return 2; }
274
+ let valid = true; try { new RegExp(pattern, 'i'); } catch { valid = false; }
275
+ if (!valid) { console.error(` invalid regex: ${pattern}`); return 2; }
276
+ const sev = (argv[argv.indexOf('--sev') + 1] === 'error') ? 'error' : 'warn';
277
+ const file = addProjectRule({ id, pattern, why: whyParts.join(' ') || id, sev });
278
+ console.log(` ✓ added project rule "${id}" to ${path.relative(process.cwd(), file)}`);
279
+ return 0;
280
+ }
281
+ const project = loadProjectRules();
155
282
  console.log('\n atris slop — deterministic rules:\n');
156
283
  for (const r of RULES) console.log(` ${ICON[r.sev]} ${r.id.padEnd(20)} ${r.why}`);
157
- console.log('');
284
+ for (const r of project) console.log(` ${ICON[r.sev]} ${r.id.padEnd(20)} ${r.why} (project)`);
285
+ console.log(`\n ${RULES.length} built-in${project.length ? ` + ${project.length} project` : ''} rule${RULES.length + project.length === 1 ? '' : 's'}\n`);
158
286
  return 0;
159
287
  }
160
288
  // help
161
289
  console.log(`
162
- atris slop — deterministic frontend-slop detector (no LLM)
290
+ atris slop — deterministic slop detector + repairer (no LLM)
163
291
 
164
- atris slop detect [path] scan a file or dir (default: .)
165
- atris slop detect src --json machine output for CI / the loop
166
- atris slop rules list the active rules
292
+ atris slop detect [path] scan a file or dir (default: .)
293
+ atris slop detect --diff scan only changed lines (commit/PR gate)
294
+ atris slop detect --staged scan only staged changes (pre-commit hook)
295
+ atris slop detect --fix auto-repair the safe tells (em dashes), report the rest
296
+ atris slop detect [path] --json machine output for CI / the loop
297
+ atris slop rules list active rules (built-in + project)
298
+ atris slop rules --add <id> <pattern> <why> grow the project ruleset
299
+ atris slop hook install a pre-commit gate (runs --staged)
167
300
 
301
+ Project rules live in .atris/slop.rules.json and compound over time.
168
302
  exit 0 = clean, 1 = slop found. Wire into PR checks and the autopilot gate.
169
303
  `);
170
304
  return 0;
171
305
  }
172
306
 
173
- module.exports = { slopCommand, detect, scanFile, RULES };
307
+ module.exports = { slopCommand, detect, scanFile, RULES, loadProjectRules, addProjectRule, gitChangedLines, applyFixes, installHook };