sdocs-dev 1.6.1 → 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,245 @@
1
+ // Pure data model for the SmallDocs agent integration block.
2
+ //
3
+ // IMPORTANT: keep AGENT_BLOCK_BODY in sync with the per-agent setup
4
+ // snippets in public/sdoc.md (the "Set up your agent" section). If you
5
+ // reword one, reword the other.
6
+ //
7
+ // Release checklist when AGENT_BLOCK_BODY changes:
8
+ // 1. Bump AGENT_BLOCK_VERSION below.
9
+ // 2. Set AGENT_BLOCK_REASON to a one-line summary of what changed.
10
+ // 3. Prepend a new section to public/agent-changes.md.
11
+ // 4. Reword public/sdoc.md per-agent snippets to match.
12
+ //
13
+ // This module also owns the on-disk schema for ~/.sdocs/setup.json
14
+ // (read/write/migrate). The tests cover both the block format and the
15
+ // state migration, so they live together as one cohesive module.
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+ const { SETUP_CACHE } = require('./constants');
20
+
21
+ const AGENT_BLOCK_VERSION = 10;
22
+ const AGENT_BLOCK_REASON = 'Documents `sdoc bridge file.md`: a live editing session for iterating on a file with the user (browser edits autosave to disk, file edits push to the page). Notes that it parks the terminal so it should run in the background, and that the browser asks for local-process / "Apps on device" permission the user must accept. Reframes plain `sdoc file.md` as the default way to open a file for comfortable reading or quick sharing. Renames the block heading from SDocs to SmallDocs, the project\'s new name and home, and spells out that "sdoc it" / "sdoc me the plan" / "make me a smalldoc" all mean: write the .md and open it with sdoc.';
23
+
24
+ const AGENT_BLOCK_BODY = `## SmallDocs
25
+
26
+ The \`sdoc\` CLI (\`sdoc path/to/file.md\`) is installed globally and renders local Markdown files securely in the browser (at https://smalldocs.org) in a way that's comfortable for your user to read and share. Nothing hits a server unless the user explicitly saves the file to the SmallDocs cloud or runs \`sdoc share\`.
27
+
28
+ When the user says "sdoc it", "sdoc me the plan", or asks for a smalldoc, they mean this: write (or locate) the \`.md\` file and open it with \`sdoc\`.
29
+
30
+ Use it (or offer it) when the user wants to read, share, or export a \`.md\` file, or when a styled / interactive artifact will land harder than chat prose. Skip it for quick Q&A that already fits in a reply - SmallDocs adds friction without value when there's no document, no rendering opportunity, and nothing to share.
31
+
32
+ ### Basic \`sdoc\` usage
33
+
34
+ - \`sdoc file.md\` - the default way to open a file, for comfortable reading or quick sharing.
35
+ - \`sdoc bridge file.md\` - open a live editing session while you iterate on a file with the user: edits in the browser autosave to the file on disk, and your edits to the file push to the open page. It parks the terminal until the tab closes, so run it in the background when you want to keep working. The first time the page connects, the browser asks to reach a local process (Chrome calls this "Apps on device" / Local Network Access) - the user has to accept, or the page stays read-only. Reach for this when you and the user are working a file back and forth, not for a one-off open.
36
+ - \`sdoc library\` - opens a library view in the browser. SmallDocs automatically indexes every \`.md\` under the user's home directory; filter by directory, date, or tags (the index doesn't search file content - fall back to \`grep\` for that). Opt out per-directory with \`.sdocsignore\` or per-file with \`sdocs-library: false\` in front matter. (\`sdoc library --help\` for the full reference.)
37
+ - \`sdoc file.md +tag1 +tag2\` - open the file and inject tags into its YAML front matter which persist. The \`+\` prefix is shell-safe. Tag files when they're worth rediscovering - the library filters by tag, not by content.
38
+ - \`sdoc library ls --tags\` - print the tags (tag - count) for the current project directory. If you think you might tag the file, run this first so you reuse the project's existing tag vocabulary instead of inventing parallel ones.
39
+ - \`sdoc share file.md\` - copy an encrypted short URL to the clipboard for sending to someone else. The link decrypts in the recipient's browser; the server only sees ciphertext. The agent can't actually deliver - paste the link into wherever the user talks to that person.
40
+ - \`sdoc --help\` - full reference.
41
+
42
+ ### SmallDocs expands what you can create with Markdown
43
+
44
+ SmallDocs uses the browser to extend what Markdown can be: a styled doc, a chart, a diagram, a slide deck, or an interactive form whose answers come back to you. Reach for one of these when a visual or interactive artifact will land harder than prose - not as a default for every reply. To create something new, write the \`.md\` file first, then \`sdoc path/to/file.md\`.
45
+
46
+ Each command below prints its reference when run with no arguments - run it before writing the matching fenced block. The JSON / DSL shapes are specific and easy to get wrong from memory.
47
+
48
+ - \`sdoc charts\` - rendering inline charts (\`\`\`chart blocks)
49
+ - \`sdoc diagrams\` - rendering inline Mermaid diagrams (\`\`\`mermaid blocks; has full-screen mode for zoom). Reach for this when drawing system or architectural diagrams (sequence, flow, component layout) - a diagram often communicates the shape of something faster than the equivalent prose.
50
+ - \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. \`sdoc present file.md\` - open file directly in fullscreen presentation mode.
51
+ - \`sdoc cells\` - rendering spreadsheets (\`\`\`cells blocks): CSV rows where plain values and =formulas (SUM, AVERAGE, IF, ROUND...) sit in the same grid and compute live. The reader can sort, select ranges for quick stats, edit a scratch copy fullscreen, and download the sheet as Excel (.xlsx) with the formulas still working. Reach for this when handing the user numbers they will want to check or play with - totals, budgets, projections. \`sdoc report.csv\` opens a CSV file directly as a sheet.
52
+ - \`sdoc schema\` - styling Markdown (fonts, colors, spacing). The default styles are already comfortable to read; reach for this only when they aren't enough - client-facing polish or a bit of fun.
53
+ - \`sdoc feedback\` - rendering interactive elements (\`\`\`form blocks) to receive structured input from the user. Run \`sdoc feedback file.md\` and the user's submission lands as a JSON line on stdout. Good for eliciting complex/subtle feedback. All standard interactive HTML elements with prefilled (but editable) content of your choosing.
54
+ `;
55
+
56
+ const AGENT_BLOCK_START_PREFIX = '<!-- sdocs-agent-block:start v=';
57
+ const AGENT_BLOCK_START_RE = /<!-- sdocs-agent-block:start v=(\d+) -->/;
58
+ const AGENT_BLOCK_END_MARKER = '<!-- sdocs-agent-block:end -->';
59
+ const AGENT_BLOCK_LEGACY_OPEN = '<!-- sdocs-agent-block -->';
60
+
61
+ // `detectDir` (optional) is the directory whose existence signals "this agent
62
+ // is installed". It defaults to `dir`. pi keeps its global instructions one
63
+ // level down (`~/.pi/agent/AGENTS.md`), so we detect on the parent `~/.pi`
64
+ // the installer creates and let writeBookendedBlock mkdir the `agent` subdir.
65
+ const AGENT_TARGETS = [
66
+ { name: 'Claude Code', dir: '.claude', file: 'CLAUDE.md' },
67
+ { name: 'Codex', dir: '.codex', file: 'AGENTS.md' },
68
+ { name: 'Gemini CLI', dir: '.gemini', file: 'GEMINI.md' },
69
+ { name: 'opencode', dir: path.join('.config', 'opencode'), file: 'AGENTS.md' },
70
+ { name: 'pi', dir: path.join('.pi', 'agent'), file: 'AGENTS.md', detectDir: '.pi' },
71
+ { name: 'CodeWhale', dir: '.codewhale', file: 'AGENTS.md' },
72
+ ];
73
+
74
+ function formatAgentBlock(version, body) {
75
+ return `${AGENT_BLOCK_START_PREFIX}${version} -->\n${body}${AGENT_BLOCK_END_MARKER}\n`;
76
+ }
77
+
78
+ // Find a current bookended block. Returns { start, end, version, body } | null.
79
+ // Bails on ambiguity (multiple start markers).
80
+ function findBookendedBlock(content) {
81
+ const startMatch = AGENT_BLOCK_START_RE.exec(content);
82
+ if (!startMatch) return null;
83
+ const startIdx = startMatch.index;
84
+ const startLineEnd = content.indexOf('\n', startIdx);
85
+ if (startLineEnd < 0) return null;
86
+ const endIdx = content.indexOf(AGENT_BLOCK_END_MARKER, startLineEnd);
87
+ if (endIdx < 0) return null;
88
+ const endMarkerEnd = endIdx + AGENT_BLOCK_END_MARKER.length;
89
+ const trailingNewline = content[endMarkerEnd] === '\n' ? 1 : 0;
90
+ const second = content.indexOf(AGENT_BLOCK_START_PREFIX, endMarkerEnd);
91
+ if (second >= 0) return null;
92
+ return {
93
+ start: startIdx,
94
+ end: endMarkerEnd + trailingNewline,
95
+ version: parseInt(startMatch[1], 10),
96
+ body: content.slice(startLineEnd + 1, endIdx),
97
+ };
98
+ }
99
+
100
+ // Find a legacy open-only block (1.4.x format). Returns { start, end, version } | null.
101
+ // Only matches bodies whose terminator is the JoshInLisbon URL line, which is the
102
+ // known shape of v1 (1.4.0/1.4.1) and v2 (1.4.2). Hand-edited bodies return null.
103
+ function findLegacyBlock(content) {
104
+ const idx = content.indexOf(AGENT_BLOCK_LEGACY_OPEN);
105
+ if (idx < 0) return null;
106
+ const second = content.indexOf(AGENT_BLOCK_LEGACY_OPEN, idx + AGENT_BLOCK_LEGACY_OPEN.length);
107
+ if (second >= 0) return null;
108
+ const terminator = 'Source: https://github.com/JoshInLisbon/SDocs\n';
109
+ const termIdx = content.indexOf(terminator, idx);
110
+ if (termIdx < 0) return null;
111
+ const blockEnd = termIdx + terminator.length;
112
+ const region = content.slice(idx, blockEnd);
113
+ // Heuristic to recover from-version: v2 added the copy-code line, v1 didn't.
114
+ const version = region.includes('Also handy for copying specific code') ? 2 : 1;
115
+ return { start: idx, end: blockEnd, version };
116
+ }
117
+
118
+ // Pure: takes content, returns refresh result.
119
+ // { changed: false, reason: 'absent'|'current'|'newer'|'hand_edited' }
120
+ // { changed: true, content, fromVersion, toVersion }
121
+ function refreshContent(content) {
122
+ const bookended = findBookendedBlock(content);
123
+ if (bookended) {
124
+ if (bookended.version === AGENT_BLOCK_VERSION) {
125
+ return { changed: false, reason: 'current' };
126
+ }
127
+ if (bookended.version > AGENT_BLOCK_VERSION) {
128
+ return { changed: false, reason: 'newer' };
129
+ }
130
+ return {
131
+ changed: true,
132
+ content: content.slice(0, bookended.start)
133
+ + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
134
+ + content.slice(bookended.end),
135
+ fromVersion: bookended.version,
136
+ toVersion: AGENT_BLOCK_VERSION,
137
+ };
138
+ }
139
+ const legacy = findLegacyBlock(content);
140
+ if (!legacy) {
141
+ return { changed: false, reason: content.includes(AGENT_BLOCK_LEGACY_OPEN) ? 'hand_edited' : 'absent' };
142
+ }
143
+ return {
144
+ changed: true,
145
+ content: content.slice(0, legacy.start)
146
+ + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
147
+ + content.slice(legacy.end),
148
+ fromVersion: legacy.version,
149
+ toVersion: AGENT_BLOCK_VERSION,
150
+ };
151
+ }
152
+
153
+ function compareVersions(a, b) {
154
+ const A = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
155
+ const B = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
156
+ for (let i = 0; i < 3; i++) {
157
+ if ((A[i] || 0) > (B[i] || 0)) return 1;
158
+ if ((A[i] || 0) < (B[i] || 0)) return -1;
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ // ── Setup state (~/.sdocs/setup.json) ───────────────────────
164
+
165
+ const SETUP_SCHEMA_VERSION = 1;
166
+
167
+ // Pre-1.5.0 setup.json had no `schemaVersion`. Existing users wrote the block
168
+ // (so they want it kept current) but were never asked about auto-install.
169
+ function migrateSetupState(raw) {
170
+ if (!raw || typeof raw !== 'object') return null;
171
+ if (raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
172
+ if (raw.schemaVersion && raw.schemaVersion > SETUP_SCHEMA_VERSION) {
173
+ return null;
174
+ }
175
+ if (!raw.setupCompleted) return null;
176
+ return {
177
+ schemaVersion: SETUP_SCHEMA_VERSION,
178
+ setupCompleted: raw.setupCompleted,
179
+ writtenTo: raw.writtenTo || [],
180
+ declined: !!raw.declined,
181
+ autoRefreshAgentFiles: !raw.declined,
182
+ autoInstallUpdates: false,
183
+ lastRunVersion: null,
184
+ };
185
+ }
186
+
187
+ function readSetupState() {
188
+ let raw;
189
+ try { raw = JSON.parse(fs.readFileSync(SETUP_CACHE, 'utf-8')); }
190
+ catch (_) { return null; }
191
+ if (raw && raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
192
+ const migrated = migrateSetupState(raw);
193
+ if (migrated) {
194
+ writeSetupState(migrated);
195
+ return migrated;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function writeSetupState(state) {
201
+ try {
202
+ fs.mkdirSync(path.dirname(SETUP_CACHE), { recursive: true });
203
+ const payload = { schemaVersion: SETUP_SCHEMA_VERSION, ...state };
204
+ payload.schemaVersion = SETUP_SCHEMA_VERSION;
205
+ fs.writeFileSync(SETUP_CACHE, JSON.stringify(payload, null, 2));
206
+ } catch (_) {}
207
+ }
208
+
209
+ // Pure: given a batch of refresh results plus the current binary version,
210
+ // decide whether a missing setup.json should be lazily populated. Returns the
211
+ // state object to write, or null to leave state untouched.
212
+ function implicitConsentState(results, version, now = new Date()) {
213
+ const changed = results.filter(r => r.changed);
214
+ if (changed.length === 0) return null;
215
+ if (results.some(r => r.error)) return null;
216
+ return {
217
+ setupCompleted: now.toISOString(),
218
+ writtenTo: changed.map(r => r.path),
219
+ declined: false,
220
+ autoRefreshAgentFiles: true,
221
+ autoInstallUpdates: false,
222
+ lastRunVersion: version,
223
+ };
224
+ }
225
+
226
+ module.exports = {
227
+ AGENT_BLOCK_VERSION,
228
+ AGENT_BLOCK_REASON,
229
+ AGENT_BLOCK_BODY,
230
+ AGENT_BLOCK_START_PREFIX,
231
+ AGENT_BLOCK_START_RE,
232
+ AGENT_BLOCK_END_MARKER,
233
+ AGENT_BLOCK_LEGACY_OPEN,
234
+ AGENT_TARGETS,
235
+ SETUP_SCHEMA_VERSION,
236
+ formatAgentBlock,
237
+ findBookendedBlock,
238
+ findLegacyBlock,
239
+ refreshContent,
240
+ compareVersions,
241
+ migrateSetupState,
242
+ readSetupState,
243
+ writeSetupState,
244
+ implicitConsentState,
245
+ };
@@ -0,0 +1,162 @@
1
+ // Per-file refresh of the SDocs agent block.
2
+ //
3
+ // Operates on AGENT_TARGETS in $HOME. Atomic writes via tmp + rename,
4
+ // short-lived exclusive locks to avoid two `sdoc` runs stomping on each
5
+ // other, and a backup file beside each agent file before we modify it.
6
+ // Symlinks are skipped unless explicitly followed.
7
+
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+
12
+ const {
13
+ AGENT_BLOCK_VERSION,
14
+ AGENT_BLOCK_BODY,
15
+ AGENT_BLOCK_LEGACY_OPEN,
16
+ AGENT_TARGETS,
17
+ formatAgentBlock,
18
+ findBookendedBlock,
19
+ refreshContent,
20
+ } = require('./agent-block');
21
+
22
+ const { AGENT_CHANGES_URL } = require('./constants');
23
+
24
+ function detectAgents() {
25
+ const home = os.homedir();
26
+ return AGENT_TARGETS
27
+ .map(t => ({ ...t, dirPath: path.join(home, t.detectDir || t.dir), filePath: path.join(home, t.dir, t.file) }))
28
+ .filter(t => fs.existsSync(t.dirPath));
29
+ }
30
+
31
+ function fileHasBlock(filePath) {
32
+ try {
33
+ const content = fs.readFileSync(filePath, 'utf-8');
34
+ return findBookendedBlock(content) !== null
35
+ || content.includes(AGENT_BLOCK_LEGACY_OPEN);
36
+ } catch (_) { return false; }
37
+ }
38
+
39
+ function isSymlink(filePath) {
40
+ try { return fs.lstatSync(filePath).isSymbolicLink(); }
41
+ catch (_) { return false; }
42
+ }
43
+
44
+ // Atomic write: tmp file in the SAME directory (so rename can't hit EXDEV),
45
+ // then rename. Cleans up the tmp on any error.
46
+ function atomicWrite(filePath, content) {
47
+ const dir = path.dirname(filePath);
48
+ const base = path.basename(filePath);
49
+ const tmp = path.join(dir, `.${base}.sdocs.tmp.${process.pid}.${Date.now()}`);
50
+ fs.writeFileSync(tmp, content);
51
+ try { fs.renameSync(tmp, filePath); }
52
+ catch (e) {
53
+ try { fs.unlinkSync(tmp); } catch (_) {}
54
+ throw e;
55
+ }
56
+ }
57
+
58
+ function backupFile(filePath) {
59
+ try {
60
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
61
+ fs.copyFileSync(filePath, `${filePath}.sdocs.bak.${stamp}`);
62
+ } catch (_) {}
63
+ }
64
+
65
+ // Best-effort exclusive lock. Returns a release function or null on contention.
66
+ // Stale locks (>60s) are reaped.
67
+ function acquireLock(filePath) {
68
+ const lockPath = `${filePath}.sdocs.lock`;
69
+ try {
70
+ const fd = fs.openSync(lockPath, 'wx');
71
+ try { fs.writeSync(fd, String(process.pid)); } catch (_) {}
72
+ fs.closeSync(fd);
73
+ return () => { try { fs.unlinkSync(lockPath); } catch (_) {} };
74
+ } catch (e) {
75
+ if (e.code !== 'EEXIST') return null;
76
+ try {
77
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
78
+ if (age > 60000) {
79
+ fs.unlinkSync(lockPath);
80
+ return acquireLock(filePath);
81
+ }
82
+ } catch (_) {}
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function writeBookendedBlock(filePath) {
88
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
89
+ const block = formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY);
90
+ if (!fs.existsSync(filePath)) {
91
+ atomicWrite(filePath, block);
92
+ return;
93
+ }
94
+ const existing = fs.readFileSync(filePath, 'utf-8');
95
+ const prefix = existing.endsWith('\n') ? '\n' : '\n\n';
96
+ atomicWrite(filePath, existing + prefix + block);
97
+ }
98
+
99
+ // Refresh a single agent file.
100
+ // Returns { path, name?, changed, fromVersion?, toVersion?, reason?, error? }.
101
+ function refreshAgentFile(filePath, opts = {}) {
102
+ if (!fs.existsSync(filePath)) return { path: filePath, changed: false, reason: 'absent' };
103
+ if (isSymlink(filePath) && !opts.followSymlinks) return { path: filePath, changed: false, reason: 'symlink' };
104
+
105
+ const release = acquireLock(filePath);
106
+ if (!release) return { path: filePath, changed: false, reason: 'locked' };
107
+
108
+ try {
109
+ const content = fs.readFileSync(filePath, 'utf-8');
110
+ const result = refreshContent(content);
111
+ if (!result.changed) return { path: filePath, changed: false, reason: result.reason };
112
+ backupFile(filePath);
113
+ atomicWrite(filePath, result.content);
114
+ return {
115
+ path: filePath, changed: true,
116
+ fromVersion: result.fromVersion, toVersion: result.toVersion,
117
+ };
118
+ } catch (e) {
119
+ return { path: filePath, changed: false, error: e.message };
120
+ } finally {
121
+ release();
122
+ }
123
+ }
124
+
125
+ function refreshAllAgentFiles(opts = {}) {
126
+ const home = os.homedir();
127
+ return AGENT_TARGETS.map(t => {
128
+ const filePath = path.join(home, t.dir, t.file);
129
+ return { name: t.name, ...refreshAgentFile(filePath, opts) };
130
+ });
131
+ }
132
+
133
+ function printRefreshSummary(results) {
134
+ const changed = results.filter(r => r.changed);
135
+ if (changed.length > 0) {
136
+ const n = changed.length;
137
+ console.log(`✓ SDocs agent block updated to v${AGENT_BLOCK_VERSION} in ${n} ${n === 1 ? 'file' : 'files'}`);
138
+ console.log(` Changes: ${AGENT_CHANGES_URL}#v${AGENT_BLOCK_VERSION}`);
139
+ }
140
+ for (const r of results.filter(r => r.error)) {
141
+ console.log(`! ${r.path}: ${r.error}`);
142
+ }
143
+ for (const r of results.filter(r => r.reason === 'symlink')) {
144
+ console.log(`! ${r.path}: symlink, skipped (run \`sdoc setup --follow-symlinks\` to follow)`);
145
+ }
146
+ for (const r of results.filter(r => r.reason === 'hand_edited')) {
147
+ console.log(`! ${r.path}: local edits detected, run \`sdoc setup\` to refresh manually`);
148
+ }
149
+ }
150
+
151
+ module.exports = {
152
+ detectAgents,
153
+ fileHasBlock,
154
+ isSymlink,
155
+ atomicWrite,
156
+ backupFile,
157
+ acquireLock,
158
+ writeBookendedBlock,
159
+ refreshAgentFile,
160
+ refreshAllAgentFiles,
161
+ printRefreshSummary,
162
+ };
@@ -0,0 +1,171 @@
1
+ // CLI bridge integration. Two entry points:
2
+ //
3
+ // - `runBridgedOpen(opts)` — invoked by the default `sdoc <file>` handler
4
+ // when a file path is present. Starts a bridge in 'open' mode so the
5
+ // browser is connected to the live file on disk. Closing the tab exits 0.
6
+ //
7
+ // - `feedbackCommand(opts)` — `sdoc feedback FILE [--message "..."]`.
8
+ // Starts a bridge in 'feedback' mode. The browser shows a Done button and
9
+ // the optional message as a banner above the document. Done exits 0,
10
+ // closing the tab without Done exits 2, no browser within the connect
11
+ // timeout exits 3.
12
+ //
13
+ // Both go through `runBridge` so the timeout flags, signal handling, and
14
+ // terminal-bound await are shared.
15
+
16
+ 'use strict';
17
+
18
+ const path = require('path');
19
+ const fs = require('fs');
20
+
21
+ const { startBridge } = require('../bin/sdocs-bridge');
22
+ const { DEFAULT_URL } = require('./constants');
23
+ const { openBrowser } = require('./io');
24
+ const { stripAndCompress } = require('./url');
25
+
26
+ function baseUrlFor(opts) {
27
+ return opts.url || process.env.SDOCS_URL || DEFAULT_URL;
28
+ }
29
+
30
+ // Wrapped files (.csv, .mmd, .mermaid) render a derived view the bridge builds
31
+ // on connect, not the raw bytes, so we don't embed a static snapshot for them -
32
+ // they keep the pre-existing connect-or-blank behaviour. Everything else gets a
33
+ // `md=` snapshot so the page renders read-only before the socket connects and
34
+ // falls back to read-only if it never does.
35
+ const WRAPPED_EXT = new Set(['.csv', '.mmd', '.mermaid']);
36
+
37
+ // The compressed `md=` snapshot for the bridged file, or null when we can't /
38
+ // shouldn't embed one (wrapped file, unreadable, etc.). Failure is non-fatal:
39
+ // without `md=` the page just behaves as it did before this change.
40
+ function bridgeSnapshot(file) {
41
+ if (!file) return null;
42
+ if (WRAPPED_EXT.has(path.extname(file).toLowerCase())) return null;
43
+ try {
44
+ return stripAndCompress(fs.readFileSync(file, 'utf-8'));
45
+ } catch (_) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function buildBridgeUrl(opts, bridge) {
51
+ const base = baseUrlFor(opts);
52
+ const params = new URLSearchParams();
53
+ params.set('bridge', '127.0.0.1:' + bridge.port);
54
+ params.set('token', bridge.token);
55
+ if (opts.file) params.set('file', path.basename(opts.file));
56
+ // Progressive enhancement: embed the document so the page can render it
57
+ // (read-only) before the live socket connects, and fall back to it if the
58
+ // socket never does, instead of showing a blank page.
59
+ const snapshot = bridgeSnapshot(opts.file);
60
+ if (snapshot) params.set('md', snapshot);
61
+ // `sdoc present <file>` triggers fullscreen slide view on load.
62
+ if (opts.present) params.set('present', '0');
63
+ return base + '/#' + params.toString();
64
+ }
65
+
66
+ // Resolve timeout flags into the bridge's millisecond API. Negative or NaN
67
+ // inputs throw — better to fail loudly than silently disable a guard.
68
+ function timeoutOpts(opts) {
69
+ const out = {};
70
+ if (opts.connectTimeoutS != null) {
71
+ if (!Number.isFinite(opts.connectTimeoutS) || opts.connectTimeoutS < 0) {
72
+ throw new Error('--connect-timeout must be a non-negative number of seconds (0 = wait forever)');
73
+ }
74
+ out.noConnectTimeoutMs = Math.round(opts.connectTimeoutS * 1000);
75
+ }
76
+ if (opts.idleTimeoutS != null) {
77
+ if (!Number.isFinite(opts.idleTimeoutS) || opts.idleTimeoutS < 0) {
78
+ throw new Error('--idle-timeout must be a non-negative number of seconds (0 = off)');
79
+ }
80
+ out.idleTimeoutMs = Math.round(opts.idleTimeoutS * 1000);
81
+ }
82
+ if (opts.reconnectGraceMs != null) {
83
+ if (!Number.isFinite(opts.reconnectGraceMs) || opts.reconnectGraceMs < 0) {
84
+ throw new Error('--reconnect-grace must be a non-negative number of milliseconds');
85
+ }
86
+ out.reconnectGraceMs = opts.reconnectGraceMs;
87
+ }
88
+ return out;
89
+ }
90
+
91
+ async function runBridge(opts, mode, label) {
92
+ if (!opts.file) {
93
+ // `sdoc feedback` (no args) prints the form DSL reference. Other
94
+ // bridge commands still require a file.
95
+ if (mode === 'feedback') {
96
+ const { FORM_DSL_REFERENCE } = require('./constants');
97
+ process.stdout.write(FORM_DSL_REFERENCE);
98
+ process.exit(0);
99
+ }
100
+ console.error('sdoc: ' + (opts.subcommand || 'open') + ' needs a file path');
101
+ process.exit(1);
102
+ }
103
+
104
+ let bridge;
105
+ try {
106
+ bridge = await startBridge(Object.assign(
107
+ { files: [opts.file], mode },
108
+ mode === 'feedback' && opts.messageText ? { message: opts.messageText } : {},
109
+ opts.keepOpenFlag ? { keepOpen: true } : {},
110
+ opts.logFile ? { logFile: opts.logFile } : {},
111
+ timeoutOpts(opts),
112
+ ));
113
+ } catch (e) {
114
+ console.error('sdoc: could not start bridge -', e.message);
115
+ process.exit(1);
116
+ }
117
+
118
+ const url = buildBridgeUrl(opts, bridge);
119
+ const onSignal = () => { bridge.close(); };
120
+ process.on('SIGINT', onSignal);
121
+ process.on('SIGTERM', onSignal);
122
+
123
+ openBrowser(url);
124
+ // Startup chatter goes to stderr so stdout stays a clean event
125
+ // channel (one JSON line per submit) in --keep-open mode.
126
+ console.error(`${label} ${path.basename(opts.file)} in browser. Close the tab or press Ctrl-C to stop.`);
127
+
128
+ // Loud warning if the parent isn't going to notice the submit. The
129
+ // form's whole protocol assumes the spawning agent waits for this
130
+ // process to exit. If stdout isn't a TTY AND we're in feedback mode,
131
+ // the caller is probably an agent harness - flag the wrong shape
132
+ // (shell `&` fire-and-forget) before the user wastes time filling in
133
+ // a form whose answer no one's listening for.
134
+ if (mode === 'feedback' && !process.stdout.isTTY) {
135
+ console.error(
136
+ 'sdoc feedback: stdout is not a TTY. If your agent harness uses\n' +
137
+ ' shell `&` to background this process, the parent will NOT be\n' +
138
+ ' notified when the user submits. Use your harness\'s\n' +
139
+ ' run-in-background primitive instead (e.g. Claude Code\'s Bash\n' +
140
+ ' `run_in_background: true` flag), or run foreground and capture\n' +
141
+ ' stdout directly. See `sdoc feedback` (no args) for details.'
142
+ );
143
+ }
144
+
145
+ const result = await bridge.awaitTerminal();
146
+ process.off('SIGINT', onSignal);
147
+ process.off('SIGTERM', onSignal);
148
+
149
+ if (result.kind === 'no-connect') {
150
+ console.error('sdoc: no browser connected within the connect timeout.');
151
+ } else if (result.kind === 'cancel') {
152
+ console.error('sdoc: cancelled (tab closed without clicking Done).');
153
+ } else if (result.kind === 'submit') {
154
+ // Belt-and-braces confirmation on stderr. The submit JSON already
155
+ // went to stdout (clean event channel); this line is for harnesses
156
+ // that merge stderr/stdout to a single log and need a human-
157
+ // readable "yes the submit happened, look in stdout" marker.
158
+ console.error('sdoc feedback: submission received - JSON line on stdout, exiting 0.');
159
+ }
160
+ process.exit(result.code || 0);
161
+ }
162
+
163
+ function runBridgedOpen(opts) { return runBridge(opts, 'open', 'Open'); }
164
+ function feedbackCommand(opts) { return runBridge(opts, 'feedback', 'Feedback on'); }
165
+
166
+ module.exports = {
167
+ runBridgedOpen,
168
+ feedbackCommand,
169
+ buildBridgeUrl, // for tests
170
+ timeoutOpts, // for tests
171
+ };