@hogsend/cli 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/dist/bin.js +575 -104
  2. package/dist/bin.js.map +1 -1
  3. package/package.json +4 -1
  4. package/skills/hogsend-authoring-buckets/SKILL.md +69 -0
  5. package/skills/hogsend-authoring-buckets/references/bucket-id-aliases.md +117 -0
  6. package/skills/hogsend-authoring-buckets/references/bucket-meta.md +142 -0
  7. package/skills/hogsend-authoring-buckets/references/buckets-vs-journeys.md +96 -0
  8. package/skills/hogsend-authoring-buckets/references/register-a-bucket.md +129 -0
  9. package/skills/hogsend-authoring-emails/SKILL.md +68 -0
  10. package/skills/hogsend-authoring-emails/references/email-components.md +112 -0
  11. package/skills/hogsend-authoring-emails/references/preview-and-render.md +116 -0
  12. package/skills/hogsend-authoring-emails/references/template-four-file-contract.md +134 -0
  13. package/skills/hogsend-authoring-emails/references/tracking-and-unsubscribe.md +127 -0
  14. package/skills/hogsend-authoring-journeys/SKILL.md +88 -0
  15. package/skills/hogsend-authoring-journeys/references/branch-on-engagement.md +93 -0
  16. package/skills/hogsend-authoring-journeys/references/journey-context.md +110 -0
  17. package/skills/hogsend-authoring-journeys/references/journey-meta.md +142 -0
  18. package/skills/hogsend-authoring-journeys/references/register-a-journey.md +99 -0
  19. package/skills/hogsend-authoring-journeys/references/sending-email-from-a-journey.md +82 -0
  20. package/skills/hogsend-cli/SKILL.md +1 -0
  21. package/skills/hogsend-conditions/SKILL.md +70 -0
  22. package/skills/hogsend-conditions/references/condition-types.md +251 -0
  23. package/skills/hogsend-conditions/references/durations.md +90 -0
  24. package/skills/hogsend-conditions/references/examples.md +188 -0
  25. package/skills/hogsend-database/SKILL.md +70 -0
  26. package/skills/hogsend-database/references/client-track-schema.md +97 -0
  27. package/skills/hogsend-database/references/migrations.md +132 -0
  28. package/skills/hogsend-database/references/schema-drift.md +123 -0
  29. package/skills/hogsend-deploy/SKILL.md +62 -0
  30. package/skills/hogsend-deploy/references/env-and-secrets.md +118 -0
  31. package/skills/hogsend-deploy/references/railway-two-services.md +122 -0
  32. package/skills/hogsend-deploy/references/upgrade-engine.md +92 -0
  33. package/skills/hogsend-webhooks-and-workflows/SKILL.md +68 -0
  34. package/skills/hogsend-webhooks-and-workflows/references/backfill-pattern.md +148 -0
  35. package/skills/hogsend-webhooks-and-workflows/references/custom-workflow.md +156 -0
  36. package/skills/hogsend-webhooks-and-workflows/references/webhook-source.md +172 -0
  37. package/src/commands/doctor.ts +22 -0
  38. package/src/commands/index.ts +4 -0
  39. package/src/commands/skills.ts +36 -96
  40. package/src/commands/studio.ts +261 -0
  41. package/src/commands/upgrade.ts +245 -0
  42. package/src/lib/skills.ts +186 -0
  43. package/studio/assets/index-BVA9GZqq.css +1 -0
  44. package/studio/assets/index-kPwzOOyG.js +230 -0
  45. package/studio/index.html +13 -0
package/dist/bin.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/bin.ts
4
- import { createRequire as createRequire2 } from "module";
4
+ import { createRequire as createRequire3 } from "module";
5
5
 
6
6
  // src/commands/contacts.ts
7
7
  import { parseArgs } from "util";
@@ -110,10 +110,10 @@ function renderTable(rows, columns) {
110
110
  );
111
111
  const pad = (text, width) => text + " ".repeat(width - text.length);
112
112
  const header = cols.map((c, i) => color.bold(pad(c, widths[i] ?? 0))).join(" ");
113
- const sep3 = cols.map((_, i) => "-".repeat(widths[i] ?? 0)).join(" ");
113
+ const sep4 = cols.map((_, i) => "-".repeat(widths[i] ?? 0)).join(" ");
114
114
  const body = rows.map((r) => cols.map((c, i) => pad(cell(r[c]), widths[i] ?? 0)).join(" ")).join("\n");
115
115
  return `${header}
116
- ${color.dim(sep3)}
116
+ ${color.dim(sep4)}
117
117
  ${body}`;
118
118
  }
119
119
  function renderKv(obj) {
@@ -430,6 +430,137 @@ var contactsCommand = {
430
430
 
431
431
  // src/commands/doctor.ts
432
432
  import { parseArgs as parseArgs2 } from "util";
433
+
434
+ // src/lib/skills.ts
435
+ import {
436
+ cpSync,
437
+ existsSync,
438
+ mkdirSync,
439
+ readdirSync,
440
+ readFileSync,
441
+ statSync,
442
+ writeFileSync
443
+ } from "fs";
444
+ import { createRequire } from "module";
445
+ import { join } from "path";
446
+ import { fileURLToPath } from "url";
447
+ function bundledSkillsDir() {
448
+ return fileURLToPath(new URL("../skills", import.meta.url));
449
+ }
450
+ function installDir(cwd) {
451
+ return join(cwd, ".claude", "skills");
452
+ }
453
+ function stampPath(cwd) {
454
+ return join(cwd, ".claude", ".hogsend-skills.json");
455
+ }
456
+ function cliVersion() {
457
+ try {
458
+ const require2 = createRequire(import.meta.url);
459
+ const pkg = require2("../package.json");
460
+ return pkg.version ?? "0.0.0";
461
+ } catch {
462
+ return "0.0.0";
463
+ }
464
+ }
465
+ function readFileSyncSafe(path) {
466
+ try {
467
+ return readFileSync(path, "utf8");
468
+ } catch {
469
+ return "";
470
+ }
471
+ }
472
+ function readFrontmatterField(skillDir, field) {
473
+ const skillFile = join(skillDir, "SKILL.md");
474
+ if (!existsSync(skillFile)) return "";
475
+ const raw = readFileSyncSafe(skillFile);
476
+ const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
477
+ if (!fmMatch) return "";
478
+ const block = fmMatch[1] ?? "";
479
+ for (const line of block.split("\n")) {
480
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
481
+ if (m && m[1] === field) {
482
+ return (m[2] ?? "").replace(/^["']|["']$/g, "").trim();
483
+ }
484
+ }
485
+ return "";
486
+ }
487
+ function listBundledSkills(cwd) {
488
+ const dir = bundledSkillsDir();
489
+ if (!existsSync(dir)) return [];
490
+ const target = installDir(cwd);
491
+ const entries = readdirSync(dir).filter((name) => {
492
+ const full = join(dir, name);
493
+ return statSync(full).isDirectory() && existsSync(join(full, "SKILL.md"));
494
+ });
495
+ return entries.sort().map((name) => ({
496
+ name,
497
+ description: readFrontmatterField(join(dir, name), "description"),
498
+ installed: existsSync(join(target, name))
499
+ }));
500
+ }
501
+ function copySkill(name, cwd, force) {
502
+ const src = join(bundledSkillsDir(), name);
503
+ const dest = join(installDir(cwd), name);
504
+ const exists = existsSync(dest);
505
+ if (exists && !force) {
506
+ return { name, installed: false, skipped: true, path: dest };
507
+ }
508
+ mkdirSync(installDir(cwd), { recursive: true });
509
+ cpSync(src, dest, { recursive: true, force: true });
510
+ return { name, installed: true, skipped: false, path: dest };
511
+ }
512
+ function writeSkillsStamp(cwd, skills) {
513
+ const stamp = {
514
+ cliVersion: cliVersion(),
515
+ skills: [...skills].sort(),
516
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
517
+ };
518
+ mkdirSync(join(cwd, ".claude"), { recursive: true });
519
+ writeFileSync(stampPath(cwd), `${JSON.stringify(stamp, null, 2)}
520
+ `);
521
+ }
522
+ function readSkillsStamp(cwd) {
523
+ try {
524
+ const parsed = JSON.parse(readFileSync(stampPath(cwd), "utf8"));
525
+ return parsed && typeof parsed.cliVersion === "string" ? parsed : null;
526
+ } catch {
527
+ return null;
528
+ }
529
+ }
530
+ function compareVersions(a, b) {
531
+ const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
532
+ const pa = parse(a);
533
+ const pb = parse(b);
534
+ for (let i = 0; i < 3; i++) {
535
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
536
+ if (d !== 0) return d < 0 ? -1 : 1;
537
+ }
538
+ return 0;
539
+ }
540
+ function skillsStaleness(cwd) {
541
+ const stamp = readSkillsStamp(cwd);
542
+ if (!stamp) return null;
543
+ const current = cliVersion();
544
+ return {
545
+ stale: compareVersions(stamp.cliVersion, current) < 0,
546
+ installed: stamp.cliVersion,
547
+ current
548
+ };
549
+ }
550
+
551
+ // src/commands/doctor.ts
552
+ function skillsNudge(ctx) {
553
+ const verdict = skillsStaleness(process.cwd());
554
+ if (!verdict?.stale || ctx.json) return;
555
+ ctx.out.note(
556
+ [
557
+ `Vendored Claude skills are from v${verdict.installed}; this CLI is v${verdict.current}.`,
558
+ "",
559
+ `Refresh: ${color.cyan("hogsend upgrade")} ${color.dim("(deps + skills)")} or ${color.cyan("hogsend skills add --all --force")}.`
560
+ ].join("\n"),
561
+ "Skills out of date"
562
+ );
563
+ }
433
564
  var usage2 = `hogsend doctor [--url <baseUrl>] [--admin-key <key>] [--json]
434
565
 
435
566
  Probe a running Hogsend instance via GET /v1/health and report its health:
@@ -538,7 +669,8 @@ async function run2(ctx) {
538
669
  uptime: health.uptime,
539
670
  timestamp: health.timestamp,
540
671
  components: health.components,
541
- schema: health.schema
672
+ schema: health.schema,
673
+ skills: skillsStaleness(process.cwd()) ?? void 0
542
674
  });
543
675
  if (!ok) process.exit(1);
544
676
  return;
@@ -561,6 +693,7 @@ async function run2(ctx) {
561
693
  ` ${trackLine("client", health.schema.client)}`
562
694
  ];
563
695
  ctx.out.note(lines.join("\n"), "Doctor");
696
+ skillsNudge(ctx);
564
697
  if (ok) {
565
698
  ctx.out.outro(color.green("doctor: ok"));
566
699
  return;
@@ -576,15 +709,15 @@ var doctorCommand = {
576
709
  };
577
710
 
578
711
  // src/commands/eject.ts
579
- import { existsSync as existsSync2, realpathSync } from "fs";
580
- import { createRequire } from "module";
581
- import { dirname, join as join2, sep as sep2 } from "path";
712
+ import { existsSync as existsSync3, realpathSync } from "fs";
713
+ import { createRequire as createRequire2 } from "module";
714
+ import { dirname, join as join3, sep as sep2 } from "path";
582
715
  import { parseArgs as parseArgs3 } from "util";
583
716
 
584
717
  // src/eject.ts
585
- import { existsSync } from "fs";
718
+ import { existsSync as existsSync2 } from "fs";
586
719
  import { cp, readFile, rm, stat, writeFile } from "fs/promises";
587
- import { basename, join, relative, sep } from "path";
720
+ import { basename, join as join2, relative, sep } from "path";
588
721
  var EjectError = class extends Error {
589
722
  constructor(message) {
590
723
  super(message);
@@ -609,8 +742,8 @@ async function writePackageJson(file, value) {
609
742
  async function eject(opts) {
610
743
  const { pkg, consumerRoot, sourceDir, force = false } = opts;
611
744
  const vendorName = basename(pkg);
612
- const vendorPath = join(consumerRoot, "vendor", vendorName);
613
- const consumerPkgPath = join(consumerRoot, "package.json");
745
+ const vendorPath = join2(consumerRoot, "vendor", vendorName);
746
+ const consumerPkgPath = join2(consumerRoot, "package.json");
614
747
  const consumerPkg = await readPackageJson(consumerPkgPath);
615
748
  let depMap;
616
749
  let depSpecBefore;
@@ -626,7 +759,7 @@ async function eject(opts) {
626
759
  `${pkg} is not a dependency of the consumer package.json`
627
760
  );
628
761
  }
629
- if (existsSync(vendorPath)) {
762
+ if (existsSync2(vendorPath)) {
630
763
  if (!force) {
631
764
  throw new EjectError(
632
765
  `vendor/${vendorName} already exists; pass --force to overwrite`
@@ -654,7 +787,7 @@ async function eject(opts) {
654
787
  }
655
788
  });
656
789
  copiedFiles = await countFiles(vendorPath);
657
- const vendoredPkgPath = join(vendorPath, "package.json");
790
+ const vendoredPkgPath = join2(vendorPath, "package.json");
658
791
  const vendoredPkg = await readPackageJson(vendoredPkgPath);
659
792
  if (vendoredPkg.private === true) {
660
793
  delete vendoredPkg.private;
@@ -677,7 +810,7 @@ async function countFiles(dir) {
677
810
  let count = 0;
678
811
  const entries = await readdir(dir, { withFileTypes: true });
679
812
  for (const entry of entries) {
680
- const full = join(dir, entry.name);
813
+ const full = join2(dir, entry.name);
681
814
  if (entry.isDirectory()) {
682
815
  count += await countFiles(full);
683
816
  } else if (entry.isFile()) {
@@ -705,16 +838,16 @@ Options:
705
838
 
706
839
  After ejecting, run: pnpm install`;
707
840
  function resolveSourceDir(pkg, consumerRoot) {
708
- const direct = join2(consumerRoot, "node_modules", pkg, "package.json");
709
- if (existsSync2(direct)) {
841
+ const direct = join3(consumerRoot, "node_modules", pkg, "package.json");
842
+ if (existsSync3(direct)) {
710
843
  return dirname(realpathSync(direct));
711
844
  }
712
- const require2 = createRequire(`${consumerRoot}${sep2}`);
845
+ const require2 = createRequire2(`${consumerRoot}${sep2}`);
713
846
  try {
714
847
  const entry = require2.resolve(pkg);
715
848
  let dir = dirname(entry);
716
849
  while (dir !== dirname(dir)) {
717
- if (existsSync2(join2(dir, "package.json"))) return dir;
850
+ if (existsSync3(join3(dir, "package.json"))) return dir;
718
851
  dir = dirname(dir);
719
852
  }
720
853
  } catch {
@@ -1213,8 +1346,8 @@ var patchCommand = {
1213
1346
  // src/commands/setup.ts
1214
1347
  import { spawnSync as spawnSync2 } from "child_process";
1215
1348
  import { randomBytes } from "crypto";
1216
- import { copyFileSync, existsSync as existsSync3, readFileSync, writeFileSync } from "fs";
1217
- import { join as join3 } from "path";
1349
+ import { copyFileSync, existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1350
+ import { join as join4 } from "path";
1218
1351
  import { parseArgs as parseArgs7 } from "util";
1219
1352
  import { confirm } from "@clack/prompts";
1220
1353
 
@@ -1252,16 +1385,16 @@ function generateSecret() {
1252
1385
  var SECRET_KEY = "BETTER_AUTH_SECRET";
1253
1386
  var PLACEHOLDER_PREFIX = "change-me";
1254
1387
  function ensureEnv(cwd) {
1255
- const envPath = join3(cwd, ".env");
1256
- const examplePath = join3(cwd, ".env.example");
1388
+ const envPath = join4(cwd, ".env");
1389
+ const examplePath = join4(cwd, ".env.example");
1257
1390
  let copied;
1258
- if (existsSync3(envPath)) {
1391
+ if (existsSync4(envPath)) {
1259
1392
  copied = {
1260
1393
  step: "env",
1261
1394
  status: "skipped",
1262
1395
  detail: ".env already exists"
1263
1396
  };
1264
- } else if (existsSync3(examplePath)) {
1397
+ } else if (existsSync4(examplePath)) {
1265
1398
  copyFileSync(examplePath, envPath);
1266
1399
  copied = {
1267
1400
  step: "env",
@@ -1285,7 +1418,7 @@ function ensureEnv(cwd) {
1285
1418
  }
1286
1419
  let raw;
1287
1420
  try {
1288
- raw = readFileSync(envPath, "utf8");
1421
+ raw = readFileSync2(envPath, "utf8");
1289
1422
  } catch (err) {
1290
1423
  return {
1291
1424
  copied,
@@ -1321,7 +1454,7 @@ function ensureEnv(cwd) {
1321
1454
  } else {
1322
1455
  lines[idx] = newLine;
1323
1456
  }
1324
- writeFileSync(envPath, lines.join("\n"));
1457
+ writeFileSync2(envPath, lines.join("\n"));
1325
1458
  return {
1326
1459
  copied,
1327
1460
  secret: {
@@ -1355,12 +1488,12 @@ async function run7(ctx) {
1355
1488
  return;
1356
1489
  }
1357
1490
  const cwd = values.cwd ?? process.cwd();
1358
- if (!existsSync3(join3(cwd, "package.json"))) {
1491
+ if (!existsSync4(join4(cwd, "package.json"))) {
1359
1492
  ctx.out.fail(
1360
1493
  `no package.json in ${cwd} \u2014 run setup from a scaffolded Hogsend app (or pass --cwd).`
1361
1494
  );
1362
1495
  }
1363
- const hasCompose = existsSync3(join3(cwd, "docker-compose.yml")) || existsSync3(join3(cwd, "docker-compose.yaml")) || existsSync3(join3(cwd, "compose.yml")) || existsSync3(join3(cwd, "compose.yaml"));
1496
+ const hasCompose = existsSync4(join4(cwd, "docker-compose.yml")) || existsSync4(join4(cwd, "docker-compose.yaml")) || existsSync4(join4(cwd, "compose.yml")) || existsSync4(join4(cwd, "compose.yaml"));
1364
1497
  const skipConfirm = ctx.json || values.yes;
1365
1498
  if (!ctx.json) {
1366
1499
  ctx.out.intro(
@@ -1467,16 +1600,8 @@ var setupCommand = {
1467
1600
  };
1468
1601
 
1469
1602
  // src/commands/skills.ts
1470
- import {
1471
- cpSync,
1472
- existsSync as existsSync4,
1473
- mkdirSync,
1474
- readdirSync,
1475
- readFileSync as readFileSync2,
1476
- statSync
1477
- } from "fs";
1478
- import { join as join4 } from "path";
1479
- import { fileURLToPath } from "url";
1603
+ import { existsSync as existsSync5 } from "fs";
1604
+ import { join as join5 } from "path";
1480
1605
  import { parseArgs as parseArgs8 } from "util";
1481
1606
  import { multiselect } from "@clack/prompts";
1482
1607
  var usage8 = `hogsend skills <subcommand> [options]
@@ -1489,10 +1614,13 @@ Subcommands:
1489
1614
  list List bundled skills + whether each is installed.
1490
1615
  add [name] [--force] Copy a bundled skill into ./.claude/skills/<name>/.
1491
1616
  Omit name for an interactive multiselect (human),
1492
- or copy all bundled skills (--json / non-interactive).
1617
+ or copy all bundled skills (--all / --json /
1618
+ non-interactive).
1493
1619
 
1494
1620
  Options:
1495
- --force Overwrite an already-installed skill.
1621
+ --all Install every bundled skill (skips the interactive picker).
1622
+ --force Overwrite an already-installed skill. Use after upgrading the
1623
+ engine to refresh vendored skills to the latest guidance.
1496
1624
  --json Emit machine-readable JSON only (implies non-interactive).
1497
1625
  -h, --help Show this help.
1498
1626
 
@@ -1500,49 +1628,11 @@ Examples:
1500
1628
  hogsend skills list
1501
1629
  hogsend skills list --json
1502
1630
  hogsend skills add
1503
- hogsend skills add hogsend-cli --force`;
1504
- function bundledSkillsDir() {
1505
- return fileURLToPath(new URL("../skills", import.meta.url));
1506
- }
1507
- function installDir(cwd) {
1508
- return join4(cwd, ".claude", "skills");
1509
- }
1510
- function readFrontmatterField(skillDir, field) {
1511
- const skillFile = join4(skillDir, "SKILL.md");
1512
- if (!existsSync4(skillFile)) return "";
1513
- const raw = readFileSyncSafe(skillFile);
1514
- const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
1515
- if (!fmMatch) return "";
1516
- const block = fmMatch[1] ?? "";
1517
- for (const line of block.split("\n")) {
1518
- const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
1519
- if (m && m[1] === field) {
1520
- return (m[2] ?? "").replace(/^["']|["']$/g, "").trim();
1521
- }
1522
- }
1523
- return "";
1524
- }
1525
- function readFileSyncSafe(path) {
1526
- try {
1527
- return readFileSync2(path, "utf8");
1528
- } catch {
1529
- return "";
1530
- }
1531
- }
1532
- function listBundledSkills(cwd) {
1533
- const dir = bundledSkillsDir();
1534
- if (!existsSync4(dir)) return [];
1535
- const target = installDir(cwd);
1536
- const entries = readdirSync(dir).filter((name) => {
1537
- const full = join4(dir, name);
1538
- return statSync(full).isDirectory() && existsSync4(join4(full, "SKILL.md"));
1539
- });
1540
- return entries.sort().map((name) => ({
1541
- name,
1542
- description: readFrontmatterField(join4(dir, name), "description"),
1543
- installed: existsSync4(join4(target, name))
1544
- }));
1545
- }
1631
+ hogsend skills add --all
1632
+ hogsend skills add hogsend-cli --force
1633
+ hogsend skills add --all --force # refresh everything after an upgrade
1634
+
1635
+ Tip: \`hogsend upgrade\` bumps the engine AND refreshes these skills in one step.`;
1546
1636
  function runList3(ctx) {
1547
1637
  const skills = listBundledSkills(process.cwd());
1548
1638
  if (ctx.json) {
@@ -1571,25 +1661,15 @@ function runList3(ctx) {
1571
1661
  ["name", "installed", "description"]
1572
1662
  );
1573
1663
  ctx.out.outro(
1574
- `Install with ${color.cyan("hogsend skills add <name>")} (or just ${color.cyan("hogsend skills add")}).`
1664
+ `Install with ${color.cyan("hogsend skills add <name>")} (or ${color.cyan("hogsend skills add --all")}). Refresh after an engine upgrade with ${color.cyan("--force")}.`
1575
1665
  );
1576
1666
  }
1577
- function copySkill(name, cwd, force) {
1578
- const src = join4(bundledSkillsDir(), name);
1579
- const dest = join4(installDir(cwd), name);
1580
- const exists = existsSync4(dest);
1581
- if (exists && !force) {
1582
- return { name, installed: false, skipped: true, path: dest };
1583
- }
1584
- mkdirSync(installDir(cwd), { recursive: true });
1585
- cpSync(src, dest, { recursive: true, force: true });
1586
- return { name, installed: true, skipped: false, path: dest };
1587
- }
1588
1667
  async function runAdd(ctx, argv) {
1589
1668
  const { values, positionals } = parseArgs8({
1590
1669
  args: argv,
1591
1670
  allowPositionals: true,
1592
1671
  options: {
1672
+ all: { type: "boolean", default: false },
1593
1673
  force: { type: "boolean", default: false },
1594
1674
  help: { type: "boolean", short: "h", default: false }
1595
1675
  }
@@ -1614,6 +1694,8 @@ async function runAdd(ctx, argv) {
1614
1694
  );
1615
1695
  }
1616
1696
  names = [requested];
1697
+ } else if (values.all) {
1698
+ names = bundled.map((s) => s.name);
1617
1699
  } else if (ctx.out.interactive) {
1618
1700
  const picked = bail(
1619
1701
  await multiselect({
@@ -1630,7 +1712,13 @@ async function runAdd(ctx, argv) {
1630
1712
  } else {
1631
1713
  names = bundled.map((s) => s.name);
1632
1714
  }
1633
- const results = names.map((name) => copySkill(name, cwd, force));
1715
+ const results = names.map(
1716
+ (name) => copySkill(name, cwd, force)
1717
+ );
1718
+ if (results.some((r) => r.installed)) {
1719
+ const installedNames = listBundledSkills(cwd).filter((s) => existsSync5(join5(installDir(cwd), s.name))).map((s) => s.name);
1720
+ writeSkillsStamp(cwd, installedNames);
1721
+ }
1634
1722
  if (ctx.json) {
1635
1723
  ctx.out.json({
1636
1724
  installDir: installDir(cwd),
@@ -1748,6 +1836,387 @@ var statsCommand = {
1748
1836
  run: run9
1749
1837
  };
1750
1838
 
1839
+ // src/commands/studio.ts
1840
+ import { spawn } from "child_process";
1841
+ import { createReadStream, existsSync as existsSync6, readFileSync as readFileSync3, statSync as statSync2 } from "fs";
1842
+ import { createServer } from "http";
1843
+ import { extname, join as join6, normalize, resolve, sep as sep3 } from "path";
1844
+ import { fileURLToPath as fileURLToPath2 } from "url";
1845
+ import { parseArgs as parseArgs10 } from "util";
1846
+ var usage10 = `hogsend studio [options]
1847
+
1848
+ Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
1849
+ browser. The Studio is a static single-page app; this command starts a tiny
1850
+ local web server for it on a port of your choosing.
1851
+
1852
+ By default the Studio talks to the API at the same origin it is served from,
1853
+ which won't be a running API here \u2014 so point it at your instance with
1854
+ --base-url (the SPA uses cookie auth, so the instance must allow CORS from the
1855
+ Studio origin, or you can simply open the Studio that the engine mounts at
1856
+ \`<instance>/studio\` instead).
1857
+
1858
+ Options:
1859
+ --port <n> Local port to serve on (default 3333).
1860
+ --base-url <url> API instance the Studio should call (injected at runtime).
1861
+ Omit to use same-origin (the local server, for static
1862
+ preview only).
1863
+ --open Open the Studio in your default browser after starting.
1864
+ --dist <path> Override the Studio dist directory (advanced).
1865
+ -h, --help Show this help.
1866
+
1867
+ Examples:
1868
+ hogsend studio --open
1869
+ hogsend studio --base-url https://api.example.com --open
1870
+ hogsend studio --port 4000`;
1871
+ function resolveStudioDist(distFlag) {
1872
+ const candidates = [];
1873
+ if (distFlag && distFlag.length > 0) {
1874
+ candidates.push(resolve(process.cwd(), distFlag));
1875
+ }
1876
+ candidates.push(fileURLToPath2(new URL("../studio", import.meta.url)));
1877
+ candidates.push(
1878
+ fileURLToPath2(new URL("../../studio/dist", import.meta.url)),
1879
+ fileURLToPath2(new URL("../../../studio/dist", import.meta.url))
1880
+ );
1881
+ candidates.push(resolve(process.cwd(), "packages/studio/dist"));
1882
+ for (const dir of candidates) {
1883
+ if (existsSync6(join6(dir, "index.html"))) {
1884
+ return dir;
1885
+ }
1886
+ }
1887
+ return null;
1888
+ }
1889
+ var MIME = {
1890
+ ".html": "text/html; charset=utf-8",
1891
+ ".js": "text/javascript; charset=utf-8",
1892
+ ".mjs": "text/javascript; charset=utf-8",
1893
+ ".css": "text/css; charset=utf-8",
1894
+ ".json": "application/json; charset=utf-8",
1895
+ ".svg": "image/svg+xml",
1896
+ ".png": "image/png",
1897
+ ".jpg": "image/jpeg",
1898
+ ".jpeg": "image/jpeg",
1899
+ ".gif": "image/gif",
1900
+ ".ico": "image/x-icon",
1901
+ ".woff": "font/woff",
1902
+ ".woff2": "font/woff2",
1903
+ ".ttf": "font/ttf",
1904
+ ".map": "application/json; charset=utf-8"
1905
+ };
1906
+ function mimeFor(path) {
1907
+ return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
1908
+ }
1909
+ function indexHtml(distPath, baseUrl) {
1910
+ const raw = readFileSync3(join6(distPath, "index.html"), "utf8");
1911
+ if (!baseUrl) return raw;
1912
+ const inject = `<script>window.__HOGSEND_STUDIO__=${JSON.stringify({
1913
+ baseUrl
1914
+ })};</script>`;
1915
+ if (raw.includes("</head>")) {
1916
+ return raw.replace("</head>", `${inject}</head>`);
1917
+ }
1918
+ return `${inject}${raw}`;
1919
+ }
1920
+ function openBrowser(url) {
1921
+ const platform = process.platform;
1922
+ const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1923
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1924
+ try {
1925
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
1926
+ child.on("error", () => {
1927
+ });
1928
+ child.unref();
1929
+ } catch {
1930
+ }
1931
+ }
1932
+ async function run10(ctx) {
1933
+ const { values, positionals } = parseArgs10({
1934
+ args: ctx.argv,
1935
+ allowPositionals: true,
1936
+ strict: false,
1937
+ options: {
1938
+ port: { type: "string" },
1939
+ "base-url": { type: "string" },
1940
+ open: { type: "boolean", default: false },
1941
+ dist: { type: "string" },
1942
+ help: { type: "boolean", short: "h", default: false }
1943
+ }
1944
+ });
1945
+ if (values.help) {
1946
+ ctx.out.log(usage10);
1947
+ return;
1948
+ }
1949
+ const port = Number(values.port ?? "3333");
1950
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
1951
+ ctx.out.fail(`invalid --port "${values.port}" (expected 1-65535)`);
1952
+ }
1953
+ const baseUrl = typeof values["base-url"] === "string" ? values["base-url"] : void 0;
1954
+ const distPath = resolveStudioDist(
1955
+ typeof values.dist === "string" ? values.dist : positionals[0]
1956
+ );
1957
+ if (!distPath) {
1958
+ ctx.out.fail(
1959
+ "could not find a built Studio (dist/). Build it with `pnpm --filter @hogsend/studio build`, or pass --dist <path>."
1960
+ );
1961
+ }
1962
+ const cleanBase = baseUrl ? baseUrl.replace(/\/+$/, "") : void 0;
1963
+ const index = indexHtml(distPath, cleanBase);
1964
+ const server = createServer((req, res) => {
1965
+ const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
1966
+ const rel = urlPath.replace(/^\/studio/, "");
1967
+ if (rel === "" || rel === "/") {
1968
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1969
+ res.end(index);
1970
+ return;
1971
+ }
1972
+ const target = normalize(join6(distPath, rel));
1973
+ if (target !== distPath && !target.startsWith(distPath + sep3)) {
1974
+ res.writeHead(403);
1975
+ res.end("Forbidden");
1976
+ return;
1977
+ }
1978
+ if (existsSync6(target) && statSync2(target).isFile()) {
1979
+ res.writeHead(200, { "content-type": mimeFor(target) });
1980
+ createReadStream(target).pipe(res);
1981
+ return;
1982
+ }
1983
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1984
+ res.end(index);
1985
+ });
1986
+ await new Promise((resolveListen, reject) => {
1987
+ server.once("error", reject);
1988
+ server.listen(port, () => resolveListen());
1989
+ }).catch((err) => {
1990
+ const msg = err instanceof Error ? err.message : String(err);
1991
+ ctx.out.fail(`could not start server on port ${port}: ${msg}`);
1992
+ });
1993
+ const localUrl = `http://localhost:${port}/studio/`;
1994
+ if (ctx.json) {
1995
+ ctx.out.json({
1996
+ url: localUrl,
1997
+ port,
1998
+ baseUrl: cleanBase ?? null,
1999
+ dist: distPath
2000
+ });
2001
+ } else {
2002
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} studio`);
2003
+ ctx.out.note(
2004
+ [
2005
+ `${color.green("\u25CF")} Studio serving at ${color.cyan(localUrl)}`,
2006
+ cleanBase ? color.dim(`API instance: ${cleanBase}`) : color.dim(
2007
+ "No --base-url set (same-origin / static preview). The API calls will hit this local server and fail \u2014 pass --base-url <instance>, or open <instance>/studio directly."
2008
+ ),
2009
+ "",
2010
+ color.dim("First load shows a create-admin screen if no admin exists."),
2011
+ color.dim("Press Ctrl+C to stop.")
2012
+ ].join("\n"),
2013
+ "Studio"
2014
+ );
2015
+ }
2016
+ if (values.open) {
2017
+ openBrowser(localUrl);
2018
+ }
2019
+ await new Promise((resolveForever) => {
2020
+ const stop = () => {
2021
+ server.close(() => resolveForever());
2022
+ };
2023
+ process.on("SIGINT", stop);
2024
+ process.on("SIGTERM", stop);
2025
+ });
2026
+ }
2027
+ var studioCommand = {
2028
+ name: "studio",
2029
+ summary: "Serve the bundled Hogsend Studio admin SPA locally",
2030
+ usage: usage10,
2031
+ run: run10
2032
+ };
2033
+
2034
+ // src/commands/upgrade.ts
2035
+ import { spawnSync as spawnSync3 } from "child_process";
2036
+ import { existsSync as existsSync7, readFileSync as readFileSync4 } from "fs";
2037
+ import { join as join7 } from "path";
2038
+ import { parseArgs as parseArgs11 } from "util";
2039
+ import { confirm as confirm2 } from "@clack/prompts";
2040
+ var usage11 = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
2041
+
2042
+ Upgrade a scaffolded Hogsend app in one step:
2043
+ 1. bump every @hogsend/* dependency to latest (or --to <version>), then
2044
+ 2. refresh the vendored Claude Code skills in ./.claude/skills to match.
2045
+
2046
+ Run this after a new engine release so your app AND the agent guidance move
2047
+ together. Skills are version-stamped so \`hogsend doctor\` can warn when they
2048
+ fall behind.
2049
+
2050
+ Options:
2051
+ --cwd <dir> Project root to upgrade (defaults to the current directory).
2052
+ --pm <manager> Package manager (default: detected from the lockfile, else pnpm).
2053
+ --to <version> Target version for @hogsend/* deps (default: latest).
2054
+ --deps-only Bump dependencies only; don't touch skills.
2055
+ --skills-only Refresh skills only; don't touch dependencies.
2056
+ --yes, -y Skip the confirmation prompt. Implied by --json.
2057
+ --json Run non-interactively and emit a single JSON result.
2058
+ -h, --help Show this help.`;
2059
+ var VALID_PMS = ["pnpm", "npm", "yarn", "bun"];
2060
+ function detectPm(cwd) {
2061
+ if (existsSync7(join7(cwd, "pnpm-lock.yaml"))) return "pnpm";
2062
+ if (existsSync7(join7(cwd, "yarn.lock"))) return "yarn";
2063
+ if (existsSync7(join7(cwd, "bun.lockb")) || existsSync7(join7(cwd, "bun.lock")))
2064
+ return "bun";
2065
+ if (existsSync7(join7(cwd, "package-lock.json"))) return "npm";
2066
+ return "pnpm";
2067
+ }
2068
+ function hogsendDeps(cwd) {
2069
+ const pkg = JSON.parse(readFileSync4(join7(cwd, "package.json"), "utf8"));
2070
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
2071
+ return Object.keys(all).filter((n) => n.startsWith("@hogsend/")).sort();
2072
+ }
2073
+ function addArgs(pm, specs) {
2074
+ return [pm === "npm" ? "install" : "add", ...specs];
2075
+ }
2076
+ async function run11(ctx) {
2077
+ const { values } = parseArgs11({
2078
+ args: ctx.argv,
2079
+ allowPositionals: true,
2080
+ options: {
2081
+ cwd: { type: "string" },
2082
+ pm: { type: "string" },
2083
+ to: { type: "string" },
2084
+ "deps-only": { type: "boolean", default: false },
2085
+ "skills-only": { type: "boolean", default: false },
2086
+ yes: { type: "boolean", short: "y", default: false },
2087
+ help: { type: "boolean", short: "h", default: false }
2088
+ }
2089
+ });
2090
+ if (values.help) {
2091
+ ctx.out.log(usage11);
2092
+ return;
2093
+ }
2094
+ if (values["deps-only"] && values["skills-only"]) {
2095
+ ctx.out.fail("--deps-only and --skills-only are mutually exclusive.");
2096
+ }
2097
+ const cwd = values.cwd ?? process.cwd();
2098
+ if (!existsSync7(join7(cwd, "package.json"))) {
2099
+ ctx.out.fail(
2100
+ `no package.json in ${cwd} \u2014 run upgrade from a scaffolded Hogsend app (or pass --cwd).`
2101
+ );
2102
+ }
2103
+ let pm;
2104
+ if (values.pm !== void 0) {
2105
+ if (!VALID_PMS.includes(values.pm)) {
2106
+ ctx.out.fail(
2107
+ `invalid --pm "${values.pm}". Expected one of: ${VALID_PMS.join(", ")}.`
2108
+ );
2109
+ }
2110
+ pm = values.pm;
2111
+ } else {
2112
+ pm = detectPm(cwd);
2113
+ }
2114
+ const target = values.to ?? "latest";
2115
+ const doDeps = !values["skills-only"];
2116
+ const doSkills = !values["deps-only"];
2117
+ const deps = doDeps ? hogsendDeps(cwd) : [];
2118
+ if (doDeps && deps.length === 0) {
2119
+ ctx.out.fail(
2120
+ `no @hogsend/* dependencies found in ${join7(cwd, "package.json")}.`
2121
+ );
2122
+ }
2123
+ const skipConfirm = ctx.json || values.yes;
2124
+ if (!ctx.json) {
2125
+ ctx.out.intro(
2126
+ `${color.bgMagenta(color.black(" hogsend "))} ${color.dim("upgrade")}`
2127
+ );
2128
+ }
2129
+ if (ctx.out.interactive && !skipConfirm) {
2130
+ const plan = [
2131
+ doDeps ? `bump ${deps.length} @hogsend/* dep(s) to ${target} (${pm})` : null,
2132
+ doSkills ? "refresh .claude/skills" : null
2133
+ ].filter(Boolean).join(" + ");
2134
+ const proceed = bail(
2135
+ await confirm2({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` })
2136
+ );
2137
+ if (!proceed) {
2138
+ ctx.out.outro(color.dim("Nothing changed."));
2139
+ return;
2140
+ }
2141
+ }
2142
+ const results = [];
2143
+ if (doDeps) {
2144
+ const specs = deps.map((n) => `${n}@${target}`);
2145
+ const dep = await ctx.out.step(
2146
+ `Bumping @hogsend/* -> ${target} (${pm})`,
2147
+ async () => spawnSync3(pm, addArgs(pm, specs), {
2148
+ cwd,
2149
+ stdio: ctx.json ? "ignore" : "inherit",
2150
+ shell: process.platform === "win32"
2151
+ })
2152
+ );
2153
+ results.push({
2154
+ step: "deps",
2155
+ status: dep.status === 0 ? "ok" : "failed",
2156
+ detail: dep.status === 0 ? `${deps.join(", ")} -> ${target}` : `${pm} exited with code ${dep.status ?? "?"}`
2157
+ });
2158
+ } else {
2159
+ results.push({ step: "deps", status: "skipped", detail: "--skills-only" });
2160
+ }
2161
+ const depsFailed = results.some(
2162
+ (r) => r.step === "deps" && r.status === "failed"
2163
+ );
2164
+ if (!doSkills) {
2165
+ results.push({
2166
+ step: "skills",
2167
+ status: "skipped",
2168
+ detail: "--deps-only"
2169
+ });
2170
+ } else if (depsFailed) {
2171
+ results.push({
2172
+ step: "skills",
2173
+ status: "skipped",
2174
+ detail: "skipped \u2014 dependency bump failed; fix it then re-run"
2175
+ });
2176
+ } else {
2177
+ const bundled = listBundledSkills(cwd);
2178
+ const copied = bundled.map((s) => copySkill(s.name, cwd, true));
2179
+ writeSkillsStamp(
2180
+ cwd,
2181
+ bundled.map((s) => s.name)
2182
+ );
2183
+ results.push({
2184
+ step: "skills",
2185
+ status: "ok",
2186
+ detail: `refreshed ${copied.length} skill(s) -> ${installDir(cwd)}`
2187
+ });
2188
+ }
2189
+ const failed = results.filter((r) => r.status === "failed");
2190
+ const ok = failed.length === 0;
2191
+ if (ctx.json) {
2192
+ ctx.out.json({ ok, cwd, pm, target, steps: results });
2193
+ if (!ok) process.exit(1);
2194
+ return;
2195
+ }
2196
+ ctx.out.table(
2197
+ results.map((r) => ({
2198
+ step: r.step,
2199
+ status: r.status === "ok" ? color.green("ok") : r.status === "skipped" ? color.dim("skipped") : color.red("failed"),
2200
+ detail: r.detail
2201
+ })),
2202
+ ["step", "status", "detail"]
2203
+ );
2204
+ if (!ok) {
2205
+ ctx.out.fail(
2206
+ `${failed.length} step(s) failed \u2014 see the table above. Fix and re-run hogsend upgrade.`
2207
+ );
2208
+ }
2209
+ ctx.out.outro(
2210
+ `${color.green("Upgraded.")} ${color.dim("Engine + agent skills are on the latest line.")}`
2211
+ );
2212
+ }
2213
+ var upgradeCommand = {
2214
+ name: "upgrade",
2215
+ summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
2216
+ usage: usage11,
2217
+ run: run11
2218
+ };
2219
+
1751
2220
  // src/commands/index.ts
1752
2221
  var commands = [
1753
2222
  doctorCommand,
@@ -1755,19 +2224,21 @@ var commands = [
1755
2224
  contactsCommand,
1756
2225
  statsCommand,
1757
2226
  eventsCommand,
2227
+ studioCommand,
1758
2228
  setupCommand,
1759
2229
  skillsCommand,
2230
+ upgradeCommand,
1760
2231
  ejectCommand,
1761
2232
  patchCommand
1762
2233
  ];
1763
2234
 
1764
2235
  // src/lib/config.ts
1765
- import { existsSync as existsSync5, readFileSync as readFileSync3 } from "fs";
1766
- import { join as join5 } from "path";
1767
- import { parseArgs as parseArgs10 } from "util";
2236
+ import { existsSync as existsSync8, readFileSync as readFileSync5 } from "fs";
2237
+ import { join as join8 } from "path";
2238
+ import { parseArgs as parseArgs12 } from "util";
1768
2239
  var DEFAULT_BASE_URL = "http://localhost:3002";
1769
2240
  function parseGlobalFlags(argv) {
1770
- const { values, tokens } = parseArgs10({
2241
+ const { values, tokens } = parseArgs12({
1771
2242
  args: argv,
1772
2243
  allowPositionals: true,
1773
2244
  strict: false,
@@ -1804,11 +2275,11 @@ function parseGlobalFlags(argv) {
1804
2275
  }
1805
2276
  function loadDotEnv(cwd = process.cwd()) {
1806
2277
  const out = {};
1807
- const file = join5(cwd, ".env");
1808
- if (!existsSync5(file)) return out;
2278
+ const file = join8(cwd, ".env");
2279
+ if (!existsSync8(file)) return out;
1809
2280
  let raw;
1810
2281
  try {
1811
- raw = readFileSync3(file, "utf8");
2282
+ raw = readFileSync5(file, "utf8");
1812
2283
  } catch {
1813
2284
  return out;
1814
2285
  }
@@ -1841,7 +2312,7 @@ function resolveConfig(flags, cwd = process.cwd()) {
1841
2312
  // src/bin.ts
1842
2313
  function version() {
1843
2314
  try {
1844
- const require2 = createRequire2(import.meta.url);
2315
+ const require2 = createRequire3(import.meta.url);
1845
2316
  const pkg = require2("../package.json");
1846
2317
  return pkg.version ?? "0.0.0";
1847
2318
  } catch {