sdocs-dev 1.15.0 → 1.19.2

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/bin/sdocs-dev.js CHANGED
@@ -32,8 +32,10 @@ const io = require('../lib/io');
32
32
  const helpText = require('../lib/help-text');
33
33
  const commands = require('../lib/commands');
34
34
  const cellsVerify = require('../lib/cells-verify');
35
+ const slidesVerify = require('../lib/slides-verify');
35
36
  const bridgeCommands = require('../lib/bridge-commands');
36
37
  const libraryCommands = require('../lib/library-commands');
38
+ const cloudCommands = require('../lib/cloud-commands');
37
39
 
38
40
  // ── Router ────────────────────────────────────────────────
39
41
  // One place that knows the full set of verbs. New chunks register here.
@@ -49,6 +51,8 @@ function buildRouter() {
49
51
  r.register('diagrams', { handler: () => { console.log(helpText.DIAGRAMS_HELP); process.exit(0); } });
50
52
  r.register('videos', { handler: () => { console.log(helpText.VIDEOS_HELP); process.exit(0); } });
51
53
  r.register('video', { handler: () => { console.log(helpText.VIDEOS_HELP); process.exit(0); } });
54
+ r.register('apps', { handler: () => { console.log(helpText.APPS_HELP); process.exit(0); } });
55
+ r.register('app', { handler: () => { console.log(helpText.APPS_HELP); process.exit(0); } });
52
56
  // `sdoc cells` prints the reference; `sdoc cells verify <file>` evaluates a
53
57
  // document's tabs headlessly and prints the computed values (the handler
54
58
  // calls process.exit with the 0/1/2 result code).
@@ -60,7 +64,10 @@ function buildRouter() {
60
64
  r.register('comments', { handler: () => { console.log(helpText.COMMENTS_HELP); process.exit(0); } });
61
65
 
62
66
  // Setup / refresh / auto-update.
63
- r.register('setup', { handler: async (opts) => { await setup.runSetup({ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag }); process.exit(0); } });
67
+ r.register('setup', { handler: async (opts) => { await setup.runSetup({
68
+ force: true, yes: !!opts.yesFlag, dryRun: !!opts.dryRunFlag,
69
+ edition: opts.skillEdition, accountId: opts.accountFlag,
70
+ }); process.exit(0); } });
64
71
  r.register('refresh', { handler: async () => { await setup.runRefresh(); process.exit(0); } });
65
72
  r.register('auto-update', { handler: (opts) => {
66
73
  // Sub-arg lives in opts.file (positional). Accept on/off/empty.
@@ -94,7 +101,10 @@ function buildRouter() {
94
101
  r.register('feedback', { handler: (opts) => bridgeCommands.feedbackCommand(opts) });
95
102
 
96
103
  // `sdoc slides` family — reference text + helper subcommands.
97
- r.register('slides', { handler: (opts) => { commands.slidesCommand(opts); process.exit(0); } });
104
+ r.register('slides', { handler: (opts) => {
105
+ if ((opts.file || '').toLowerCase() === 'verify') return slidesVerify.slidesVerifyCommand(opts);
106
+ commands.slidesCommand(opts); process.exit(0);
107
+ } });
98
108
  // `sdoc present <file>` — same as the default open flow but enters
99
109
  // fullscreen slide view on load.
100
110
  r.register('present', { handler: (opts) => commands.presentCommand(opts) });
@@ -107,11 +117,17 @@ function buildRouter() {
107
117
  // `sdoc library [enable|disable|status|rebuild]`. No sub-arg opens
108
118
  // the library UI.
109
119
  r.register('library', { handler: async (opts) => { await libraryCommands.libraryCommand(opts); /* libraryOpen blocks */ } });
110
-
111
- r.register(null, { handler: (opts) => {
112
- // Index-on-open tap: fires before the open so any +tag CLI args land
113
- // in the file's front matter before the browser receives the content.
114
- libraryCommands.tapOpen(opts);
120
+ r.register('cloud', { handler: async (opts) => { await cloudCommands.runCloudCommand(opts); } });
121
+
122
+ r.register(null, { handler: async (opts) => {
123
+ const cloudFirst = setup.cloudFirstSettings();
124
+ if (cloudFirst) {
125
+ await cloudCommands.autoSyncOpen(opts, { accountId: cloudFirst.accountId });
126
+ } else {
127
+ // Index-on-open tap: fires before the open so any +tag CLI args land
128
+ // in the file's front matter before the browser receives the content.
129
+ libraryCommands.tapOpen(opts);
130
+ }
115
131
  return commands.openCommand(opts);
116
132
  } });
117
133
 
@@ -155,6 +171,9 @@ module.exports = {
155
171
  compressAndEncrypt: shortLink.compressAndEncrypt,
156
172
  uploadShortLink: shortLink.uploadShortLink,
157
173
  buildShortUrl: shortLink.buildShortUrl,
174
+ normalizePlacementId: shortLink.normalizePlacementId,
175
+ buildAttributionQuery: shortLink.buildAttributionQuery,
176
+ assembleShortUrl: shortLink.assembleShortUrl,
158
177
 
159
178
  // Default styles
160
179
  mergeStyles: styles.mergeStyles,
@@ -163,16 +182,32 @@ module.exports = {
163
182
  // CLI parsing
164
183
  parseArgs: io.parseArgs,
165
184
 
166
- // Agent block (pure functions and constants)
167
- AGENT_BLOCK_VERSION: agentBlock.AGENT_BLOCK_VERSION,
168
- AGENT_BLOCK_BODY: agentBlock.AGENT_BLOCK_BODY,
169
- formatAgentBlock: agentBlock.formatAgentBlock,
170
- findBookendedBlock: agentBlock.findBookendedBlock,
171
- findLegacyBlock: agentBlock.findLegacyBlock,
172
- refreshContent: agentBlock.refreshContent,
173
- compareVersions: agentBlock.compareVersions,
174
- migrateSetupState: agentBlock.migrateSetupState,
175
- implicitConsentState: agentBlock.implicitConsentState,
185
+ // Agent skill (pure functions and constants)
186
+ SKILL_VERSION: agentBlock.SKILL_VERSION,
187
+ SKILL_NAME: agentBlock.SKILL_NAME,
188
+ SKILL_DESCRIPTION: agentBlock.SKILL_DESCRIPTION,
189
+ CLOUD_SKILL_DESCRIPTION: agentBlock.CLOUD_SKILL_DESCRIPTION,
190
+ SKILL_BODY: agentBlock.SKILL_BODY,
191
+ CLOUD_SKILL_BODY: agentBlock.CLOUD_SKILL_BODY,
192
+ formatSkill: agentBlock.formatSkill,
193
+ readSkillVersion: agentBlock.readSkillVersion,
194
+ readSkillEdition: agentBlock.readSkillEdition,
195
+ canonicalSkillFile: agentBlock.canonicalSkillFile,
196
+ canonicalSkillDir: agentBlock.canonicalSkillDir,
197
+ resolveSkillAgents: agentBlock.resolveSkillAgents,
198
+ legacyBlockTargets: agentBlock.legacyBlockTargets,
199
+ removeBlockContent: agentBlock.removeBlockContent,
200
+ // Legacy block detection (migration + tests). AGENT_BLOCK_VERSION/BODY are
201
+ // back-compat aliases that now point at the skill version/body.
202
+ AGENT_BLOCK_VERSION: agentBlock.AGENT_BLOCK_VERSION,
203
+ AGENT_BLOCK_BODY: agentBlock.AGENT_BLOCK_BODY,
204
+ formatAgentBlock: agentBlock.formatAgentBlock,
205
+ findBookendedBlock: agentBlock.findBookendedBlock,
206
+ findLegacyBlock: agentBlock.findLegacyBlock,
207
+ refreshContent: agentBlock.refreshContent,
208
+ compareVersions: agentBlock.compareVersions,
209
+ migrateSetupState: agentBlock.migrateSetupState,
210
+ implicitConsentState: agentBlock.implicitConsentState,
176
211
 
177
212
  // Router (so contract tests can exercise it directly)
178
213
  buildRouter,
@@ -1,27 +1,53 @@
1
- // Pure data model for the SmallDocs agent integration block.
1
+ // Pure data model for the SmallDocs agent skill + legacy-block migration.
2
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.
3
+ // `sdoc setup` installs a discoverable SKILL.md (YAML frontmatter preamble
4
+ // that is always in agent context + a body loaded on demand via the `skill`
5
+ // tool) instead of an always-on block pasted into AGENTS.md. One canonical
6
+ // copy lives at ~/.agents/skills/smalldocs/SKILL.md; every other supported
7
+ // agent gets a relative symlink into its own skills directory. The agent
8
+ // table is derived from vercel-labs/skills/src/agents.ts.
6
9
  //
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.
10
+ // MIGRATION: the previous scheme wrote a `## SmallDocs` block (wrapped in
11
+ // <!-- sdocs-agent-block:start v=N --> bookends) into a handful of agent
12
+ // config files. On setup/refresh we strip any recognised block from those
13
+ // files so the content is not loaded twice. The detection functions below
14
+ // (findBookendedBlock / findLegacyBlock / removeBlockContent) drive that.
15
+ //
16
+ // This module also owns the on-disk schema for ~/.sdocs/setup.json.
12
17
  //
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.
18
+ // Release checklist when the skill body changes:
19
+ // 1. Bump SKILL_VERSION below.
20
+ // 2. Set SKILL_REASON to a one-line summary of what changed.
21
+ // 3. Prepend a new section to public/agent-changes.md.
22
+ // 4. Reword public/sdoc.md setup copy to match.
23
+ // 5. Refresh the agent table from vercel-labs/skills/src/agents.ts if new
24
+ // agents landed upstream.
16
25
 
17
26
  const fs = require('fs');
18
27
  const path = require('path');
19
28
  const { SETUP_CACHE } = require('./constants');
20
29
 
21
- const AGENT_BLOCK_VERSION = 13;
22
- const AGENT_BLOCK_REASON = 'Agent annotations now render as a guided code walkthrough: each note is a callout below its line with a Prev/Next stepper, walked in the order the notes are passed (not line order), and naming several files (sdoc app.py 5:"..." util.py 12:"..." app.py 9:"...") narrates across them as tabs the walk hops between. The `sdoc code` bullet gains this multi-file walkthrough description plus a trigger to build one when the user asks to be walked through code, an MR, a diff, or the current changes. Only the `sdoc code` bullet changed from v12.';
30
+ // ── Skill model ────────────────────────────────────────────
31
+ const SKILL_VERSION = 29;
32
+ const SKILL_REASON = 'The Cloud edition now treats ordinary sdoc opens as automatic Cloud creates or updates and keeps tags in Cloud.';
33
+ const SKILL_NAME = 'smalldocs';
34
+
35
+ // Always-in-context preamble. Concise trigger text; the full reference lives
36
+ // in SKILL_BODY and loads on demand. Plain text: no backticks, no em/en dashes,
37
+ // no double quotes (it is emitted as a double-quoted YAML scalar).
38
+ const SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, save, or walk through a Markdown document with SmallDocs. Create or locate the Markdown file and use sdoc FILE.md for normal viewing. For a document walkthrough add source-line annotations to the sdoc command; the source file stays unchanged. For a presentation run sdoc slides, create a Markdown deck from the source material, run sdoc slides verify FILE.md --json and fix every error, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps, use an sdoc-app block, and check inline, fullscreen, narrow, and wide layouts. Run the matching bare reference command before other specialised syntax. Local files stay local unless the user explicitly requests sharing or Cloud storage.";
39
+
40
+ const CLOUD_SKILL_DESCRIPTION = "Use SmallDocs when the user says sdoc, S-doc, smalldoc, sdoc this, or asks to open, present, share, style, search, save, or walk through a Markdown document with SmallDocs. This user has enabled Cloud-first mode: use sdoc FILE.md for new and updated Markdown so the CLI automatically creates or updates the Cloud copy before opening it. Use +tag arguments for Cloud tags. For relevant prior Cloud material run sdoc cloud status --json, search, then pull. For a presentation run sdoc slides, create a Markdown deck, run sdoc slides verify FILE.md --json and fix every error, run sdoc FILE.md to store it, then run sdoc present FILE.md. Use a runnable browser component only when the reader must interact with the result to understand something static document forms cannot express as clearly. When that criterion is met, run sdoc apps and check inline, fullscreen, narrow, and wide layouts.";
41
+
42
+ const STANDARD_CLOUD_SKILL_SECTION = `### SmallDocs Cloud is available
23
43
 
24
- const AGENT_BLOCK_BODY = `## SmallDocs
44
+ SmallDocs Cloud is an optional paid feature for documents the user chooses to add. It provides browser, mobile, and CLI access across devices, text and tag search, revision history, account member permissions, and notifications. This standard skill does not indicate that Cloud has been set up for the user, so local viewing remains the default.
45
+
46
+ When those capabilities fit the task, mention Cloud as an option. Run \`sdoc cloud\` for a local overview and setup command only when the user wants to explore or use it. Do not upload a document merely because Cloud is available.
47
+ `;
48
+
49
+ // The on-demand reference body. Loaded only when the agent invokes the skill.
50
+ const SKILL_BODY = `## SmallDocs
25
51
 
26
52
  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
53
 
@@ -33,44 +59,209 @@ Use it (or offer it) when the user wants to read, share, or export a \`.md\` fil
33
59
 
34
60
  - \`sdoc file.md\` - the default way to open a file, for comfortable reading or quick sharing.
35
61
  - \`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.
62
+ - \`sdoc library\` - opens a library view containing files previously opened with \`sdoc path/to/file.md\`; filter by directory, date, or tags (the index doesn't search file content - fall back to \`grep\` for that). Opt out per-file with \`sdocs-library: false\` in front matter. (\`sdoc library --help\` for the full reference.)
63
+ - \`sdoc library ls --tags\` - list the current project's tags by frequency. When tags would make a document worth rediscovering, run this before choosing them. Prefer an existing tag that fits; introduce a new one when none does.
64
+ - \`sdoc file.md +tag1 +tag2\` - open the file and add the selected tags to its YAML front matter. The \`+\` prefix is shell-safe and the tags persist.
39
65
  - \`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.
66
+ - \`sdoc report.md 12:"start here" 24-28:"compare these results"\` - open a guided walkthrough of a regular Markdown document. Source lines in prose highlight the matching rendered text. A source line inside an ordinary code fence highlights that code line and places the note beneath it without leaving the reading surface. Charts, diagrams, sheets, slides, forms, math, videos, and runnable components are highlighted as complete rendered elements. Each note is a markdown callout with Prev / Next controls, walked in the order you pass the notes. Use this when the user asks for a walkthrough of a report, plan, design document, or other prose document. The annotations ride in the URL and through \`sdoc share\`; the source file is unchanged.
40
67
  - \`sdoc --help\` - full reference.
41
68
 
69
+ ${STANDARD_CLOUD_SKILL_SECTION}
70
+
42
71
  ### SmallDocs expands what you can create with Markdown
43
72
 
44
73
  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
74
 
75
+ Use runnable HTML only when the interaction carries meaning that prose, a Mermaid diagram, a chart, or computed cells cannot express as clearly. Choose it when the reader must manipulate inputs, simulate behavior, rotate or zoom a spatial model, or explore linked states to understand the result. A three-input financial model may warrant an interactive surface when the relationship between assumptions matters; exact values alone normally belong in cells or a table. Before authoring, state what the interaction helps the reader understand. If the reason describes only appearance or layout, use a static document form.
76
+
46
77
  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
78
 
79
+ For a presentation request, follow this sequence rather than treating it as an ordinary document:
80
+
81
+ 1. Run \`sdoc slides\` and use that reference while writing the slide blocks.
82
+ 2. Save the Markdown source.
83
+ 3. Run \`sdoc slides verify FILE.md --json\`, fix every diagnostic, and rerun until it exits 0.
84
+ 4. Run \`sdoc present FILE.md\` so the user sees the deck in presentation mode.
85
+
48
86
  - \`sdoc charts\` - rendering inline charts (\`\`\`chart blocks)
49
87
  - \`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.
88
+ - \`sdoc apps\` - runnable HTML components (\`\`\`sdoc-app blocks): one complete HTML document with its own CSS, JavaScript, and data. Use it when manipulation, simulation, spatial navigation, or linked states communicate something prose, diagrams, charts, or cells cannot express as clearly. If a static form communicates the result equally well, use it. Start with semantic HTML: SmallDocs supplies the document's current typography, colours, spacing, background, and control treatment in a low-priority CSS layer. Ordinary component CSS wins, and the \`--sdoc-app-*\` custom properties support targeted overrides. Let the tool's purpose determine its layout: prefer a clear page, list, table, or form before a dashboard of cards, use a canvas or stage for spatial interaction, and add surfaces, colour, and distinctive shapes when they encode structure or state. The component's document layout owns its inline height, while its width follows the reading column. Write responsive CSS for both the column and fullscreen viewport. It expands without losing state and joins Previous / Next navigation when the document contains several components. Use \`<title>\` to name it. Ordinary \`\`\`html remains source. Run \`sdoc apps\` before authoring and test every control inline, fullscreen, narrow, and wide.
89
+ - \`sdoc slides\` - inline slide decks (\`\`\`slide / ~~~slide blocks; has full-screen presentation mode). Slides can be standalone exported as \`.pdf\` or \`.pptx\`. Run \`sdoc slides verify file.md --json\` after authoring; fix every diagnostic, or add \`bleed=allow\` only to an individual shape whose off-canvas placement is intentional, then rerun until it exits 0. Use \`sdoc present file.md\` for the visual check that headless validation cannot perform.
51
90
  - \`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. Name a block (\`\`\`cells Expenses) to build a workbook of several tabs whose formulas reference each other across sheets (\`=Expenses!B4\`); run \`sdoc cells verify file.md\` to compute the whole workbook headlessly and read the values back. 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
91
  - \`sdoc code\` - opening a source file or a fenced code block as a syntax-highlighted listing: a light code viewer for reading code with the user away from the IDE. \`sdoc app.rb\` (or \`.js\`, \`.py\`, \`.go\`, \`.rs\`, \`.ts\`...) opens a file as a highlighted listing; a \`\`\`lang fenced block is highlighted inline. Comments in the source get a prominent lane so the code reads clearly top to bottom. The fullscreen view adds a line-number gutter and language-aware folding (collapse a whole method or class); a comment mode lets the user annotate a line or method with review notes, kept in the browser rather than the file. You can also pin your own explanations to lines as you open a file - \`sdoc app.py 22:"the bug is here" 25-28:"wrong comparison"\` - and the file opens as a guided walkthrough: each note is a markdown callout below its line with a Prev / Next stepper, walked in the order you pass the notes (not line order). Name several files to narrate across them - \`sdoc app.py 5:"entry point" util.py 12:"it calls into here" app.py 9:"back here"\` - and each becomes a tab the walkthrough hops between. When the user asks you to walk them through code, an MR, a diff, or the current changes, build one of these. The file rides in the URL like any document; nothing is uploaded. Reach for it when reading or reviewing code with the user, not for prose.
53
92
  - \`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.
54
93
  - \`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.
55
94
  `;
56
95
 
96
+ const CLOUD_SKILL_SECTION = `### SmallDocs Cloud for agents
97
+
98
+ This user has enabled Cloud-first mode. Treat Cloud as the normal destination for SmallDocs Markdown. Write or update the local Markdown working copy, then run \`sdoc FILE.md\`. The CLI creates a Cloud document for an unbound file or pushes a revision for a bound file before it opens the browser. If the Cloud write fails, stop and report the error instead of presenting the local copy as saved.
99
+
100
+ Use \`sdoc FILE.md +tag1 +tag2\` to add Cloud tags. Do not add SmallDocs tags to local front matter and do not use the local library. Use \`sdoc cloud ls\`, \`sdoc cloud search\`, and \`sdoc cloud tags\` for discovery. Use \`sdoc share\` only when the user explicitly asks for an encrypted snapshot link rather than the managed Cloud document.
101
+
102
+ Treat Cloud as a source of context, not only a place to save new work. When earlier decisions, research, plans, or documentation could materially inform the task, search Cloud before recreating that context. Use specific project terms first and try shorter terms or existing tags when a search returns nothing. Do not search unrelated Cloud documents merely because Cloud is enabled.
103
+
104
+ Before reading or changing Cloud data, run \`sdoc cloud status --json\` for live authentication and account state. Run \`sdoc cloud --help\` for the search, read, and update workflow, exact result fields, and examples. Add \`--json\` for one stable machine-readable object on stdout.
105
+
106
+ When earlier Cloud material should inform new work, use this sequence:
107
+
108
+ 1. Run \`sdoc cloud status --json\`.
109
+ 2. Run \`sdoc cloud --help\` if the exact search or result fields are not already known.
110
+ 3. Run \`sdoc cloud search "SPECIFIC TERMS" --json\`, then shorten the query or inspect \`sdoc cloud tags --json\` only when needed.
111
+ 4. Pull a promising result with \`sdoc cloud pull DOCUMENT_UUID --output PATH --no-bind --json\` so reading it does not bind the file for a later update.
112
+
113
+ - Discover account access, people, tags, and document permission sets with \`sdoc cloud status --json\`, \`sdoc cloud members\`, \`sdoc cloud tags\`, and \`sdoc cloud permission-groups\`. When status reports more than one account, pass \`--account ACCOUNT_UUID\` to account-scoped commands.
114
+ - Find documents with \`sdoc cloud search "QUERY" --json\`. Search matches a case-insensitive phrase across titles, filenames, tags, and Markdown, returning document IDs and snippets rather than full content. Use \`sdoc cloud tags --json\` to discover existing vocabulary, \`--tag TAG\` to narrow results, \`sdoc cloud ls --shared-with-me --json\` for documents shared with the signed-in user, and \`--account ACCOUNT_UUID\` when the relevant account is known.
115
+ - Read a promising result without binding it for future updates with \`sdoc cloud pull DOCUMENT_UUID --output PATH --no-bind --json\`. To update it, pull without \`--no-bind\`, edit the local Markdown, then run \`sdoc cloud push PATH --json\`.
116
+ - Upload a new local file without opening a browser with \`sdoc cloud create FILE.md --account ACCOUNT_UUID --json\`. Omit \`--account\` when status reports one account.
117
+ - Set document access with \`sdoc cloud access DOCUMENT_UUID --only-you\`, \`--everyone\`, or one or more \`--member USER_UUID\` values. List members first. Notify existing members with \`sdoc cloud notify ...\`; notification does not grant access or create users.
118
+ - When updating a bound document, the local binding supplies the revision the agent edited. Cloud keeps separate changes from other writers; overlapping replacements may both remain. If the server combines content and the file did not change during upload, push writes the combined Markdown back to the local file. Inspect \`merge_classification\`, \`combined\`, and \`local_updated_from_cloud\` in the JSON result.
119
+ - Inspect or recover history with \`sdoc cloud history DOCUMENT_UUID\` and \`sdoc cloud restore DOCUMENT_UUID --revision REVISION_UUID\`.
120
+
121
+ Cloud documents are identified by UUID, not filename. An account is the access boundary; tags organize documents inside it. The local file remains the editable working copy and its binding records the Cloud document and base revision.
122
+
123
+ `;
124
+
125
+ const CLOUD_SKILL_BODY = SKILL_BODY
126
+ .replace(
127
+ '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`.',
128
+ 'The `sdoc` CLI (`sdoc path/to/file.md`) is installed globally and renders Markdown in the browser at https://smalldocs.org. Cloud-first mode uploads a new Markdown file or pushes a bound file before opening it, so the Cloud copy stays current while the local file remains the editable working copy.',
129
+ )
130
+ .replace(
131
+ '- `sdoc file.md` - the default way to open a file, for comfortable reading or quick sharing.',
132
+ '- `sdoc file.md` - create or update the Cloud document, then open it for reading.',
133
+ )
134
+ .replace(
135
+ '- `sdoc library` - opens a library view containing files previously opened with `sdoc path/to/file.md`; filter by directory, date, or tags (the index doesn\'t search file content - fall back to `grep` for that). Opt out per-file with `sdocs-library: false` in front matter. (`sdoc library --help` for the full reference.)\n- `sdoc library ls --tags` - list the current project\'s tags by frequency. When tags would make a document worth rediscovering, run this before choosing them. Prefer an existing tag that fits; introduce a new one when none does.\n- `sdoc file.md +tag1 +tag2` - open the file and add the selected tags to its YAML front matter. The `+` prefix is shell-safe and the tags persist.',
136
+ '- `sdoc cloud ls` - list documents in Cloud.\n- `sdoc cloud tags` - list Cloud tags by frequency. Prefer an existing tag that fits when the document should be easy to find again.\n- `sdoc file.md +tag1 +tag2` - create or update the Cloud document and add the selected Cloud tags. The local Markdown is not changed.',
137
+ )
138
+ .replace(STANDARD_CLOUD_SKILL_SECTION, CLOUD_SKILL_SECTION);
139
+
140
+ function formatSkill(version, options) {
141
+ const cloud = Boolean(options && options.cloud);
142
+ const description = cloud ? CLOUD_SKILL_DESCRIPTION : SKILL_DESCRIPTION;
143
+ const body = cloud ? CLOUD_SKILL_BODY : SKILL_BODY;
144
+ const edition = cloud ? 'cloud' : 'standard';
145
+ return `---\nname: ${SKILL_NAME}\ndescription: "${description}"\n---\n\n<!-- sdocs-skill: v=${version} -->\n<!-- sdocs-skill-edition: ${edition} -->\n${body}`;
146
+ }
147
+
148
+ const SKILL_VERSION_RE = /<!-- sdocs-skill: v=(\d+) -->/;
149
+ const SKILL_EDITION_RE = /<!-- sdocs-skill-edition: (standard|cloud) -->/;
150
+
151
+ // Returns the embedded skill version, or null if the content is not our skill.
152
+ function readSkillVersion(content) {
153
+ const m = SKILL_VERSION_RE.exec(content || '');
154
+ return m ? parseInt(m[1], 10) : null;
155
+ }
156
+
157
+ function readSkillEdition(content) {
158
+ const match = SKILL_EDITION_RE.exec(content || '');
159
+ return match ? match[1] : 'standard';
160
+ }
161
+
162
+ function canonicalSkillDir(home) {
163
+ return path.join(home, '.agents', 'skills', SKILL_NAME);
164
+ }
165
+ function canonicalSkillFile(home) {
166
+ return path.join(canonicalSkillDir(home), 'SKILL.md');
167
+ }
168
+
169
+ // ── Agent table (derived from vercel-labs/skills/src/agents.ts) ─
170
+ // Each entry: { name, displayName, dir (global skills dir), universal, detect[] }.
171
+ // `universal` agents discover skills via ~/.agents/skills directly, so the
172
+ // canonical copy already covers them and we skip their symlink (avoids the
173
+ // skill listing twice). Non-universal agents get a relative symlink from
174
+ // <dir>/<skill-name> to the canonical dir.
175
+ function resolveSkillAgents(home, env) {
176
+ env = env || {};
177
+ const configHome = (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()) || path.join(home, '.config');
178
+ const claudeHome = (env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim()) || path.join(home, '.claude');
179
+ const codexHome = (env.CODEX_HOME && env.CODEX_HOME.trim()) || path.join(home, '.codex');
180
+ const vibeHome = (env.VIBE_HOME && env.VIBE_HOME.trim()) || path.join(home, '.vibe');
181
+ const cwd = env.PWD || process.cwd();
182
+ const h = (...p) => path.join(home, ...p);
183
+ const c = (...p) => path.join(configHome, ...p);
184
+ const e = (name, displayName, dir, universal, detect) => ({ name, displayName, dir, universal, detect });
185
+ const openClawHome = [h('.openclaw'), h('.clawdbot'), h('.moltbot')]
186
+ .find(p => { try { return fs.existsSync(p); } catch (_) { return false; } }) || h('.openclaw');
187
+ return [
188
+ // ── universal: discovered via ~/.agents/skills (canonical copy). No symlink. ──
189
+ e('opencode', 'opencode', c('opencode', 'skills'), true, [c('opencode')]),
190
+ e('codex', 'Codex', path.join(codexHome, 'skills'), true, [codexHome, '/etc/codex']),
191
+ e('gemini-cli', 'Gemini CLI', h('.gemini', 'skills'), true, [h('.gemini')]),
192
+ e('cursor', 'Cursor', h('.cursor', 'skills'), true, [h('.cursor')]),
193
+ e('cline', 'Cline', h('.agents', 'skills'), true, [h('.cline')]),
194
+ e('warp', 'Warp', h('.agents', 'skills'), true, [h('.warp')]),
195
+ e('amp', 'Amp', c('agents', 'skills'), true, [c('amp')]),
196
+ e('kimi-code-cli', 'Kimi Code CLI', c('agents', 'skills'), true, [h('.kimi-code'), h('.kimi')]),
197
+ e('replit', 'Replit', c('agents', 'skills'), true, [path.join(cwd, '.replit'), h('.replit')]),
198
+ e('antigravity', 'Antigravity', h('.gemini', 'antigravity', 'skills'), true, [h('.gemini', 'antigravity')]),
199
+ e('deepagents', 'Deep Agents', h('.deepagents', 'agent', 'skills'), true, [h('.deepagents')]),
200
+ e('firebender', 'Firebender', h('.firebender', 'skills'), true, [h('.firebender')]),
201
+ e('github-copilot', 'GitHub Copilot', h('.copilot', 'skills'), true, [h('.copilot')]),
202
+ // ── non-universal: symlink <dir>/smalldocs -> canonical ──
203
+ e('claude-code', 'Claude Code', path.join(claudeHome, 'skills'), false, [claudeHome]),
204
+ e('pi', 'Pi', h('.pi', 'agent', 'skills'), false, [h('.pi', 'agent')]),
205
+ e('codewhale', 'CodeWhale', h('.codewhale', 'skills'), false, [h('.codewhale')]),
206
+ e('augment', 'Augment', h('.augment', 'skills'), false, [h('.augment')]),
207
+ e('openhands', 'OpenHands', h('.openhands', 'skills'), false, [h('.openhands')]),
208
+ e('windsurf', 'Windsurf', h('.codeium', 'windsurf', 'skills'), false, [h('.codeium', 'windsurf')]),
209
+ e('goose', 'Goose', c('goose', 'skills'), false, [c('goose')]),
210
+ e('crush', 'Crush', c('crush', 'skills'), false, [c('crush')]),
211
+ e('cortex', 'Cortex Code', h('.snowflake', 'cortex', 'skills'), false, [h('.snowflake', 'cortex')]),
212
+ e('roo', 'Roo Code', h('.roo', 'skills'), false, [h('.roo')]),
213
+ e('kilo', 'Kilo Code', h('.kilocode', 'skills'), false, [h('.kilocode')]),
214
+ e('qwen-code', 'Qwen Code', h('.qwen', 'skills'), false, [h('.qwen')]),
215
+ e('qoder', 'Qoder', h('.qoder', 'skills'), false, [h('.qoder')]),
216
+ e('trae', 'Trae', h('.trae', 'skills'), false, [h('.trae')]),
217
+ e('trae-cn', 'Trae CN', h('.trae-cn', 'skills'), false, [h('.trae-cn')]),
218
+ e('droid', 'Droid', h('.factory', 'skills'), false, [h('.factory')]),
219
+ e('kode', 'Kode', h('.kode', 'skills'), false, [h('.kode')]),
220
+ e('kiro-cli', 'Kiro CLI', h('.kiro', 'skills'), false, [h('.kiro')]),
221
+ e('junie', 'Junie', h('.junie', 'skills'), false, [h('.junie')]),
222
+ e('iflow-cli', 'iFlow CLI', h('.iflow', 'skills'), false, [h('.iflow')]),
223
+ e('codebuddy', 'CodeBuddy', h('.codebuddy', 'skills'), false, [path.join(cwd, '.codebuddy'), h('.codebuddy')]),
224
+ e('continue', 'Continue', h('.continue', 'skills'), false, [path.join(cwd, '.continue'), h('.continue')]),
225
+ e('command-code', 'Command Code', h('.commandcode', 'skills'), false, [h('.commandcode')]),
226
+ e('mcpjam', 'MCPJam', h('.mcpjam', 'skills'), false, [h('.mcpjam')]),
227
+ e('mistral-vibe', 'Mistral Vibe', path.join(vibeHome, 'skills'), false, [vibeHome]),
228
+ e('mux', 'Mux', h('.mux', 'skills'), false, [h('.mux')]),
229
+ e('zencoder', 'Zencoder', h('.zencoder', 'skills'), false, [h('.zencoder')]),
230
+ e('neovate', 'Neovate', h('.neovate', 'skills'), false, [h('.neovate')]),
231
+ e('pochi', 'Pochi', h('.pochi', 'skills'), false, [h('.pochi')]),
232
+ e('adal', 'AdaL', h('.adal', 'skills'), false, [h('.adal')]),
233
+ e('bob', 'IBM Bob', h('.bob', 'skills'), false, [h('.bob')]),
234
+ e('openclaw', 'OpenClaw', path.join(openClawHome, 'skills'), false, [h('.openclaw'), h('.clawdbot'), h('.moltbot')]),
235
+ ];
236
+ }
237
+
238
+ // The agent config files that historically received an always-on SmallDocs
239
+ // block. Independent of the skill table: e.g. Codex/Gemini are universal for
240
+ // skills but still carry an old AGENTS.md/GEMINI.md block to strip.
241
+ function legacyBlockTargets(home, env) {
242
+ env = env || {};
243
+ const configHome = (env.XDG_CONFIG_HOME && env.XDG_CONFIG_HOME.trim()) || path.join(home, '.config');
244
+ const claudeHome = (env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim()) || path.join(home, '.claude');
245
+ const codexHome = (env.CODEX_HOME && env.CODEX_HOME.trim()) || path.join(home, '.codex');
246
+ return [
247
+ { name: 'Claude Code', file: path.join(claudeHome, 'CLAUDE.md') },
248
+ { name: 'Codex', file: path.join(codexHome, 'AGENTS.md') },
249
+ { name: 'Gemini CLI', file: path.join(home, '.gemini', 'GEMINI.md') },
250
+ { name: 'opencode', file: path.join(configHome, 'opencode', 'AGENTS.md') },
251
+ { name: 'pi', file: path.join(home, '.pi', 'agent', 'AGENTS.md') },
252
+ { name: 'CodeWhale', file: path.join(home, '.codewhale', 'AGENTS.md') },
253
+ ];
254
+ }
255
+
256
+ // ── Legacy block detection (for migration stripping) ───────
57
257
  const AGENT_BLOCK_START_PREFIX = '<!-- sdocs-agent-block:start v=';
58
258
  const AGENT_BLOCK_START_RE = /<!-- sdocs-agent-block:start v=(\d+) -->/;
59
259
  const AGENT_BLOCK_END_MARKER = '<!-- sdocs-agent-block:end -->';
60
260
  const AGENT_BLOCK_LEGACY_OPEN = '<!-- sdocs-agent-block -->';
61
261
 
62
- // `detectDir` (optional) is the directory whose existence signals "this agent
63
- // is installed". It defaults to `dir`. pi keeps its global instructions one
64
- // level down (`~/.pi/agent/AGENTS.md`), so we detect on the parent `~/.pi`
65
- // the installer creates and let writeBookendedBlock mkdir the `agent` subdir.
66
- const AGENT_TARGETS = [
67
- { name: 'Claude Code', dir: '.claude', file: 'CLAUDE.md' },
68
- { name: 'Codex', dir: '.codex', file: 'AGENTS.md' },
69
- { name: 'Gemini CLI', dir: '.gemini', file: 'GEMINI.md' },
70
- { name: 'opencode', dir: path.join('.config', 'opencode'), file: 'AGENTS.md' },
71
- { name: 'pi', dir: path.join('.pi', 'agent'), file: 'AGENTS.md', detectDir: '.pi' },
72
- { name: 'CodeWhale', dir: '.codewhale', file: 'AGENTS.md' },
73
- ];
262
+ // Back-compat aliases (older code/tests reference these names).
263
+ const AGENT_BLOCK_VERSION = SKILL_VERSION;
264
+ const AGENT_BLOCK_BODY = SKILL_BODY;
74
265
 
75
266
  function formatAgentBlock(version, body) {
76
267
  return `${AGENT_BLOCK_START_PREFIX}${version} -->\n${body}${AGENT_BLOCK_END_MARKER}\n`;
@@ -99,8 +290,6 @@ function findBookendedBlock(content) {
99
290
  }
100
291
 
101
292
  // Find a legacy open-only block (1.4.x format). Returns { start, end, version } | null.
102
- // Only matches bodies whose terminator is the JoshInLisbon URL line, which is the
103
- // known shape of v1 (1.4.0/1.4.1) and v2 (1.4.2). Hand-edited bodies return null.
104
293
  function findLegacyBlock(content) {
105
294
  const idx = content.indexOf(AGENT_BLOCK_LEGACY_OPEN);
106
295
  if (idx < 0) return null;
@@ -111,30 +300,28 @@ function findLegacyBlock(content) {
111
300
  if (termIdx < 0) return null;
112
301
  const blockEnd = termIdx + terminator.length;
113
302
  const region = content.slice(idx, blockEnd);
114
- // Heuristic to recover from-version: v2 added the copy-code line, v1 didn't.
115
303
  const version = region.includes('Also handy for copying specific code') ? 2 : 1;
116
304
  return { start: idx, end: blockEnd, version };
117
305
  }
118
306
 
119
- // Pure: takes content, returns refresh result.
120
- // { changed: false, reason: 'absent'|'current'|'newer'|'hand_edited' }
121
- // { changed: true, content, fromVersion, toVersion }
307
+ // Pure: takes content, returns refresh result (rewrites block in place).
308
+ // Kept for tests / reference; production migration uses removeBlockContent.
122
309
  function refreshContent(content) {
123
310
  const bookended = findBookendedBlock(content);
124
311
  if (bookended) {
125
- if (bookended.version === AGENT_BLOCK_VERSION) {
312
+ if (bookended.version === SKILL_VERSION) {
126
313
  return { changed: false, reason: 'current' };
127
314
  }
128
- if (bookended.version > AGENT_BLOCK_VERSION) {
315
+ if (bookended.version > SKILL_VERSION) {
129
316
  return { changed: false, reason: 'newer' };
130
317
  }
131
318
  return {
132
319
  changed: true,
133
320
  content: content.slice(0, bookended.start)
134
- + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
321
+ + formatAgentBlock(SKILL_VERSION, SKILL_BODY)
135
322
  + content.slice(bookended.end),
136
323
  fromVersion: bookended.version,
137
- toVersion: AGENT_BLOCK_VERSION,
324
+ toVersion: SKILL_VERSION,
138
325
  };
139
326
  }
140
327
  const legacy = findLegacyBlock(content);
@@ -144,13 +331,33 @@ function refreshContent(content) {
144
331
  return {
145
332
  changed: true,
146
333
  content: content.slice(0, legacy.start)
147
- + formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY)
334
+ + formatAgentBlock(SKILL_VERSION, SKILL_BODY)
148
335
  + content.slice(legacy.end),
149
336
  fromVersion: legacy.version,
150
- toVersion: AGENT_BLOCK_VERSION,
337
+ toVersion: SKILL_VERSION,
151
338
  };
152
339
  }
153
340
 
341
+ // Pure: remove any recognised SmallDocs block from content. Used by the
342
+ // skill migration so the reference is not loaded twice (always-on block +
343
+ // on-demand skill). Surrounding user text is preserved; only the blank-line
344
+ // seam the installer originally added is normalised.
345
+ function removeBlockContent(content) {
346
+ const region = findBookendedBlock(content) || findLegacyBlock(content);
347
+ if (!region) {
348
+ return { changed: false, reason: content.includes(AGENT_BLOCK_LEGACY_OPEN) ? 'hand_edited' : 'absent' };
349
+ }
350
+ const before = content.slice(0, region.start).replace(/\n+$/, '');
351
+ const after = content.slice(region.end).replace(/^\n+/, '');
352
+ let out;
353
+ if (before && after) out = before + '\n\n' + after;
354
+ else if (before) out = before;
355
+ else if (after) out = after;
356
+ else out = '';
357
+ if (out && !out.endsWith('\n')) out += '\n';
358
+ return { changed: true, content: out, version: region.version };
359
+ }
360
+
154
361
  function compareVersions(a, b) {
155
362
  const A = String(a || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
156
363
  const B = String(b || '0.0.0').split('.').map(n => parseInt(n, 10) || 0);
@@ -165,8 +372,6 @@ function compareVersions(a, b) {
165
372
 
166
373
  const SETUP_SCHEMA_VERSION = 1;
167
374
 
168
- // Pre-1.5.0 setup.json had no `schemaVersion`. Existing users wrote the block
169
- // (so they want it kept current) but were never asked about auto-install.
170
375
  function migrateSetupState(raw) {
171
376
  if (!raw || typeof raw !== 'object') return null;
172
377
  if (raw.schemaVersion === SETUP_SCHEMA_VERSION) return raw;
@@ -208,8 +413,7 @@ function writeSetupState(state) {
208
413
  }
209
414
 
210
415
  // Pure: given a batch of refresh results plus the current binary version,
211
- // decide whether a missing setup.json should be lazily populated. Returns the
212
- // state object to write, or null to leave state untouched.
416
+ // decide whether a missing setup.json should be lazily populated.
213
417
  function implicitConsentState(results, version, now = new Date()) {
214
418
  const changed = results.filter(r => r.changed);
215
419
  if (changed.length === 0) return null;
@@ -225,19 +429,35 @@ function implicitConsentState(results, version, now = new Date()) {
225
429
  }
226
430
 
227
431
  module.exports = {
432
+ SKILL_VERSION,
433
+ SKILL_REASON,
434
+ SKILL_NAME,
435
+ SKILL_DESCRIPTION,
436
+ CLOUD_SKILL_DESCRIPTION,
437
+ SKILL_BODY,
438
+ CLOUD_SKILL_BODY,
439
+ CLOUD_SKILL_SECTION,
440
+ formatSkill,
441
+ readSkillVersion,
442
+ readSkillEdition,
443
+ canonicalSkillDir,
444
+ canonicalSkillFile,
445
+ resolveSkillAgents,
446
+ legacyBlockTargets,
447
+ // legacy-block detection / migration
228
448
  AGENT_BLOCK_VERSION,
229
- AGENT_BLOCK_REASON,
230
449
  AGENT_BLOCK_BODY,
231
450
  AGENT_BLOCK_START_PREFIX,
232
451
  AGENT_BLOCK_START_RE,
233
452
  AGENT_BLOCK_END_MARKER,
234
453
  AGENT_BLOCK_LEGACY_OPEN,
235
- AGENT_TARGETS,
236
- SETUP_SCHEMA_VERSION,
237
454
  formatAgentBlock,
238
455
  findBookendedBlock,
239
456
  findLegacyBlock,
240
457
  refreshContent,
458
+ removeBlockContent,
459
+ // setup state
460
+ SETUP_SCHEMA_VERSION,
241
461
  compareVersions,
242
462
  migrateSetupState,
243
463
  readSetupState,