@hogsend/cli 0.0.1 → 0.1.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.
- package/dist/bin.js +1750 -58
- package/dist/bin.js.map +1 -1
- package/package.json +6 -1
- package/skills/hogsend-cli/SKILL.md +80 -0
- package/skills/hogsend-cli/references/debug-a-journey.md +66 -0
- package/skills/hogsend-cli/references/manage-journeys.md +53 -0
- package/skills/hogsend-cli/references/query-stats.md +66 -0
- package/skills/hogsend-cli/references/setup-local.md +52 -0
- package/src/bin.ts +73 -111
- package/src/commands/contacts.ts +316 -0
- package/src/commands/doctor.ts +217 -0
- package/src/commands/eject.ts +106 -0
- package/src/commands/events.ts +154 -0
- package/src/commands/index.ts +32 -0
- package/src/commands/journeys.ts +343 -0
- package/src/commands/patch.ts +80 -0
- package/src/commands/setup.ts +322 -0
- package/src/commands/skills.ts +268 -0
- package/src/commands/stats.ts +87 -0
- package/src/commands/types.ts +41 -0
- package/src/index.ts +2 -0
- package/src/lib/config.ts +147 -0
- package/src/lib/http.ts +145 -0
- package/src/lib/output.ts +185 -0
- package/src/lib/prompt.ts +17 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { confirm } from "@clack/prompts";
|
|
7
|
+
import { color } from "../lib/output.js";
|
|
8
|
+
import { bail } from "../lib/prompt.js";
|
|
9
|
+
import type { Command, CommandContext } from "./types.js";
|
|
10
|
+
|
|
11
|
+
const usage = `hogsend setup [--cwd <dir>] [--yes] [--json]
|
|
12
|
+
|
|
13
|
+
Interactive local onboarding for a scaffolded Hogsend app. Mirrors the
|
|
14
|
+
create-hogsend "next steps":
|
|
15
|
+
|
|
16
|
+
1. docker compose up -d # Postgres + Redis + Hatchet-Lite
|
|
17
|
+
2. cp .env.example .env (if missing)
|
|
18
|
+
3. generate a BETTER_AUTH_SECRET (if still the placeholder)
|
|
19
|
+
4. pnpm db:migrate # engine track then client track
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--cwd <dir> Project root to run in (defaults to the current directory).
|
|
23
|
+
--yes, -y Skip confirmation prompts (assume yes). Implied by --json.
|
|
24
|
+
--json Run non-interactively and emit a single JSON result document.
|
|
25
|
+
-h, --help Show this help.
|
|
26
|
+
|
|
27
|
+
Run ${color.cyan("hogsend doctor")} afterwards to verify the instance is healthy.`;
|
|
28
|
+
|
|
29
|
+
/** Generate a 64-char hex secret (32 bytes) for BETTER_AUTH_SECRET. */
|
|
30
|
+
function generateSecret(): string {
|
|
31
|
+
return randomBytes(32).toString("hex");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const SECRET_KEY = "BETTER_AUTH_SECRET";
|
|
35
|
+
const PLACEHOLDER_PREFIX = "change-me";
|
|
36
|
+
|
|
37
|
+
interface StepResult {
|
|
38
|
+
step: string;
|
|
39
|
+
status: "ok" | "skipped" | "failed";
|
|
40
|
+
detail: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Ensure a `.env` exists (copying `.env.example` when absent) and that
|
|
45
|
+
* BETTER_AUTH_SECRET holds a real generated value rather than the placeholder.
|
|
46
|
+
* Pure-ish: only touches the filesystem, returns a structured result.
|
|
47
|
+
*/
|
|
48
|
+
function ensureEnv(cwd: string): { copied: StepResult; secret: StepResult } {
|
|
49
|
+
const envPath = join(cwd, ".env");
|
|
50
|
+
const examplePath = join(cwd, ".env.example");
|
|
51
|
+
|
|
52
|
+
let copied: StepResult;
|
|
53
|
+
if (existsSync(envPath)) {
|
|
54
|
+
copied = {
|
|
55
|
+
step: "env",
|
|
56
|
+
status: "skipped",
|
|
57
|
+
detail: ".env already exists",
|
|
58
|
+
};
|
|
59
|
+
} else if (existsSync(examplePath)) {
|
|
60
|
+
copyFileSync(examplePath, envPath);
|
|
61
|
+
copied = {
|
|
62
|
+
step: "env",
|
|
63
|
+
status: "ok",
|
|
64
|
+
detail: "copied .env.example -> .env",
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
copied = {
|
|
68
|
+
step: "env",
|
|
69
|
+
status: "failed",
|
|
70
|
+
detail: "no .env and no .env.example to copy from",
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
copied,
|
|
74
|
+
secret: {
|
|
75
|
+
step: "secret",
|
|
76
|
+
status: "skipped",
|
|
77
|
+
detail: "skipped — no .env",
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// (Re)read the file we just ensured exists and refresh the secret if it is
|
|
83
|
+
// missing or still the scaffold placeholder. Never overwrite a real secret.
|
|
84
|
+
let raw: string;
|
|
85
|
+
try {
|
|
86
|
+
raw = readFileSync(envPath, "utf8");
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
copied,
|
|
90
|
+
secret: {
|
|
91
|
+
step: "secret",
|
|
92
|
+
status: "failed",
|
|
93
|
+
detail: `could not read .env: ${err instanceof Error ? err.message : String(err)}`,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const lines = raw.split(/\r?\n/);
|
|
99
|
+
const idx = lines.findIndex((l) =>
|
|
100
|
+
l
|
|
101
|
+
.replace(/^export\s+/, "")
|
|
102
|
+
.trimStart()
|
|
103
|
+
.startsWith(`${SECRET_KEY}=`),
|
|
104
|
+
);
|
|
105
|
+
const existingLine = idx === -1 ? undefined : lines[idx];
|
|
106
|
+
const current =
|
|
107
|
+
existingLine === undefined
|
|
108
|
+
? undefined
|
|
109
|
+
: existingLine.slice(existingLine.indexOf("=") + 1).trim();
|
|
110
|
+
const isPlaceholder =
|
|
111
|
+
current === undefined ||
|
|
112
|
+
current === "" ||
|
|
113
|
+
current.startsWith(PLACEHOLDER_PREFIX);
|
|
114
|
+
|
|
115
|
+
if (!isPlaceholder) {
|
|
116
|
+
return {
|
|
117
|
+
copied,
|
|
118
|
+
secret: {
|
|
119
|
+
step: "secret",
|
|
120
|
+
status: "skipped",
|
|
121
|
+
detail: `${SECRET_KEY} already set`,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const secret = generateSecret();
|
|
127
|
+
const newLine = `${SECRET_KEY}=${secret}`;
|
|
128
|
+
if (idx === -1) {
|
|
129
|
+
if (raw.length > 0 && !raw.endsWith("\n")) lines.push("");
|
|
130
|
+
lines.push(newLine);
|
|
131
|
+
} else {
|
|
132
|
+
lines[idx] = newLine;
|
|
133
|
+
}
|
|
134
|
+
writeFileSync(envPath, lines.join("\n"));
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
copied,
|
|
138
|
+
secret: {
|
|
139
|
+
step: "secret",
|
|
140
|
+
status: "ok",
|
|
141
|
+
detail: `generated ${SECRET_KEY} (64-char hex)`,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Run a shell command, capturing exit status. */
|
|
147
|
+
function runCmd(
|
|
148
|
+
cmd: string,
|
|
149
|
+
args: string[],
|
|
150
|
+
cwd: string,
|
|
151
|
+
json: boolean,
|
|
152
|
+
): { status: number | null; ok: boolean } {
|
|
153
|
+
const result = spawnSync(cmd, args, {
|
|
154
|
+
cwd,
|
|
155
|
+
// In json mode stay silent (we report structured status); otherwise stream
|
|
156
|
+
// so the user sees docker / migration output inline.
|
|
157
|
+
stdio: json ? "ignore" : "inherit",
|
|
158
|
+
});
|
|
159
|
+
return { status: result.status, ok: result.status === 0 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
163
|
+
const { values } = parseArgs({
|
|
164
|
+
args: ctx.argv,
|
|
165
|
+
allowPositionals: true,
|
|
166
|
+
options: {
|
|
167
|
+
cwd: { type: "string" },
|
|
168
|
+
yes: { type: "boolean", short: "y", default: false },
|
|
169
|
+
help: { type: "boolean", short: "h", default: false },
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (values.help) {
|
|
174
|
+
ctx.out.log(usage);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cwd = values.cwd ?? process.cwd();
|
|
179
|
+
|
|
180
|
+
if (!existsSync(join(cwd, "package.json"))) {
|
|
181
|
+
ctx.out.fail(
|
|
182
|
+
`no package.json in ${cwd} — run setup from a scaffolded Hogsend app (or pass --cwd).`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const hasCompose =
|
|
187
|
+
existsSync(join(cwd, "docker-compose.yml")) ||
|
|
188
|
+
existsSync(join(cwd, "docker-compose.yaml")) ||
|
|
189
|
+
existsSync(join(cwd, "compose.yml")) ||
|
|
190
|
+
existsSync(join(cwd, "compose.yaml"));
|
|
191
|
+
|
|
192
|
+
// --json implies non-interactive; in TTY human mode we confirm first.
|
|
193
|
+
const skipConfirm = ctx.json || values.yes;
|
|
194
|
+
|
|
195
|
+
if (!ctx.json) {
|
|
196
|
+
ctx.out.intro(
|
|
197
|
+
`${color.bgMagenta(color.black(" hogsend "))} ${color.dim("local onboarding")}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (ctx.out.interactive && !skipConfirm) {
|
|
202
|
+
const proceed = bail(
|
|
203
|
+
await confirm({
|
|
204
|
+
message: `Set up local infra in ${color.cyan(cwd)}? (docker compose up, .env, db:migrate)`,
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
if (!proceed) {
|
|
208
|
+
ctx.out.outro(color.dim("Nothing changed."));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const results: StepResult[] = [];
|
|
214
|
+
|
|
215
|
+
// 1. docker compose up -d
|
|
216
|
+
if (hasCompose) {
|
|
217
|
+
const docker = await ctx.out.step(
|
|
218
|
+
"Starting infra (docker compose up -d)",
|
|
219
|
+
async () => runCmd("docker", ["compose", "up", "-d"], cwd, ctx.json),
|
|
220
|
+
);
|
|
221
|
+
results.push({
|
|
222
|
+
step: "docker",
|
|
223
|
+
status: docker.ok ? "ok" : "failed",
|
|
224
|
+
detail: docker.ok
|
|
225
|
+
? "Postgres + Redis + Hatchet-Lite up"
|
|
226
|
+
: `docker compose exited with code ${docker.status ?? "?"}`,
|
|
227
|
+
});
|
|
228
|
+
} else {
|
|
229
|
+
results.push({
|
|
230
|
+
step: "docker",
|
|
231
|
+
status: "skipped",
|
|
232
|
+
detail: "no docker-compose file found",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 2 + 3. .env + secret (synchronous fs work, wrapped in a step for the spinner)
|
|
237
|
+
const env = await ctx.out.step("Preparing .env + auth secret", async () =>
|
|
238
|
+
ensureEnv(cwd),
|
|
239
|
+
);
|
|
240
|
+
results.push(env.copied, env.secret);
|
|
241
|
+
|
|
242
|
+
// 4. db:migrate (only attempt if docker didn't hard-fail; still try if skipped)
|
|
243
|
+
const dockerFailed = results.some(
|
|
244
|
+
(r) => r.step === "docker" && r.status === "failed",
|
|
245
|
+
);
|
|
246
|
+
if (dockerFailed) {
|
|
247
|
+
results.push({
|
|
248
|
+
step: "migrate",
|
|
249
|
+
status: "skipped",
|
|
250
|
+
detail:
|
|
251
|
+
"skipped — docker compose failed; bring infra up then run pnpm db:migrate",
|
|
252
|
+
});
|
|
253
|
+
} else {
|
|
254
|
+
const migrate = await ctx.out.step(
|
|
255
|
+
"Running migrations (pnpm db:migrate)",
|
|
256
|
+
async () => runCmd("pnpm", ["db:migrate"], cwd, ctx.json),
|
|
257
|
+
);
|
|
258
|
+
results.push({
|
|
259
|
+
step: "migrate",
|
|
260
|
+
status: migrate.ok ? "ok" : "failed",
|
|
261
|
+
detail: migrate.ok
|
|
262
|
+
? "engine + client migrations applied"
|
|
263
|
+
: `pnpm db:migrate exited with code ${migrate.status ?? "?"}`,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const failed = results.filter((r) => r.status === "failed");
|
|
268
|
+
const ok = failed.length === 0;
|
|
269
|
+
|
|
270
|
+
if (ctx.json) {
|
|
271
|
+
ctx.out.json({
|
|
272
|
+
ok,
|
|
273
|
+
cwd,
|
|
274
|
+
steps: results,
|
|
275
|
+
});
|
|
276
|
+
if (!ok) process.exit(1);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Human summary.
|
|
281
|
+
ctx.out.table(
|
|
282
|
+
results.map((r) => ({
|
|
283
|
+
step: r.step,
|
|
284
|
+
status:
|
|
285
|
+
r.status === "ok"
|
|
286
|
+
? color.green("ok")
|
|
287
|
+
: r.status === "skipped"
|
|
288
|
+
? color.dim("skipped")
|
|
289
|
+
: color.red("failed"),
|
|
290
|
+
detail: r.detail,
|
|
291
|
+
})),
|
|
292
|
+
["step", "status", "detail"],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
ctx.out.note(
|
|
296
|
+
[
|
|
297
|
+
`${color.cyan("pnpm dev")} ${color.dim("# HTTP API on :3002")}`,
|
|
298
|
+
`${color.cyan("pnpm worker:dev")} ${color.dim("# Hatchet worker, 2nd terminal")}`,
|
|
299
|
+
"",
|
|
300
|
+
`${color.dim("Verify with")} ${color.cyan("hogsend doctor")}${color.dim(".")}`,
|
|
301
|
+
`${color.dim("Grab HATCHET_CLIENT_TOKEN at")} ${color.cyan("http://localhost:8888")} ${color.dim("and set it in .env.")}`,
|
|
302
|
+
].join("\n"),
|
|
303
|
+
"Next steps",
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
if (!ok) {
|
|
307
|
+
ctx.out.fail(
|
|
308
|
+
`${failed.length} step(s) failed — see the table above. Fix and re-run hogsend setup.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
ctx.out.outro(
|
|
313
|
+
`${color.green("Done.")} ${color.dim("Local infra is up — go write a journey.")}`,
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export const setupCommand: Command = {
|
|
318
|
+
name: "setup",
|
|
319
|
+
summary: "Local onboarding: docker compose up, gen secret, db:migrate",
|
|
320
|
+
usage,
|
|
321
|
+
run,
|
|
322
|
+
};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
statSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { multiselect } from "@clack/prompts";
|
|
13
|
+
import { color } from "../lib/output.js";
|
|
14
|
+
import { bail } from "../lib/prompt.js";
|
|
15
|
+
import type { Command, CommandContext } from "./types.js";
|
|
16
|
+
|
|
17
|
+
const usage = `hogsend skills <subcommand> [options]
|
|
18
|
+
|
|
19
|
+
Manage the Claude Code skills bundled with @hogsend/cli. Bundled skills teach
|
|
20
|
+
agents how to drive the hogsend CLI; \`add\` copies them into your project's
|
|
21
|
+
./.claude/skills/<name>/ so Claude Code can discover them.
|
|
22
|
+
|
|
23
|
+
Subcommands:
|
|
24
|
+
list List bundled skills + whether each is installed.
|
|
25
|
+
add [name] [--force] Copy a bundled skill into ./.claude/skills/<name>/.
|
|
26
|
+
Omit name for an interactive multiselect (human),
|
|
27
|
+
or copy all bundled skills (--json / non-interactive).
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--force Overwrite an already-installed skill.
|
|
31
|
+
--json Emit machine-readable JSON only (implies non-interactive).
|
|
32
|
+
-h, --help Show this help.
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
hogsend skills list
|
|
36
|
+
hogsend skills list --json
|
|
37
|
+
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
|
+
}
|
|
86
|
+
|
|
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
|
+
}
|
|
102
|
+
|
|
103
|
+
function runList(ctx: CommandContext): void {
|
|
104
|
+
const skills = listBundledSkills(process.cwd());
|
|
105
|
+
|
|
106
|
+
if (ctx.json) {
|
|
107
|
+
ctx.out.json({
|
|
108
|
+
bundledSkillsDir: bundledSkillsDir(),
|
|
109
|
+
installDir: installDir(process.cwd()),
|
|
110
|
+
skills,
|
|
111
|
+
});
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} skills`);
|
|
116
|
+
if (skills.length === 0) {
|
|
117
|
+
ctx.out.note(
|
|
118
|
+
"No bundled skills found in this package build.",
|
|
119
|
+
"skills list",
|
|
120
|
+
);
|
|
121
|
+
ctx.out.outro("Nothing to install.");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
ctx.out.table(
|
|
125
|
+
skills.map((s) => ({
|
|
126
|
+
name: s.name,
|
|
127
|
+
installed: s.installed ? color.green("yes") : color.dim("no"),
|
|
128
|
+
description:
|
|
129
|
+
s.description.length > 60
|
|
130
|
+
? `${s.description.slice(0, 57)}...`
|
|
131
|
+
: s.description,
|
|
132
|
+
})),
|
|
133
|
+
["name", "installed", "description"],
|
|
134
|
+
);
|
|
135
|
+
ctx.out.outro(
|
|
136
|
+
`Install with ${color.cyan("hogsend skills add <name>")} (or just ${color.cyan("hogsend skills add")}).`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
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
|
+
async function runAdd(ctx: CommandContext, argv: string[]): Promise<void> {
|
|
161
|
+
const { values, positionals } = parseArgs({
|
|
162
|
+
args: argv,
|
|
163
|
+
allowPositionals: true,
|
|
164
|
+
options: {
|
|
165
|
+
force: { type: "boolean", default: false },
|
|
166
|
+
help: { type: "boolean", short: "h", default: false },
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (values.help) {
|
|
171
|
+
ctx.out.log(usage);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const cwd = process.cwd();
|
|
176
|
+
const bundled = listBundledSkills(cwd);
|
|
177
|
+
if (bundled.length === 0) {
|
|
178
|
+
ctx.out.fail("no bundled skills found in this package build");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const requested = positionals[0];
|
|
182
|
+
const force = Boolean(values.force);
|
|
183
|
+
|
|
184
|
+
// Resolve which skills to install.
|
|
185
|
+
let names: string[];
|
|
186
|
+
if (requested) {
|
|
187
|
+
const match = bundled.find((s) => s.name === requested);
|
|
188
|
+
if (!match) {
|
|
189
|
+
ctx.out.fail(
|
|
190
|
+
`unknown skill "${requested}". Available: ${bundled.map((s) => s.name).join(", ")}`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
names = [requested];
|
|
194
|
+
} else if (ctx.out.interactive) {
|
|
195
|
+
const picked = bail(
|
|
196
|
+
await multiselect({
|
|
197
|
+
message: "Which skills do you want to install?",
|
|
198
|
+
options: bundled.map((s) => ({
|
|
199
|
+
value: s.name,
|
|
200
|
+
label: s.name,
|
|
201
|
+
hint: s.installed ? "installed" : undefined,
|
|
202
|
+
})),
|
|
203
|
+
required: true,
|
|
204
|
+
}),
|
|
205
|
+
) as string[];
|
|
206
|
+
names = picked;
|
|
207
|
+
} else {
|
|
208
|
+
// Non-interactive (json or non-TTY) with no name => install all.
|
|
209
|
+
names = bundled.map((s) => s.name);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const results = names.map((name) => copySkill(name, cwd, force));
|
|
213
|
+
|
|
214
|
+
if (ctx.json) {
|
|
215
|
+
ctx.out.json({
|
|
216
|
+
installDir: installDir(cwd),
|
|
217
|
+
force,
|
|
218
|
+
results,
|
|
219
|
+
});
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} skills add`);
|
|
224
|
+
for (const r of results) {
|
|
225
|
+
if (r.skipped) {
|
|
226
|
+
ctx.out.log(
|
|
227
|
+
`${color.yellow("skip")} ${r.name} ${color.dim("(already installed; use --force to overwrite)")}`,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
ctx.out.log(`${color.green("✓")} ${r.name} ${color.dim(`-> ${r.path}`)}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const installedCount = results.filter((r) => r.installed).length;
|
|
234
|
+
const skippedCount = results.filter((r) => r.skipped).length;
|
|
235
|
+
ctx.out.outro(
|
|
236
|
+
`Installed ${installedCount} skill${installedCount === 1 ? "" : "s"}` +
|
|
237
|
+
(skippedCount > 0 ? `, skipped ${skippedCount}.` : "."),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
242
|
+
const sub = ctx.argv[0];
|
|
243
|
+
|
|
244
|
+
switch (sub) {
|
|
245
|
+
case "list":
|
|
246
|
+
runList(ctx);
|
|
247
|
+
return;
|
|
248
|
+
case "add":
|
|
249
|
+
await runAdd(ctx, ctx.argv.slice(1));
|
|
250
|
+
return;
|
|
251
|
+
case undefined:
|
|
252
|
+
case "-h":
|
|
253
|
+
case "--help":
|
|
254
|
+
ctx.out.log(usage);
|
|
255
|
+
return;
|
|
256
|
+
default:
|
|
257
|
+
ctx.out.fail(
|
|
258
|
+
`unknown skills subcommand "${sub}". Use: list | add. See hogsend skills --help.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const skillsCommand: Command = {
|
|
264
|
+
name: "skills",
|
|
265
|
+
summary: "List + install bundled Claude Code skills into .claude/skills",
|
|
266
|
+
usage,
|
|
267
|
+
run,
|
|
268
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { color } from "../lib/output.js";
|
|
3
|
+
import type { Command, CommandContext } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const usage = `hogsend stats [--json]
|
|
6
|
+
|
|
7
|
+
Show system-wide overview metrics from a running Hogsend instance.
|
|
8
|
+
Wraps GET /v1/admin/metrics/overview.
|
|
9
|
+
|
|
10
|
+
Fields:
|
|
11
|
+
totalContacts Live (non-deleted) contacts.
|
|
12
|
+
activeJourneys Journey states currently active or waiting.
|
|
13
|
+
emailsSent24h Emails sent in the last 24 hours.
|
|
14
|
+
emailsSent7d Emails sent in the last 7 days.
|
|
15
|
+
emailsSent30d Emails sent in the last 30 days.
|
|
16
|
+
bounceRate30d Bounced / sent over the last 30 days (0..1).
|
|
17
|
+
unsubscribeRate Unsubscribed / total preferences (0..1).
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
--url <baseUrl> API base URL (default HOGSEND_API_URL or http://localhost:3002).
|
|
21
|
+
--admin-key <key> Admin bearer key (default HOGSEND_ADMIN_KEY / ADMIN_API_KEY).
|
|
22
|
+
--json Emit machine-readable JSON only.
|
|
23
|
+
-h, --help Show this help.`;
|
|
24
|
+
|
|
25
|
+
/** Shape returned by GET /v1/admin/metrics/overview. */
|
|
26
|
+
interface OverviewMetrics {
|
|
27
|
+
totalContacts: number;
|
|
28
|
+
activeJourneys: number;
|
|
29
|
+
emailsSent24h: number;
|
|
30
|
+
emailsSent7d: number;
|
|
31
|
+
emailsSent30d: number;
|
|
32
|
+
bounceRate30d: number;
|
|
33
|
+
unsubscribeRate: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Render a 0..1 rate as a percentage with two decimals, e.g. 0.0123 -> "1.23%". */
|
|
37
|
+
function pct(rate: number): string {
|
|
38
|
+
return `${(rate * 100).toFixed(2)}%`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function run(ctx: CommandContext): Promise<void> {
|
|
42
|
+
const { values } = parseArgs({
|
|
43
|
+
args: ctx.argv,
|
|
44
|
+
allowPositionals: true,
|
|
45
|
+
options: {
|
|
46
|
+
help: { type: "boolean", short: "h", default: false },
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (values.help) {
|
|
51
|
+
ctx.out.log(usage);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const metrics = await ctx.out.step("Fetching overview metrics", () =>
|
|
56
|
+
ctx.http.get<OverviewMetrics>("/v1/admin/metrics/overview"),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (ctx.json) {
|
|
60
|
+
ctx.out.json(metrics);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ctx.out.intro(`${color.bgMagenta(color.black(" hogsend "))} stats`);
|
|
65
|
+
|
|
66
|
+
ctx.out.kv(
|
|
67
|
+
{
|
|
68
|
+
"Total contacts": metrics.totalContacts,
|
|
69
|
+
"Active journeys": metrics.activeJourneys,
|
|
70
|
+
"Emails sent (24h)": metrics.emailsSent24h,
|
|
71
|
+
"Emails sent (7d)": metrics.emailsSent7d,
|
|
72
|
+
"Emails sent (30d)": metrics.emailsSent30d,
|
|
73
|
+
"Bounce rate (30d)": pct(metrics.bounceRate30d),
|
|
74
|
+
"Unsubscribe rate": pct(metrics.unsubscribeRate),
|
|
75
|
+
},
|
|
76
|
+
"Overview",
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
ctx.out.outro(color.dim(ctx.http.cfg.baseUrl));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const statsCommand: Command = {
|
|
83
|
+
name: "stats",
|
|
84
|
+
summary: "Show system-wide overview metrics",
|
|
85
|
+
usage,
|
|
86
|
+
run,
|
|
87
|
+
};
|