@nynb/sandpaper 0.1.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/LICENSE +21 -0
- package/README.md +119 -0
- package/bin/brain-inject.js +23 -0
- package/bin/brain-stamp-check.js +36 -0
- package/bin/cli.js +62 -0
- package/brain/README.md +99 -0
- package/brain/assets/brain.css +472 -0
- package/brain/assets/brain.js +189 -0
- package/brain/assets/theme.css +88 -0
- package/package.json +19 -0
- package/public/sp-markdown.js +172 -0
- package/public/toolbar.css +220 -0
- package/public/toolbar.js +564 -0
- package/skill/sandpaper/SKILL.md +114 -0
- package/skill/sandpaper/commands/canvas.md +31 -0
- package/skill/sandpaper/commands/decide.md +16 -0
- package/skill/sandpaper/commands/help.md +25 -0
- package/skill/sandpaper/commands/init.md +113 -0
- package/skill/sandpaper/commands/learn.md +11 -0
- package/skill/sandpaper/commands/log.md +9 -0
- package/skill/sandpaper/commands/open.md +15 -0
- package/skill/sandpaper/commands/plan.md +16 -0
- package/skill/sandpaper/commands/serve.md +8 -0
- package/skill/sandpaper/commands/stamp.md +25 -0
- package/skill/sandpaper/commands/sync.md +17 -0
- package/skill/sandpaper/commands/theme.md +12 -0
- package/src/claude.js +226 -0
- package/src/edit.js +113 -0
- package/src/server.js +327 -0
- package/src/setup.js +564 -0
package/src/setup.js
ADDED
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// setup.js — the `npx sandpaper` packaging commands: install-skill · init (scaffold) · doctor.
|
|
2
|
+
// The plumbing half of Sandpaper (no AI): copy the skill + hooks + design-system templates from
|
|
3
|
+
// THIS package into a target repo, write the manifest, and health-check a setup. Zero deps.
|
|
4
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, copyFileSync, statSync, renameSync } from 'node:fs';
|
|
5
|
+
import { join, dirname, basename, normalize, extname, resolve } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
const ok = (m) => console.log(' ✓ ' + m);
|
|
9
|
+
const warn = (m) => console.log(' · ' + m);
|
|
10
|
+
const bad = (m) => console.log(' ✗ ' + m);
|
|
11
|
+
|
|
12
|
+
const ensureDir = (d) => { if (!existsSync(d)) mkdirSync(d, { recursive: true }); };
|
|
13
|
+
const copyDirFiles = (srcDir, dstDir, skipExisting = false) => {
|
|
14
|
+
ensureDir(dstDir);
|
|
15
|
+
let n = 0;
|
|
16
|
+
for (const f of readdirSync(srcDir)) {
|
|
17
|
+
const s = join(srcDir, f);
|
|
18
|
+
if (!statSync(s).isFile()) continue;
|
|
19
|
+
const d = join(dstDir, f);
|
|
20
|
+
if (skipExisting && existsSync(d)) continue; // never clobber a user's customised file
|
|
21
|
+
copyFileSync(s, d); n++;
|
|
22
|
+
}
|
|
23
|
+
return n;
|
|
24
|
+
};
|
|
25
|
+
const projectName = (target) => {
|
|
26
|
+
try { return JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')).name || basename(target); }
|
|
27
|
+
catch { return basename(target); }
|
|
28
|
+
};
|
|
29
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
30
|
+
|
|
31
|
+
// ---- branded terminal output (degrades to plain on a non-TTY or with NO_COLOR) ----
|
|
32
|
+
const useColor = (process.stdout.isTTY || process.env.FORCE_COLOR) && !process.env.NO_COLOR;
|
|
33
|
+
const paint = (code, s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
34
|
+
const bold = (s) => paint('1', s), dim = (s) => paint('2', s), green = (s) => paint('32', s), clay = (s) => paint('38;5;173', s);
|
|
35
|
+
function banner() {
|
|
36
|
+
console.log('\n' + clay([
|
|
37
|
+
' ░█▀▀░█▀█░█▀█░█▀▄░█▀█░█▀█░█▀█░█▀▀░█▀▄',
|
|
38
|
+
' ░▀▀█░█▀█░█░█░█░█░█▀▀░█▀█░█▀▀░█▀▀░█▀▄',
|
|
39
|
+
' ░▀▀▀░▀░▀░▀░▀░▀▀░░▀░░░▀░▀░▀░░░▀▀▀░▀░▀',
|
|
40
|
+
].join('\n')));
|
|
41
|
+
console.log(' ' + dim('a living brain for your repo — refine it on the page') + '\n');
|
|
42
|
+
}
|
|
43
|
+
const section = (name) => console.log(` ${bold(name)}`);
|
|
44
|
+
const row = (label, target, note) => console.log(` ${green('✓')} ${label.padEnd(18)}${(target + ' ').padEnd(32)}${note ? dim(note) : ''}`); // the two spaces guarantee a gap when target overruns the column
|
|
45
|
+
const nextStep = () => {
|
|
46
|
+
console.log(`\n ${clay('▸ NEXT')} run ${bold('/sandpaper:init')} in Claude Code — it reads this repo`);
|
|
47
|
+
console.log(' and fills your brain: the cover, the lenses, and the books.\n');
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ---- the out-link source base: what keeps brain/ publishable away from its repo ----
|
|
51
|
+
// The brain's refs to canonical truth (spec · source · package.json) are written RELATIVE —
|
|
52
|
+
// local-first. When brain/ is deployed detached, the on-page resolver (brain.js) rewrites them
|
|
53
|
+
// to this base at click time. Derived from the git origin, falling back to package.json's
|
|
54
|
+
// "repository"; null when neither exists — the meta is then omitted and a detached brain dims
|
|
55
|
+
// its out-links instead of rewriting them.
|
|
56
|
+
export function repoSource(target) {
|
|
57
|
+
let url = '', dir = '';
|
|
58
|
+
try { url = execFileSync('git', ['-C', target, 'remote', 'get-url', 'origin'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
|
|
59
|
+
// the target may sit BELOW the git root (a monorepo package) — out-links are relative to
|
|
60
|
+
// the target, so the base must carry that prefix. show-prefix ends with '/' when non-empty.
|
|
61
|
+
try { dir = execFileSync('git', ['-C', target, 'rev-parse', '--show-prefix'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch {}
|
|
62
|
+
if (!url) {
|
|
63
|
+
try {
|
|
64
|
+
const r = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')).repository;
|
|
65
|
+
url = typeof r === 'string' ? r : (r && r.url) || '';
|
|
66
|
+
if (!dir && r && r.directory) dir = r.directory.replace(/\/*$/, '/');
|
|
67
|
+
} catch {}
|
|
68
|
+
}
|
|
69
|
+
url = url.replace(/^git\+/, '').replace(/\.git$/, '')
|
|
70
|
+
.replace(/^github:/, 'https://github.com/')
|
|
71
|
+
.replace(/^git@([^:]+):/, 'https://$1/')
|
|
72
|
+
.replace(/^ssh:\/\/git@([^:/]+)(?::\d+)?\//, 'https://$1/'); // drop any SSH port — dead over TLS
|
|
73
|
+
if (!/^https:\/\//.test(url)) return null;
|
|
74
|
+
url = url.replace(/^(https:\/\/)[^@/]+@/, '$1'); // NEVER carry credentials into a published page
|
|
75
|
+
url = url.replace(/\/+$/, '');
|
|
76
|
+
const view = /\/\/bitbucket\.org\//.test(url) ? '/src/HEAD/' : '/blob/HEAD/'; // GitHub/GitLab grammar, Bitbucket's variant
|
|
77
|
+
let pkgName = '';
|
|
78
|
+
try { pkgName = JSON.parse(readFileSync(join(target, 'package.json'), 'utf8')).name || ''; } catch {}
|
|
79
|
+
return { base: url + view + dir, pkg: pkgName }; // HEAD = default branch, survives renames
|
|
80
|
+
}
|
|
81
|
+
// escape for interpolation into HTML text/attributes — the base URL and package name come from
|
|
82
|
+
// the TARGET repo (its remote, its package.json): treat them as untrusted (a cloned repo could
|
|
83
|
+
// carry a hostile name) or the meta becomes stored XSS on every brain page.
|
|
84
|
+
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
85
|
+
const sourceMetaTag = (source) => source
|
|
86
|
+
? `<meta name="sandpaper:source" content="${esc(source.base)}"${source.pkg ? ` data-pkg="${esc(source.pkg)}"` : ''} />`
|
|
87
|
+
: '';
|
|
88
|
+
|
|
89
|
+
// every .html page under brain/, recursively
|
|
90
|
+
function htmlPages(brain) {
|
|
91
|
+
const pages = [];
|
|
92
|
+
(function walk(d) { for (const e of readdirSync(d)) { const p = join(d, e); if (statSync(p).isDirectory()) walk(p); else if (extname(p) === '.html') pages.push(p); } })(brain);
|
|
93
|
+
return pages;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// inject (or refresh) the sandpaper:source meta on every EXISTING brain page. Idempotent;
|
|
97
|
+
// returns how many pages were touched. New pages get it via pageShell.
|
|
98
|
+
export function ensureSourceMeta(brain, source) {
|
|
99
|
+
if (!source) return 0;
|
|
100
|
+
const tag = sourceMetaTag(source);
|
|
101
|
+
let touched = 0;
|
|
102
|
+
for (const p of htmlPages(brain)) {
|
|
103
|
+
const html = readFileSync(p, 'utf8');
|
|
104
|
+
const next = html.includes('name="sandpaper:source"')
|
|
105
|
+
? html.replace(/<meta name="sandpaper:source"[^>]*\/?>/, tag)
|
|
106
|
+
: /<meta name="viewport"/.test(html)
|
|
107
|
+
? html.replace(/(<meta name="viewport"[^>]*\/?>\s*\n?)/, `$1${tag}\n`)
|
|
108
|
+
: html.replace(/(<head[^>]*>\s*\n?)/i, `$1${tag}\n`); // no viewport meta — inject at the head open
|
|
109
|
+
if (next !== html) { writeFileSync(p, next); touched++; }
|
|
110
|
+
else if (!html.includes('name="sandpaper:source"')) warn(`could not place the source meta in ${basename(p)} — no <head>?`);
|
|
111
|
+
}
|
|
112
|
+
return touched;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Do the brain scaffold work (assets · manifest · multi-page skeleton) and print its BRAIN rows.
|
|
116
|
+
function scaffoldBrain(target, pkg) {
|
|
117
|
+
const brain = join(target, 'brain'), project = projectName(target), date = today();
|
|
118
|
+
const nA = copyDirFiles(join(pkg, 'brain', 'assets'), join(brain, 'assets'), true); // never clobber a skin
|
|
119
|
+
row('design system', 'brain/assets/', nA ? 'theme · engine · search' : 'kept your skin');
|
|
120
|
+
ensureDir(join(target, '.sandpaper'));
|
|
121
|
+
const manPath = join(target, '.sandpaper', 'manifest.json'), hadMan = existsSync(manPath);
|
|
122
|
+
if (!hadMan) writeFileSync(manPath, JSON.stringify({
|
|
123
|
+
version: 1, project, created: date, theme: 'brain/assets/theme.css', pkg, port: 4848,
|
|
124
|
+
lenses: ['product', 'engineering', 'project'], books: ['log', 'decisions', 'learnings'],
|
|
125
|
+
cidPrefixes: { worklog: 'w', task: 't', decision: 'd', learning: 'l', initiative: 'i' },
|
|
126
|
+
counters: { w: 1, t: 0, d: 0, l: 0, i: 0 },
|
|
127
|
+
}, null, 2) + '\n');
|
|
128
|
+
const source = repoSource(target);
|
|
129
|
+
const nSkel = writeSkeleton(brain, project, date, source);
|
|
130
|
+
row('multi-page shell', nSkel ? 'cover · 3 lenses · 3 books' : 'already present', nSkel ? 'nav wired · ready to fill' : '');
|
|
131
|
+
row('manifest', '.sandpaper/manifest.json', hadMan ? 'kept · id counters' : 'ids · prefixes · port');
|
|
132
|
+
if (source) {
|
|
133
|
+
ensureSourceMeta(brain, source);
|
|
134
|
+
row('source meta', source.base.replace('https://', '').replace('/blob/HEAD/', ''), 'out-links survive any deploy');
|
|
135
|
+
} else {
|
|
136
|
+
row('source meta', 'none yet', 'no git remote — re-run after `git remote add`');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- install-skill: make /sandpaper:* available + wire the auto-update hooks (use --no-hooks to skip) ----
|
|
141
|
+
const HOOKS = [
|
|
142
|
+
['SessionStart', 'node .sandpaper/hooks/brain-inject.js', 10],
|
|
143
|
+
['Stop', 'node .sandpaper/hooks/brain-stamp-check.js', 20],
|
|
144
|
+
];
|
|
145
|
+
const hooksSnippet = () => JSON.stringify({ hooks: Object.fromEntries(HOOKS.map(([e, c, t]) =>
|
|
146
|
+
[e, [{ matcher: '*', hooks: [{ type: 'command', command: c, timeout: t }] }]])) }, null, 2)
|
|
147
|
+
.split('\n').map((l) => ' ' + l).join('\n');
|
|
148
|
+
|
|
149
|
+
// merge our hooks into the target's .claude/settings.json — preserve existing settings. Dedupe by the
|
|
150
|
+
// hook SCRIPT's filename, not the exact command: a repo may already run brain-inject.js from another
|
|
151
|
+
// path (e.g. the Sandpaper repo itself runs it from bin/) and a second wiring would fire it twice.
|
|
152
|
+
function wireHooks(target) {
|
|
153
|
+
const sp = join(target, '.claude', 'settings.json');
|
|
154
|
+
let s = {};
|
|
155
|
+
if (existsSync(sp)) { try { s = JSON.parse(readFileSync(sp, 'utf8')); } catch { return { ok: false, reason: '.claude/settings.json exists but is not valid JSON — left it untouched' }; } }
|
|
156
|
+
s.hooks = s.hooks || {};
|
|
157
|
+
let added = 0;
|
|
158
|
+
for (const [evt, cmd, to] of HOOKS) {
|
|
159
|
+
s.hooks[evt] = s.hooks[evt] || [];
|
|
160
|
+
const script = cmd.split('/').pop();
|
|
161
|
+
const present = s.hooks[evt].some((g) => (g.hooks || []).some((h) => (h.command || '').includes(script)));
|
|
162
|
+
if (!present) { s.hooks[evt].push({ matcher: '*', hooks: [{ type: 'command', command: cmd, timeout: to }] }); added++; }
|
|
163
|
+
}
|
|
164
|
+
if (!added) return { ok: true, added }; // nothing to wire — leave the user's file byte-identical
|
|
165
|
+
try { ensureDir(dirname(sp)); writeFileSync(sp, JSON.stringify(s, null, 2) + '\n'); return { ok: true, added }; }
|
|
166
|
+
catch (e) { return { ok: false, reason: 'could not write .claude/settings.json (' + e.message + ')' }; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function installSkill(target, pkg, opts = {}) {
|
|
170
|
+
banner();
|
|
171
|
+
console.log(` ${clay('▸')} installing into ${bold(projectName(target))}\n`);
|
|
172
|
+
section('SKILL');
|
|
173
|
+
const nCmds = copyDirFiles(join(pkg, 'skill', 'sandpaper', 'commands'), join(target, '.claude', 'commands', 'sandpaper'));
|
|
174
|
+
row(`${nCmds} slash commands`, '.claude/commands/sandpaper/', '/sandpaper:<name>');
|
|
175
|
+
const hookDir = join(target, '.sandpaper', 'hooks');
|
|
176
|
+
ensureDir(hookDir);
|
|
177
|
+
for (const h of ['brain-inject.js', 'brain-stamp-check.js']) copyFileSync(join(pkg, 'bin', h), join(hookDir, h));
|
|
178
|
+
if (opts.noHooks) {
|
|
179
|
+
row('2 auto-hooks', '.sandpaper/hooks/', 'not wired (--no-hooks)');
|
|
180
|
+
console.log('\n' + hooksSnippet());
|
|
181
|
+
} else {
|
|
182
|
+
const r = wireHooks(target);
|
|
183
|
+
row('2 auto-hooks', '.sandpaper/hooks/', r.ok ? (r.added ? 'wired · keeps the brain current' : 'already wired') : 'needs wiring by hand');
|
|
184
|
+
if (!r.ok) { console.log(' ' + dim(r.reason)); console.log('\n' + hooksSnippet()); }
|
|
185
|
+
}
|
|
186
|
+
console.log('');
|
|
187
|
+
// Scaffold the brain from THIS package now, so /sandpaper:init has the design-system assets
|
|
188
|
+
// + the multi-page skeleton LOCALLY and never has to hunt the filesystem for a reference brain.
|
|
189
|
+
section('BRAIN');
|
|
190
|
+
scaffoldBrain(target, pkg);
|
|
191
|
+
nextStep();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- init: scaffold brain/ (assets + manifest + the multi-page skeleton) — the mechanical part ----
|
|
195
|
+
export function scaffold(target, pkg) {
|
|
196
|
+
banner();
|
|
197
|
+
console.log(` ${clay('▸')} scaffolding the brain into ${bold(projectName(target))}\n`);
|
|
198
|
+
section('BRAIN');
|
|
199
|
+
scaffoldBrain(target, pkg);
|
|
200
|
+
nextStep();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- doctor: health-check a Sandpaper setup ----
|
|
204
|
+
export function doctor(target) {
|
|
205
|
+
console.log(`\n 🪵 Sandpaper doctor — ${target}\n`);
|
|
206
|
+
let problems = 0;
|
|
207
|
+
const brain = join(target, 'brain');
|
|
208
|
+
if (!existsSync(brain)) { bad('no brain/ — run `npx sandpaper init` (then /sandpaper:init)'); return finish(1); }
|
|
209
|
+
ok('brain/ exists');
|
|
210
|
+
for (const a of ['theme.css', 'brain.css', 'brain.js']) {
|
|
211
|
+
if (existsSync(join(brain, 'assets', a))) ok(`assets/${a}`); else { bad(`missing assets/${a}`); problems++; }
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const css = readFileSync(join(brain, 'assets', 'brain.css'), 'utf8');
|
|
215
|
+
if (/@import\s+["']theme\.css/.test(css)) ok('brain.css @imports theme.css'); else warn('brain.css does not @import theme.css — re-skins may not propagate');
|
|
216
|
+
} catch {}
|
|
217
|
+
try {
|
|
218
|
+
const h = readFileSync(join(brain, 'index.html'), 'utf8');
|
|
219
|
+
const m = h.match(/id="brain-state">([\s\S]*?)<\/script>/);
|
|
220
|
+
if (m) { JSON.parse(m[1]); ok('#brain-state digest parses'); } else { warn('cover has no #brain-state digest'); }
|
|
221
|
+
} catch { bad('brain/index.html unreadable'); problems++; }
|
|
222
|
+
const broken = checkLinks(brain);
|
|
223
|
+
if (broken === 0) ok('internal links resolve'); else { bad(`${broken} broken internal link(s)`); problems++; }
|
|
224
|
+
// the out-link source meta: every page should carry the SAME base (or none at all)
|
|
225
|
+
const metas = htmlPages(brain).map((p) => (readFileSync(p, 'utf8').match(/name="sandpaper:source" content="([^"]*)"/) || [, null])[1]);
|
|
226
|
+
const distinct = [...new Set(metas)];
|
|
227
|
+
if (distinct.length === 1 && distinct[0]) ok(`sandpaper:source meta on all pages (${distinct[0].replace('https://', '').replace('/blob/HEAD/', '')})`);
|
|
228
|
+
else if (distinct.length === 1) warn('no sandpaper:source meta — a detached deploy dims its out-links; run `npx sandpaper upgrade` after adding a git remote');
|
|
229
|
+
else { bad(`sandpaper:source meta inconsistent across pages (${metas.filter(Boolean).length}/${metas.length} set) — run \`npx sandpaper upgrade\``); problems++; }
|
|
230
|
+
const man = join(target, '.sandpaper', 'manifest.json');
|
|
231
|
+
if (existsSync(man)) { try { JSON.parse(readFileSync(man, 'utf8')); ok('.sandpaper/manifest.json valid'); } catch { bad('manifest.json invalid JSON'); problems++; } }
|
|
232
|
+
else warn('no .sandpaper/manifest.json — run `npx sandpaper init`');
|
|
233
|
+
if (existsSync(join(target, '.sandpaper', 'hooks', 'brain-stamp-check.js'))) ok('hooks present (.sandpaper/hooks/)'); else warn('hooks not installed — run `npx sandpaper install-skill`');
|
|
234
|
+
finish(problems);
|
|
235
|
+
function finish(p) { console.log(`\n ${p ? '✗ ' + p + ' problem(s).' : '✓ healthy.'}\n`); process.exitCode = p ? 1 : 0; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- upgrade: bring an EXISTING brain up to the current package (assets · hooks · commands · the canvas) ----
|
|
239
|
+
export function upgrade(target, pkg) {
|
|
240
|
+
console.log(`\n 🪵 Upgrading Sandpaper in ${target}\n`);
|
|
241
|
+
const brain = join(target, 'brain');
|
|
242
|
+
if (!existsSync(brain)) { bad('no brain/ here — this upgrades an existing brain. Run `npx sandpaper init` first.'); process.exitCode = 1; return; }
|
|
243
|
+
|
|
244
|
+
// 1. commands + hooks → latest (idempotent; this is how board-first reaches an old install)
|
|
245
|
+
const nCmds = copyDirFiles(join(pkg, 'skill', 'sandpaper', 'commands'), join(target, '.claude', 'commands', 'sandpaper'));
|
|
246
|
+
ok(`${nCmds} commands refreshed → .claude/commands/sandpaper/`);
|
|
247
|
+
const hookDir = join(target, '.sandpaper', 'hooks');
|
|
248
|
+
ensureDir(hookDir);
|
|
249
|
+
for (const h of ['brain-inject.js', 'brain-stamp-check.js']) copyFileSync(join(pkg, 'bin', h), join(hookDir, h));
|
|
250
|
+
ok('2 hooks refreshed → .sandpaper/hooks/');
|
|
251
|
+
const wr = wireHooks(target);
|
|
252
|
+
if (wr.ok) ok(wr.added ? 'auto-update hooks wired into .claude/settings.json' : 'auto-update hooks already wired');
|
|
253
|
+
else warn(wr.reason);
|
|
254
|
+
|
|
255
|
+
// 2. engine assets → latest brain.css + brain.js (these carry the canvas styles); PRESERVE theme.css
|
|
256
|
+
// (the skin). Same-path guard: run inside the Sandpaper repo itself, src and dst are ONE file —
|
|
257
|
+
// copyFileSync would truncate it before reading.
|
|
258
|
+
const aSrc = join(pkg, 'brain', 'assets'), aDst = join(brain, 'assets');
|
|
259
|
+
ensureDir(aDst);
|
|
260
|
+
const samePath = resolve(aSrc) === resolve(aDst);
|
|
261
|
+
for (const a of ['brain.css', 'brain.js']) {
|
|
262
|
+
if (samePath) { ok(`assets/${a} is the package copy`); continue; }
|
|
263
|
+
if (existsSync(join(aSrc, a))) { copyFileSync(join(aSrc, a), join(aDst, a)); ok(`assets/${a} → latest`); }
|
|
264
|
+
}
|
|
265
|
+
if (existsSync(join(aDst, 'theme.css'))) warn('assets/theme.css kept — it is your skin (delete it + re-run to take the shipped one)');
|
|
266
|
+
else if (existsSync(join(aSrc, 'theme.css'))) { copyFileSync(join(aSrc, 'theme.css'), join(aDst, 'theme.css')); ok('assets/theme.css added'); }
|
|
267
|
+
|
|
268
|
+
// 3. multi-page structure → add any MISSING skeleton pages (a single-pager / old brain lacks the
|
|
269
|
+
// lens pages + books). skipExisting, so real content is never touched.
|
|
270
|
+
const source = repoSource(target);
|
|
271
|
+
const nSkel = writeSkeleton(brain, projectName(target), today(), source);
|
|
272
|
+
if (nSkel) ok(`${nSkel} missing skeleton page(s) added — lens pages / books were absent`);
|
|
273
|
+
else ok('multi-page skeleton already present');
|
|
274
|
+
if (source) {
|
|
275
|
+
const nMeta = ensureSourceMeta(brain, source);
|
|
276
|
+
ok(nMeta ? `sandpaper:source meta set on ${nMeta} page(s) — out-links survive any deploy` : 'sandpaper:source meta already current');
|
|
277
|
+
} else warn('no git remote / repository field — sandpaper:source meta skipped (a detached deploy dims its out-links)');
|
|
278
|
+
|
|
279
|
+
// 4. inject the canvas region into the cover if it predates the canvas
|
|
280
|
+
const r = ensureCanvas(join(brain, 'index.html'));
|
|
281
|
+
if (r.had) ok('cover already hosts the canvas');
|
|
282
|
+
else if (r.injected) ok(`canvas added to the cover (${r.anchor})`);
|
|
283
|
+
else { warn('couldn\'t find a safe spot to add the canvas — paste this into brain/index.html just below the NOW plate:'); console.log('\n' + canvasSection() + '\n'); }
|
|
284
|
+
|
|
285
|
+
console.log('\n Upgraded. `npx sandpaper open` to view.');
|
|
286
|
+
if (nSkel) console.log(' Added missing structure — run /sandpaper:init in Claude Code to fill the new pages.\n (For a clean rebuild of a single-pager, move brain/ aside and re-run install-skill + /sandpaper:init.)');
|
|
287
|
+
console.log('');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- rebuild: a full, safe reset — back up the old brain, then reinstall + a fresh skeleton ----
|
|
291
|
+
export function rebuild(target, pkg) {
|
|
292
|
+
const brain = join(target, 'brain');
|
|
293
|
+
if (existsSync(brain)) {
|
|
294
|
+
const bak = backupName(target);
|
|
295
|
+
try { renameSync(brain, bak); console.log(`\n ${clay('▸')} backed up your old brain → ${bold(basename(bak) + '/')} ${dim('(kept, just in case)')}`); }
|
|
296
|
+
catch (e) { bad(`couldn't move the old brain aside (${e.message}) — aborting`); process.exitCode = 1; return; }
|
|
297
|
+
}
|
|
298
|
+
// reinstall (refresh commands + hooks) + a fresh multi-page skeleton — installSkill prints the branded flow
|
|
299
|
+
installSkill(target, pkg);
|
|
300
|
+
}
|
|
301
|
+
// a non-clobbering backup path: brain.bak-YYYY-MM-DD, then -2, -3, … if that already exists
|
|
302
|
+
function backupName(target) {
|
|
303
|
+
const base = join(target, `brain.bak-${today()}`);
|
|
304
|
+
if (!existsSync(base)) return base;
|
|
305
|
+
let i = 2; while (existsSync(`${base}-${i}`)) i++;
|
|
306
|
+
return `${base}-${i}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The canvas section (empty state) — shared by the scaffold's starter cover and `upgrade`.
|
|
310
|
+
function canvasSection() {
|
|
311
|
+
return ` <section class="canvas" id="s-canvas" data-cid="s-canvas" aria-label="Canvas">
|
|
312
|
+
<div class="canvas-rail"><div class="eyebrow">Canvas <span class="canvas-sub">— where Claude's explanations show up</span></div></div>
|
|
313
|
+
<!-- BRAIN:CANVAS — the current board lives in .whiteboard; older ones fold into .canvas-earlier below -->
|
|
314
|
+
<div class="whiteboard" data-cid="whiteboard">
|
|
315
|
+
<div class="canvas-empty" data-cid="canvas-empty">
|
|
316
|
+
<p class="canvas-empty-lead">Your canvas is empty — for now.</p>
|
|
317
|
+
<p>As you work with Claude here, the things worth keeping — how a part works, why a choice was
|
|
318
|
+
made — land on this whiteboard as little cards you can read and come back to, instead of
|
|
319
|
+
scrolling past in the terminal.</p>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
<!-- /BRAIN:CANVAS -->
|
|
323
|
+
</section>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Add the canvas section to an existing cover that lacks it. Best-effort: try a few stable anchors.
|
|
327
|
+
function ensureCanvas(coverPath) {
|
|
328
|
+
let html;
|
|
329
|
+
try { html = readFileSync(coverPath, 'utf8'); } catch { return { injected: false }; }
|
|
330
|
+
if (html.includes('BRAIN:CANVAS') || html.includes('class="whiteboard"')) return { had: true };
|
|
331
|
+
const section = canvasSection();
|
|
332
|
+
// ordered anchors: just after the NOW plate, else above the doors / first section
|
|
333
|
+
const anchors = [
|
|
334
|
+
{ find: '<!-- /BRAIN:EDITION -->', after: true, name: 'below the NOW plate' },
|
|
335
|
+
{ find: '<nav class="doors"', after: false, name: 'above the lens doors' },
|
|
336
|
+
{ find: '<section class="zone"', after: false, name: 'above the first section' },
|
|
337
|
+
{ find: '</header>', after: true, name: 'below the header' },
|
|
338
|
+
];
|
|
339
|
+
for (const a of anchors) {
|
|
340
|
+
const i = html.indexOf(a.find);
|
|
341
|
+
if (i < 0) continue;
|
|
342
|
+
const pos = a.after ? i + a.find.length : i;
|
|
343
|
+
const out = html.slice(0, pos) + (a.after ? '\n' + section : section + '\n ') + html.slice(pos);
|
|
344
|
+
try { writeFileSync(coverPath, out); return { injected: true, anchor: a.name }; } catch { return { injected: false }; }
|
|
345
|
+
}
|
|
346
|
+
return { injected: false };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// walk brain/*.html, return count of broken internal href/src/data-ref (file missing or #anchor
|
|
350
|
+
// absent). data-ref is the brain's citation attribute — linting it here is the stamp-time guard
|
|
351
|
+
// that keeps every out-of-brain ref true on disk (the resolver can then trust the mapping).
|
|
352
|
+
function checkLinks(brain) {
|
|
353
|
+
const pages = htmlPages(brain);
|
|
354
|
+
let bad = 0;
|
|
355
|
+
for (const p of pages) {
|
|
356
|
+
const html = readFileSync(p, 'utf8'), dir = dirname(p);
|
|
357
|
+
for (const m of html.matchAll(/(?:href|src|data-ref)="([^"]+)"/g)) {
|
|
358
|
+
const hr = m[1];
|
|
359
|
+
if (/^(https?:|#|mailto:|data:)/.test(hr)) continue;
|
|
360
|
+
const [path, anchor] = hr.split('#');
|
|
361
|
+
const t = normalize(join(dir, path.endsWith('/') ? path + 'index.html' : path));
|
|
362
|
+
if (!existsSync(t)) { bad++; continue; }
|
|
363
|
+
if (anchor) { const x = readFileSync(t, 'utf8'); if (!x.includes(`id="${anchor}"`) && !x.includes(`name="${anchor}"`)) bad++; }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return bad;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ---- the multi-page skeleton: one shared shell + per-page bodies (so the brain is never a single page) ----
|
|
370
|
+
// Write any MISSING skeleton pages (cover + 3 lens pages + 3 books). skipExisting → only adds; returns
|
|
371
|
+
// the count added. Shared by scaffold (fresh) and upgrade (fills gaps in an existing brain).
|
|
372
|
+
function writeSkeleton(brain, project, date, source = null) {
|
|
373
|
+
let added = 0;
|
|
374
|
+
const write = (rel, html) => {
|
|
375
|
+
const p = join(brain, rel);
|
|
376
|
+
if (existsSync(p)) return;
|
|
377
|
+
ensureDir(dirname(p)); writeFileSync(p, html); added++;
|
|
378
|
+
};
|
|
379
|
+
write('index.html', pageShell({ project, prefix: '', title: 'cover', headExtra: coverDigest(project, date), main: coverMain(project, date), source }));
|
|
380
|
+
for (const [slug, name, blurb] of [['product', 'Product', 'what it is & why it earns its place'],
|
|
381
|
+
['engineering', 'Engineering', 'how it is built'], ['project', 'Project', 'the plan & progress']])
|
|
382
|
+
write(`${slug}/index.html`, pageShell({ project, prefix: '../', title: name, main: lensMain(name, blurb), source }));
|
|
383
|
+
for (const [slug, name, blurb] of [['log', 'Log', 'the work log — newest first'],
|
|
384
|
+
['decisions', 'Decisions', 'the ledger of calls made'], ['learnings', 'Learnings', 'gotchas & verdicts']])
|
|
385
|
+
write(`${slug}.html`, pageShell({ project, prefix: '', title: name, main: bookMain(name, blurb), source }));
|
|
386
|
+
const readme = join(brain, 'README.md'); // the deploy guide rides along — not counted as a skeleton PAGE
|
|
387
|
+
if (!existsSync(readme)) { ensureDir(brain); writeFileSync(readme, deployReadme()); }
|
|
388
|
+
return added;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// the deploy guide that ships inside every brain — kept generic (any project's brain)
|
|
392
|
+
function deployReadme() {
|
|
393
|
+
return `# Deploying the brain
|
|
394
|
+
|
|
395
|
+
## What this folder is
|
|
396
|
+
|
|
397
|
+
This folder is the project's living brain: a small static site — a cover (\`index.html\`),
|
|
398
|
+
lens pages, and the books (log · decisions · learnings) — styled by \`assets/theme.css\` +
|
|
399
|
+
\`assets/brain.css\` with a little vanilla JS in \`assets/brain.js\`. No framework, no build
|
|
400
|
+
step, no server-side anything. It is **always publishable**: point any static host at this
|
|
401
|
+
folder as-is and it works.
|
|
402
|
+
|
|
403
|
+
One design choice shapes everything below: the brain **links, never copies**. Canonical
|
|
404
|
+
truth lives in the parent repo — the spec docs, source files, \`package.json\` — and the
|
|
405
|
+
brain references them with relative paths (\`../…\`) so they resolve on disk and whenever
|
|
406
|
+
the whole repo is served.
|
|
407
|
+
|
|
408
|
+
## Two deploy shapes
|
|
409
|
+
|
|
410
|
+
### 1. Whole-repo deploy (recommended for public repos)
|
|
411
|
+
|
|
412
|
+
Serve the repo root and visit \`/brain/\`. Every out-of-brain link resolves: spec HTML docs
|
|
413
|
+
render with working \`#anchors\`, source files are viewable. GitHub Pages serving the repo
|
|
414
|
+
root does this perfectly.
|
|
415
|
+
|
|
416
|
+
### 2. Brain-only deploy (site root = this folder)
|
|
417
|
+
|
|
418
|
+
The relative \`../\` refs can't resolve — there's nothing above the root. The built-in
|
|
419
|
+
resolver in \`assets/brain.js\` handles it. Each page's head carries:
|
|
420
|
+
|
|
421
|
+
\`\`\`html
|
|
422
|
+
<meta name="sandpaper:source" content="https://github.com/<owner>/<repo>/blob/HEAD/" data-pkg="<package-name>" />
|
|
423
|
+
\`\`\`
|
|
424
|
+
|
|
425
|
+
On load, the page probes \`../package.json\` and checks its \`name\` against \`data-pkg\`.
|
|
426
|
+
If the probe fails (or the name doesn't match), the page knows it is detached, and
|
|
427
|
+
out-links open the source-host copy instead (rewritten at click time). Source and meta
|
|
428
|
+
files render fine on GitHub's blob view; spec **HTML** docs land on blob *source* view —
|
|
429
|
+
unrendered. Use the whole-repo shape if you want rendered specs. With no meta configured,
|
|
430
|
+
out-links dim with a tooltip instead of 404ing.
|
|
431
|
+
|
|
432
|
+
The meta is written automatically by \`npx sandpaper init\` / \`upgrade\` from the git
|
|
433
|
+
origin (or \`package.json\` → \`"repository"\`). \`npx sandpaper doctor\` verifies it is
|
|
434
|
+
present and consistent across pages.
|
|
435
|
+
|
|
436
|
+
## Deployed brains are read-only
|
|
437
|
+
|
|
438
|
+
The refine toolbar (Sand / Hands / Sling) is injected only by the local \`sandpaper\`
|
|
439
|
+
server — a deployed brain has no toolbar and can't be edited from the page. By design:
|
|
440
|
+
the public copy is for reading.
|
|
441
|
+
|
|
442
|
+
## Recipes
|
|
443
|
+
|
|
444
|
+
**GitHub Pages (simplest)** — Settings → Pages → Source: *Deploy from a branch*, branch
|
|
445
|
+
\`main\`, folder \`/ (root)\`. That's the whole-repo shape — visit
|
|
446
|
+
\`https://<owner>.github.io/<repo>/brain/\`. For the brain-only shape, use Source:
|
|
447
|
+
*GitHub Actions* with this workflow:
|
|
448
|
+
|
|
449
|
+
\`\`\`yaml
|
|
450
|
+
name: Deploy brain
|
|
451
|
+
on: { push: { branches: [main] } }
|
|
452
|
+
permissions: { contents: read, pages: write, id-token: write }
|
|
453
|
+
jobs:
|
|
454
|
+
deploy:
|
|
455
|
+
runs-on: ubuntu-latest
|
|
456
|
+
environment: { name: github-pages, url: "\${{ steps.deployment.outputs.page_url }}" }
|
|
457
|
+
steps:
|
|
458
|
+
- uses: actions/checkout@v4
|
|
459
|
+
- uses: actions/upload-pages-artifact@v3
|
|
460
|
+
with: { path: brain } # 'path: .' switches to the whole-repo shape
|
|
461
|
+
- id: deployment
|
|
462
|
+
uses: actions/deploy-pages@v4
|
|
463
|
+
\`\`\`
|
|
464
|
+
|
|
465
|
+
**Vercel** — New Project → import the repo. Root Directory = repo root (or \`brain/\` for
|
|
466
|
+
brain-only), Framework Preset = *Other*, no build command, Output Directory = \`./\`.
|
|
467
|
+
|
|
468
|
+
**Netlify** — New site from Git. No build command. Publish directory: \`brain\` (or the
|
|
469
|
+
repo root).
|
|
470
|
+
|
|
471
|
+
**Cloudflare Pages** — Connect the repo. No build command. Build output directory:
|
|
472
|
+
\`brain\` (or \`/\`).
|
|
473
|
+
|
|
474
|
+
## Privacy
|
|
475
|
+
|
|
476
|
+
Deploying the whole repo publishes **all** of its files, not just the brain. Brain-only
|
|
477
|
+
publishes just this folder — but its out-links point at the source host, which must be
|
|
478
|
+
public for them to work. Either way, assume everything the brain links to is visible.
|
|
479
|
+
Don't deploy a brain whose repo isn't ready to be read.
|
|
480
|
+
`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// prefix: '' for pages at brain/ root (cover, books), '../' for pages one dir deep (lenses).
|
|
484
|
+
function pageShell({ project, prefix, title, headExtra = '', main, source = null }) {
|
|
485
|
+
const link = (href, label) => `<a href="${prefix}${href}">${label}</a>`;
|
|
486
|
+
const meta = sourceMetaTag(source);
|
|
487
|
+
return `<!DOCTYPE html>
|
|
488
|
+
<html lang="en">
|
|
489
|
+
<head>
|
|
490
|
+
<meta charset="UTF-8" />
|
|
491
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
492
|
+
${meta ? meta + '\n' : ''}<title>${esc(project)} — ${title}</title>
|
|
493
|
+
<link rel="stylesheet" href="${prefix}assets/brain.css" />
|
|
494
|
+
${headExtra}</head>
|
|
495
|
+
<body>
|
|
496
|
+
<div class="wrap">
|
|
497
|
+
<div class="shell">
|
|
498
|
+
<div class="shell-id">
|
|
499
|
+
<a class="shell-mark" href="${prefix}index.html">${esc(project)}</a>
|
|
500
|
+
<div class="shell-state"><a href="${prefix}log.html">fresh brain</a></div>
|
|
501
|
+
</div>
|
|
502
|
+
<nav class="shell-rail" aria-label="Lenses">
|
|
503
|
+
${link('index.html', 'Cover')}
|
|
504
|
+
${link('product/index.html', 'Product')}
|
|
505
|
+
${link('engineering/index.html', 'Engineering')}
|
|
506
|
+
${link('project/index.html', 'Project')}
|
|
507
|
+
</nav>
|
|
508
|
+
</div>
|
|
509
|
+
${main}
|
|
510
|
+
<footer class="portal-foot" data-cid="footer">
|
|
511
|
+
<div class="foot-col"><div class="foot-h">Books</div>
|
|
512
|
+
${link('log.html', 'Log')}
|
|
513
|
+
${link('decisions.html', 'Decisions')}
|
|
514
|
+
${link('learnings.html', 'Learnings')}
|
|
515
|
+
</div>
|
|
516
|
+
</footer>
|
|
517
|
+
</div>
|
|
518
|
+
<script src="${prefix}assets/brain.js" defer></script>
|
|
519
|
+
</body>
|
|
520
|
+
</html>
|
|
521
|
+
`;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// the cover needs the #brain-state digest in <head> — the SessionStart hook reads it to rehydrate
|
|
525
|
+
function coverDigest(project, date) {
|
|
526
|
+
return `<script type="application/json" id="brain-state">
|
|
527
|
+
{ "v":1, "project":${JSON.stringify(String(project))}, "phase":"fresh", "updated":"${date}", "session":"S01",
|
|
528
|
+
"focus":{ "one":"Brain scaffolded — run /sandpaper:init in Claude to harvest this repo and fill it", "ref":"#" },
|
|
529
|
+
"worklog":[ {"date":"${date}","one":"Brain scaffolded by sandpaper","cid":"w-0001"} ],
|
|
530
|
+
"open":[], "docs":[] }
|
|
531
|
+
</script>
|
|
532
|
+
`;
|
|
533
|
+
}
|
|
534
|
+
function coverMain(project, date) {
|
|
535
|
+
return ` <header class="plate" data-cid="cover" style="margin-top:14px">
|
|
536
|
+
<div class="pl-meta">Fresh brain · stamped ${date}</div>
|
|
537
|
+
<p class="now-line" data-cid="now" data-kind="now">Run <code>/sandpaper:init</code> in Claude Code to harvest
|
|
538
|
+
this repo and fill the brain — it discovers your code, specs, and docs, asks a few questions, then fills
|
|
539
|
+
these pages.</p>
|
|
540
|
+
</header>
|
|
541
|
+
${canvasSection()}
|
|
542
|
+
<section class="zone"><div class="eyebrow">Where it stands</div>
|
|
543
|
+
<p class="muted">The plan board, decisions, and log fill in when you run <code>/sandpaper:init</code>.</p>
|
|
544
|
+
</section>`;
|
|
545
|
+
}
|
|
546
|
+
function lensMain(name, blurb) {
|
|
547
|
+
const slug = name.toLowerCase();
|
|
548
|
+
return ` <header class="lens-hero lens--${slug}" data-cid="lens-${slug}" data-lens="${slug}">
|
|
549
|
+
<div class="eyebrow">${name}</div>
|
|
550
|
+
<h1>${blurb}</h1>
|
|
551
|
+
<p>Run <code>/sandpaper:init</code> to fill this lens with real, linked content.</p>
|
|
552
|
+
</header>
|
|
553
|
+
<!-- FILL: ${name} lens prose + records (.entry grammar). Keep this a SEPARATE page; do not merge lenses. -->
|
|
554
|
+
<section class="zone"><p class="muted">Not filled yet.</p></section>`;
|
|
555
|
+
}
|
|
556
|
+
function bookMain(name, blurb) {
|
|
557
|
+
return ` <section class="zone flush">
|
|
558
|
+
<div class="eyebrow">${name}</div>
|
|
559
|
+
<h1 style="font-size:30px;letter-spacing:-.02em;margin:6px 0 0">${name}</h1>
|
|
560
|
+
<p class="muted">${blurb}</p>
|
|
561
|
+
</section>
|
|
562
|
+
<!-- FILL: ${name} entries. Keep this a SEPARATE page. -->
|
|
563
|
+
<section class="zone flush"><p class="muted">Empty until /sandpaper:init.</p></section>`;
|
|
564
|
+
}
|