sdocs-dev 1.15.0 → 1.18.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.
@@ -30,16 +30,8 @@ function libraryDisable() {
30
30
  function libraryStatus() {
31
31
  const s = store.loadState();
32
32
  const idx = store.loadIndex();
33
- const last = s.lastScanAt ? new Date(s.lastScanAt).toISOString() : 'never';
34
33
  console.log(`library: ${s.enabled === false ? 'disabled' : 'enabled'}`);
35
34
  console.log(`entries: ${idx.entries.length}`);
36
- console.log(`last scan: ${last}`);
37
- }
38
-
39
- function libraryRebuild() {
40
- console.log('library: rebuilding...');
41
- const result = libIndex.rebuild();
42
- console.log(`library: scanned ${result.scanned}, added ${result.added}, updated ${result.updated}`);
43
35
  }
44
36
 
45
37
  // Walk up from a directory looking for `.git/`. Falls back to the start
@@ -114,7 +106,7 @@ function libraryLs(opts) {
114
106
  const tags = libIndex.tagsUnderPrefix(scope);
115
107
  if (!tags.length) {
116
108
  console.log(`no tagged markdown files indexed under ${scope} yet`);
117
- console.log(`(tip: run \`sdoc library rebuild\` if you expected results, or open a file with \`sdoc <file> +tag\` to start tagging)`);
109
+ console.log(`(tip: open a file with \`sdoc <file> +tag\` to add and tag it)`);
118
110
  return;
119
111
  }
120
112
  console.log(`most frequent tags for tagged markdown files under ${scope} (tag - count):`);
@@ -132,7 +124,7 @@ function libraryLs(opts) {
132
124
  const entries = entriesUnderScope(scope);
133
125
  if (!entries.length) {
134
126
  console.log(`library has no markdown indexed under ${scope} yet`);
135
- console.log(`(tip: run \`sdoc library rebuild\` to scan, or open a file with \`sdoc <file>\` to index it)`);
127
+ console.log(`(tip: open a file with \`sdoc <file>\` to add it)`);
136
128
  return;
137
129
  }
138
130
 
@@ -209,8 +201,8 @@ async function libraryOpen() {
209
201
  const { agentUrl } = await libServer.createServer();
210
202
  const pageUrl = `${siteUrl}/library?agent=${encodeURIComponent(agentUrl)}`;
211
203
  console.log(`library: ${pageUrl}`);
212
- console.log(`library: ${idx.entries.length} entries indexed` + (state.enabled === false ? ' (scanning disabled)' : ''));
213
- if (!idx.entries.length) console.log('library: click "rescan" in the UI to walk your home for markdown.');
204
+ console.log(`library: ${idx.entries.length} entries indexed` + (state.enabled === false ? ' (indexing disabled)' : ''));
205
+ if (!idx.entries.length) console.log('library: open a file with `sdoc <file>` to add it.');
214
206
  ensureAutostart();
215
207
  console.log(`library: agent at ${agentUrl} (ctrl-c to stop)`);
216
208
  openBrowser(pageUrl);
@@ -263,7 +255,6 @@ async function libraryCommand(opts) {
263
255
  case 'enable': libraryEnable(); break;
264
256
  case 'disable': libraryDisable(); break;
265
257
  case 'status': libraryStatus(); break;
266
- case 'rebuild': libraryRebuild(); break;
267
258
  case 'autostart': {
268
259
  const action = (opts.extra || '').toLowerCase();
269
260
  if (action === 'enable') autostartEnable();
@@ -278,7 +269,7 @@ async function libraryCommand(opts) {
278
269
  }
279
270
  default:
280
271
  console.error(`sdoc library: unknown subcommand "${sub}"`);
281
- console.error('usage: sdoc library [ls|enable|disable|status|rebuild|autostart|help]');
272
+ console.error('usage: sdoc library [ls|enable|disable|status|autostart|help]');
282
273
  process.exit(1);
283
274
  }
284
275
  }
@@ -300,7 +291,7 @@ function tapOpen(opts) {
300
291
 
301
292
  module.exports = {
302
293
  libraryCommand,
303
- libraryEnable, libraryDisable, libraryStatus, libraryRebuild, libraryOpen,
294
+ libraryEnable, libraryDisable, libraryStatus, libraryOpen,
304
295
  libraryLs, libraryHelp,
305
296
  resolveProjectRoot, resolveLsScope, entriesUnderScope,
306
297
  tapOpen,
@@ -15,18 +15,13 @@ const paths = require('./library-paths');
15
15
  const DEFAULT_MAX_SIZE = 1 * 1024 * 1024;
16
16
 
17
17
  const DIRNAME_BLOCKLIST = new Set([
18
- 'node_modules',
19
18
  '.git',
20
19
  '.svn',
21
20
  '.hg',
22
- 'dist',
23
- 'build',
24
- 'vendor',
25
21
  '.venv',
26
22
  '.next',
27
23
  '.cache',
28
24
  '__pycache__',
29
- 'target',
30
25
  '.gradle',
31
26
  '.idea',
32
27
  '.vscode',
@@ -46,6 +41,19 @@ const DIRNAME_BLOCKLIST = new Set([
46
41
  '.password-store',
47
42
  ]);
48
43
 
44
+ // Directories skipped during scanning traversal for speed, but NOT blocked
45
+ // from path-gating when opening an explicitly indexed file.
46
+ const SCAN_SKIP_DIRNAMES = new Set([
47
+ 'assets',
48
+ 'graphify',
49
+ 'node_modules',
50
+ 'vendor',
51
+ 'target',
52
+ 'dist',
53
+ 'build',
54
+ 'venv',
55
+ ]);
56
+
49
57
  // File basenames that should never make it into the library, regardless
50
58
  // of which directory they live in or what extension they carry. Most of
51
59
  // these don't have a markdown extension and so the existing extension
@@ -129,8 +137,10 @@ function deniedByPattern(absPath) {
129
137
  }
130
138
 
131
139
  function shouldSkipDir(absDir, base, skipSet, exemptRoots) {
132
- if (base.startsWith('.') && base !== '.' && base !== '..') return true;
140
+ // Exempt .sdocs so AI coding agent markdown artifacts are indexed
141
+ if (base.startsWith('.') && base !== '.' && base !== '..' && base !== '.sdocs') return true;
133
142
  if (DIRNAME_BLOCKLIST.has(base)) return true;
143
+ if (SCAN_SKIP_DIRNAMES.has(base)) return true;
134
144
  if (skipSet.has(absDir)) return true;
135
145
  // Skip ephemeral paths during descent unless we're inside a root that
136
146
  // the caller explicitly named (in which case they want it scanned).
@@ -35,9 +35,8 @@ try {
35
35
  // 2. The deny-pattern list (SSH keys, .env, credentials.{json,...}
36
36
  // and anything under .ssh/.aws/.gnupg/...).
37
37
  // 3. Library-membership: the real path must appear in the index.
38
- // The index is what the user has explicitly opened with sdoc or
39
- // placed under a scanned root; arbitrary paths outside that set
40
- // are refused.
38
+ // The index contains files the user explicitly opened with sdoc;
39
+ // arbitrary paths outside that set are refused.
41
40
  //
42
41
  // Returns { ok: true, realPath } on pass, { ok: false, reason, status }
43
42
  // on refusal. Caller picks the HTTP status from `status`.
@@ -295,12 +294,6 @@ function createServer({ port } = {}) {
295
294
  return;
296
295
  }
297
296
 
298
- if (req.method === 'POST' && route === '/api/library/rescan') {
299
- const result = libIndex.scanAndIndex();
300
- sendJson(res, 200, result);
301
- return;
302
- }
303
-
304
297
  // Serve the current contents of a local file. The editor page
305
298
  // uses this to refresh content after the URL-hash snapshot goes
306
299
  // stale (e.g. after the user edited tags then reloaded). Gated
@@ -319,9 +312,8 @@ function createServer({ port } = {}) {
319
312
  }
320
313
 
321
314
  // Re-index a single file. Called by the editor page after a Bridge
322
- // save so the library catches up immediately (instead of waiting
323
- // for the next manual scan). Pure read-then-index; never writes
324
- // the file the path points at.
315
+ // save so the library catches up immediately. Pure read-then-index;
316
+ // never writes the file the path points at.
325
317
  if (req.method === 'POST' && route === '/api/library/reindex') {
326
318
  const body = await readBody(req);
327
319
  const filePath = body && body.path;
package/lib/setup.js CHANGED
@@ -1,24 +1,30 @@
1
1
  // `sdoc setup`, `sdoc refresh`, `sdoc auto-update`, and the implicit
2
- // post-command refresh that keeps agent files in sync as new sdoc
2
+ // post-command refresh that keeps the SmallDocs skill current as new sdoc
3
3
  // versions ship.
4
4
  //
5
- // runSetup: first-run interactive flow. Detects agent configs, writes
6
- // the block into the ones the user agrees to. Pass dryRun:true to
7
- // preview what would be written without touching any file or state.
8
- // runRefresh: unconditional refresh of every agent file that already
9
- // has a recognised block.
5
+ // runSetup: first-run interactive flow. Detects installed agents, installs
6
+ // the skill (canonical copy + symlinks), strips legacy blocks. Pass
7
+ // dryRun:true to preview without touching anything.
8
+ // runRefresh: unconditional refresh of the canonical skill + symlinks.
10
9
  // runAutoUpdateSubcommand: flips state.autoInstallUpdates.
11
10
  // maybeAutoRefresh: called after every successful command. Quiet, only
12
- // touches files whose existing block we already manage.
11
+ // rewrites the canonical skill when its version is stale.
13
12
 
14
- const os = require('os');
13
+ const os = require('os');
15
14
  const path = require('path');
15
+ const fs = require('fs');
16
16
  const readline = require('readline');
17
17
 
18
18
  const {
19
- AGENT_BLOCK_VERSION,
20
- AGENT_BLOCK_BODY,
21
- formatAgentBlock,
19
+ SKILL_VERSION,
20
+ SKILL_BODY,
21
+ SKILL_NAME,
22
+ formatSkill,
23
+ canonicalSkillFile,
24
+ canonicalSkillDir,
25
+ legacyBlockTargets,
26
+ findBookendedBlock,
27
+ findLegacyBlock,
22
28
  compareVersions,
23
29
  readSetupState,
24
30
  writeSetupState,
@@ -28,11 +34,12 @@ const {
28
34
  const { upgradeCommand } = require('./update-check');
29
35
 
30
36
  const {
31
- detectAgents,
32
- fileHasBlock,
33
- writeBookendedBlock,
34
- refreshAllAgentFiles,
35
- printRefreshSummary,
37
+ detectSkillAgents,
38
+ hasSetupEvidence,
39
+ syncAgentSkill,
40
+ syncChanged,
41
+ toImplicitResults,
42
+ printSyncSummary,
36
43
  } = require('./agent-files');
37
44
 
38
45
  const { VERSION, AGENT_CHANGES_URL } = require('./constants');
@@ -59,18 +66,56 @@ async function askAutoInstallConsent() {
59
66
  }
60
67
 
61
68
  async function askAutoRefreshConsent() {
62
- console.log('\nKeep this block updated on future sdoc upgrades?');
69
+ console.log('\nKeep this skill updated on future sdoc upgrades?');
63
70
  console.log('');
64
- console.log('When sdoc adds a feature we sometimes update this section so');
65
- console.log('your agent learns about it. Each time the block changes we');
66
- console.log(`print a notice with a link to ${AGENT_CHANGES_URL}`);
67
- console.log('showing the exact delta - the new wording, and why it changed.');
71
+ console.log('When sdoc adds a feature we sometimes update the skill so your');
72
+ console.log('agent learns about it. Each change prints a notice with a link to');
73
+ console.log(`${AGENT_CHANGES_URL} showing the exact delta - the new wording, and why.`);
68
74
  console.log('');
69
75
  console.log('Re-run `sdoc setup` any time to change this.\n');
70
76
  const a = await ask('Enable? [Y/n] ');
71
77
  return !a || a === 'y' || a === 'yes';
72
78
  }
73
79
 
80
+ // Preview what setup would do: print the skill, the symlinks it would create,
81
+ // the agents covered by the canonical copy, and any legacy blocks it would
82
+ // strip. Touches no file and writes no state.
83
+ function dryRunPreview() {
84
+ const home = os.homedir();
85
+ const env = process.env;
86
+ const skillPath = canonicalSkillFile(home);
87
+ console.log(`--- ${skillPath} ---`);
88
+ console.log(formatSkill(SKILL_VERSION));
89
+
90
+ const detected = detectSkillAgents(home, env);
91
+ const linked = detected.filter(a => !a.universal);
92
+ const universal = detected.filter(a => a.universal);
93
+
94
+ if (universal.length) {
95
+ console.log('\nCovered by the canonical copy (~/.agents/skills, no symlink needed):');
96
+ for (const a of universal) console.log(` ${a.displayName}`);
97
+ }
98
+ if (linked.length) {
99
+ console.log('\nSymlinks to create (<agent skills dir> -> canonical):');
100
+ for (const a of linked) console.log(` ${path.join(a.dir, SKILL_NAME)} -> ${canonicalSkillDir(home)}`);
101
+ }
102
+ if (detected.length === 0) {
103
+ console.log('\nNo coding-agent configs detected. The canonical skill is still written');
104
+ console.log('so any agent that discovers ~/.agents/skills picks it up.');
105
+ }
106
+
107
+ const wouldStrip = [];
108
+ for (const t of legacyBlockTargets(home, env)) {
109
+ let content;
110
+ try { content = fs.readFileSync(t.file, 'utf-8'); } catch (_) { continue; }
111
+ if (findBookendedBlock(content) || findLegacyBlock(content)) wouldStrip.push(t.file);
112
+ }
113
+ if (wouldStrip.length) {
114
+ console.log('\nLegacy SmallDocs blocks to remove:');
115
+ for (const f of wouldStrip) console.log(` ${f}`);
116
+ }
117
+ }
118
+
74
119
  async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
75
120
  if (!force) {
76
121
  if (!process.stdout.isTTY || !process.stdin.isTTY) return;
@@ -79,143 +124,67 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
79
124
  }
80
125
 
81
126
  // ── --yes (non-interactive) path ───────────────────────────
82
- // Pulled out of the detection branch so this is the SINGLE place that
83
- // handles every --yes case: fresh install, old block (upgrade), legacy
84
- // open-marker (migration), already-current (no-op), no agents at all.
85
- // Idempotent by design - an agent or user can re-paste the install prompt
86
- // any number of times and the result is a current block in every detected
87
- // config, or a clean "nothing to do".
88
127
  if (yes) {
89
- // ── --dry-run (preview only) path ─────────────────────────────────────
90
- // Prints each file path and the block that would be written, then exits
91
- // without touching any file or mutating setup state. Must return before
92
- // the write steps below.
93
- if (dryRun) {
94
- const toWrite = detectAgents().filter(t => !fileHasBlock(t.filePath));
95
- if (toWrite.length === 0) {
96
- console.log('All SDocs agent blocks already at current version. Nothing to do.');
97
- return;
98
- }
99
- for (const t of toWrite) {
100
- console.log(`--- ${t.filePath} ---`);
101
- console.log(formatAgentBlock(AGENT_BLOCK_VERSION, AGENT_BLOCK_BODY));
102
- }
103
- return;
104
- }
128
+ if (dryRun) { dryRunPreview(); return; }
105
129
 
106
- // Step 1: refresh any existing outdated / legacy blocks. This is what
107
- // closes the gap where re-running setup --yes used to silently no-op
108
- // on a stale install.
109
- const refreshResults = refreshAllAgentFiles();
110
- const refreshedFiles = refreshResults.filter(r => r.changed).map(r => r.path);
111
- if (refreshResults.some(r => r.changed)) printRefreshSummary(refreshResults);
112
-
113
- // Step 2: any agent whose config dir exists but doesn't yet have a
114
- // block gets one written. Re-detect after refresh because the refresh
115
- // step may have flipped some files from "needs block" to "has block".
116
- const stillMissing = detectAgents().filter(t => !fileHasBlock(t.filePath));
117
- const writtenTo = [];
118
- for (const t of stillMissing) {
119
- try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
120
- catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
130
+ const result = syncAgentSkill({});
131
+ const changed = syncChanged(result);
132
+ const detected = detectSkillAgents(os.homedir(), process.env);
133
+
134
+ if (changed || result.errors.length) {
135
+ printSyncSummary(result);
121
136
  }
137
+ if (result.errors.length) return;
122
138
 
123
- const affected = [...new Set([...writtenTo, ...refreshedFiles])];
124
-
125
- if (affected.length === 0) {
126
- const anyAgentDir = detectAgents().length > 0;
127
- writeSetupState({
128
- setupCompleted: new Date().toISOString(),
129
- writtenTo: [], declined: !anyAgentDir,
130
- autoRefreshAgentFiles: anyAgentDir,
131
- autoInstallUpdates: false,
132
- lastRunVersion: VERSION,
133
- });
134
- if (anyAgentDir) {
135
- console.log('All SDocs agent blocks already at current version. Nothing to do.');
139
+ if (!changed) {
140
+ if (detected.length > 0) {
141
+ console.log('SmallDocs skill already at current version. Nothing to do.');
136
142
  } else {
137
- console.log('No coding-agent configs detected. Nothing to write.');
138
- console.log('Re-run `sdoc setup` (interactive) if you want to include opencode.');
143
+ console.log('No coding-agent configs detected. The canonical skill is at');
144
+ console.log('~/.agents/skills/smalldocs/SKILL.md; any agent that discovers');
145
+ console.log('~/.agents/skills will pick it up.');
139
146
  }
140
- return;
141
147
  }
142
148
 
143
149
  writeSetupState({
144
150
  setupCompleted: new Date().toISOString(),
145
- writtenTo: affected, declined: false,
151
+ writtenTo: changed ? [canonicalSkillFile(os.homedir())] : [],
152
+ declined: false,
146
153
  autoRefreshAgentFiles: true,
147
154
  autoInstallUpdates: false,
148
155
  lastRunVersion: VERSION,
149
156
  });
150
- const n = affected.length;
151
- const verb = writtenTo.length && refreshedFiles.length
152
- ? 'Wrote/refreshed'
153
- : (writtenTo.length ? 'Wrote' : 'Refreshed');
154
- console.log(`\nDone. ${verb} SDocs block in ${n} ${n === 1 ? 'file' : 'files'}.`);
155
157
  return;
156
158
  }
157
159
 
158
- const detected = detectAgents().filter(t => !fileHasBlock(t.filePath));
159
-
160
- if (detected.length === 0) {
161
- const opencodeAlreadyDone = fileHasBlock(path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md'));
162
- if (opencodeAlreadyDone) {
163
- writeSetupState({
164
- setupCompleted: new Date().toISOString(),
165
- writtenTo: [], declined: false,
166
- autoRefreshAgentFiles: true, autoInstallUpdates: false,
167
- lastRunVersion: VERSION,
168
- });
169
- console.log('\nSDocs is already set up in all detected agent configs. Nothing to do.');
170
- return;
171
- }
172
- console.log('\n✨─────── SDocs setup ───────✨');
173
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
174
- console.log('No coding-agent configs detected.');
175
- const a = await ask('Do you use opencode? [y/N] ');
176
- const writtenTo = [];
177
- let autoRefresh = false;
178
- let autoInstall = false;
179
- if (a === 'y' || a === 'yes') {
180
- const target = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
181
- try { writeBookendedBlock(target); writtenTo.push(target); console.log(`✓ Wrote SDocs section to ${target}`); }
182
- catch (e) { console.error(`Failed to write ${target}: ${e.message}`); }
183
- autoRefresh = await askAutoRefreshConsent();
184
- autoInstall = await askAutoInstallConsent();
185
- console.log('Done. Run `sdoc setup` any time to revisit.');
186
- } else {
187
- console.log('Skipped. Run `sdoc setup` any time to revisit.');
188
- }
189
- writeSetupState({
190
- setupCompleted: new Date().toISOString(),
191
- writtenTo, declined: writtenTo.length === 0,
192
- autoRefreshAgentFiles: autoRefresh,
193
- autoInstallUpdates: autoInstall,
194
- lastRunVersion: VERSION,
195
- });
196
- return;
160
+ // ── interactive path ───────────────────────────────────────
161
+ const home = os.homedir();
162
+ const detected = detectSkillAgents(home, process.env);
163
+
164
+ console.log('\n\u2728─────── SmallDocs setup ───────\u2728');
165
+ console.log('Install the SmallDocs skill so your coding agents know `sdoc`.\n');
166
+
167
+ if (detected.length > 0) {
168
+ console.log('Detected: ' + detected.map(a => a.displayName).join(', '));
169
+ console.log('\nWill write the skill to ~/.agents/skills/smalldocs/SKILL.md.');
170
+ console.log('Agents using that universal location read it directly; other');
171
+ console.log('detected agents receive a symlink in their skills directory.');
172
+ } else {
173
+ console.log('No coding-agent configs detected. Setup still writes the canonical');
174
+ console.log('skill at ~/.agents/skills/smalldocs/SKILL.md, which any agent that');
175
+ console.log('discovers ~/.agents/skills will pick up.');
197
176
  }
198
-
199
- console.log('\n✨─────── SDocs setup ───────✨');
200
- console.log('First run only - wire SDocs into your CLI coding agents.\n');
201
- console.log('Detected: ' + detected.map(t => t.name).join(', '));
202
- console.log('\nWill append a short SDocs section to:');
203
- for (const t of detected) console.log(' ' + t.filePath);
204
- console.log('\nThese files are loaded into every conversation across all your');
205
- console.log('projects, so SDocs becomes available no matter where you\'re working.');
206
- console.log('');
207
- console.log('You can ask your agent things like:');
177
+ console.log('\nYou can ask your agent things like:');
208
178
  console.log(' "write up the plan and sdoc it to me"');
209
179
  console.log(' "explain async/await to me in a sdoc"');
210
180
  console.log(' "draft the release notes as a sdoc I can share"');
211
- console.log('');
212
- console.log('This is the best way to work with SDocs');
213
- const RULE = '═'.repeat(36);
214
- console.log(`\n═══════════ Block to add ═══════════`);
215
- console.log(AGENT_BLOCK_BODY.trim());
181
+
182
+ const RULE = '\u2550'.repeat(36);
183
+ console.log(`\n${RULE} Skill body ${RULE}`);
184
+ console.log(SKILL_BODY.trim());
216
185
  console.log(RULE);
217
186
 
218
- const a = await ask('\nAdd to all? [Y/n/skip] ');
187
+ const a = await ask('\nInstall? [Y/n/skip] ');
219
188
  const skipped = a === 'skip' || (a && a !== 'y' && a !== 'yes');
220
189
  if (skipped) {
221
190
  writeSetupState({
@@ -228,18 +197,18 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
228
197
  return;
229
198
  }
230
199
 
231
- const writtenTo = [];
232
- for (const t of detected) {
233
- try { writeBookendedBlock(t.filePath); writtenTo.push(t.filePath); console.log(`✓ ${t.name}: ${t.filePath}`); }
234
- catch (e) { console.error(`✗ ${t.name}: ${e.message}`); }
235
- }
200
+ const result = syncAgentSkill({});
201
+ const changed = syncChanged(result);
202
+ if (changed || result.errors.length) printSyncSummary(result);
203
+ if (result.errors.length) return;
236
204
 
237
- const autoRefresh = writtenTo.length > 0 ? await askAutoRefreshConsent() : false;
238
- const autoInstall = writtenTo.length > 0 ? await askAutoInstallConsent() : false;
205
+ const autoRefresh = await askAutoRefreshConsent();
206
+ const autoInstall = await askAutoInstallConsent();
239
207
 
240
208
  writeSetupState({
241
209
  setupCompleted: new Date().toISOString(),
242
- writtenTo, declined: false,
210
+ writtenTo: changed ? [canonicalSkillFile(home)] : [],
211
+ declined: false,
243
212
  autoRefreshAgentFiles: autoRefresh,
244
213
  autoInstallUpdates: autoInstall,
245
214
  lastRunVersion: VERSION,
@@ -247,25 +216,27 @@ async function runSetup({ force = false, yes = false, dryRun = false } = {}) {
247
216
  console.log('\nDone. Run `sdoc setup` any time to revisit.');
248
217
  }
249
218
 
250
- // Auto-refresh existing agent files when the binary version is newer than the
251
- // version that last ran. No prompt: the user already consented during setup.
252
- // Bails on downgrades (block version > shipped version), errors, or partial
253
- // failures (lastRunVersion only advances when every changed file succeeded).
219
+ // Auto-refresh when the binary version is newer than the version that last
220
+ // ran. No prompt: the user already consented during setup. Rewrites only the
221
+ // canonical skill (every symlink follows); re-checks symlinks and re-strips
222
+ // any block that reappeared. Bails on downgrades or errors.
254
223
  async function maybeAutoRefresh() {
255
224
  if (process.env.SDOCS_NO_REFRESH) return;
256
225
  let state = readSetupState();
257
226
 
258
- // Implicit-consent migration for users who have a recognised SDocs block in
259
- // an agent file but no `~/.sdocs/setup.json`. This is the pre-1.5.0 install
260
- // path. `refreshContent` only signals `changed` for a block whose exact
261
- // shape we wrote (legacy JoshInLisbon terminator, or our bookend markers);
262
- // anything else is left untouched, so a user who deleted the block or
263
- // hand-edited it doesn't get state silently created.
227
+ // Implicit-consent migration for users who have evidence of a prior setup
228
+ // (a skill file on disk, or a recognised always-on block in one of the
229
+ // historical config files) but no ~/.sdocs/setup.json. A brand-new user has
230
+ // no evidence and is left for the interactive first-run prompt instead, so
231
+ // nothing is auto-installed without consent.
264
232
  if (!state) {
265
- const results = refreshAllAgentFiles();
266
- const next = implicitConsentState(results, VERSION);
233
+ if (!hasSetupEvidence(os.homedir(), process.env)) return;
234
+ const result = syncAgentSkill({});
235
+ if (result.errors.length) { printSyncSummary(result); return; }
236
+ if (!syncChanged(result)) return;
237
+ const next = implicitConsentState(toImplicitResults(result), VERSION);
267
238
  if (!next) return;
268
- printRefreshSummary(results);
239
+ printSyncSummary(result);
269
240
  writeSetupState(next);
270
241
  return;
271
242
  }
@@ -273,53 +244,37 @@ async function maybeAutoRefresh() {
273
244
  if (!state.autoRefreshAgentFiles) return;
274
245
  if (compareVersions(VERSION, state.lastRunVersion) <= 0) return;
275
246
 
276
- const results = refreshAllAgentFiles();
277
- const anyChanged = results.some(r => r.changed);
278
- if (anyChanged) printRefreshSummary(results);
247
+ const result = syncAgentSkill({});
248
+ if (syncChanged(result) || result.errors.length) printSyncSummary(result);
279
249
 
280
- const anyError = results.some(r => r.error);
281
- if (!anyError) {
250
+ if (!result.errors.length) {
282
251
  writeSetupState({ ...state, lastRunVersion: VERSION });
283
252
  }
284
253
  }
285
254
 
286
- // `sdoc refresh` unconditional agent-block refresh. Useful for users whose
287
- // setup.json was never written (pre-1.5.0 installs) or has been deleted, and
288
- // for agents that want to trigger the migration explicitly without going
289
- // through the interactive setup flow.
255
+ // `sdoc refresh` - unconditional skill refresh. Useful when setup.json was
256
+ // never written or has been deleted, or to force the migration explicitly.
290
257
  async function runRefresh() {
291
258
  const existing = readSetupState();
292
- const results = refreshAllAgentFiles();
293
- const changed = results.filter(r => r.changed);
294
- const errors = results.filter(r => r.error);
295
- const current = results.filter(r => r.reason === 'current');
296
- const blocksPresent = changed.length + current.length;
297
-
298
- printRefreshSummary(results);
299
-
300
- if (changed.length === 0 && errors.length === 0) {
301
- if (blocksPresent === 0) {
302
- console.log('No SDocs blocks found in any agent file. Run `sdoc setup` to add one.');
303
- return;
304
- }
305
- console.log(`All SDocs agent blocks already at v${AGENT_BLOCK_VERSION}.`);
306
- }
259
+ const result = syncAgentSkill({});
260
+ printSyncSummary(result);
307
261
 
308
- if (errors.length > 0) return;
309
-
310
- if (blocksPresent === 0 && !existing) return;
262
+ if (!syncChanged(result) && !result.errors.length) {
263
+ console.log(`SmallDocs skill already at v${SKILL_VERSION}.`);
264
+ }
265
+ if (result.errors.length) return;
311
266
 
312
267
  writeSetupState({
313
- setupCompleted: existing?.setupCompleted || new Date().toISOString(),
314
- writtenTo: [...changed, ...current].map(r => r.path),
268
+ setupCompleted: existing && existing.setupCompleted || new Date().toISOString(),
269
+ writtenTo: [canonicalSkillFile(os.homedir())],
315
270
  declined: false,
316
271
  autoRefreshAgentFiles: existing ? existing.autoRefreshAgentFiles !== false : true,
317
- autoInstallUpdates: existing?.autoInstallUpdates ?? false,
272
+ autoInstallUpdates: existing && existing.autoInstallUpdates != null ? existing.autoInstallUpdates : false,
318
273
  lastRunVersion: VERSION,
319
274
  });
320
275
  }
321
276
 
322
- // `sdoc auto-update on|off|status` flips state.autoInstallUpdates.
277
+ // `sdoc auto-update on|off|status` - flips state.autoInstallUpdates.
323
278
  function runAutoUpdateSubcommand(arg) {
324
279
  let state = readSetupState();
325
280
  if (!state) {
@@ -328,12 +283,12 @@ function runAutoUpdateSubcommand(arg) {
328
283
  }
329
284
  if (arg === 'on') {
330
285
  writeSetupState({ ...state, autoInstallUpdates: true });
331
- console.log(' Auto-install of sdoc updates: on');
286
+ console.log('\u2713 Auto-install of sdoc updates: on');
332
287
  return;
333
288
  }
334
289
  if (arg === 'off') {
335
290
  writeSetupState({ ...state, autoInstallUpdates: false });
336
- console.log(' Auto-install of sdoc updates: off');
291
+ console.log('\u2713 Auto-install of sdoc updates: off');
337
292
  return;
338
293
  }
339
294
  console.log(`Auto-install of sdoc updates: ${state.autoInstallUpdates ? 'on' : 'off'}`);
@@ -344,6 +299,7 @@ module.exports = {
344
299
  ask,
345
300
  askAutoInstallConsent,
346
301
  askAutoRefreshConsent,
302
+ dryRunPreview,
347
303
  runSetup,
348
304
  runRefresh,
349
305
  runAutoUpdateSubcommand,