sdocs-dev 1.6.2 → 1.12.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.
@@ -0,0 +1,111 @@
1
+ // cells-transclude.js - bake {{path/to/file.csv}} references into ```cells
2
+ // blocks at CLI time.
3
+ //
4
+ // The browser can never read a local file, so a CSV reference only means
5
+ // something while the CLI is involved. On open (and on share), every
6
+ // ```cells block whose body is a bare {{...}} reference is replaced with the
7
+ // file's full contents plus a metadata line the renderer reads:
8
+ //
9
+ // ```cells ```cells
10
+ // {{data/report.csv}} -> sdoc-cells: source=report.csv
11
+ // ``` Region,Q1,Q2
12
+ // North,100,150
13
+ // ...
14
+ // ```
15
+ //
16
+ // The whole file is baked in (the user chose "whole CSV always travels"), so
17
+ // the resulting doc is self-contained and a share link never errors. Only the
18
+ // basename is recorded as `source=` - the full local path would leak the
19
+ // author's directory structure into a shared link. A read failure bakes an
20
+ // `error=` directive the renderer surfaces instead.
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+
25
+ // A ```cells fenced block (captures leading boundary + body). Tilde fences and
26
+ // inline-data blocks are left untouched.
27
+ const CELLS_BLOCK = /(^|\n)```cells[ \t]*\n([\s\S]*?)\n```/g;
28
+ const REFERENCE = /^\{\{\s*([^}]+?)\s*\}\}$/;
29
+ // A trailing :range suffix like :B5:J32 or :B5 (a view hint; data is baked in
30
+ // whole regardless, so we just strip it off the path for now).
31
+ const RANGE_SUFFIX = /:([A-Za-z]+\d+(?::[A-Za-z]+\d+)?)$/;
32
+
33
+ function directiveValue(s) {
34
+ return /\s|"/.test(s) ? JSON.stringify(s) : s;
35
+ }
36
+
37
+ function bakeBlock(boundary, ref, baseDir, readFile, preLines) {
38
+ var range = '';
39
+ var filePath = ref;
40
+ var rm = ref.match(RANGE_SUFFIX);
41
+ if (rm) { range = rm[1]; filePath = ref.slice(0, ref.length - rm[0].length); }
42
+
43
+ var base = path.basename(filePath);
44
+ // Author format: lines (e.g. `format: B=$`) sit before the reference and are
45
+ // preserved verbatim above the baked data.
46
+ var head = (preLines && preLines.length ? preLines.join('\n') + '\n' : '');
47
+ var csv;
48
+ try {
49
+ csv = readFile(path.resolve(baseDir, filePath));
50
+ } catch (e) {
51
+ return boundary + '```cells\n' + head + 'sdoc-cells: error=' +
52
+ directiveValue('Could not read ' + base) + '\n```';
53
+ }
54
+ csv = String(csv).replace(/\s+$/, '');
55
+ var directive = 'sdoc-cells: source=' + directiveValue(base) +
56
+ (range ? ' range=' + range : '');
57
+ return boundary + '```cells\n' + head + directive + '\n' + csv + '\n```';
58
+ }
59
+
60
+ // Replace every {{file.csv}} cells block in `content` with the baked data.
61
+ // `readFile` is injectable for tests; defaults to fs.readFileSync(utf-8).
62
+ function transcludeCells(content, baseDir, readFile) {
63
+ if (typeof content !== 'string' || content.indexOf('```cells') === -1) return content;
64
+ var read = readFile || function (p) { return fs.readFileSync(p, 'utf-8'); };
65
+ return content.replace(CELLS_BLOCK, function (whole, boundary, body) {
66
+ // Peel any leading author `format:` lines, then require a sole {{ref}}.
67
+ var lines = body.split('\n');
68
+ var pre = [];
69
+ var i = 0;
70
+ while (i < lines.length && /^\s*format:\s*/i.test(lines[i])) { pre.push(lines[i].trim()); i++; }
71
+ var rest = lines.slice(i).join('\n').trim();
72
+ var m = rest.match(REFERENCE);
73
+ if (!m) return whole; // inline data - leave alone
74
+ return bakeBlock(boundary, m[1], baseDir, read, pre);
75
+ });
76
+ }
77
+
78
+ // Wrap a standalone .csv file's contents in a ```cells block (so `sdoc x.csv`
79
+ // opens as a sheet, mirroring the .mmd -> mermaid wrapping).
80
+ function wrapCsvFile(csv, filename) {
81
+ var base = path.basename(filename);
82
+ return '```cells\nsdoc-cells: source=' + directiveValue(base) + '\n' +
83
+ String(csv).replace(/\s+$/, '') + '\n```\n';
84
+ }
85
+
86
+ // A "wrapped" file is one whose renderable document is DERIVED from the file
87
+ // (its contents inside a fenced block) rather than the file itself. The two
88
+ // open paths (URL snapshot via readContent, live sync via the bridge) both
89
+ // wrap these for display, and the bridge refuses to save the derived view
90
+ // back - that would overwrite the .csv / .mmd with fence markup.
91
+ function isWrappedFile(filePath) {
92
+ return /\.(csv|mmd|mermaid)$/i.test(String(filePath || ''));
93
+ }
94
+
95
+ // File contents -> renderable document. Wrapped types get their fence;
96
+ // everything else (markdown) passes through untouched.
97
+ function wrapForDisplay(raw, filePath) {
98
+ var name = String(filePath || '');
99
+ if (/\.(mmd|mermaid)$/i.test(name)) {
100
+ return '```mermaid\n' + String(raw).replace(/\s+$/, '') + '\n```\n';
101
+ }
102
+ if (/\.csv$/i.test(name)) return wrapCsvFile(raw, name);
103
+ return String(raw);
104
+ }
105
+
106
+ module.exports = {
107
+ transcludeCells: transcludeCells,
108
+ wrapCsvFile: wrapCsvFile,
109
+ isWrappedFile: isWrappedFile,
110
+ wrapForDisplay: wrapForDisplay,
111
+ };
@@ -0,0 +1,291 @@
1
+ // Verb handlers wired into the router.
2
+ //
3
+ // Each handler takes parsed opts and returns a Promise (or void). They
4
+ // share `prepareUrl` for the load-content / apply-defaults / build-URL
5
+ // flow that `open` and `share` both need.
6
+
7
+ const path = require('path');
8
+ const { execSync } = require('child_process');
9
+
10
+ const SDocYaml = require('../shared/sdocs-yaml.js');
11
+
12
+ const { DEFAULT_URL } = require('./constants');
13
+ const { readContent, openBrowser } = require('./io');
14
+ const { loadDefaultStyles, applyDefaultStyles, showDefaults, resetDefaults } = require('./styles');
15
+ const { buildUrl } = require('./url');
16
+ const { buildShortUrl } = require('./short-link');
17
+ const { refreshUpdateCache, maybeUpdateBinary } = require('./update-check');
18
+ const { runSetup, maybeAutoRefresh } = require('./setup');
19
+
20
+ // Shared "after the command ran" tail used by `open` and `share`.
21
+ async function postCommandHooks() {
22
+ refreshUpdateCache();
23
+ await maybeUpdateBinary();
24
+ await runSetup();
25
+ await maybeAutoRefresh();
26
+ }
27
+
28
+ // Load content (file or stdin), apply ~/.sdocs/styles.yaml defaults, inject
29
+ // `file:` into front matter, and build either a hash URL or a short URL.
30
+ // Returns { url, contentPresent }.
31
+ async function prepareUrl(opts) {
32
+ let content = await readContent(opts.file);
33
+ const defaults = loadDefaultStyles();
34
+ if (content && defaults) {
35
+ content = applyDefaultStyles(content);
36
+ }
37
+
38
+ // Inject `file:` into front matter (basename only — safe to share).
39
+ // Respects user-set file: if already present.
40
+ if (content && opts.file) {
41
+ const parsed = SDocYaml.parseFrontMatter(content);
42
+ if (!parsed.meta.file) {
43
+ parsed.meta.file = path.basename(opts.file);
44
+ content = SDocYaml.serializeFrontMatter(parsed.meta) + '\n' + parsed.body;
45
+ }
46
+ }
47
+
48
+ // Runtime-only local metadata for the opener's view.
49
+ // `share` omits it so shared URLs never carry paths.
50
+ let local = null;
51
+ if (opts.file && opts.subcommand !== 'share') {
52
+ const abs = path.resolve(opts.file);
53
+ const rel = path.relative(process.cwd(), abs);
54
+ local = { fullPath: abs };
55
+ if (!rel.startsWith('..') && !path.isAbsolute(rel)) {
56
+ local.path = './' + rel;
57
+ }
58
+ }
59
+
60
+ let url;
61
+ if (opts.shortFlag) {
62
+ if (opts.subcommand !== 'share') {
63
+ console.error('sdoc: --short is only valid with the `share` subcommand');
64
+ process.exit(1);
65
+ }
66
+ if (!content) {
67
+ console.error('sdoc: --short needs content (a file path or piped stdin)');
68
+ process.exit(1);
69
+ }
70
+ try {
71
+ url = await buildShortUrl(content, {
72
+ url: opts.url,
73
+ mode: opts.mode,
74
+ theme: opts.theme,
75
+ section: opts.section,
76
+ });
77
+ } catch (e) {
78
+ console.error('sdoc: could not create short link -', e.message);
79
+ process.exit(1);
80
+ }
81
+ } else {
82
+ url = buildUrl(content, {
83
+ url: opts.url,
84
+ mode: opts.mode,
85
+ theme: opts.theme,
86
+ defaultStyles: !content ? defaults : null,
87
+ section: opts.section,
88
+ local,
89
+ present: opts.present,
90
+ });
91
+ }
92
+
93
+ return { url, contentPresent: !!content };
94
+ }
95
+
96
+ // Default flow: `sdoc <file>` or `sdoc` (no args, or piped stdin).
97
+ //
98
+ // The document travels in the URL hash and renders read-only-by-default in the
99
+ // browser; nothing connects back to disk. This is the everywhere-works path -
100
+ // no local socket, no browser permission prompt. The live, autosaving session
101
+ // (browser <-> file on disk) is opt-in via `sdoc bridge <file>`.
102
+ //
103
+ // The non-blocking, share-by-URL case is `sdoc share <file>`.
104
+ async function openCommand(opts) {
105
+ const { url } = await prepareUrl(opts);
106
+ openBrowser(url);
107
+ console.log(`SDocs → ${url.length > 80 ? url.slice(0, 77) + '...' : url}`);
108
+ await postCommandHooks();
109
+ }
110
+
111
+ async function shareCommand(opts) {
112
+ const { url } = await prepareUrl(opts);
113
+ try {
114
+ const clip = process.platform === 'darwin' ? 'pbcopy'
115
+ : execSync('which xclip 2>/dev/null', { encoding: 'utf-8' }).trim() ? 'xclip -selection clipboard'
116
+ : 'xsel --clipboard --input';
117
+ execSync(clip, { input: url, stdio: ['pipe', 'ignore', 'ignore'] });
118
+ const name = opts.file ? path.basename(opts.file) : 'stdin';
119
+ const label = opts.shortFlag ? 'Short link' : 'Link';
120
+ console.log(`✓ ${label} for ${name} copied to clipboard`);
121
+ if (opts.shortFlag) console.log(` ${url}`);
122
+ } catch (_) {
123
+ process.stdout.write(url + '\n');
124
+ }
125
+ await postCommandHooks();
126
+ }
127
+
128
+ function defaultsCommand(opts) {
129
+ if (opts.resetFlag) resetDefaults();
130
+ else showDefaults();
131
+ }
132
+
133
+ // `sdoc color-analysis <file>` — grade every text-on-background pair in the
134
+ // document's custom palette against WCAG ratios, for both the light and dark
135
+ // themes. Exits 1 if anything is unreadable so an agent (or CI) notices.
136
+ async function colorAnalysisCommand(opts) {
137
+ const SDocContrast = require('../shared/sdocs-contrast.js');
138
+ const content = await readContent(opts.file);
139
+ if (!content) {
140
+ console.error('sdoc color-analysis: pass a markdown file (or pipe one in)');
141
+ console.error(' e.g. sdoc color-analysis report.md');
142
+ process.exit(1);
143
+ }
144
+ const meta = SDocYaml.parseFrontMatter(content).meta || {};
145
+ const styles = meta.styles || null;
146
+ const name = opts.file ? path.basename(opts.file) : 'stdin';
147
+
148
+ if (!styles || !SDocContrast.hasCustomColors(styles)) {
149
+ console.log(`sdoc color-analysis: ${name}`);
150
+ console.log(' No custom colours set - the built-in palette is contrast-safe in both themes.');
151
+ process.exit(0);
152
+ }
153
+
154
+ const a = SDocContrast.analyzeStyles(styles);
155
+ const pad = (s, n) => (s + ' '.repeat(n)).slice(0, n);
156
+ const minRatio = SDocContrast.MIN_CONTRAST;
157
+ function line(p) {
158
+ const tag = p.ok ? 'ok ' : 'FAIL';
159
+ const ratio = p.ratio == null ? ' ? ' : (p.ratio.toFixed(2) + ':1');
160
+ return ` ${tag} ${pad(p.label, 16)} ${pad(p.fg + ' on ' + p.bg, 22)} ${pad(ratio, 9)} ${p.ok ? '' : '(needs ' + minRatio + ':1)'}`;
161
+ }
162
+
163
+ console.log(`sdoc color-analysis: ${name}\n`);
164
+ console.log('LIGHT THEME');
165
+ a.light.forEach(p => console.log(line(p)));
166
+ console.log('\nDARK THEME');
167
+ a.dark.forEach(p => console.log(line(p)));
168
+
169
+ console.log('');
170
+ if (a.fails.length === 0) {
171
+ console.log('All text/background pairs meet WCAG AA. ✓');
172
+ process.exit(0);
173
+ }
174
+ console.log(`${a.fails.length} unreadable pair${a.fails.length === 1 ? '' : 's'} (contrast below ${minRatio}:1).`);
175
+ console.log('Fix the flagged colours, or add a `dark:` override so the dark theme has its own readable values.');
176
+ console.log('Reminder: top-level colours are the LIGHT theme; dark mode is auto-derived unless you set `dark:`.');
177
+ process.exit(1);
178
+ }
179
+
180
+ function newCommand(opts) {
181
+ const baseUrl = opts.url || process.env.SDOCS_URL || DEFAULT_URL;
182
+ const url = baseUrl + '/new';
183
+ openBrowser(url);
184
+ console.log(`SDocs → ${url}`);
185
+ }
186
+
187
+ // `sdoc slides` family. Dispatches on the positional after `slides`:
188
+ // sdoc slides -> prints SLIDES_HELP
189
+ // sdoc slides list -> built-in template registry
190
+ // sdoc slides custom-shapes -> raw-shape reference
191
+ // sdoc slides icons [query] -> Lucide icon name listing
192
+ function slidesCommand(opts) {
193
+ const helpText = require('./help-text');
194
+ const sub = opts.file;
195
+ if (sub === 'list') { printSlideStdlib(); return; }
196
+ if (sub === 'custom-shapes') { console.log(helpText.SLIDES_CUSTOM_SHAPES_HELP); return; }
197
+ if (sub === 'icons') { printIconList(opts.extra); return; }
198
+ console.log(helpText.SLIDES_HELP);
199
+ }
200
+
201
+ // `sdoc present <file>` opens the file straight into fullscreen slide
202
+ // view. Delegates to openCommand with `present: true` set so the URL
203
+ // gets `&present=0` and the browser auto-enters present mode on load.
204
+ function presentCommand(opts) {
205
+ return openCommand(Object.assign({}, opts, { present: true }));
206
+ }
207
+
208
+ function printSlideStdlib() {
209
+ // Require lazily so the browser-side slide stdlib (which uses window
210
+ // globals) is only loaded when this command actually runs.
211
+ const SDocSlideStdlib = require('../../public/sdocs-slide-stdlib.js');
212
+ const names = SDocSlideStdlib.names || Object.keys(SDocSlideStdlib.templates || {});
213
+ const slots = SDocSlideStdlib.slots || {};
214
+ console.log('Built-in slide templates');
215
+ console.log('========================');
216
+ const pad = 22;
217
+ for (let i = 0; i < names.length; i++) {
218
+ const n = names[i];
219
+ let label = '@extends ' + n;
220
+ while (label.length < pad) label += ' ';
221
+ const slotList = (slots[n] || []).join(', ');
222
+ console.log(label + ' ' + slotList);
223
+ }
224
+ console.log('');
225
+ console.log('`!` marks a required slot (resolver errors when omitted).');
226
+ console.log('Use a built-in by adding `@extends <name>` to a slide block.');
227
+ console.log('Define a user @template with the same name to override (you\'ll get a warning).');
228
+ }
229
+
230
+ function printIconList(query) {
231
+ let names;
232
+ try {
233
+ // The manifest sits next to this file (via cli/bin/). Require by
234
+ // resolved path so it works whether we're invoked from a globally
235
+ // installed binary or from a checkout.
236
+ names = require('../bin/sdocs-icon-names.js');
237
+ } catch (e) {
238
+ console.error('sdoc: icon names manifest missing (cli/bin/sdocs-icon-names.js).');
239
+ console.error('Run `node scripts/build-icons.js` to generate it.');
240
+ process.exit(1);
241
+ }
242
+
243
+ const q = (query || '').toLowerCase().trim();
244
+ const matches = q ? names.filter(n => n.indexOf(q) !== -1) : names;
245
+
246
+ if (q && matches.length === 0) {
247
+ console.log('No Lucide icons match "' + query + '".');
248
+ console.log('Browse the full set at https://lucide.dev/icons/ or run `sdoc slides icons` to list everything.');
249
+ return;
250
+ }
251
+
252
+ if (q) {
253
+ console.log('Lucide icons matching "' + query + '" (' + matches.length + ' of ' + names.length + ')');
254
+ } else {
255
+ console.log('Lucide icons available to the `icon` shape kind (' + names.length + ' total)');
256
+ }
257
+ console.log('Source: https://lucide.dev/icons/ - use `name=<icon>` in slides');
258
+ console.log('');
259
+
260
+ const longest = matches.reduce((m, n) => n.length > m ? n.length : m, 0);
261
+ const colWidth = longest + 2;
262
+ const cols = 4;
263
+ const rows = Math.ceil(matches.length / cols);
264
+ for (let r = 0; r < rows; r++) {
265
+ let line = '';
266
+ for (let c = 0; c < cols; c++) {
267
+ const idx = c * rows + r;
268
+ if (idx >= matches.length) break;
269
+ let name = matches[idx];
270
+ while (name.length < colWidth) name += ' ';
271
+ line += name;
272
+ }
273
+ console.log(line.replace(/\s+$/, ''));
274
+ }
275
+
276
+ if (!q) {
277
+ console.log('');
278
+ console.log('Tip: filter with `sdoc slides icons <substring>` (e.g. `sdoc slides icons cloud`).');
279
+ }
280
+ }
281
+
282
+ module.exports = {
283
+ prepareUrl,
284
+ openCommand,
285
+ shareCommand,
286
+ defaultsCommand,
287
+ colorAnalysisCommand,
288
+ newCommand,
289
+ slidesCommand,
290
+ presentCommand,
291
+ };
@@ -0,0 +1,283 @@
1
+ // Shared constants used across the CLI lib.
2
+ //
3
+ // VERSION resolves to the CLI's own package version (cli/package.json),
4
+ // not the server's root package.json.
5
+
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ exports.DEFAULT_URL = 'https://smalldocs.org';
10
+ exports.VERSION = require('../package.json').version;
11
+ exports.UPDATE_CACHE = path.join(os.homedir(), '.sdocs', 'update-check.json');
12
+ exports.SETUP_CACHE = path.join(os.homedir(), '.sdocs', 'setup.json');
13
+ exports.ONE_DAY = 86400000;
14
+ exports.AGENT_CHANGES_URL = 'https://smalldocs.org/agent-changes';
15
+ exports.INSTALL_SH_URL = exports.DEFAULT_URL + '/install';
16
+ exports.GITHUB_REPO_URL = 'https://github.com/espressoplease/SDocs';
17
+
18
+ // Printed by `sdoc feedback` with no args. Goal: an agent can read this
19
+ // once and write valid form blocks afterwards. Short, dense, and every
20
+ // supported field type appears in the example.
21
+ exports.FORM_DSL_REFERENCE = `\
22
+ sdoc feedback — interactive form DSL
23
+ ====================================
24
+
25
+ Ask the user something structured. Write a fenced \`\`\`form block into a
26
+ markdown file, then run:
27
+
28
+ sdoc feedback file.md # opens the file, exits on first submit
29
+
30
+ That is the simplest, recommended shape: ONE submit button, single
31
+ process invocation. The user clicks, the file gets the answers, the
32
+ process prints one JSON line on stdout, and exits. You just spawn the
33
+ command, wait for it to finish, and read its stdout.
34
+
35
+ You only need the other modes when you genuinely want multiple submits
36
+ in one session:
37
+
38
+ sdoc feedback file.md --keep-open # bridge stays alive across many
39
+ # submits; you tail stdout to
40
+ # react per click
41
+ sdoc feedback file.md --keep-open \\
42
+ --log-file /tmp/sdoc.jsonl # also mirror events to a file
43
+ # (fallback for harnesses that
44
+ # can't tail a background process)
45
+ sdoc feedback file.md --message "Q" # show "Q" above the document
46
+
47
+ Strong default: write forms with ONE button. Reach for --keep-open + a
48
+ multi-button form only when "the user submits several things during one
49
+ session" is the actual goal.
50
+
51
+ A form block has four sections: id, fields, buttons, and (added by the
52
+ bridge on submit) answers + submissions. You author id, fields, buttons.
53
+
54
+ Full example
55
+ ------------
56
+
57
+ \`\`\`form
58
+ id: q3-review
59
+ fields:
60
+ - name: ready
61
+ type: radio
62
+ label: "Are you ready to ship?"
63
+ options: [Yes, "Needs more time", Push out]
64
+ required: true
65
+ default: Yes
66
+ help: "All teams have signed off."
67
+
68
+ - name: notes
69
+ type: textarea
70
+ label: "Detailed thoughts"
71
+ rows: 5
72
+ placeholder: "Anything else?"
73
+ default: |
74
+ Pre-filled text the user can edit.
75
+ Spans multiple lines via the | scalar.
76
+
77
+ - name: name_field
78
+ type: text
79
+ label: "Your name"
80
+ default: "Jane" # pre-fills the input; user can edit to "Jane!"
81
+
82
+ - name: tags
83
+ type: checkbox
84
+ label: "Which areas?"
85
+ options: [api, web, docs, infra]
86
+ default: [api, docs]
87
+
88
+ - name: tier
89
+ type: select
90
+ label: "Pricing tier"
91
+ options: [free, pro, team, enterprise]
92
+ default: pro
93
+
94
+ - name: head_count
95
+ type: number
96
+ label: "How many people?"
97
+ min: 1
98
+ max: 500
99
+ default: 5
100
+
101
+ - name: target_date
102
+ type: date
103
+ label: "Target ship date"
104
+ default: "2026-06-01"
105
+
106
+ buttons:
107
+ - name: send_decision
108
+ label: "Send decision"
109
+ scope: [ready] # this button only submits the 'ready' field
110
+ after: ready # render this button inline, right under the
111
+ # 'ready' field, instead of in the footer row
112
+
113
+ - name: send_all
114
+ label: "Submit everything"
115
+ final: true # this button always ends the session
116
+ \`\`\`
117
+
118
+ Field types
119
+ -----------
120
+
121
+ text single-line input. default, placeholder, required, maxlength
122
+ textarea multi-line. default (block-scalar OK), rows, placeholder, required, maxlength
123
+ radio one of N choices. options[] required. default selects one.
124
+ checkbox multi-select. options[] required. default is an array.
125
+ select dropdown. options[] required. default selects one.
126
+ number numeric input. min, max, step. default is a number.
127
+ date date picker (YYYY-MM-DD). min, max. default is the ISO date string.
128
+
129
+ Per-field keys
130
+ --------------
131
+
132
+ name required, [a-z0-9_-]{1,64}, unique per form
133
+ label shown above the control
134
+ help small grey description under the control
135
+ required true/false
136
+ default pre-fill value the user can edit (array for checkbox, number
137
+ for number, ISO date for date, string otherwise)
138
+ options radio / checkbox / select; array of strings
139
+ placeholder text / textarea / number; greyed-out hint that vanishes on type
140
+ rows textarea only
141
+ min/max number, date
142
+ step number only
143
+
144
+ Buttons
145
+ -------
146
+
147
+ name required, unique per form
148
+ label button text
149
+ scope optional list of field names. Defaults to all fields.
150
+ final optional bool. true means this submit ends the session even
151
+ when --keep-open was passed.
152
+ after optional field name. Renders the button inline right under
153
+ that field instead of in the bottom row. Combine with scope
154
+ for a "submit just this section" pattern.
155
+ help optional one-line override for the auto-generated hint
156
+ (see "Button hints" below). Only set this when your own
157
+ one-liner is clearly better than the default.
158
+
159
+ Button hints (automatic, you do not author these)
160
+ -------------------------------------------------
161
+
162
+ Every button gets a small grey line under it explaining what happens
163
+ when the user clicks. You do not write the hint; it is derived from the
164
+ button's shape:
165
+
166
+ final: true -> "Submitting hands off to the agent and ends
167
+ this session."
168
+ scope: [a, b] -> "Sends just these answers (a, b). You can
169
+ keep editing."
170
+ no scope (non-final) -> "Sends all answers. You can keep editing."
171
+
172
+ After a successful submit, a green italic line also appears under that
173
+ same button: "Saved to <filename> at HH:MM:SS". It updates in place on
174
+ each subsequent click of that button. You do not author this either.
175
+
176
+ Set the per-button \`help: "..."\` key only when your own copy is
177
+ genuinely more useful than the default (e.g. when the field semantics
178
+ are non-obvious and the auto hint would mislead).
179
+
180
+ Multi-round flow (with --keep-open)
181
+ -----------------------------------
182
+
183
+ 1. Write a file with a form block, run \`sdoc feedback file.md --keep-open\`.
184
+ 2. User edits, clicks a non-final submit. Bridge writes the file with
185
+ answers + a submission entry. The bridge stays alive.
186
+ 3. Read the file. \`submissions[-1]\` has the user's latest answer.
187
+ 4. Rewrite the file with the next question (keep the form id stable to
188
+ preserve answers + history; change fields or labels as needed).
189
+ 5. The browser refreshes automatically. The user answers the new form.
190
+ 6. Repeat until the user clicks a button with \`final: true\`, or closes
191
+ the tab.
192
+
193
+ How to know a submit happened (events)
194
+ --------------------------------------
195
+
196
+ Every successful submit emits one JSON line to stdout, e.g.
197
+
198
+ {"event":"submit","form_id":"q1","by":"send_decision",
199
+ "at":"2026-05-24T10:01:32.123Z","scope":["ready"],
200
+ "values":{"ready":"Yes"},"final":false}
201
+
202
+ Startup chatter is on stderr, so stdout is a clean event channel.
203
+
204
+ DO NOT fire-and-forget
205
+ ----------------------
206
+
207
+ The submit JSON arrives WHEN the user clicks - which could be seconds
208
+ or minutes after you spawn the command. The whole protocol depends on
209
+ you (or your harness) noticing when the process exits.
210
+
211
+ Wrong:
212
+
213
+ bash -c 'sdoc feedback file.md > log.json 2>&1 &'
214
+ ^^ ^^
215
+ starts shell backgrounding;
216
+ immediately parent NEVER notices exit
217
+
218
+ That looks like it worked. The user fills out the form, clicks submit,
219
+ the JSON lands in log.json, the process exits 0... and your agent
220
+ never knows. You just sit there.
221
+
222
+ Right (depends on your harness):
223
+
224
+ Claude Code Bash tool with run_in_background: true
225
+ (you get a notification when the process exits)
226
+ Codex Codex's background-task primitive, same shape
227
+ Plain shell Foreground: sdoc feedback file.md, then wait.
228
+ Or: sdoc feedback file.md & wait \$!
229
+ (the wait \$! is what was missing)
230
+ Make / script Run it foreground and capture stdout - no '&'
231
+
232
+ The CLI prints a stderr warning when stdout is not a TTY at startup,
233
+ so you get a hint if you set it up wrong.
234
+
235
+ Two reading patterns:
236
+
237
+ Single-shot sdoc feedback file.md
238
+ User clicks once, bridge writes the file, prints one
239
+ JSON line to stdout, exits 0. Agent runs the command,
240
+ waits for it to finish, reads stdout. No tailing. Works
241
+ in every harness that can run a child process.
242
+
243
+ Multi-click sdoc feedback file.md --keep-open
244
+ Bridge stays alive. Each click prints another JSON
245
+ line. Agent runs the command in the background and
246
+ reads new lines as they arrive (Claude Code, Codex,
247
+ opencode all support this).
248
+
249
+ Log fallback --log-file PATH
250
+ The same JSON lines, also appended to a file. Use this
251
+ when your harness can run a backgrounded process but
252
+ can't stream its stdout (Aider, older Cursor modes).
253
+ Agent reads the file periodically.
254
+
255
+ On submit, the form block grows two new sections:
256
+
257
+ \`\`\`yaml
258
+ answers:
259
+ ready: Yes
260
+ notes: |
261
+ Multi-line answer text the user kept or edited.
262
+ tags: [api, docs]
263
+ head_count: 5
264
+ target_date: "2026-06-01"
265
+ submissions:
266
+ - by: send_decision
267
+ at: "2026-05-23T10:01:32Z"
268
+ scope: [ready]
269
+ values:
270
+ ready: Yes
271
+ \`\`\`
272
+
273
+ Constraints
274
+ -----------
275
+
276
+ - 64KB max per form block source.
277
+ - Field and button names: [a-z0-9_-]{1,64}.
278
+ - Strings containing triple-backticks are rejected on submit.
279
+ - Markdown around the form block is preserved byte-for-byte.
280
+
281
+ End.
282
+ `;
283
+