heximon 0.0.1 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,940 @@
1
+ import { createRequire } from "node:module";
2
+ import { cancel, confirm, intro, log, outro, password, select, text } from "@clack/prompts";
3
+ import process from "node:process";
4
+ import { spawn } from "node:child_process";
5
+ import { defineCommand } from "citty";
6
+ import { dirname, join, relative, resolve } from "pathe";
7
+ import { cp, lstat, mkdir, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
8
+ //#region src/prompter.ts
9
+ /** Thrown when the operator cancels an interactive prompt (Ctrl-C). */
10
+ var PromptCancelledError = class extends Error {
11
+ constructor() {
12
+ super("Prompt cancelled.");
13
+ this.name = "PromptCancelledError";
14
+ }
15
+ };
16
+ //#endregion
17
+ //#region src/clack-prompter.ts
18
+ /**
19
+ * The {@link Prompter} port backed by `@clack/prompts` — the one interactive surface the CLI exposes. A cancelled
20
+ * prompt (clack's sentinel symbol) is normalized to a thrown {@link PromptCancelledError} so the credential
21
+ * resolver handles one cancel failure mode.
22
+ */
23
+ var ClackPrompter = class {
24
+ /** @throws PromptCancelledError when cancelled. */
25
+ async secret(message) {
26
+ const value = await password({ message });
27
+ if (typeof value === "symbol") throw new PromptCancelledError();
28
+ return value;
29
+ }
30
+ /** @throws PromptCancelledError when cancelled. */
31
+ async text(message) {
32
+ const value = await text({ message });
33
+ if (typeof value === "symbol") throw new PromptCancelledError();
34
+ return value;
35
+ }
36
+ };
37
+ //#endregion
38
+ //#region src/cli-environment.ts
39
+ /** Detects how the CLI is being run, so the deploy credential ladder knows whether interactive prompting is safe. */
40
+ var CliEnvironment = class {
41
+ /**
42
+ * Whether this is an interactive session a prompt may block on: a real TTY on stdin AND not a CI runner. In CI
43
+ * (or a piped/non-TTY invocation) the deploy credential ladder must fail fast on a missing value rather than
44
+ * hang on a prompt no one can answer.
45
+ */
46
+ static isInteractive() {
47
+ return process.stdin.isTTY === true && process.env["CI"] === void 0;
48
+ }
49
+ };
50
+ //#endregion
51
+ //#region src/registry-access.ts
52
+ /** The verdict {@link RegistryAccess.probe} classifies an `npm view` attempt into. */
53
+ const RegistryAccessStatus = {
54
+ /** The probe package resolved — the registry is reachable and authorized. */
55
+ Ok: "ok",
56
+ /** The registry rejected the request for lacking (or holding invalid) credentials. */
57
+ Unauthorized: "unauthorized",
58
+ /** The registry could not be reached at all (DNS, network, timeout, an unrecognized failure). */
59
+ Unreachable: "unreachable",
60
+ /** The registry was reached and authorized the request, but the probe package is not there. */
61
+ NotFound: "not-found"
62
+ };
63
+ /**
64
+ * Probes whether `@heximon/*` packages are readable from GitHub Packages, the private registry the
65
+ * framework publishes to (the `heximon` CLI itself stays on public npm). A scaffolded project needs a
66
+ * `.npmrc` scope line plus a token in the user's `~/.npmrc`; this probe reuses npm's own config
67
+ * resolution — the same files npm itself reads — so the verdict matches what an install would do.
68
+ */
69
+ var RegistryAccess = class RegistryAccess {
70
+ /** The private registry `@heximon/*` packages publish to. */
71
+ static gitHubPackagesRegistry = "https://npm.pkg.github.com";
72
+ /** The package probed to decide reachability — small, always published, always current. */
73
+ static probePackage = "@heximon/runtime";
74
+ /** The scope line a scaffolded project's `.npmrc` carries, routing `@heximon/*` to GitHub Packages. */
75
+ static npmrcScopeLine = `@heximon:registry=${RegistryAccess.gitHubPackagesRegistry}`;
76
+ run;
77
+ /**
78
+ * @param run - The process runner to probe with. Defaults to spawning `npm` for real; a test injects a
79
+ * stub so the probe never touches the network.
80
+ */
81
+ constructor(run = (command, commandArguments) => RegistryAccess.spawnProcess(command, commandArguments)) {
82
+ this.run = run;
83
+ }
84
+ /**
85
+ * Runs `npm view @heximon/runtime version` against GitHub Packages and classifies the result.
86
+ *
87
+ * @returns The classified verdict, with the raw output kept as `detail` for diagnostics.
88
+ */
89
+ async probe() {
90
+ const result = await this.run("npm", [
91
+ "view",
92
+ RegistryAccess.probePackage,
93
+ "version",
94
+ `--registry=${RegistryAccess.gitHubPackagesRegistry}`,
95
+ "--json"
96
+ ]);
97
+ const combined = `${result.stdout}\n${result.stderr}`;
98
+ const detail = combined.trim();
99
+ if (result.exitCode === 0) return {
100
+ status: RegistryAccessStatus.Ok,
101
+ detail: detail.length === 0 ? void 0 : detail
102
+ };
103
+ if (/E401|E403|ENEEDAUTH/.test(combined)) return {
104
+ status: RegistryAccessStatus.Unauthorized,
105
+ detail
106
+ };
107
+ if (/E404/.test(combined)) return {
108
+ status: RegistryAccessStatus.NotFound,
109
+ detail
110
+ };
111
+ return {
112
+ status: RegistryAccessStatus.Unreachable,
113
+ detail
114
+ };
115
+ }
116
+ /**
117
+ * The fix instructions printed when {@link probe} does not report {@link RegistryAccessStatus.Ok}.
118
+ *
119
+ * @param status - The non-Ok verdict, when known — shapes the wording between an auth fix and a network hint.
120
+ */
121
+ static fixInstructions(status = RegistryAccessStatus.Unauthorized) {
122
+ if (status === RegistryAccessStatus.Unreachable) return [
123
+ `Cannot reach ${RegistryAccess.gitHubPackagesRegistry}.`,
124
+ "Check your network connection.",
125
+ "Then run the install again."
126
+ ].join("\n");
127
+ return [
128
+ "Cannot read @heximon/* from GitHub Packages.",
129
+ "Add these lines to ~/.npmrc:",
130
+ ` ${RegistryAccess.npmrcScopeLine}`,
131
+ " //npm.pkg.github.com/:_authToken=<a GitHub token with the read:packages scope>",
132
+ "Then run the install again."
133
+ ].join("\n");
134
+ }
135
+ /** The default {@link ProcessRunner}: spawns `npm` for real, capturing stdout/stderr. */
136
+ static spawnProcess(command, commandArguments) {
137
+ return new Promise((resolveRun) => {
138
+ const child = spawn(command, commandArguments, { shell: false });
139
+ let stdout = "";
140
+ let stderr = "";
141
+ child.stdout.on("data", (chunk) => {
142
+ stdout += chunk.toString();
143
+ });
144
+ child.stderr.on("data", (chunk) => {
145
+ stderr += chunk.toString();
146
+ });
147
+ child.on("error", (error) => {
148
+ resolveRun({
149
+ exitCode: 1,
150
+ stdout: "",
151
+ stderr: error.message
152
+ });
153
+ });
154
+ child.on("close", (exitCode) => {
155
+ resolveRun({
156
+ exitCode: exitCode ?? 1,
157
+ stdout,
158
+ stderr
159
+ });
160
+ });
161
+ });
162
+ }
163
+ };
164
+ //#endregion
165
+ //#region src/project-scaffolder.ts
166
+ /**
167
+ * A template fetch that failed — the giget ref could not be downloaded. Distinct from every local failure so a
168
+ * caller may retry another ref without also retrying (or cleaning up after) a validation or manifest error.
169
+ */
170
+ var TemplateDownloadError = class extends Error {
171
+ /** The giget source string that could not be fetched. */
172
+ gigetSource;
173
+ constructor(gigetSource, cause) {
174
+ super(`Could not download the template ${gigetSource}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
175
+ this.name = "TemplateDownloadError";
176
+ this.gigetSource = gigetSource;
177
+ }
178
+ };
179
+ /**
180
+ * Materializes a new Heximon project from a starters template: resolves the template directory name from
181
+ * the {@link TemplateSelection}, fetches (giget) or copies (local) it, then stamps the project name and
182
+ * the `@heximon/*` dependency ranges onto the fetched `package.json`. Pure filesystem work — prompting,
183
+ * git, and dependency installation live with the CLI command.
184
+ */
185
+ var ProjectScaffolder = class ProjectScaffolder {
186
+ /** A project/directory name: lowercase npm-safe segments (no scopes — the directory is the name). */
187
+ static projectNamePattern = /^[a-z0-9][a-z0-9._-]*$/;
188
+ /**
189
+ * Resolve the concrete template directory name for a selection — the validator variant is a `-zod` /
190
+ * `-valibot` suffix; `"none"` selects the base directory.
191
+ *
192
+ * @param selection - The template + validator axes.
193
+ * @returns The starters directory name (e.g. `"database-valibot"`).
194
+ */
195
+ static templateNameOf(selection) {
196
+ return selection.validator === "none" ? selection.template : `${selection.template}-${selection.validator}`;
197
+ }
198
+ /**
199
+ * Validate a project name for use as both the directory and the `package.json` name.
200
+ *
201
+ * @param name - The candidate name.
202
+ * @returns `undefined` when valid, else a human-readable problem statement.
203
+ */
204
+ static validateProjectName(name) {
205
+ if (name.length === 0) return "The project name is empty.";
206
+ if (!ProjectScaffolder.projectNamePattern.test(name)) return `"${name}" is not a valid project name — use lowercase letters, digits, and . _ - (it becomes the directory and package name).`;
207
+ }
208
+ /**
209
+ * Materialize the project: fetch/copy the template into `<parentDirectory>/<projectName>` and stamp the
210
+ * manifest. A target directory that already holds content is refused, never overwritten.
211
+ *
212
+ * @param request - What to scaffold, from where, and with which stamped versions.
213
+ * @returns The created project directory + the resolved template name.
214
+ */
215
+ async scaffold(request) {
216
+ const templateName = ProjectScaffolder.templateNameOf(request);
217
+ const { projectDirectory } = await ProjectScaffolder.prepareTarget(request.parentDirectory, request.projectName);
218
+ if (request.source.localDirectory !== void 0) await cp(join(request.source.localDirectory, templateName), projectDirectory, { recursive: true });
219
+ else if (request.source.gigetSource !== void 0) await ProjectScaffolder.fetchTemplate(request.source.gigetSource, projectDirectory);
220
+ else throw new Error("No template source: provide a giget source or a local starters directory.");
221
+ await this.stampManifest(projectDirectory, request);
222
+ await ProjectScaffolder.writeNpmrc(projectDirectory, request.heximonVersion);
223
+ return {
224
+ projectDirectory,
225
+ templateName
226
+ };
227
+ }
228
+ /**
229
+ * Accept a target directory: the name must be valid, the resolved path must stay a direct child of the
230
+ * parent, and the directory must be absent or empty. A directory holding content is refused outright —
231
+ * `create` never overwrites.
232
+ *
233
+ * @param parentDirectory - The directory the project directory is created under.
234
+ * @param projectName - The candidate project (directory + package) name.
235
+ * @returns The accepted target, and whether this call created the directory.
236
+ */
237
+ static async prepareTarget(parentDirectory, projectName) {
238
+ const problem = ProjectScaffolder.validateProjectName(projectName);
239
+ if (problem !== void 0) throw new Error(problem);
240
+ const parent = resolve(parentDirectory);
241
+ const projectDirectory = resolve(parent, projectName);
242
+ if (projectDirectory === parent || dirname(projectDirectory) !== parent) throw new Error(`"${projectName}" resolves to ${projectDirectory}, which is outside ${parent} — the project directory must be a direct child of the parent directory.`);
243
+ const entries = await ProjectScaffolder.readEntries(projectDirectory);
244
+ if (entries === void 0) {
245
+ await mkdir(projectDirectory, { recursive: true });
246
+ return {
247
+ projectDirectory,
248
+ createdDirectory: true
249
+ };
250
+ }
251
+ if (entries.length > 0) throw new Error(`The directory ${projectDirectory} already exists and is not empty — choose another name, or remove the directory yourself.`);
252
+ return {
253
+ projectDirectory,
254
+ createdDirectory: false
255
+ };
256
+ }
257
+ /**
258
+ * Discard what a failed scaffold left in a prepared target: the directory itself when this run created it,
259
+ * otherwise only the entries — a pre-existing directory was empty when accepted, so nothing else is ours.
260
+ */
261
+ static async discardPreparedTarget(target) {
262
+ if (target.createdDirectory) {
263
+ await rm(target.projectDirectory, {
264
+ recursive: true,
265
+ force: true
266
+ });
267
+ return;
268
+ }
269
+ for (const entry of await ProjectScaffolder.readEntries(target.projectDirectory) ?? []) await rm(join(target.projectDirectory, entry), {
270
+ recursive: true,
271
+ force: true
272
+ });
273
+ }
274
+ /** Fetch a giget template, tagging a fetch failure so a caller can tell it from a local error. */
275
+ static async fetchTemplate(gigetSource, projectDirectory) {
276
+ const { downloadTemplate } = await import("giget");
277
+ try {
278
+ await downloadTemplate(gigetSource, { dir: projectDirectory });
279
+ } catch (error) {
280
+ throw new TemplateDownloadError(gigetSource, error);
281
+ }
282
+ }
283
+ /**
284
+ * Rewrite the scaffolded `package.json`: the project name, and every `@heximon/*` range to the CLI's
285
+ * lockstep version (`^<version>`) or `workspace:*`.
286
+ */
287
+ async stampManifest(projectDirectory, request) {
288
+ const manifestPath = join(projectDirectory, "package.json");
289
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
290
+ manifest["name"] = request.projectName;
291
+ const range = request.heximonVersion === "workspace" ? "workspace:*" : `^${request.heximonVersion}`;
292
+ for (const section of ["dependencies", "devDependencies"]) {
293
+ const record = manifest[section];
294
+ if (record === void 0) continue;
295
+ for (const dependencyName of Object.keys(record)) if (dependencyName.startsWith("@heximon/") || dependencyName === "heximon") record[dependencyName] = range;
296
+ }
297
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
298
+ }
299
+ /**
300
+ * Write the `.npmrc` scope line that routes `@heximon/*` installs to GitHub Packages — skipped when the
301
+ * template already ships its own `.npmrc` (never overwritten), and in workspace mode (`heximonVersion ===
302
+ * "workspace"`), where the `@heximon/*` ranges are workspace links and need no registry at all.
303
+ */
304
+ static async writeNpmrc(projectDirectory, heximonVersion) {
305
+ if (heximonVersion === "workspace") return;
306
+ const npmrcPath = join(projectDirectory, ".npmrc");
307
+ if (await ProjectScaffolder.pathExists(npmrcPath)) return;
308
+ await writeFile(npmrcPath, `${RegistryAccess.npmrcScopeLine}\n`);
309
+ }
310
+ /** Whether a filesystem path exists, of any kind. */
311
+ static async pathExists(path) {
312
+ try {
313
+ await stat(path);
314
+ return true;
315
+ } catch (error) {
316
+ if (error.code === "ENOENT") return false;
317
+ throw error;
318
+ }
319
+ }
320
+ /** A directory's entries, or `undefined` when the directory does not exist. */
321
+ static async readEntries(directory) {
322
+ try {
323
+ return await readdir(directory);
324
+ } catch (error) {
325
+ if (error.code === "ENOENT") return;
326
+ throw error;
327
+ }
328
+ }
329
+ };
330
+ //#endregion
331
+ //#region src/skill-installer.ts
332
+ /**
333
+ * Installs the published Heximon agent skill into a scaffolded project from the docs site's RFC 8615
334
+ * well-known endpoint — `/.well-known/skills/index.json` plus `/.well-known/skills/<name>/<file>`, the
335
+ * same layout `npx skills add https://heximon.dev` consumes (Docus serves the docs repo's `skills/`
336
+ * directory there natively). Best-effort by contract: the caller treats a throw as a skippable step with
337
+ * a printed hint, never a scaffold failure.
338
+ */
339
+ var SkillInstaller = class SkillInstaller {
340
+ sourceUrl;
341
+ /** The published docs site serving the skill. */
342
+ static defaultSourceUrl = "https://heximon.dev";
343
+ /** Where skills land inside the project — the universal agent layout; `.claude/skills` symlinks here. */
344
+ static targetDirectory = ".agents/skills";
345
+ /** Per-agent skills directories, each created as a relative symlink onto {@link targetDirectory}. */
346
+ static agentSymlinks = [".claude/skills"];
347
+ /** Per-request timeout — scaffolding must never hang on an unreachable docs deploy. */
348
+ static fetchTimeoutMs = 15e3;
349
+ constructor(sourceUrl = SkillInstaller.defaultSourceUrl) {
350
+ this.sourceUrl = sourceUrl;
351
+ }
352
+ /**
353
+ * Install every skill the source publishes into `projectDirectory`.
354
+ *
355
+ * @returns The installed skills, in index order.
356
+ * @throws {Error} When the index is unreachable/malformed or any listed file fails to download.
357
+ */
358
+ async install(projectDirectory) {
359
+ const base = this.sourceUrl.replace(/\/+$/, "");
360
+ const index = await SkillInstaller.fetchIndex(`${base}/.well-known/skills/index.json`);
361
+ const installed = [];
362
+ for (const entry of index) {
363
+ const files = entry.files.filter((file) => SkillInstaller.isSafeRelativePath(file));
364
+ for (const file of files) {
365
+ const content = await SkillInstaller.fetchText(`${base}/.well-known/skills/${entry.name}/${file}`);
366
+ const targetPath = join(projectDirectory, SkillInstaller.targetDirectory, entry.name, file);
367
+ await mkdir(dirname(targetPath), { recursive: true });
368
+ await writeFile(targetPath, content);
369
+ }
370
+ installed.push({
371
+ name: entry.name,
372
+ fileCount: files.length
373
+ });
374
+ }
375
+ if (installed.length === 0) throw new Error(`No skills published at ${base}/.well-known/skills/index.json`);
376
+ await SkillInstaller.linkAgentDirectories(projectDirectory);
377
+ return installed;
378
+ }
379
+ /**
380
+ * Point each per-agent skills directory (`.claude/skills`) at the universal `.agents/skills` copy via a
381
+ * relative symlink, so every agent convention reads the one install. Best-effort: an existing entry is
382
+ * left untouched, and a filesystem that cannot symlink (e.g. Windows without developer mode) is not an
383
+ * install failure — the universal copy stands on its own.
384
+ */
385
+ static async linkAgentDirectories(projectDirectory) {
386
+ for (const agentDirectory of SkillInstaller.agentSymlinks) {
387
+ const linkPath = join(projectDirectory, agentDirectory);
388
+ const parentSegments = agentDirectory.split("/").slice(0, -1);
389
+ const relativeTarget = join(...parentSegments.map(() => ".."), SkillInstaller.targetDirectory);
390
+ try {
391
+ await lstat(linkPath);
392
+ continue;
393
+ } catch {}
394
+ try {
395
+ await mkdir(dirname(linkPath), { recursive: true });
396
+ await symlink(relativeTarget, linkPath, "dir");
397
+ } catch {}
398
+ }
399
+ }
400
+ /** Fetch and validate the discovery index, keeping only well-shaped entries with safe names. */
401
+ static async fetchIndex(indexUrl) {
402
+ const body = await SkillInstaller.fetchJson(indexUrl);
403
+ if (!Array.isArray(body.skills)) throw new Error(`Malformed skills index at ${indexUrl}`);
404
+ return body.skills.flatMap((entry) => typeof entry.name === "string" && /^[a-z0-9][a-z0-9-]*$/.test(entry.name) && Array.isArray(entry.files) ? [{
405
+ name: entry.name,
406
+ files: entry.files.filter((file) => typeof file === "string")
407
+ }] : []);
408
+ }
409
+ /** A relative file path with no traversal, absolute prefix, or backslash trickery. */
410
+ static isSafeRelativePath(file) {
411
+ return file.length > 0 && !file.startsWith("/") && !file.includes("\\") && !file.split("/").includes("..");
412
+ }
413
+ static async fetchJson(url) {
414
+ const response = await fetch(url, { signal: AbortSignal.timeout(SkillInstaller.fetchTimeoutMs) });
415
+ if (!response.ok) throw new Error(`GET ${url} failed with ${response.status.toString()}`);
416
+ return response.json();
417
+ }
418
+ static async fetchText(url) {
419
+ const response = await fetch(url, { signal: AbortSignal.timeout(SkillInstaller.fetchTimeoutMs) });
420
+ if (!response.ok) throw new Error(`GET ${url} failed with ${response.status.toString()}`);
421
+ return response.text();
422
+ }
423
+ };
424
+ //#endregion
425
+ //#region src/skill-sources.ts
426
+ /**
427
+ * The project's recorded agent-skill sources, persisted as `heximon.skills` in its `package.json`.
428
+ *
429
+ * The manifest rather than the config sidecar, for one reason: `heximon create` records the default source
430
+ * before an app exists to compile, and a tool reads the list without evaluating any TypeScript. Every entry
431
+ * is a base URL {@link SkillInstaller} can install from — the same value `npx skills add <url>` takes.
432
+ */
433
+ var SkillSources = class SkillSources {
434
+ /** The `package.json` member holding the framework's own manifest block. */
435
+ static manifestKey = "heximon";
436
+ /** The member of that block holding the source list. */
437
+ static listKey = "skills";
438
+ /**
439
+ * The sources recorded for a project, in the order they were added.
440
+ *
441
+ * @param projectDirectory - The project root holding `package.json`.
442
+ * @returns The recorded base URLs; empty when the manifest records none (or has no readable list).
443
+ * @throws {Error} When `package.json` is missing or is not readable JSON — a project whose manifest cannot
444
+ * be read is not one to silently report as having no skills.
445
+ */
446
+ static async read(projectDirectory) {
447
+ const block = (await SkillSources.readManifest(projectDirectory))[SkillSources.manifestKey];
448
+ if (typeof block !== "object" || block === null || Array.isArray(block)) return [];
449
+ const list = block[SkillSources.listKey];
450
+ return Array.isArray(list) ? list.filter((entry) => typeof entry === "string") : [];
451
+ }
452
+ /**
453
+ * Record one source, leaving every other manifest member untouched.
454
+ *
455
+ * @param projectDirectory - The project root holding `package.json`.
456
+ * @param sourceUrl - The base URL to record, normalized by {@link normalize} first.
457
+ * @returns `true` when the source was added, `false` when it was already recorded — so recording twice is
458
+ * a no-op the caller can report as such rather than a duplicated entry.
459
+ */
460
+ static async record(projectDirectory, sourceUrl) {
461
+ const normalized = SkillSources.normalize(sourceUrl);
462
+ const manifest = await SkillSources.readManifest(projectDirectory);
463
+ const existingBlock = manifest[SkillSources.manifestKey];
464
+ const block = typeof existingBlock === "object" && existingBlock !== null && !Array.isArray(existingBlock) ? { ...existingBlock } : {};
465
+ const recorded = Array.isArray(block[SkillSources.listKey]) ? block[SkillSources.listKey].filter((entry) => typeof entry === "string") : [];
466
+ if (recorded.includes(normalized)) return false;
467
+ block[SkillSources.listKey] = [...recorded, normalized];
468
+ manifest[SkillSources.manifestKey] = block;
469
+ await writeFile(join(projectDirectory, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`);
470
+ return true;
471
+ }
472
+ /**
473
+ * The canonical form of a source URL: trailing slashes removed, so `https://heximon.dev/` and
474
+ * `https://heximon.dev` are one recorded entry rather than two installs of the same site.
475
+ */
476
+ static normalize(sourceUrl) {
477
+ return sourceUrl.replace(/\/+$/, "");
478
+ }
479
+ /** Whether a string is an absolute `http`/`https` URL — the only form the installer can fetch from. */
480
+ static isInstallable(sourceUrl) {
481
+ try {
482
+ const url = new URL(sourceUrl);
483
+ return url.protocol === "http:" || url.protocol === "https:";
484
+ } catch {
485
+ return false;
486
+ }
487
+ }
488
+ /** The project manifest as a mutable record. */
489
+ static async readManifest(projectDirectory) {
490
+ const manifestPath = join(projectDirectory, "package.json");
491
+ const contents = await readFile(manifestPath, "utf8");
492
+ const manifest = JSON.parse(contents);
493
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) throw new Error(`${manifestPath} is not a JSON object.`);
494
+ return manifest;
495
+ }
496
+ };
497
+ //#endregion
498
+ //#region src/create-command.ts
499
+ /** The `heximon create` verb: scaffold a new Heximon app from the starters templates. */
500
+ const createCommand = defineCommand({
501
+ meta: {
502
+ name: "create",
503
+ description: "Scaffold a new Heximon app from a starter template (minimal or database, with an optional zod/valibot validator)."
504
+ },
505
+ args: {
506
+ name: {
507
+ type: "positional",
508
+ description: "The project directory (and package) name.",
509
+ required: false
510
+ },
511
+ cwd: {
512
+ type: "string",
513
+ description: "Parent directory to create the project in.",
514
+ default: "."
515
+ },
516
+ template: {
517
+ type: "string",
518
+ description: "Starter template: minimal | database."
519
+ },
520
+ validator: {
521
+ type: "string",
522
+ description: "Request-body validator baked into the starter: zod | valibot | none."
523
+ },
524
+ pm: {
525
+ type: "string",
526
+ description: "Package manager for the install step + printed commands (npm/pnpm/yarn/bun). Default: detected from the invoking manager."
527
+ },
528
+ install: {
529
+ type: "boolean",
530
+ description: "Install dependencies after scaffolding (--no-install to skip)."
531
+ },
532
+ git: {
533
+ type: "boolean",
534
+ description: "Initialize a git repository in the new project (skipped automatically inside an existing repository).",
535
+ default: true
536
+ },
537
+ yes: {
538
+ type: "boolean",
539
+ description: "Accept every default without prompting (non-interactive/CI).",
540
+ default: false
541
+ },
542
+ skills: {
543
+ type: "boolean",
544
+ description: "Install the Heximon agent skill from heximon.dev into .agents/skills, with .claude/skills symlinked onto it (--no-skills to skip).",
545
+ default: true
546
+ },
547
+ local: {
548
+ type: "string",
549
+ description: "Maintainer path: scaffold from a local starters directory instead of fetching from GitHub."
550
+ },
551
+ workspace: {
552
+ type: "boolean",
553
+ description: "Maintainer path: stamp workspace:* onto the @heximon/* ranges (in-monorepo dogfooding).",
554
+ default: false
555
+ },
556
+ "skip-registry-check": {
557
+ type: "boolean",
558
+ description: "Skip the GitHub Packages read check before installing (@heximon/* publishes there; heximon itself is public npm).",
559
+ default: false
560
+ }
561
+ },
562
+ async run({ args }) {
563
+ const interactive = CliEnvironment.isInteractive() && !args.yes;
564
+ const packageManager = CreateDefaults.resolvePackageManager(args.pm);
565
+ intro("heximon create");
566
+ try {
567
+ let projectName = args.name;
568
+ if (projectName === void 0 || projectName.length === 0) {
569
+ if (interactive) {
570
+ const answer = await text({
571
+ message: "Project name",
572
+ placeholder: CreateDefaults.projectName,
573
+ defaultValue: CreateDefaults.projectName,
574
+ validate: (value) => ProjectScaffolder.validateProjectName(value === void 0 || value === "" ? CreateDefaults.projectName : value)
575
+ });
576
+ if (typeof answer === "symbol") {
577
+ cancel("Cancelled — nothing created.");
578
+ process.exitCode = 1;
579
+ return;
580
+ }
581
+ projectName = answer;
582
+ } else projectName = CreateDefaults.projectName;
583
+ }
584
+ const nameProblem = ProjectScaffolder.validateProjectName(projectName);
585
+ if (nameProblem !== void 0) throw new Error(nameProblem);
586
+ const selection = await CreateDefaults.resolveSelection(args.template, args.validator, { interactive });
587
+ if (selection === void 0) {
588
+ cancel("Cancelled — nothing created.");
589
+ process.exitCode = 1;
590
+ return;
591
+ }
592
+ const scaffolder = new ProjectScaffolder();
593
+ const heximonVersion = args.workspace ? "workspace" : CreateDefaults.cliVersion();
594
+ const parentDirectory = resolve(process.cwd(), args.cwd);
595
+ const templateName = ProjectScaffolder.templateNameOf(selection);
596
+ let result;
597
+ if (args.local !== void 0) result = await scaffolder.scaffold({
598
+ ...selection,
599
+ projectName,
600
+ parentDirectory,
601
+ heximonVersion,
602
+ source: { localDirectory: resolve(process.cwd(), args.local) }
603
+ });
604
+ else result = await CreateDefaults.scaffoldFromStarters(scaffolder, {
605
+ selection,
606
+ projectName,
607
+ parentDirectory,
608
+ heximonVersion,
609
+ templateName
610
+ });
611
+ log.success(`Scaffolded ${result.templateName} into ${result.projectDirectory}.`);
612
+ if (args.git && !args.workspace) {
613
+ const initialized = await CreateDefaults.initializeGit(result.projectDirectory);
614
+ log.info(initialized ? "Initialized a git repository." : "Skipped git init (already inside a repository, or git is unavailable).");
615
+ }
616
+ let installNow = args.install;
617
+ if (installNow === void 0 && interactive) {
618
+ const answer = await confirm({ message: `Install dependencies with ${packageManager}?` });
619
+ if (typeof answer === "symbol") {
620
+ cancel("Cancelled after scaffolding — the project directory was kept.");
621
+ process.exitCode = 1;
622
+ return;
623
+ }
624
+ installNow = answer;
625
+ }
626
+ const willInstall = installNow === true || installNow === void 0 && args.yes;
627
+ let installedDependencies = false;
628
+ if (willInstall) {
629
+ const registryReport = args.workspace || args["skip-registry-check"] ? { status: RegistryAccessStatus.Ok } : await new RegistryAccess().probe();
630
+ if (registryReport.status === RegistryAccessStatus.Ok) {
631
+ log.info(`Installing dependencies with ${packageManager}…`);
632
+ await CreateDefaults.runInstall(packageManager, result.projectDirectory);
633
+ log.success("Dependencies installed.");
634
+ installedDependencies = true;
635
+ } else {
636
+ log.error(RegistryAccess.fixInstructions(registryReport.status));
637
+ process.exitCode = 1;
638
+ }
639
+ }
640
+ if (args.skills) try {
641
+ const installed = await new SkillInstaller().install(result.projectDirectory);
642
+ await SkillSources.record(result.projectDirectory, SkillInstaller.defaultSourceUrl);
643
+ log.success(`Installed the ${installed.map((skill) => skill.name).join(", ")} agent skill into .agents/skills (.claude/skills symlinked), recorded in package.json.`);
644
+ } catch {
645
+ log.info(`Could not fetch the agent skill — install it later with: npx skills add ${SkillInstaller.defaultSourceUrl}`);
646
+ }
647
+ const changeDirectory = relative(process.cwd(), result.projectDirectory);
648
+ const steps = [
649
+ `cd ${changeDirectory === "" ? "." : changeDirectory}`,
650
+ ...installedDependencies ? [] : [`${packageManager} install`],
651
+ `${packageManager} run dev`
652
+ ];
653
+ outro(`Done. Next steps:\n\n ${steps.join("\n ")}\n\nThen: curl http://localhost:3000`);
654
+ } catch (error) {
655
+ cancel(error instanceof Error ? error.message : String(error));
656
+ process.exitCode = 1;
657
+ }
658
+ }
659
+ });
660
+ /**
661
+ * The `create` command's defaults + environment glue: package-manager detection, the CLI's own lockstep
662
+ * version, the starters giget refs (version branch → main fallback), git init, and the install spawn.
663
+ */
664
+ var CreateDefaults = class CreateDefaults {
665
+ /** The default project (directory + package) name when none is given. */
666
+ static projectName = "my-heximon-app";
667
+ /** The GitHub repository the published templates are fetched from. */
668
+ static startersRepository = "heximon/starters";
669
+ /**
670
+ * The CLI's own version — the framework version, since `heximon` releases in the lockstep group. It is
671
+ * what `@heximon/*` ranges are stamped to.
672
+ *
673
+ * @returns The version from this package's own manifest.
674
+ */
675
+ static cliVersion() {
676
+ return createRequire(import.meta.url)("../package.json").version;
677
+ }
678
+ /**
679
+ * Detect the invoking package manager from `npm_config_user_agent` (the create-vite trick).
680
+ *
681
+ * @param override - An explicit `--pm` value, which wins when given.
682
+ * @returns One of npm/pnpm/yarn/bun.
683
+ */
684
+ static resolvePackageManager(override) {
685
+ if (override !== void 0 && [
686
+ "npm",
687
+ "pnpm",
688
+ "yarn",
689
+ "bun"
690
+ ].includes(override)) return override;
691
+ const userAgent = process.env["npm_config_user_agent"] ?? "";
692
+ for (const manager of [
693
+ "pnpm",
694
+ "yarn",
695
+ "bun"
696
+ ]) if (userAgent.startsWith(`${manager}/`)) return manager;
697
+ return "npm";
698
+ }
699
+ /**
700
+ * Resolve the template + validator axes from flags, prompts, or defaults.
701
+ *
702
+ * @param template - The `--template` flag value, if any.
703
+ * @param validator - The `--validator` flag value, if any.
704
+ * @param options - Whether prompting is allowed.
705
+ * @returns The resolved selection, or `undefined` when the user cancelled a prompt.
706
+ */
707
+ static async resolveSelection(template, validator, options) {
708
+ let resolvedTemplate = CreateDefaults.narrow(template, ["minimal", "database"]);
709
+ let resolvedValidator = CreateDefaults.narrow(validator, [
710
+ "zod",
711
+ "valibot",
712
+ "none"
713
+ ]);
714
+ if (template !== void 0 && resolvedTemplate === void 0) throw new Error(`Unknown template "${template}" — expected minimal or database.`);
715
+ if (validator !== void 0 && resolvedValidator === void 0) throw new Error(`Unknown validator "${validator}" — expected zod, valibot, or none.`);
716
+ if (resolvedTemplate === void 0) {
717
+ if (options.interactive) {
718
+ const answer = await select({
719
+ message: "Template",
720
+ options: [{
721
+ value: "minimal",
722
+ label: "minimal — the smallest real app"
723
+ }, {
724
+ value: "database",
725
+ label: "database — CRUD over Drizzle + libsql/SQLite"
726
+ }]
727
+ });
728
+ if (typeof answer === "symbol") return;
729
+ resolvedTemplate = answer;
730
+ } else resolvedTemplate = "minimal";
731
+ }
732
+ if (resolvedValidator === void 0) {
733
+ if (options.interactive) {
734
+ const answer = await select({
735
+ message: "Request-body validator",
736
+ options: [
737
+ {
738
+ value: "zod",
739
+ label: "zod (the docs default)"
740
+ },
741
+ {
742
+ value: "valibot",
743
+ label: "valibot"
744
+ },
745
+ {
746
+ value: "none",
747
+ label: "none — hand-checked bodies"
748
+ }
749
+ ]
750
+ });
751
+ if (typeof answer === "symbol") return;
752
+ resolvedValidator = answer;
753
+ } else resolvedValidator = "zod";
754
+ }
755
+ return {
756
+ template: resolvedTemplate,
757
+ validator: resolvedValidator
758
+ };
759
+ }
760
+ /**
761
+ * Scaffold from the published starters repository: try the CLI's `v<major.minor>` branch first (the
762
+ * templates matching this CLI's framework minor), falling back to `main`.
763
+ *
764
+ * The target is accepted before the first attempt, so a refusal (bad name, escaping path, existing
765
+ * content) never reaches the retry — the cleanup below discards only what this call itself created.
766
+ */
767
+ static async scaffoldFromStarters(scaffolder, request) {
768
+ const [major = "0", minor = "0"] = CreateDefaults.cliVersion().split(".");
769
+ const base = `gh:${CreateDefaults.startersRepository}/${request.templateName}`;
770
+ const target = await ProjectScaffolder.prepareTarget(request.parentDirectory, request.projectName);
771
+ try {
772
+ return await CreateDefaults.attemptScaffold(scaffolder, request, `${base}#v${major}.${minor}`);
773
+ } catch (error) {
774
+ await ProjectScaffolder.discardPreparedTarget(target);
775
+ if (!(error instanceof TemplateDownloadError)) throw error;
776
+ }
777
+ try {
778
+ return await CreateDefaults.attemptScaffold(scaffolder, request, `${base}#main`);
779
+ } catch (error) {
780
+ await ProjectScaffolder.discardPreparedTarget(target);
781
+ throw error;
782
+ }
783
+ }
784
+ /**
785
+ * `git init` the project directory, unless it already sits inside a work tree (or git is unavailable).
786
+ *
787
+ * @returns Whether a repository was initialized.
788
+ */
789
+ static async initializeGit(projectDirectory) {
790
+ if (await CreateDefaults.run("git", ["rev-parse", "--is-inside-work-tree"], {
791
+ cwd: projectDirectory,
792
+ captureOnly: true
793
+ }) === 0) return false;
794
+ return await CreateDefaults.run("git", ["init"], {
795
+ cwd: projectDirectory,
796
+ captureOnly: true
797
+ }) === 0;
798
+ }
799
+ /** Run `<pm> install` in the project directory with inherited stdio (the user sees the real output). */
800
+ static async runInstall(packageManager, projectDirectory) {
801
+ const exitCode = await CreateDefaults.run(packageManager, ["install"], {
802
+ cwd: projectDirectory,
803
+ captureOnly: false
804
+ });
805
+ if (exitCode !== 0) throw new Error(`${packageManager} install failed (exit ${exitCode}) — run it manually inside the project.`);
806
+ }
807
+ /** Run one starters attempt against a single giget ref. */
808
+ static attemptScaffold(scaffolder, request, gigetSource) {
809
+ return scaffolder.scaffold({
810
+ ...request.selection,
811
+ projectName: request.projectName,
812
+ parentDirectory: request.parentDirectory,
813
+ heximonVersion: request.heximonVersion,
814
+ source: { gigetSource }
815
+ });
816
+ }
817
+ /** Narrow a raw flag value to one of the allowed literals, or `undefined`. */
818
+ static narrow(value, allowed) {
819
+ return allowed.includes(value) ? value : void 0;
820
+ }
821
+ /** Spawn a child process and resolve its exit code (non-zero-safe; spawn errors resolve to 1). */
822
+ static run(command, commandArguments, options) {
823
+ return new Promise((resolveExit) => {
824
+ const child = spawn(command, commandArguments, {
825
+ cwd: options.cwd,
826
+ stdio: options.captureOnly ? "ignore" : "inherit",
827
+ shell: false
828
+ });
829
+ child.on("error", () => resolveExit(1));
830
+ child.on("close", (code) => resolveExit(code ?? 1));
831
+ });
832
+ }
833
+ };
834
+ //#endregion
835
+ //#region src/skills-command.ts
836
+ /** The shared behavior behind `heximon skills add` and `heximon skills sync`. */
837
+ var SkillsRunner = class SkillsRunner {
838
+ /** The argument every `skills` subcommand takes. */
839
+ static baseArgs = { cwd: {
840
+ type: "string",
841
+ description: "Project root directory.",
842
+ default: "."
843
+ } };
844
+ /**
845
+ * Install one source into `projectDirectory`, logging what landed.
846
+ *
847
+ * @returns `true` when the install succeeded; a failure is logged and reported to the caller, never thrown,
848
+ * so `sync` over several sources still attempts the rest.
849
+ */
850
+ static async install(projectDirectory, sourceUrl) {
851
+ try {
852
+ const installed = await new SkillInstaller(sourceUrl).install(projectDirectory);
853
+ log.success(`${sourceUrl} → ${SkillsRunner.describe(installed)} in .agents/skills`);
854
+ return true;
855
+ } catch (error) {
856
+ log.error(`${sourceUrl} — ${error instanceof Error ? error.message : String(error)}`);
857
+ return false;
858
+ }
859
+ }
860
+ /** One install's skills, as `name (n files)` entries. */
861
+ static describe(installed) {
862
+ return installed.map((skill) => `${skill.name} (${skill.fileCount.toString()} files)`).join(", ");
863
+ }
864
+ };
865
+ const addCommand = defineCommand({
866
+ meta: {
867
+ name: "add",
868
+ description: "Record an agent-skill source in package.json and install it into .agents/skills."
869
+ },
870
+ args: {
871
+ ...SkillsRunner.baseArgs,
872
+ url: {
873
+ type: "positional",
874
+ description: `The site publishing the skills (default ${SkillInstaller.defaultSourceUrl}).`,
875
+ required: false
876
+ }
877
+ },
878
+ async run({ args }) {
879
+ const projectDirectory = resolve(process.cwd(), args.cwd);
880
+ const requested = args.url ?? SkillInstaller.defaultSourceUrl;
881
+ const sourceUrl = SkillSources.normalize(requested);
882
+ intro("heximon skills add");
883
+ if (!SkillSources.isInstallable(sourceUrl)) {
884
+ process.exitCode = 1;
885
+ outro(`'${requested}' is not an absolute http(s) URL — pass the site publishing the skills.`);
886
+ return;
887
+ }
888
+ if (!await SkillsRunner.install(projectDirectory, sourceUrl)) {
889
+ process.exitCode = 1;
890
+ outro("Nothing was installed, so nothing was recorded.");
891
+ return;
892
+ }
893
+ const added = await SkillSources.record(projectDirectory, sourceUrl);
894
+ outro(added ? `Recorded ${sourceUrl} in package.json (heximon.skills). Re-install it any time with \`heximon skills sync\`.` : `${sourceUrl} was already recorded — the install refreshed it.`);
895
+ }
896
+ });
897
+ const syncCommand = defineCommand({
898
+ meta: {
899
+ name: "sync",
900
+ description: "Re-install every agent-skill source package.json records."
901
+ },
902
+ args: SkillsRunner.baseArgs,
903
+ async run({ args }) {
904
+ const projectDirectory = resolve(process.cwd(), args.cwd);
905
+ intro("heximon skills sync");
906
+ const sources = await SkillSources.read(projectDirectory);
907
+ if (sources.length === 0) {
908
+ outro("No skill sources recorded in package.json (heximon.skills) — add one with `heximon skills add <url>`.");
909
+ return;
910
+ }
911
+ let failures = 0;
912
+ for (const sourceUrl of sources) if (!await SkillsRunner.install(projectDirectory, sourceUrl)) failures += 1;
913
+ if (failures > 0) {
914
+ process.exitCode = 1;
915
+ outro(`${failures.toString()} of ${sources.length.toString()} source(s) failed — see above.`);
916
+ return;
917
+ }
918
+ outro(`${sources.length.toString()} source(s) re-installed into .agents/skills.`);
919
+ }
920
+ });
921
+ /**
922
+ * The `heximon skills` command — install the published agent skills into a project and keep the list of
923
+ * sources in `package.json` (`heximon.skills`), so a second machine or a fresh clone reproduces the same
924
+ * install with one verb instead of a remembered URL.
925
+ *
926
+ * `heximon create` already installs the default source and records it, so an existing project's first use of
927
+ * this command is usually `sync` after a clone, or `add` for a second, private skill site.
928
+ */
929
+ const skillsCommand = defineCommand({
930
+ meta: {
931
+ name: "skills",
932
+ description: "Install and re-install the agent skills a project records in its package.json."
933
+ },
934
+ subCommands: {
935
+ add: addCommand,
936
+ sync: syncCommand
937
+ }
938
+ });
939
+ //#endregion
940
+ export { SkillInstaller as a, RegistryAccess as c, ClackPrompter as d, PromptCancelledError as f, SkillSources as i, RegistryAccessStatus as l, CreateDefaults as n, ProjectScaffolder as o, createCommand as r, TemplateDownloadError as s, skillsCommand as t, CliEnvironment as u };