dogsbay 0.2.0-beta.6 → 0.2.0-beta.8

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.
@@ -17,7 +17,7 @@
17
17
  * See plans/dogsbay-agent-skills.md for the four-tier ownership
18
18
  * model and how this fits.
19
19
  */
20
- import { existsSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, readlinkSync } from "node:fs";
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync, statSync, readlinkSync } from "node:fs";
21
21
  import { dirname, join, relative, resolve } from "node:path";
22
22
  import { fileURLToPath } from "node:url";
23
23
  import pc from "picocolors";
@@ -25,15 +25,22 @@ const SUPPORTED_AGENTS = ["claude", "cursor"];
25
25
  const AGENT_TARGETS = {
26
26
  claude: {
27
27
  name: "claude",
28
- path: ".claude/skills/dogsbay",
29
- label: "Claude Code (.claude/skills/dogsbay/)",
28
+ skillsDir: ".claude/skills",
29
+ label: "Claude Code",
30
30
  },
31
31
  cursor: {
32
32
  name: "cursor",
33
- path: ".cursor/rules/dogsbay",
34
- label: "Cursor (.cursor/rules/dogsbay/)",
33
+ skillsDir: ".cursor/rules",
34
+ label: "Cursor",
35
35
  },
36
36
  };
37
+ /**
38
+ * Prefix every Dogsbay-shipped skill so it doesn't collide with
39
+ * the user's own skills under the same agent's discovery path.
40
+ */
41
+ const PLATFORM_PREFIX = "dogsbay-";
42
+ const SITE_PREFIX = "dogsbay-site-";
43
+ const PLUGIN_PREFIX = "dogsbay-plugin-";
37
44
  export async function agentInstall(cwd, options) {
38
45
  const projectRoot = resolve(cwd || ".");
39
46
  // Resolve the bundled platform-skills directory. We're running
@@ -99,13 +106,58 @@ The agent loader checks overrides first.
99
106
  if (!existsSync(pluginsDir)) {
100
107
  mkdirSync(pluginsDir, { recursive: true });
101
108
  }
102
- // 2. Per-agent discovery symlinks.
109
+ // 2. Per-agent discovery — symlink each skill INDIVIDUALLY at
110
+ // the top level of the agent's skills dir. Claude Code (and
111
+ // similar harnesses) discover skills as direct children of
112
+ // .claude/skills/, each containing a SKILL.md. Nested
113
+ // .claude/skills/dogsbay/platform/<skill>/ doesn't get scanned.
103
114
  for (const agent of agents) {
104
115
  const target = AGENT_TARGETS[agent];
105
- const agentLink = join(projectRoot, target.path);
106
- mkdirSync(dirname(agentLink), { recursive: true });
107
- refreshSymlink(agentLink, skillsDir);
108
- console.log(pc.green(` ✓ ${target.label} .dogsbay/skills/`));
116
+ const agentSkillsDir = join(projectRoot, target.skillsDir);
117
+ mkdirSync(agentSkillsDir, { recursive: true });
118
+ let installed = 0;
119
+ let removed = 0;
120
+ // Platform skills: dogsbay-<name>
121
+ for (const entry of readSkillDirs(join(skillsDir, "platform"))) {
122
+ const dest = join(agentSkillsDir, `${PLATFORM_PREFIX}${entry.name}`);
123
+ refreshSymlink(dest, entry.path);
124
+ installed++;
125
+ }
126
+ // Site skills: dogsbay-site-<name> (skipping overrides/ and the README)
127
+ for (const entry of readSkillDirs(join(skillsDir, "site"))) {
128
+ if (entry.name === "overrides")
129
+ continue;
130
+ const dest = join(agentSkillsDir, `${SITE_PREFIX}${entry.name}`);
131
+ refreshSymlink(dest, entry.path);
132
+ installed++;
133
+ }
134
+ // Plugin skills: dogsbay-plugin-<name>
135
+ for (const entry of readSkillDirs(join(skillsDir, "plugins"))) {
136
+ const dest = join(agentSkillsDir, `${PLUGIN_PREFIX}${entry.name}`);
137
+ refreshSymlink(dest, entry.path);
138
+ installed++;
139
+ }
140
+ // Sweep any stale dogsbay-* symlinks whose target no longer
141
+ // exists (e.g. user uninstalled a plugin, or a platform skill
142
+ // was renamed across CLI versions).
143
+ if (existsSync(agentSkillsDir)) {
144
+ for (const name of readdirSync(agentSkillsDir)) {
145
+ if (!name.startsWith("dogsbay-"))
146
+ continue;
147
+ const path = join(agentSkillsDir, name);
148
+ if (isBrokenSymlink(path)) {
149
+ try {
150
+ unlinkSync(path);
151
+ removed++;
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ }
156
+ }
157
+ console.log(pc.green(` ✓ ${target.label}: ${installed} skills under ${target.skillsDir}/`));
158
+ if (removed > 0) {
159
+ console.log(pc.gray(` (cleaned up ${removed} stale symlink${removed === 1 ? "" : "s"})`));
160
+ }
109
161
  }
110
162
  console.log("");
111
163
  console.log(pc.cyan("Next:"));
@@ -180,6 +232,34 @@ function printDetected(projectRoot) {
180
232
  console.log("");
181
233
  console.log(`Supported agents: ${SUPPORTED_AGENTS.join(", ")}`);
182
234
  }
235
+ /**
236
+ * List each subdirectory of `dir` that looks like a skill — i.e.
237
+ * contains a SKILL.md file. Returns an empty array if `dir`
238
+ * doesn't exist or has no skill subdirs.
239
+ */
240
+ function readSkillDirs(dir) {
241
+ if (!existsSync(dir))
242
+ return [];
243
+ const out = [];
244
+ for (const name of readdirSync(dir)) {
245
+ if (name.startsWith("."))
246
+ continue;
247
+ const path = join(dir, name);
248
+ let isDir = false;
249
+ try {
250
+ isDir = statSync(path).isDirectory();
251
+ }
252
+ catch {
253
+ isDir = false;
254
+ }
255
+ if (!isDir)
256
+ continue;
257
+ if (!existsSync(join(path, "SKILL.md")))
258
+ continue;
259
+ out.push({ name, path });
260
+ }
261
+ return out;
262
+ }
183
263
  /**
184
264
  * Replace any existing entry at `linkPath` with a fresh symlink
185
265
  * pointing at `target`. Idempotent: if the link already points at
@@ -205,12 +285,21 @@ function refreshSymlink(linkPath, target) {
205
285
  }
206
286
  symlinkSync(target, linkPath, "dir");
207
287
  }
288
+ /**
289
+ * True only when `p` IS a symlink AND its target doesn't exist.
290
+ * Used by the cleanup pass to remove links pointing at gone paths
291
+ * (e.g. an uninstalled plugin or a renamed platform skill across
292
+ * CLI versions).
293
+ */
208
294
  function isBrokenSymlink(p) {
209
295
  try {
210
- readlinkSync(p);
211
- return true; // it's a symlink, regardless of target validity
296
+ const lst = lstatSync(p);
297
+ if (!lst.isSymbolicLink())
298
+ return false;
212
299
  }
213
300
  catch {
214
301
  return false;
215
302
  }
303
+ // It's a symlink. existsSync follows the link; false → target gone.
304
+ return !existsSync(p);
216
305
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `dogsbay site dev` and `dogsbay site preview` — thin wrappers that
3
- * run `dogsbay site build` once, then hand control over to the Astro
4
- * CLI inside the site directory.
3
+ * run `dogsbay site build`, watch content for changes, and hand
4
+ * control over to the Astro CLI inside the site directory.
5
5
  *
6
6
  * dev → astro dev (live HMR for already-built pages)
7
7
  * preview → astro build && astro preview
@@ -11,12 +11,16 @@
11
11
  * existing script) is *not* what runs here — we shell out to `astro`
12
12
  * directly so this works whether the user uses pnpm / npm / yarn.
13
13
  *
14
- * Content file-watching is deferred. Today, editing source markdown
15
- * requires re-running `dogsbay site build` manually; Astro's dev
16
- * server reloads when the generated `.astro` files change.
14
+ * `site dev` also installs a content watcher that re-runs
15
+ * `dogsbay site build` whenever a markdown / yaml / json file under
16
+ * any configured source path changes (or `dogsbay.config.yml`
17
+ * itself). Astro's own dev server then hot-reloads the regenerated
18
+ * `.astro` pages. Without this, NEW files in `content/` weren't
19
+ * picked up by site dev — only edits to existing files surfaced
20
+ * (because Astro's watcher only sees `astro/src/`).
17
21
  */
18
22
  import { spawn } from "node:child_process";
19
- import { existsSync } from "node:fs";
23
+ import { existsSync, watch as fsWatch } from "node:fs";
20
24
  import { dirname, join, resolve } from "node:path";
21
25
  import pc from "picocolors";
22
26
  import { findConfig, loadConfig, resolveOutputDir } from "../config/index.js";
@@ -63,18 +67,26 @@ function runPackageManagerInstall(pm, cwd) {
63
67
  });
64
68
  }
65
69
  export async function siteDev(cwd, options, runner = defaultRunner) {
66
- const siteRoot = await prepareForAstro(cwd, options);
67
- const code = await runner(siteRoot, ["dev"]);
68
- process.exit(code);
70
+ const { siteRoot, outputDir } = await prepareForAstro(cwd, options);
71
+ const stopWatcher = startContentWatcher(siteRoot, outputDir, options);
72
+ try {
73
+ const code = await runner(outputDir, ["dev"]);
74
+ stopWatcher();
75
+ process.exit(code);
76
+ }
77
+ catch (err) {
78
+ stopWatcher();
79
+ throw err;
80
+ }
69
81
  }
70
82
  export async function sitePreview(cwd, options, runner = defaultRunner) {
71
- const siteRoot = await prepareForAstro(cwd, options);
83
+ const { outputDir } = await prepareForAstro(cwd, options);
72
84
  // Two-step: produce dist/ then serve it. Each spawn is independent
73
85
  // so we can still surface its exit code cleanly.
74
- const buildCode = await runner(siteRoot, ["build"]);
86
+ const buildCode = await runner(outputDir, ["build"]);
75
87
  if (buildCode !== 0)
76
88
  process.exit(buildCode);
77
- const previewCode = await runner(siteRoot, ["preview"]);
89
+ const previewCode = await runner(outputDir, ["preview"]);
78
90
  process.exit(previewCode);
79
91
  }
80
92
  async function prepareForAstro(cwd, options) {
@@ -117,7 +129,149 @@ async function prepareForAstro(cwd, options) {
117
129
  publish: options.full === true,
118
130
  });
119
131
  }
120
- return outputDir;
132
+ return { siteRoot, outputDir };
133
+ }
134
+ /**
135
+ * Watch the project's content paths + config file, and re-run
136
+ * `dogsbay site build` whenever something changes. Astro's own
137
+ * watcher then picks up the regenerated `astro/src/pages/*.astro`
138
+ * files and hot-reloads.
139
+ *
140
+ * Returns a cleanup fn that closes all the watchers.
141
+ *
142
+ * Debounces aggressively — many editors fire 3-5 fs events per save
143
+ * (write + rename + close-write etc.), and a single Vim save bursts
144
+ * across multiple files. 300ms is the sweet spot: low enough that
145
+ * the user feels the rebuild as immediate, high enough to coalesce
146
+ * a save burst into one rebuild.
147
+ */
148
+ function startContentWatcher(siteRoot, outputDir, options) {
149
+ // Re-load the config to know which paths to watch. We load it
150
+ // from disk on first event too (so config edits update the watch
151
+ // set on the fly).
152
+ let config = loadConfig(findOrFail(siteRoot, options.config));
153
+ // Track which paths we've armed a watcher on. fs.watch with
154
+ // recursive: true scoops up everything under each root.
155
+ const watchers = new Set();
156
+ const armed = new Set();
157
+ const arm = (root) => {
158
+ if (armed.has(root))
159
+ return;
160
+ if (!existsSync(root))
161
+ return;
162
+ armed.add(root);
163
+ try {
164
+ const w = fsWatch(root, { recursive: true }, (_event, filename) => {
165
+ if (!filename)
166
+ return;
167
+ const fname = filename.toString();
168
+ if (shouldIgnore(fname))
169
+ return;
170
+ scheduleBuild();
171
+ });
172
+ watchers.add(w);
173
+ }
174
+ catch (err) {
175
+ console.error(pc.yellow(` warn: could not watch ${root}: ${err.message}`));
176
+ }
177
+ };
178
+ // Always watch the config file itself (one level up — fs.watch on
179
+ // a single file works on macOS/Windows; on Linux some filesystems
180
+ // require watching the parent dir).
181
+ arm(siteRoot);
182
+ // Watch each local source path declared in the config.
183
+ for (const source of config.content?.sources ?? []) {
184
+ if (typeof source.path === "string") {
185
+ const abs = resolve(siteRoot, source.path);
186
+ arm(abs);
187
+ }
188
+ }
189
+ // Debounced rebuild loop. If a build is in flight when a new
190
+ // event arrives, mark dirty + rebuild after the current one
191
+ // finishes. Avoids overlapping builds racing on the same files.
192
+ let timer = null;
193
+ let building = false;
194
+ let dirty = false;
195
+ const scheduleBuild = () => {
196
+ dirty = true;
197
+ if (timer)
198
+ clearTimeout(timer);
199
+ timer = setTimeout(runBuild, 300);
200
+ };
201
+ const runBuild = async () => {
202
+ if (building)
203
+ return; // a build is in flight; the dirty flag handles re-run
204
+ if (!dirty)
205
+ return;
206
+ dirty = false;
207
+ building = true;
208
+ try {
209
+ console.log(pc.cyan("[dogsbay] content changed — rebuilding…"));
210
+ // Reload config to pick up dogsbay.config.yml edits.
211
+ config = loadConfig(findOrFail(siteRoot, options.config));
212
+ // Re-arm any newly-added source paths.
213
+ for (const source of config.content?.sources ?? []) {
214
+ if (typeof source.path === "string") {
215
+ arm(resolve(siteRoot, source.path));
216
+ }
217
+ }
218
+ await siteBuild(siteRoot, {
219
+ includeDrafts: true,
220
+ publish: options.full === true,
221
+ });
222
+ console.log(pc.green("[dogsbay] rebuild complete"));
223
+ }
224
+ catch (err) {
225
+ console.error(pc.red(`[dogsbay] rebuild failed: ${err.message}`));
226
+ }
227
+ finally {
228
+ building = false;
229
+ // Coalesced changes during the build → run again.
230
+ if (dirty)
231
+ setImmediate(runBuild);
232
+ }
233
+ };
234
+ // Diagnostic: show what's being watched on startup.
235
+ console.log(pc.gray(`[dogsbay] watching ${armed.size} path${armed.size === 1 ? "" : "s"} for content changes`));
236
+ return () => {
237
+ if (timer)
238
+ clearTimeout(timer);
239
+ for (const w of watchers) {
240
+ try {
241
+ w.close();
242
+ }
243
+ catch { /* ignore */ }
244
+ }
245
+ watchers.clear();
246
+ armed.clear();
247
+ };
248
+ }
249
+ /**
250
+ * Heuristic: skip events on paths the user doesn't care about.
251
+ * Filters out the output dir (would loop), VCS metadata, OS junk,
252
+ * and editor swap files.
253
+ */
254
+ function shouldIgnore(filename) {
255
+ if (filename.startsWith(".git/") || filename === ".git")
256
+ return true;
257
+ if (filename.startsWith("node_modules/"))
258
+ return true;
259
+ if (filename.startsWith("astro/"))
260
+ return true; // output dir — would loop
261
+ if (filename.startsWith("dist/"))
262
+ return true;
263
+ if (filename.startsWith(".dogsbay/"))
264
+ return true; // skill symlinks
265
+ if (filename.endsWith(".swp") || filename.endsWith(".swo"))
266
+ return true;
267
+ if (filename.endsWith("~"))
268
+ return true;
269
+ if (filename.endsWith(".DS_Store"))
270
+ return true;
271
+ return false;
272
+ }
273
+ function findOrFail(siteRoot, explicit) {
274
+ return resolveConfigPath(siteRoot, explicit);
121
275
  }
122
276
  function resolveConfigPath(startDir, explicit) {
123
277
  if (explicit) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dogsbay",
3
- "version": "0.2.0-beta.6",
3
+ "version": "0.2.0-beta.8",
4
4
  "description": "CLI for Dogsbay — scaffold, build, and serve documentation sites with markdown / MkDocs / Obsidian / OpenAPI sources",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,14 +31,14 @@
31
31
  "picocolors": "^1.1.0",
32
32
  "prompts": "^2.4.2",
33
33
  "yaml": "^2.8.3",
34
- "@dogsbay/format-astro": "0.2.0-beta.6",
35
- "@dogsbay/format-obsidian": "0.2.0-beta.6",
36
- "@dogsbay/format-mkdocs": "0.2.0-beta.6",
37
- "@dogsbay/format-mdx": "0.2.0-beta.6",
38
- "@dogsbay/format-starlight": "0.2.0-beta.6",
39
- "@dogsbay/format-dogsbay-md": "0.2.0-beta.6",
40
- "@dogsbay/format-openapi": "0.2.0-beta.6",
41
- "@dogsbay/types": "0.2.0-beta.6"
34
+ "@dogsbay/format-mkdocs": "0.2.0-beta.8",
35
+ "@dogsbay/format-astro": "0.2.0-beta.8",
36
+ "@dogsbay/format-obsidian": "0.2.0-beta.8",
37
+ "@dogsbay/format-mdx": "0.2.0-beta.8",
38
+ "@dogsbay/format-starlight": "0.2.0-beta.8",
39
+ "@dogsbay/format-dogsbay-md": "0.2.0-beta.8",
40
+ "@dogsbay/format-openapi": "0.2.0-beta.8",
41
+ "@dogsbay/types": "0.2.0-beta.8"
42
42
  },
43
43
  "devDependencies": {
44
44
  "@types/node": "^22.0.0",