@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
@@ -1,17 +1,17 @@
1
- import {
2
- cpSync,
3
- existsSync,
4
- mkdirSync,
5
- readdirSync,
6
- readFileSync,
7
- statSync,
8
- } from "node:fs";
1
+ import { existsSync } from "node:fs";
9
2
  import { join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
3
  import { parseArgs } from "node:util";
12
4
  import { multiselect } from "@clack/prompts";
13
5
  import { color } from "../lib/output.js";
14
6
  import { bail } from "../lib/prompt.js";
7
+ import {
8
+ bundledSkillsDir,
9
+ type CopyResult,
10
+ copySkill,
11
+ installDir,
12
+ listBundledSkills,
13
+ writeSkillsStamp,
14
+ } from "../lib/skills.js";
15
15
  import type { Command, CommandContext } from "./types.js";
16
16
 
17
17
  const usage = `hogsend skills <subcommand> [options]
@@ -24,10 +24,13 @@ Subcommands:
24
24
  list List bundled skills + whether each is installed.
25
25
  add [name] [--force] Copy a bundled skill into ./.claude/skills/<name>/.
26
26
  Omit name for an interactive multiselect (human),
27
- or copy all bundled skills (--json / non-interactive).
27
+ or copy all bundled skills (--all / --json /
28
+ non-interactive).
28
29
 
29
30
  Options:
30
- --force Overwrite an already-installed skill.
31
+ --all Install every bundled skill (skips the interactive picker).
32
+ --force Overwrite an already-installed skill. Use after upgrading the
33
+ engine to refresh vendored skills to the latest guidance.
31
34
  --json Emit machine-readable JSON only (implies non-interactive).
32
35
  -h, --help Show this help.
33
36
 
@@ -35,70 +38,11 @@ Examples:
35
38
  hogsend skills list
36
39
  hogsend skills list --json
37
40
  hogsend skills add
38
- hogsend skills add hogsend-cli --force`;
39
-
40
- /**
41
- * Resolve the directory holding the bundled skills shipped in the tarball.
42
- * At runtime bin.js lives at <pkg>/dist/bin.js, so the skills dir (shipped via
43
- * package.json files[]) is one level up at <pkg>/skills.
44
- */
45
- function bundledSkillsDir(): string {
46
- return fileURLToPath(new URL("../skills", import.meta.url));
47
- }
48
-
49
- /** Target directory for installed skills in the consumer project. */
50
- function installDir(cwd: string): string {
51
- return join(cwd, ".claude", "skills");
52
- }
53
-
54
- interface BundledSkill {
55
- name: string;
56
- description: string;
57
- installed: boolean;
58
- }
59
-
60
- /** A single line `key: value` reader for SKILL.md YAML frontmatter. */
61
- function readFrontmatterField(skillDir: string, field: string): string {
62
- const skillFile = join(skillDir, "SKILL.md");
63
- if (!existsSync(skillFile)) return "";
64
- // Tiny frontmatter scan — avoids a YAML dep. Reads only the top block.
65
- const raw = readFileSyncSafe(skillFile);
66
- const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
67
- if (!fmMatch) return "";
68
- const block = fmMatch[1] ?? "";
69
- for (const line of block.split("\n")) {
70
- const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
71
- if (m && m[1] === field) {
72
- return (m[2] ?? "").replace(/^["']|["']$/g, "").trim();
73
- }
74
- }
75
- return "";
76
- }
77
-
78
- /** Read a file as utf8, returning "" on any error (never throws). */
79
- function readFileSyncSafe(path: string): string {
80
- try {
81
- return readFileSync(path, "utf8");
82
- } catch {
83
- return "";
84
- }
85
- }
41
+ hogsend skills add --all
42
+ hogsend skills add hogsend-cli --force
43
+ hogsend skills add --all --force # refresh everything after an upgrade
86
44
 
87
- /** Enumerate bundled skills (each is a subdir with a SKILL.md). */
88
- function listBundledSkills(cwd: string): BundledSkill[] {
89
- const dir = bundledSkillsDir();
90
- if (!existsSync(dir)) return [];
91
- const target = installDir(cwd);
92
- const entries = readdirSync(dir).filter((name) => {
93
- const full = join(dir, name);
94
- return statSync(full).isDirectory() && existsSync(join(full, "SKILL.md"));
95
- });
96
- return entries.sort().map((name) => ({
97
- name,
98
- description: readFrontmatterField(join(dir, name), "description"),
99
- installed: existsSync(join(target, name)),
100
- }));
101
- }
45
+ Tip: \`hogsend upgrade\` bumps the engine AND refreshes these skills in one step.`;
102
46
 
103
47
  function runList(ctx: CommandContext): void {
104
48
  const skills = listBundledSkills(process.cwd());
@@ -133,35 +77,17 @@ function runList(ctx: CommandContext): void {
133
77
  ["name", "installed", "description"],
134
78
  );
135
79
  ctx.out.outro(
136
- `Install with ${color.cyan("hogsend skills add <name>")} (or just ${color.cyan("hogsend skills add")}).`,
80
+ `Install with ${color.cyan("hogsend skills add <name>")} (or ${color.cyan("hogsend skills add --all")}). ` +
81
+ `Refresh after an engine upgrade with ${color.cyan("--force")}.`,
137
82
  );
138
83
  }
139
84
 
140
- interface CopyResult {
141
- name: string;
142
- installed: boolean;
143
- skipped: boolean;
144
- path: string;
145
- }
146
-
147
- /** Copy one bundled skill into the project, honouring --force. */
148
- function copySkill(name: string, cwd: string, force: boolean): CopyResult {
149
- const src = join(bundledSkillsDir(), name);
150
- const dest = join(installDir(cwd), name);
151
- const exists = existsSync(dest);
152
- if (exists && !force) {
153
- return { name, installed: false, skipped: true, path: dest };
154
- }
155
- mkdirSync(installDir(cwd), { recursive: true });
156
- cpSync(src, dest, { recursive: true, force: true });
157
- return { name, installed: true, skipped: false, path: dest };
158
- }
159
-
160
85
  async function runAdd(ctx: CommandContext, argv: string[]): Promise<void> {
161
86
  const { values, positionals } = parseArgs({
162
87
  args: argv,
163
88
  allowPositionals: true,
164
89
  options: {
90
+ all: { type: "boolean", default: false },
165
91
  force: { type: "boolean", default: false },
166
92
  help: { type: "boolean", short: "h", default: false },
167
93
  },
@@ -191,6 +117,9 @@ async function runAdd(ctx: CommandContext, argv: string[]): Promise<void> {
191
117
  );
192
118
  }
193
119
  names = [requested];
120
+ } else if (values.all) {
121
+ // Explicit install-all — skip the picker even in a TTY.
122
+ names = bundled.map((s) => s.name);
194
123
  } else if (ctx.out.interactive) {
195
124
  const picked = bail(
196
125
  await multiselect({
@@ -209,7 +138,18 @@ async function runAdd(ctx: CommandContext, argv: string[]): Promise<void> {
209
138
  names = bundled.map((s) => s.name);
210
139
  }
211
140
 
212
- const results = names.map((name) => copySkill(name, cwd, force));
141
+ const results: CopyResult[] = names.map((name) =>
142
+ copySkill(name, cwd, force),
143
+ );
144
+
145
+ // Stamp the now-installed set with this CLI's version, so `hogsend doctor`
146
+ // can later tell whether the vendored skills have fallen behind the engine.
147
+ if (results.some((r) => r.installed)) {
148
+ const installedNames = listBundledSkills(cwd)
149
+ .filter((s) => existsSync(join(installDir(cwd), s.name)))
150
+ .map((s) => s.name);
151
+ writeSkillsStamp(cwd, installedNames);
152
+ }
213
153
 
214
154
  if (ctx.json) {
215
155
  ctx.out.json({
@@ -0,0 +1,261 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { extname, join, normalize, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+ import { color } from "../lib/output.js";
8
+ import type { Command, CommandContext } from "./types.js";
9
+
10
+ const usage = `hogsend studio [options]
11
+
12
+ Serve the bundled Hogsend Studio (the admin SPA) locally and open it in a
13
+ browser. The Studio is a static single-page app; this command starts a tiny
14
+ local web server for it on a port of your choosing.
15
+
16
+ By default the Studio talks to the API at the same origin it is served from,
17
+ which won't be a running API here — so point it at your instance with
18
+ --base-url (the SPA uses cookie auth, so the instance must allow CORS from the
19
+ Studio origin, or you can simply open the Studio that the engine mounts at
20
+ \`<instance>/studio\` instead).
21
+
22
+ Options:
23
+ --port <n> Local port to serve on (default 3333).
24
+ --base-url <url> API instance the Studio should call (injected at runtime).
25
+ Omit to use same-origin (the local server, for static
26
+ preview only).
27
+ --open Open the Studio in your default browser after starting.
28
+ --dist <path> Override the Studio dist directory (advanced).
29
+ -h, --help Show this help.
30
+
31
+ Examples:
32
+ hogsend studio --open
33
+ hogsend studio --base-url https://api.example.com --open
34
+ hogsend studio --port 4000`;
35
+
36
+ /**
37
+ * Resolve the built Studio `dist/` directory.
38
+ *
39
+ * Resolution order:
40
+ * 1. Explicit --dist override (positional path; absolute or cwd-relative).
41
+ * 2. The dist bundled inside this CLI package (shipped via package.json files[];
42
+ * at runtime bin.js is <pkg>/dist/bin.js, so the bundled studio is one level
43
+ * up at <pkg>/studio).
44
+ * 3. Monorepo source layout: packages/studio/dist relative to this file.
45
+ * 4. cwd-relative packages/studio/dist (running from repo root).
46
+ */
47
+ function resolveStudioDist(distFlag: string | undefined): string | null {
48
+ const candidates: string[] = [];
49
+
50
+ if (distFlag && distFlag.length > 0) {
51
+ candidates.push(resolve(process.cwd(), distFlag));
52
+ }
53
+
54
+ // Bundled in the published CLI tarball at <pkg>/studio.
55
+ candidates.push(fileURLToPath(new URL("../studio", import.meta.url)));
56
+
57
+ // Monorepo: this file is packages/cli/src/commands/studio.ts (or built into
58
+ // dist/), so the studio dist sits at ../../studio/dist relative to dist/.
59
+ candidates.push(
60
+ fileURLToPath(new URL("../../studio/dist", import.meta.url)),
61
+ fileURLToPath(new URL("../../../studio/dist", import.meta.url)),
62
+ );
63
+
64
+ candidates.push(resolve(process.cwd(), "packages/studio/dist"));
65
+
66
+ for (const dir of candidates) {
67
+ if (existsSync(join(dir, "index.html"))) {
68
+ return dir;
69
+ }
70
+ }
71
+ return null;
72
+ }
73
+
74
+ const MIME: Record<string, string> = {
75
+ ".html": "text/html; charset=utf-8",
76
+ ".js": "text/javascript; charset=utf-8",
77
+ ".mjs": "text/javascript; charset=utf-8",
78
+ ".css": "text/css; charset=utf-8",
79
+ ".json": "application/json; charset=utf-8",
80
+ ".svg": "image/svg+xml",
81
+ ".png": "image/png",
82
+ ".jpg": "image/jpeg",
83
+ ".jpeg": "image/jpeg",
84
+ ".gif": "image/gif",
85
+ ".ico": "image/x-icon",
86
+ ".woff": "font/woff",
87
+ ".woff2": "font/woff2",
88
+ ".ttf": "font/ttf",
89
+ ".map": "application/json; charset=utf-8",
90
+ };
91
+
92
+ function mimeFor(path: string): string {
93
+ return MIME[extname(path).toLowerCase()] ?? "application/octet-stream";
94
+ }
95
+
96
+ /**
97
+ * Read index.html and, when a base URL is provided, inject a runtime global the
98
+ * Studio reads (`window.__HOGSEND_STUDIO__ = { baseUrl }`) so the static bundle
99
+ * can be pointed at a remote instance without a rebuild.
100
+ */
101
+ function indexHtml(distPath: string, baseUrl: string | undefined): string {
102
+ const raw = readFileSync(join(distPath, "index.html"), "utf8");
103
+ if (!baseUrl) return raw;
104
+ const inject = `<script>window.__HOGSEND_STUDIO__=${JSON.stringify({
105
+ baseUrl,
106
+ })};</script>`;
107
+ if (raw.includes("</head>")) {
108
+ return raw.replace("</head>", `${inject}</head>`);
109
+ }
110
+ return `${inject}${raw}`;
111
+ }
112
+
113
+ /** Open a URL in the OS default browser (best-effort, never throws). */
114
+ function openBrowser(url: string): void {
115
+ const platform = process.platform;
116
+ const cmd =
117
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
118
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
119
+ try {
120
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
121
+ child.on("error", () => {});
122
+ child.unref();
123
+ } catch {
124
+ // best-effort
125
+ }
126
+ }
127
+
128
+ async function run(ctx: CommandContext): Promise<void> {
129
+ const { values, positionals } = parseArgs({
130
+ args: ctx.argv,
131
+ allowPositionals: true,
132
+ strict: false,
133
+ options: {
134
+ port: { type: "string" },
135
+ "base-url": { type: "string" },
136
+ open: { type: "boolean", default: false },
137
+ dist: { type: "string" },
138
+ help: { type: "boolean", short: "h", default: false },
139
+ },
140
+ });
141
+
142
+ if (values.help) {
143
+ ctx.out.log(usage);
144
+ return;
145
+ }
146
+
147
+ const port = Number(values.port ?? "3333");
148
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
149
+ ctx.out.fail(`invalid --port "${values.port}" (expected 1-65535)`);
150
+ }
151
+
152
+ // --base-url flag, else the resolved CLI config base URL (so it "just works"
153
+ // against the same instance the other commands target), unless that's the
154
+ // local default placeholder. Keep undefined for pure static preview.
155
+ const baseUrl =
156
+ typeof values["base-url"] === "string" ? values["base-url"] : undefined;
157
+
158
+ const distPath = resolveStudioDist(
159
+ typeof values.dist === "string" ? values.dist : positionals[0],
160
+ );
161
+
162
+ if (!distPath) {
163
+ ctx.out.fail(
164
+ "could not find a built Studio (dist/). Build it with " +
165
+ "`pnpm --filter @hogsend/studio build`, or pass --dist <path>.",
166
+ );
167
+ }
168
+
169
+ const cleanBase = baseUrl ? baseUrl.replace(/\/+$/, "") : undefined;
170
+ const index = indexHtml(distPath, cleanBase);
171
+
172
+ const server = createServer((req, res) => {
173
+ const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0] ?? "/");
174
+
175
+ // The Studio bundle is built under base "/studio/", so all asset URLs are
176
+ // prefixed with /studio. Strip that prefix to map onto the dist root.
177
+ const rel = urlPath.replace(/^\/studio/, "");
178
+ if (rel === "" || rel === "/") {
179
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
180
+ res.end(index);
181
+ return;
182
+ }
183
+
184
+ // Resolve safely inside distPath (defend against path traversal).
185
+ const target = normalize(join(distPath, rel));
186
+ if (target !== distPath && !target.startsWith(distPath + sep)) {
187
+ res.writeHead(403);
188
+ res.end("Forbidden");
189
+ return;
190
+ }
191
+
192
+ if (existsSync(target) && statSync(target).isFile()) {
193
+ res.writeHead(200, { "content-type": mimeFor(target) });
194
+ createReadStream(target).pipe(res);
195
+ return;
196
+ }
197
+
198
+ // SPA fallback: unknown paths serve index.html so client-side routes work.
199
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
200
+ res.end(index);
201
+ });
202
+
203
+ await new Promise<void>((resolveListen, reject) => {
204
+ server.once("error", reject);
205
+ server.listen(port, () => resolveListen());
206
+ }).catch((err: unknown) => {
207
+ const msg = err instanceof Error ? err.message : String(err);
208
+ ctx.out.fail(`could not start server on port ${port}: ${msg}`);
209
+ });
210
+
211
+ const localUrl = `http://localhost:${port}/studio/`;
212
+
213
+ if (ctx.json) {
214
+ ctx.out.json({
215
+ url: localUrl,
216
+ port,
217
+ baseUrl: cleanBase ?? null,
218
+ dist: distPath,
219
+ });
220
+ // In json mode we still keep the server running (foreground). Agents that
221
+ // don't want a long-running process should not pass --json to `studio`.
222
+ } else {
223
+ ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} studio`);
224
+ ctx.out.note(
225
+ [
226
+ `${color.green("●")} Studio serving at ${color.cyan(localUrl)}`,
227
+ cleanBase
228
+ ? color.dim(`API instance: ${cleanBase}`)
229
+ : color.dim(
230
+ "No --base-url set (same-origin / static preview). The API " +
231
+ "calls will hit this local server and fail — pass --base-url " +
232
+ "<instance>, or open <instance>/studio directly.",
233
+ ),
234
+ "",
235
+ color.dim("First load shows a create-admin screen if no admin exists."),
236
+ color.dim("Press Ctrl+C to stop."),
237
+ ].join("\n"),
238
+ "Studio",
239
+ );
240
+ }
241
+
242
+ if (values.open) {
243
+ openBrowser(localUrl);
244
+ }
245
+
246
+ // Keep the process alive until interrupted.
247
+ await new Promise<void>((resolveForever) => {
248
+ const stop = () => {
249
+ server.close(() => resolveForever());
250
+ };
251
+ process.on("SIGINT", stop);
252
+ process.on("SIGTERM", stop);
253
+ });
254
+ }
255
+
256
+ export const studioCommand: Command = {
257
+ name: "studio",
258
+ summary: "Serve the bundled Hogsend Studio admin SPA locally",
259
+ usage,
260
+ run,
261
+ };
@@ -0,0 +1,245 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { confirm } from "@clack/prompts";
6
+ import { color } from "../lib/output.js";
7
+ import { bail } from "../lib/prompt.js";
8
+ import {
9
+ copySkill,
10
+ installDir,
11
+ listBundledSkills,
12
+ writeSkillsStamp,
13
+ } from "../lib/skills.js";
14
+ import type { Command, CommandContext } from "./types.js";
15
+
16
+ const usage = `hogsend upgrade [--cwd <dir>] [--pm <pnpm|npm|yarn|bun>] [options]
17
+
18
+ Upgrade a scaffolded Hogsend app in one step:
19
+ 1. bump every @hogsend/* dependency to latest (or --to <version>), then
20
+ 2. refresh the vendored Claude Code skills in ./.claude/skills to match.
21
+
22
+ Run this after a new engine release so your app AND the agent guidance move
23
+ together. Skills are version-stamped so \`hogsend doctor\` can warn when they
24
+ fall behind.
25
+
26
+ Options:
27
+ --cwd <dir> Project root to upgrade (defaults to the current directory).
28
+ --pm <manager> Package manager (default: detected from the lockfile, else pnpm).
29
+ --to <version> Target version for @hogsend/* deps (default: latest).
30
+ --deps-only Bump dependencies only; don't touch skills.
31
+ --skills-only Refresh skills only; don't touch dependencies.
32
+ --yes, -y Skip the confirmation prompt. Implied by --json.
33
+ --json Run non-interactively and emit a single JSON result.
34
+ -h, --help Show this help.`;
35
+
36
+ type Pm = "pnpm" | "npm" | "yarn" | "bun";
37
+ const VALID_PMS: Pm[] = ["pnpm", "npm", "yarn", "bun"];
38
+
39
+ /** Detect the package manager from a lockfile, defaulting to pnpm. */
40
+ function detectPm(cwd: string): Pm {
41
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
42
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
43
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock")))
44
+ return "bun";
45
+ if (existsSync(join(cwd, "package-lock.json"))) return "npm";
46
+ return "pnpm";
47
+ }
48
+
49
+ /** The @hogsend/* deps declared in the app's package.json. */
50
+ function hogsendDeps(cwd: string): string[] {
51
+ const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as {
52
+ dependencies?: Record<string, string>;
53
+ devDependencies?: Record<string, string>;
54
+ };
55
+ const all = { ...pkg.dependencies, ...pkg.devDependencies };
56
+ return Object.keys(all)
57
+ .filter((n) => n.startsWith("@hogsend/"))
58
+ .sort();
59
+ }
60
+
61
+ /** Build the install verb + args for the given pm (all but npm use `add`). */
62
+ function addArgs(pm: Pm, specs: string[]): string[] {
63
+ return [pm === "npm" ? "install" : "add", ...specs];
64
+ }
65
+
66
+ interface StepResult {
67
+ step: string;
68
+ status: "ok" | "skipped" | "failed";
69
+ detail: string;
70
+ }
71
+
72
+ async function run(ctx: CommandContext): Promise<void> {
73
+ const { values } = parseArgs({
74
+ args: ctx.argv,
75
+ allowPositionals: true,
76
+ options: {
77
+ cwd: { type: "string" },
78
+ pm: { type: "string" },
79
+ to: { type: "string" },
80
+ "deps-only": { type: "boolean", default: false },
81
+ "skills-only": { type: "boolean", default: false },
82
+ yes: { type: "boolean", short: "y", default: false },
83
+ help: { type: "boolean", short: "h", default: false },
84
+ },
85
+ });
86
+
87
+ if (values.help) {
88
+ ctx.out.log(usage);
89
+ return;
90
+ }
91
+
92
+ if (values["deps-only"] && values["skills-only"]) {
93
+ ctx.out.fail("--deps-only and --skills-only are mutually exclusive.");
94
+ }
95
+
96
+ const cwd = values.cwd ?? process.cwd();
97
+ if (!existsSync(join(cwd, "package.json"))) {
98
+ ctx.out.fail(
99
+ `no package.json in ${cwd} — run upgrade from a scaffolded Hogsend app (or pass --cwd).`,
100
+ );
101
+ }
102
+
103
+ let pm: Pm;
104
+ if (values.pm !== undefined) {
105
+ if (!(VALID_PMS as string[]).includes(values.pm)) {
106
+ ctx.out.fail(
107
+ `invalid --pm "${values.pm}". Expected one of: ${VALID_PMS.join(", ")}.`,
108
+ );
109
+ }
110
+ pm = values.pm as Pm;
111
+ } else {
112
+ pm = detectPm(cwd);
113
+ }
114
+
115
+ const target = values.to ?? "latest";
116
+ const doDeps = !values["skills-only"];
117
+ const doSkills = !values["deps-only"];
118
+ const deps = doDeps ? hogsendDeps(cwd) : [];
119
+
120
+ if (doDeps && deps.length === 0) {
121
+ ctx.out.fail(
122
+ `no @hogsend/* dependencies found in ${join(cwd, "package.json")}.`,
123
+ );
124
+ }
125
+
126
+ const skipConfirm = ctx.json || values.yes;
127
+ if (!ctx.json) {
128
+ ctx.out.intro(
129
+ `${color.bgMagenta(color.black(" hogsend "))} ${color.dim("upgrade")}`,
130
+ );
131
+ }
132
+ if (ctx.out.interactive && !skipConfirm) {
133
+ const plan = [
134
+ doDeps
135
+ ? `bump ${deps.length} @hogsend/* dep(s) to ${target} (${pm})`
136
+ : null,
137
+ doSkills ? "refresh .claude/skills" : null,
138
+ ]
139
+ .filter(Boolean)
140
+ .join(" + ");
141
+ const proceed = bail(
142
+ await confirm({ message: `Upgrade ${color.cyan(cwd)}: ${plan}?` }),
143
+ );
144
+ if (!proceed) {
145
+ ctx.out.outro(color.dim("Nothing changed."));
146
+ return;
147
+ }
148
+ }
149
+
150
+ const results: StepResult[] = [];
151
+
152
+ // 1. bump @hogsend/* deps via the package manager.
153
+ if (doDeps) {
154
+ const specs = deps.map((n) => `${n}@${target}`);
155
+ const dep = await ctx.out.step(
156
+ `Bumping @hogsend/* -> ${target} (${pm})`,
157
+ async () =>
158
+ spawnSync(pm, addArgs(pm, specs), {
159
+ cwd,
160
+ stdio: ctx.json ? "ignore" : "inherit",
161
+ shell: process.platform === "win32",
162
+ }),
163
+ );
164
+ results.push({
165
+ step: "deps",
166
+ status: dep.status === 0 ? "ok" : "failed",
167
+ detail:
168
+ dep.status === 0
169
+ ? `${deps.join(", ")} -> ${target}`
170
+ : `${pm} exited with code ${dep.status ?? "?"}`,
171
+ });
172
+ } else {
173
+ results.push({ step: "deps", status: "skipped", detail: "--skills-only" });
174
+ }
175
+
176
+ // 2. refresh vendored skills + re-stamp (only if deps didn't hard-fail).
177
+ const depsFailed = results.some(
178
+ (r) => r.step === "deps" && r.status === "failed",
179
+ );
180
+ if (!doSkills) {
181
+ results.push({
182
+ step: "skills",
183
+ status: "skipped",
184
+ detail: "--deps-only",
185
+ });
186
+ } else if (depsFailed) {
187
+ results.push({
188
+ step: "skills",
189
+ status: "skipped",
190
+ detail: "skipped — dependency bump failed; fix it then re-run",
191
+ });
192
+ } else {
193
+ const bundled = listBundledSkills(cwd);
194
+ const copied = bundled.map((s) => copySkill(s.name, cwd, true));
195
+ writeSkillsStamp(
196
+ cwd,
197
+ bundled.map((s) => s.name),
198
+ );
199
+ results.push({
200
+ step: "skills",
201
+ status: "ok",
202
+ detail: `refreshed ${copied.length} skill(s) -> ${installDir(cwd)}`,
203
+ });
204
+ }
205
+
206
+ const failed = results.filter((r) => r.status === "failed");
207
+ const ok = failed.length === 0;
208
+
209
+ if (ctx.json) {
210
+ ctx.out.json({ ok, cwd, pm, target, steps: results });
211
+ if (!ok) process.exit(1);
212
+ return;
213
+ }
214
+
215
+ ctx.out.table(
216
+ results.map((r) => ({
217
+ step: r.step,
218
+ status:
219
+ r.status === "ok"
220
+ ? color.green("ok")
221
+ : r.status === "skipped"
222
+ ? color.dim("skipped")
223
+ : color.red("failed"),
224
+ detail: r.detail,
225
+ })),
226
+ ["step", "status", "detail"],
227
+ );
228
+
229
+ if (!ok) {
230
+ ctx.out.fail(
231
+ `${failed.length} step(s) failed — see the table above. Fix and re-run hogsend upgrade.`,
232
+ );
233
+ }
234
+
235
+ ctx.out.outro(
236
+ `${color.green("Upgraded.")} ${color.dim("Engine + agent skills are on the latest line.")}`,
237
+ );
238
+ }
239
+
240
+ export const upgradeCommand: Command = {
241
+ name: "upgrade",
242
+ summary: "Bump @hogsend/* deps to latest + refresh vendored skills",
243
+ usage,
244
+ run,
245
+ };