dogsbay 0.2.0-beta.2 → 0.2.0-beta.20

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,305 @@
1
+ /**
2
+ * `dogsbay agent install` — wire skill discovery for an LLM agent
3
+ * (Claude Code, Cursor, Copilot, etc.).
4
+ *
5
+ * Bundled platform skills live at `<cli-install-dir>/skills/platform/*.md`.
6
+ * This command:
7
+ * 1. Resolves the bundled platform skills directory.
8
+ * 2. Symlinks them into `<project>/.dogsbay/skills/platform/`.
9
+ * 3. Creates `.dogsbay/skills/site/` (empty + README) and
10
+ * `.dogsbay/skills/plugins/` (empty placeholder).
11
+ * 4. For each requested --agent, writes the per-agent discovery
12
+ * path (`.claude/skills/dogsbay/`, `.cursor/rules/dogsbay/`).
13
+ *
14
+ * Re-running is idempotent — symlinks are recreated; existing
15
+ * site/ files are never touched.
16
+ *
17
+ * See plans/dogsbay-agent-skills.md for the four-tier ownership
18
+ * model and how this fits.
19
+ */
20
+ import { existsSync, lstatSync, mkdirSync, readdirSync, symlinkSync, unlinkSync, writeFileSync, statSync, readlinkSync } from "node:fs";
21
+ import { dirname, join, relative, resolve } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import pc from "picocolors";
24
+ const SUPPORTED_AGENTS = ["claude", "cursor"];
25
+ const AGENT_TARGETS = {
26
+ claude: {
27
+ name: "claude",
28
+ skillsDir: ".claude/skills",
29
+ label: "Claude Code",
30
+ },
31
+ cursor: {
32
+ name: "cursor",
33
+ skillsDir: ".cursor/rules",
34
+ label: "Cursor",
35
+ },
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-";
44
+ export async function agentInstall(cwd, options) {
45
+ const projectRoot = resolve(cwd || ".");
46
+ // Resolve the bundled platform-skills directory. We're running
47
+ // from <cli-install>/dist/commands/agent.js, so walk up to
48
+ // <cli-install>/skills/platform/.
49
+ const here = dirname(fileURLToPath(import.meta.url));
50
+ const platformSkills = resolve(here, "..", "..", "skills", "platform");
51
+ if (!existsSync(platformSkills)) {
52
+ console.error(pc.red(`Error: bundled platform skills not found at ${platformSkills}.`));
53
+ console.error(` The dogsbay CLI install seems incomplete. Reinstall with`);
54
+ console.error(` 'npm install -g dogsbay@latest'.`);
55
+ process.exit(1);
56
+ }
57
+ // Pick the agents to install.
58
+ const agents = pickAgents(options);
59
+ if (agents.length === 0) {
60
+ printDetected(projectRoot);
61
+ return;
62
+ }
63
+ console.log(pc.cyan("→ Installing skill discovery"));
64
+ // 1. Always set up .dogsbay/skills/{platform,site,plugins}.
65
+ const dogsbayDir = join(projectRoot, ".dogsbay");
66
+ const skillsDir = join(dogsbayDir, "skills");
67
+ mkdirSync(skillsDir, { recursive: true });
68
+ const platformLink = join(skillsDir, "platform");
69
+ refreshSymlink(platformLink, platformSkills);
70
+ console.log(pc.green(` ✓ ${relative(projectRoot, platformLink)} → bundled platform skills`));
71
+ const siteDir = join(skillsDir, "site");
72
+ if (!existsSync(siteDir)) {
73
+ mkdirSync(siteDir, { recursive: true });
74
+ writeFileSync(join(siteDir, "README.md"), `# Site skills
75
+
76
+ This directory holds **site-specific** skills — your team's style
77
+ guide, voice / tone, terminology, glossary, internal conventions.
78
+ Anything an LLM should know that's specific to THIS site.
79
+
80
+ Each skill is a single \`.md\` file with frontmatter:
81
+
82
+ \`\`\`markdown
83
+ ---
84
+ name: site:style-guide
85
+ description: Our team's writing voice, terminology, and PR conventions.
86
+ ---
87
+
88
+ # Style guide
89
+
90
+ We use Oxford commas. Sentence-case headings. ...
91
+ \`\`\`
92
+
93
+ These skills are picked up automatically by any agent you've
94
+ installed via \`dogsbay agent install --agent <name>\`.
95
+
96
+ To override a platform skill (e.g. a different opinion on
97
+ \`nav-file.md\`), put your version under \`overrides/<skill-name>.md\`.
98
+ The agent loader checks overrides first.
99
+ `);
100
+ console.log(pc.green(` ✓ ${relative(projectRoot, siteDir)} created (empty + README)`));
101
+ }
102
+ else {
103
+ console.log(pc.gray(` · ${relative(projectRoot, siteDir)} already exists (preserved)`));
104
+ }
105
+ const pluginsDir = join(skillsDir, "plugins");
106
+ if (!existsSync(pluginsDir)) {
107
+ mkdirSync(pluginsDir, { recursive: true });
108
+ }
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.
114
+ for (const agent of agents) {
115
+ const target = AGENT_TARGETS[agent];
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
+ }
161
+ }
162
+ console.log("");
163
+ console.log(pc.cyan("Next:"));
164
+ console.log(" Open your editor — the agent should now see Dogsbay platform skills");
165
+ console.log(" on next prompt.");
166
+ console.log("");
167
+ console.log(" Add team-specific skills to .dogsbay/skills/site/.");
168
+ console.log(" Override a platform skill with .dogsbay/skills/site/overrides/<name>.md.");
169
+ }
170
+ /**
171
+ * Decide which agents to set up. Priority:
172
+ * --all → every supported agent
173
+ * --agent claude,cursor → exactly that list
174
+ * neither → return [], caller prints detected agents and exits
175
+ */
176
+ function pickAgents(options) {
177
+ if (options.all)
178
+ return [...SUPPORTED_AGENTS];
179
+ if (options.agent) {
180
+ const requested = options.agent.split(",").map((a) => a.trim().toLowerCase());
181
+ const valid = [];
182
+ for (const r of requested) {
183
+ if (SUPPORTED_AGENTS.includes(r)) {
184
+ valid.push(r);
185
+ }
186
+ else {
187
+ console.error(pc.yellow(` warn: unknown agent "${r}" (supported: ${SUPPORTED_AGENTS.join(", ")})`));
188
+ }
189
+ }
190
+ return valid;
191
+ }
192
+ return [];
193
+ }
194
+ /**
195
+ * When called without --agent or --all, just probe the project
196
+ * for known agent-config dirs and suggest commands.
197
+ */
198
+ function printDetected(projectRoot) {
199
+ const detected = [];
200
+ if (existsSync(join(projectRoot, ".claude"))) {
201
+ detected.push({ agent: "claude", signal: ".claude/" });
202
+ }
203
+ if (existsSync(join(projectRoot, ".cursor")) ||
204
+ existsSync(join(projectRoot, ".cursorrules"))) {
205
+ detected.push({ agent: "cursor", signal: ".cursor/ or .cursorrules" });
206
+ }
207
+ console.log(pc.cyan("Dogsbay agent install"));
208
+ console.log("");
209
+ console.log("Wires Dogsbay platform skills into the discovery path of an");
210
+ console.log("LLM agent so it picks them up on every prompt.");
211
+ console.log("");
212
+ if (detected.length > 0) {
213
+ console.log(pc.green("Detected in this project:"));
214
+ for (const d of detected) {
215
+ console.log(` ${d.agent.padEnd(8)} (${d.signal})`);
216
+ }
217
+ console.log("");
218
+ console.log("Run:");
219
+ for (const d of detected) {
220
+ console.log(` dogsbay agent install --agent ${d.agent}`);
221
+ }
222
+ console.log(" dogsbay agent install --all # set up every detected agent");
223
+ }
224
+ else {
225
+ console.log(pc.yellow("No supported agent configs detected in this project."));
226
+ console.log("");
227
+ console.log("Run:");
228
+ console.log(" dogsbay agent install --agent claude");
229
+ console.log(" dogsbay agent install --agent cursor");
230
+ console.log(" dogsbay agent install --all");
231
+ }
232
+ console.log("");
233
+ console.log(`Supported agents: ${SUPPORTED_AGENTS.join(", ")}`);
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
+ }
263
+ /**
264
+ * Replace any existing entry at `linkPath` with a fresh symlink
265
+ * pointing at `target`. Idempotent: if the link already points at
266
+ * the right place, leaves it alone.
267
+ */
268
+ function refreshSymlink(linkPath, target) {
269
+ if (existsSync(linkPath) || isBrokenSymlink(linkPath)) {
270
+ try {
271
+ const current = readlinkSync(linkPath);
272
+ const resolved = resolve(dirname(linkPath), current);
273
+ if (resolved === target)
274
+ return; // already correct
275
+ }
276
+ catch {
277
+ // not a symlink
278
+ }
279
+ try {
280
+ unlinkSync(linkPath);
281
+ }
282
+ catch {
283
+ // ignore
284
+ }
285
+ }
286
+ symlinkSync(target, linkPath, "dir");
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
+ */
294
+ function isBrokenSymlink(p) {
295
+ try {
296
+ const lst = lstatSync(p);
297
+ if (!lst.isSymbolicLink())
298
+ return false;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ // It's a symlink. existsSync follows the link; false → target gone.
304
+ return !existsSync(p);
305
+ }
@@ -17,7 +17,8 @@
17
17
  import { existsSync } from "node:fs";
18
18
  import { dirname, isAbsolute, resolve } from "node:path";
19
19
  import pc from "picocolors";
20
- import { emitAstroPages, emitSiteConfig, emitConfigDerivedFiles, emitAgentReadinessFiles, emitMissingTranslationStubs, emitSwitcherMap, emitTaxonomyRoutes, emitPluginRuntime, normalizeBasePath, basePathSegments, } from "@dogsbay/format-astro";
20
+ import { emitAstroPages, emitPassthroughAstroPages, emitSiteConfig, emitConfigDerivedFiles, emitDeployArtifacts, emitAgentReadinessFiles, emitMissingTranslationStubs, emitSwitcherMap, emitTaxonomyRoutes, emitPluginRuntime, normalizeBasePath, basePathSegments, resolvePrefixes, } from "@dogsbay/format-astro";
21
+ import { collectPassthroughEntries } from "../passthrough-astro.js";
21
22
  import { configToAstroOptions, findConfig, loadConfig, resolveOutputDir, } from "../config/index.js";
22
23
  import { filterSourcesForMode, importContent, primaryModeFiltersAnything, } from "../import-content.js";
23
24
  import { resolveSource } from "../source-resolver.js";
@@ -66,11 +67,21 @@ export async function siteBuild(cwd, options) {
66
67
  console.error(` Run \`dogsbay site init ${siteRoot}\` first.`);
67
68
  process.exit(1);
68
69
  }
69
- // 5. Filter to primary sources (default) or load full matrix
70
- // (--publish). Primary-only is the fast path local iteration
71
- // and CI lint jobs use; --publish builds everything for the
72
- // deploy job. See plans/multi-source-content.md "Build modes".
73
- const mode = options.publish ? "all" : "primary";
70
+ // 5. Default to publish mode (every declared source builds) so
71
+ // multi-locale / multi-version / multi-namespace sites Just Work
72
+ // when the writer runs `dogsbay site build`. Use --primary-only
73
+ // for fast-iteration CI jobs that don't need the full matrix
74
+ // (rare). The `--publish` flag is kept as a deprecated no-op for
75
+ // older scripts.
76
+ //
77
+ // Previous default was "primary-only" — silently dropped non-
78
+ // primary sources from production builds. With i18n and version
79
+ // axes, that meant declaring `locales:` resulted in only the
80
+ // English source shipping, surprising every writer who ran
81
+ // `dogsbay site build` after declaring fr. Inverted to match the
82
+ // strongest signal: declaring more sources means you want them
83
+ // all. See plans/beta-launch-followups.md.
84
+ const mode = options.primaryOnly ? "primary" : "all";
74
85
  const originalSources = config.content.sources;
75
86
  const totalSources = originalSources.length;
76
87
  const { sources: activeSources, indices: activeIndices } = filterSourcesForMode(originalSources, mode);
@@ -156,12 +167,45 @@ export async function siteBuild(cwd, options) {
156
167
  // generalize this to copy assets per-source.
157
168
  const astroOpts = configToAstroOptions(config);
158
169
  astroOpts.sourceDir = resolvedSources[0];
170
+ // siteRoot = repo root (where dogsbay.config.yml lives). Used by
171
+ // emitDeployArtifacts to drop GH Actions workflows at
172
+ // <siteRoot>/.github/ rather than inside the Astro subdir, where
173
+ // GitHub wouldn't find them.
174
+ astroOpts.projectDir = siteRoot;
159
175
  // Refresh src/data/site.json from the current config — purely
160
176
  // config-derived, so changes in dogsbay.config.yml propagate without
161
177
  // re-running site init.
162
178
  emitSiteConfig(outputDir, config.site.name, astroOpts);
163
- const { generated, outputNav } = await emitAstroPages(pages, nav, outputDir, astroOpts);
179
+ const { generated, outputNav, generatedPaths } = await emitAstroPages(pages, nav, outputDir, astroOpts);
180
+ // Passthrough Astro pages — hand-authored .astro files that live
181
+ // alongside the markdown sources and are listed in nav.yml. Picked
182
+ // up by walking the source directories for *.astro and intersecting
183
+ // with the resolved nav hrefs. Collisions with generated pages are
184
+ // a build error (loud, not silent overwrite). See
185
+ // plans/passthrough-astro-pages.md.
186
+ const passthroughCopies = [];
187
+ for (const sourceDir of resolvedSources) {
188
+ const entries = collectPassthroughEntries(sourceDir, outputNav, {
189
+ basePath: normalizeBasePath(astroOpts.basePath),
190
+ });
191
+ for (const entry of entries) {
192
+ passthroughCopies.push({
193
+ source: entry.source,
194
+ sourceAbs: entry.sourceAbs,
195
+ outputRelPath: entry.outputRelPath,
196
+ });
197
+ }
198
+ }
199
+ if (passthroughCopies.length > 0) {
200
+ const { copied } = emitPassthroughAstroPages(passthroughCopies, outputDir, generatedPaths);
201
+ console.log(` Copied ${copied} passthrough .astro page${copied === 1 ? "" : "s"}`);
202
+ }
164
203
  emitConfigDerivedFiles(outputDir, astroOpts);
204
+ // Deploy artifacts — write-if-missing so adding `deploy: github-pages`
205
+ // to dogsbay.config.yml on an existing site materializes the workflow
206
+ // on the next build. Author edits survive (only fired when the file
207
+ // doesn't exist; --force regeneration goes through site init).
208
+ emitDeployArtifacts(outputDir, astroOpts);
165
209
  emitAgentReadinessFiles(pages, outputNav, outputDir, config.site.name, astroOpts);
166
210
  // switcherMap.json — per-page version-equivalent lookup. No-op
167
211
  // when the version axis isn't active; cleans up a stale file
@@ -206,9 +250,13 @@ export async function siteBuild(cwd, options) {
206
250
  labels: raw.labels,
207
251
  };
208
252
  }
253
+ // Pass the combined prefix as basePath to taxonomy — term URLs
254
+ // need to include both urlBase (host subpath) and dogsbay
255
+ // basePath so they resolve correctly under the served URL.
256
+ const taxoCombined = resolvePrefixes(config.site.url, config.site.basePath).combined;
209
257
  const emitted = emitTaxonomyRoutes(pages, outputDir, taxonomyConfigs, {
210
258
  section: config.content.section,
211
- basePath: config.site.basePath,
259
+ basePath: taxoCombined,
212
260
  });
213
261
  if (emitted.length > 0) {
214
262
  console.log(` Emitted taxonomy routes: ${emitted.join(", ")}`);
@@ -231,16 +279,16 @@ export async function siteBuild(cwd, options) {
231
279
  console.warn(` Copy markdown + View as markdown work without it.`);
232
280
  }
233
281
  console.log(pc.green(`\nDone! Built ${generated} pages into ${outputDir}`));
234
- // When the writer has marked some sources as primary and we
235
- // built only those, surface the --publish hint. Skip the hint
236
- // for plain single-source builds and for full --publish runs
237
- // both already build everything.
282
+ // When the writer ran with --primary-only and we filtered the
283
+ // matrix, surface a hint that drops them back to the default.
284
+ // Skip for single-source / full-matrix builds both already
285
+ // built everything.
238
286
  if (mode === "primary" && primaryModeFiltersAnything(originalSources)) {
239
287
  const skipped = totalSources - activeIndices.length;
240
288
  console.log("");
241
- console.log(pc.dim(`Built ${activeIndices.length} primary source(s); ` +
242
- `${skipped} non-primary skipped. ` +
243
- `Run \`dogsbay site build --publish\` for the full matrix.`));
289
+ console.log(pc.dim(`--primary-only built ${activeIndices.length} of ${totalSources} ` +
290
+ `sources; ${skipped} non-primary skipped. ` +
291
+ `Drop --primary-only to build the full matrix.`));
244
292
  }
245
293
  console.log("");
246
294
  console.log(pc.cyan("Next:"));
@@ -315,6 +363,9 @@ function mergeOverrides(config, opts) {
315
363
  if (opts.deploy === "cloudflare-workers") {
316
364
  next.deploy = { target: "cloudflare-workers" };
317
365
  }
366
+ else if (opts.deploy === "github-pages") {
367
+ next.deploy = { target: "github-pages" };
368
+ }
318
369
  // Analytics
319
370
  if (opts.plausibleDomain) {
320
371
  next.analytics = next.analytics ?? {};