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.
- package/LICENSE +1 -1
- package/README.md +139 -6
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +2912 -0
- package/dist/index.d.mts +405 -0
- package/dist/index.mjs +2 -6
- package/dist/skills-command-W72zUEYD.mjs +940 -0
- package/package.json +58 -19
|
@@ -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 };
|