howtofixcli 0.7.8 → 0.7.9
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/chunk-27TRKD65.js +2 -0
- package/dist/chunk-3FYCFLNK.js +1 -0
- package/dist/chunk-56SEAGAQ.js +1 -0
- package/dist/chunk-63ZCQDXH.js +6 -0
- package/dist/chunk-6FUWMLWO.js +1 -0
- package/dist/chunk-7CHDNTH6.js +1 -0
- package/dist/chunk-ACOMNDUL.js +185 -0
- package/dist/chunk-AEVPET7X.js +2 -0
- package/dist/chunk-AGGIYXN7.js +2 -0
- package/dist/chunk-BHVBO4SC.js +1 -0
- package/dist/chunk-FPXVDP32.js +8 -0
- package/dist/chunk-GIUYIKH3.js +109 -0
- package/dist/chunk-GQCAJ56I.js +5 -0
- package/dist/chunk-GXB7RZHH.js +1 -0
- package/dist/chunk-IGEY3AWF.js +1 -0
- package/dist/chunk-ILDDDLRC.js +1 -0
- package/dist/chunk-JFYVRWJI.js +2 -0
- package/dist/chunk-MVLCGU3G.js +1 -0
- package/dist/chunk-N345KWWK.js +1 -0
- package/dist/chunk-NBQFPCRT.js +1 -0
- package/dist/chunk-OIPSLIZE.js +5 -0
- package/dist/chunk-ORJ2QZBG.js +3 -0
- package/dist/chunk-PCALBZOO.js +2 -0
- package/dist/chunk-PDNBGUYP.js +2 -0
- package/dist/chunk-S656RE4A.js +1 -0
- package/dist/chunk-TSEKPGK4.js +1 -0
- package/dist/chunk-U6CI6KXN.js +34 -0
- package/dist/chunk-UTYFLLSM.js +1 -0
- package/dist/chunk-V3R4FYTJ.js +1 -0
- package/dist/chunk-V656XVXV.js +8 -0
- package/dist/chunk-WKUJ7CFO.js +1 -0
- package/dist/chunk-WOT6VMZA.js +1 -0
- package/dist/chunk-WUPU6LTD.js +4 -0
- package/dist/chunk-WX3V5P7U.js +5 -0
- package/dist/chunk-ZKZ6WCI3.js +2 -0
- package/dist/main.js +94 -536
- package/package.json +15 -62
- package/dist/account-credential.js +0 -136
- package/dist/account-credential.test.js +0 -38
- package/dist/activity.js +0 -124
- package/dist/agent-defs.js +0 -237
- package/dist/agent-limiter.js +0 -68
- package/dist/agents.js +0 -554
- package/dist/anim.js +0 -361
- package/dist/args.js +0 -228
- package/dist/at-file.js +0 -28
- package/dist/brand-extension.js +0 -87
- package/dist/brand.js +0 -76
- package/dist/browser.js +0 -58
- package/dist/cache-report.js +0 -139
- package/dist/checkpoint.js +0 -437
- package/dist/claims.js +0 -167
- package/dist/cli-auth.js +0 -142
- package/dist/cli-checkin.js +0 -120
- package/dist/cli-credentials.js +0 -254
- package/dist/clipboard.js +0 -149
- package/dist/collapse.js +0 -129
- package/dist/commands.js +0 -301
- package/dist/compaction-guard.js +0 -81
- package/dist/context-extension.js +0 -17
- package/dist/dcp/commands.js +0 -172
- package/dist/dcp/compress-state.js +0 -139
- package/dist/dcp/compress.js +0 -329
- package/dist/dcp/dcp-extension.js +0 -288
- package/dist/dcp/dedup.js +0 -93
- package/dist/dcp/ids.js +0 -189
- package/dist/dcp/index.js +0 -25
- package/dist/dcp/prompts.js +0 -68
- package/dist/dcp/protected-content.js +0 -221
- package/dist/dcp/protected.js +0 -161
- package/dist/dcp/prune.js +0 -418
- package/dist/dcp/state.js +0 -176
- package/dist/device-id.js +0 -90
- package/dist/device-id.test.js +0 -23
- package/dist/errors.js +0 -63
- package/dist/extensions.js +0 -48
- package/dist/fatal.js +0 -35
- package/dist/feed.js +0 -498
- package/dist/fs-atomic.js +0 -54
- package/dist/gateway-errors.js +0 -90
- package/dist/git.js +0 -230
- package/dist/goal-extension.js +0 -88
- package/dist/goal.js +0 -222
- package/dist/hooks-extension.js +0 -176
- package/dist/hooks.js +0 -443
- package/dist/howtofix-account.js +0 -715
- package/dist/hud-line.js +0 -153
- package/dist/hwid.js +0 -130
- package/dist/interrupt.js +0 -60
- package/dist/json-events.js +0 -50
- package/dist/level-style.js +0 -112
- package/dist/loop-guard.js +0 -95
- package/dist/lsp-tools.js +0 -32
- package/dist/lsp.js +0 -347
- package/dist/mcp-config.js +0 -85
- package/dist/mcp-lifecycle.js +0 -147
- package/dist/model-catalog.js +0 -217
- package/dist/model-resolve.js +0 -65
- package/dist/notes.js +0 -78
- package/dist/oauth-login.js +0 -568
- package/dist/paths.js +0 -61
- package/dist/permissions.js +0 -298
- package/dist/plan-mode.js +0 -363
- package/dist/preset-command.js +0 -227
- package/dist/preset-extension.js +0 -35
- package/dist/preset-mcp.js +0 -292
- package/dist/presets.js +0 -727
- package/dist/prompt-files.js +0 -103
- package/dist/prompt-text.js +0 -41
- package/dist/proxy.js +0 -137
- package/dist/quality/ast.js +0 -196
- package/dist/quality/cognitive.js +0 -83
- package/dist/quality/cyclomatic.js +0 -30
- package/dist/quality/halstead.js +0 -83
- package/dist/quality/index.js +0 -18
- package/dist/quality/length.js +0 -3
- package/dist/quality/maintainability.js +0 -15
- package/dist/quality/nesting.js +0 -57
- package/dist/quality/report.js +0 -803
- package/dist/quality/smells.js +0 -67
- package/dist/quality-tool.js +0 -63
- package/dist/read-only-commands.js +0 -597
- package/dist/repo-map.js +0 -292
- package/dist/rtk.js +0 -193
- package/dist/sandbox.js +0 -105
- package/dist/schema.js +0 -214
- package/dist/session-lifecycle.js +0 -19
- package/dist/session-retention.js +0 -111
- package/dist/session.js +0 -446
- package/dist/sessions.js +0 -100
- package/dist/settings.js +0 -148
- package/dist/skills.js +0 -35
- package/dist/slop-tool.js +0 -96
- package/dist/spend-cap.js +0 -43
- package/dist/subagent.js +0 -171
- package/dist/tar.js +0 -146
- package/dist/theme.js +0 -79
- package/dist/thinking.js +0 -45
- package/dist/todo.js +0 -114
- package/dist/tool-loadout.js +0 -97
- package/dist/tool-render.js +0 -769
- package/dist/tools.js +0 -176
- package/dist/ui.js +0 -4757
- package/dist/update.js +0 -533
- package/dist/usage-segment.js +0 -69
- package/dist/web-tools.js +0 -46
- package/dist/web.js +0 -265
- package/dist/welcome.js +0 -179
- package/dist/workflow-engine.js +0 -246
- package/dist/workflow-progress.js +0 -446
- package/dist/workflow-view.js +0 -195
- package/dist/workflow.js +0 -349
package/dist/presets.js
DELETED
|
@@ -1,727 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Presets: developer-provided bundles of skills and prompt text.
|
|
3
|
-
*
|
|
4
|
-
* A preset is a directory holding a `preset.json` manifest, a `skills/` tree and
|
|
5
|
-
* an optional `prompt.md`. It is never installed on its own. Registering a skill
|
|
6
|
-
* puts its name and description into the system prompt of every request, so a
|
|
7
|
-
* preset is a catalogue entry the user opts into — not something that arrives
|
|
8
|
-
* with the install and quietly makes every request heavier.
|
|
9
|
-
*
|
|
10
|
-
* Nothing here executes anything from a preset. A preset is text: instructions
|
|
11
|
-
* the model reads. The only side effect of installing one is copying files into
|
|
12
|
-
* a directory this module owns, after checking that every path stays inside the
|
|
13
|
-
* preset it came from.
|
|
14
|
-
*
|
|
15
|
-
* Two sources feed the catalogue:
|
|
16
|
-
*
|
|
17
|
-
* - bundled — the suite shipped with the package (`presets/` at the package
|
|
18
|
-
* root, copied to `dist/presets/` by both build paths). These are
|
|
19
|
-
* the developer-provided presets.
|
|
20
|
-
* - installed — what the user has opted into, under `AGENT_DIR/presets` and
|
|
21
|
-
* `<cwd>/.howtofix/presets`.
|
|
22
|
-
*/
|
|
23
|
-
import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
-
import { tmpdir } from "node:os";
|
|
25
|
-
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
27
|
-
import { execFileSync } from "node:child_process";
|
|
28
|
-
import { createHash } from "node:crypto";
|
|
29
|
-
import { AGENT_DIR } from "./paths.js";
|
|
30
|
-
import { validateMcpBlock } from "./preset-mcp.js";
|
|
31
|
-
import { isSafeRelativePath, parseTarGz, subtreeEntries } from "./tar.js";
|
|
32
|
-
/** User-global install root. */
|
|
33
|
-
export function userPresetsDir() {
|
|
34
|
-
return join(AGENT_DIR, "presets");
|
|
35
|
-
}
|
|
36
|
-
/** Project-scoped install root. */
|
|
37
|
-
export function projectPresetsDir(cwd) {
|
|
38
|
-
return join(cwd, ".howtofix", "presets");
|
|
39
|
-
}
|
|
40
|
-
/** Integration presets stay dormant until the user explicitly enables them. */
|
|
41
|
-
export function isPresetDisabled(dir) {
|
|
42
|
-
return existsSync(join(dir, ".disabled"));
|
|
43
|
-
}
|
|
44
|
-
/**
|
|
45
|
-
* The preset suite bundled with this install (`presets/` at the package root,
|
|
46
|
-
* copied to `dist/presets/`).
|
|
47
|
-
*
|
|
48
|
-
* Resolved from `import.meta.url` for the same reason `bundledAssetsDir` is: at
|
|
49
|
-
* runtime the working directory is the user's project, which has none of our
|
|
50
|
-
* assets in it.
|
|
51
|
-
*/
|
|
52
|
-
export function bundledPresetsDir() {
|
|
53
|
-
return join(dirname(fileURLToPath(import.meta.url)), "presets");
|
|
54
|
-
}
|
|
55
|
-
/** Both install roots, in load order: project first, then user-global. */
|
|
56
|
-
export function presetRoots(cwd) {
|
|
57
|
-
return [projectPresetsDir(cwd), userPresetsDir()];
|
|
58
|
-
}
|
|
59
|
-
const NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
60
|
-
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
61
|
-
function isRecord(value) {
|
|
62
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* Check that `child` resolves inside `root`.
|
|
66
|
-
*
|
|
67
|
-
* The whole point of a preset is that it is a self-contained directory of text.
|
|
68
|
-
* A manifest naming `../../.ssh` would copy a user's keys into a skill tree and
|
|
69
|
-
* hand them to the model on the next call, so every path a manifest contributes
|
|
70
|
-
* is resolved and compared against the preset root before it is used.
|
|
71
|
-
*/
|
|
72
|
-
function insideRoot(root, child) {
|
|
73
|
-
const rel = relative(resolve(root), resolve(child));
|
|
74
|
-
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Validate a manifest and its referenced files.
|
|
78
|
-
*
|
|
79
|
-
* Returns every problem rather than the first, so a preset author sees the whole
|
|
80
|
-
* list in one run instead of fixing them one per attempt.
|
|
81
|
-
*/
|
|
82
|
-
export function validatePreset(dir, value) {
|
|
83
|
-
const problems = [];
|
|
84
|
-
if (!isRecord(value))
|
|
85
|
-
return ["preset.json must be a JSON object"];
|
|
86
|
-
const str = (key) => {
|
|
87
|
-
const raw = value[key];
|
|
88
|
-
if (typeof raw !== "string" || raw.trim() === "") {
|
|
89
|
-
problems.push(`${key} must be a non-empty string`);
|
|
90
|
-
return "";
|
|
91
|
-
}
|
|
92
|
-
return raw.trim();
|
|
93
|
-
};
|
|
94
|
-
const name = str("name");
|
|
95
|
-
const title = str("title");
|
|
96
|
-
const description = str("description");
|
|
97
|
-
const category = str("category");
|
|
98
|
-
const version = str("version");
|
|
99
|
-
if (name && !NAME_PATTERN.test(name)) {
|
|
100
|
-
problems.push(`name "${name}" must be lowercase letters, digits and dashes`);
|
|
101
|
-
}
|
|
102
|
-
if (version && !SEMVER_PATTERN.test(version)) {
|
|
103
|
-
problems.push(`version "${version}" is not a version like 1.0.0`);
|
|
104
|
-
}
|
|
105
|
-
const rawSkills = value.skills;
|
|
106
|
-
if (!Array.isArray(rawSkills) || rawSkills.length === 0) {
|
|
107
|
-
problems.push("skills must be a non-empty array of directory names");
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
for (const entry of rawSkills) {
|
|
111
|
-
if (typeof entry !== "string" || entry.trim() === "") {
|
|
112
|
-
problems.push("every skills entry must be a non-empty string");
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
const skillDir = join(dir, "skills", entry.trim());
|
|
116
|
-
if (!insideRoot(join(dir, "skills"), skillDir)) {
|
|
117
|
-
problems.push(`skill "${entry}" escapes the preset directory`);
|
|
118
|
-
continue;
|
|
119
|
-
}
|
|
120
|
-
if (!existsSync(join(skillDir, "SKILL.md"))) {
|
|
121
|
-
problems.push(`skill "${entry}" has no SKILL.md at ${join("skills", entry)}`);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
const rawPrompt = value.prompt;
|
|
126
|
-
if (rawPrompt !== undefined) {
|
|
127
|
-
if (typeof rawPrompt !== "string" || rawPrompt.trim() === "") {
|
|
128
|
-
problems.push("prompt must be a non-empty string when present");
|
|
129
|
-
}
|
|
130
|
-
else {
|
|
131
|
-
const promptPath = join(dir, rawPrompt.trim());
|
|
132
|
-
if (!insideRoot(dir, promptPath)) {
|
|
133
|
-
problems.push(`prompt "${rawPrompt}" escapes the preset directory`);
|
|
134
|
-
}
|
|
135
|
-
else if (!existsSync(promptPath)) {
|
|
136
|
-
problems.push(`prompt file "${rawPrompt}" does not exist`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
const rawTags = value.tags;
|
|
141
|
-
if (rawTags !== undefined && !Array.isArray(rawTags)) {
|
|
142
|
-
problems.push("tags must be an array when present");
|
|
143
|
-
}
|
|
144
|
-
problems.push(...validateMcpBlock(value.mcp));
|
|
145
|
-
return problems;
|
|
146
|
-
}
|
|
147
|
-
/** Read and validate one preset directory. */
|
|
148
|
-
export function readPreset(dir) {
|
|
149
|
-
const manifestPath = join(dir, "preset.json");
|
|
150
|
-
if (!existsSync(manifestPath))
|
|
151
|
-
return { problems: [`no preset.json in ${dir}`] };
|
|
152
|
-
let parsed;
|
|
153
|
-
try {
|
|
154
|
-
parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
155
|
-
}
|
|
156
|
-
catch (err) {
|
|
157
|
-
return { problems: [`preset.json is not valid JSON: ${err instanceof Error ? err.message : String(err)}`] };
|
|
158
|
-
}
|
|
159
|
-
const problems = validatePreset(dir, parsed);
|
|
160
|
-
if (problems.length > 0)
|
|
161
|
-
return { problems };
|
|
162
|
-
const record = parsed;
|
|
163
|
-
const tags = Array.isArray(record.tags) ? record.tags.filter((t) => typeof t === "string") : [];
|
|
164
|
-
return {
|
|
165
|
-
problems: [],
|
|
166
|
-
manifest: {
|
|
167
|
-
name: String(record.name),
|
|
168
|
-
title: String(record.title),
|
|
169
|
-
description: String(record.description),
|
|
170
|
-
category: String(record.category),
|
|
171
|
-
tags,
|
|
172
|
-
version: String(record.version),
|
|
173
|
-
skills: record.skills.map((s) => String(s)),
|
|
174
|
-
prompt: typeof record.prompt === "string" ? record.prompt : undefined,
|
|
175
|
-
mcp: record.mcp && typeof record.mcp === "object" && !Array.isArray(record.mcp) ? record.mcp : undefined,
|
|
176
|
-
integrates: typeof record.integrates === "string" && record.integrates.trim() !== "" ? record.integrates.trim() : undefined,
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
/** Directories that may hold presets, ignoring ones that are missing. */
|
|
181
|
-
function existingDirs(roots) {
|
|
182
|
-
return roots.filter((root) => {
|
|
183
|
-
try {
|
|
184
|
-
return statSync(root).isDirectory();
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
/**
|
|
192
|
-
* Walk one root and read every preset under it.
|
|
193
|
-
*
|
|
194
|
-
* Two layouts are supported, because the catalogue and an install do not have the
|
|
195
|
-
* same shape: the bundled suite groups presets by category
|
|
196
|
-
* (`<root>/<category>/<name>/`), while an install lands directly under the root
|
|
197
|
-
* (`<root>/<name>/`) — a category in the install path would be a second copy of
|
|
198
|
-
* the manifest's own `category` field, free to disagree with it. A directory that
|
|
199
|
-
* fails to validate is reported, not silently dropped: a broken preset that
|
|
200
|
-
* vanishes from the menu is indistinguishable from one that was never installed.
|
|
201
|
-
*/
|
|
202
|
-
function scanRoot(root, source) {
|
|
203
|
-
const entries = [];
|
|
204
|
-
const problems = [];
|
|
205
|
-
const readOne = (dir, label) => {
|
|
206
|
-
if (!existsSync(join(dir, "preset.json")))
|
|
207
|
-
return;
|
|
208
|
-
const { manifest, problems: issues } = readPreset(dir);
|
|
209
|
-
if (manifest)
|
|
210
|
-
entries.push({ manifest, dir, source });
|
|
211
|
-
else
|
|
212
|
-
problems.push(`${label}: ${issues.join("; ")}`);
|
|
213
|
-
};
|
|
214
|
-
// The root can disappear between the `isDirectory()` guard and this read — a
|
|
215
|
-
// removal in another session, a cleanup — and an ENOENT thrown here would take
|
|
216
|
-
// down whatever asked for the catalogue. A missing root is an empty catalogue.
|
|
217
|
-
let firstLevel;
|
|
218
|
-
try {
|
|
219
|
-
firstLevel = readdirSync(root).sort();
|
|
220
|
-
}
|
|
221
|
-
catch {
|
|
222
|
-
return { entries, problems };
|
|
223
|
-
}
|
|
224
|
-
for (const first of firstLevel) {
|
|
225
|
-
// A dot-entry is never a preset: the install lock and an in-progress staging
|
|
226
|
-
// directory both live here, and a staging directory holds a `preset.json` —
|
|
227
|
-
// so without this it would be catalogued as a preset that exists only while
|
|
228
|
-
// another install is running, and the menu would list a name that vanishes.
|
|
229
|
-
if (first.startsWith("."))
|
|
230
|
-
continue;
|
|
231
|
-
const firstDir = join(root, first);
|
|
232
|
-
try {
|
|
233
|
-
if (!statSync(firstDir).isDirectory())
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
// Flat layout: the preset is this directory.
|
|
240
|
-
if (existsSync(join(firstDir, "preset.json"))) {
|
|
241
|
-
readOne(firstDir, first);
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
// Grouped layout: `<category>/<name>/`.
|
|
245
|
-
let names;
|
|
246
|
-
try {
|
|
247
|
-
names = readdirSync(firstDir).sort();
|
|
248
|
-
}
|
|
249
|
-
catch {
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
for (const name of names) {
|
|
253
|
-
if (name.startsWith("."))
|
|
254
|
-
continue;
|
|
255
|
-
const dir = join(firstDir, name);
|
|
256
|
-
try {
|
|
257
|
-
if (!statSync(dir).isDirectory())
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
catch {
|
|
261
|
-
continue;
|
|
262
|
-
}
|
|
263
|
-
readOne(dir, join(first, name));
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
return { entries, problems };
|
|
267
|
-
}
|
|
268
|
-
/** The full catalogue: bundled presets plus everything installed. */
|
|
269
|
-
export function listCatalog(cwd) {
|
|
270
|
-
const bundled = scanRoot(bundledPresetsDir(), "bundled");
|
|
271
|
-
const entries = [...bundled.entries];
|
|
272
|
-
const problems = [...bundled.problems];
|
|
273
|
-
// An installed preset of the same name replaces the bundled one: opting into a
|
|
274
|
-
// preset and then editing it locally must not be silently overridden by the
|
|
275
|
-
// copy that ships with the package.
|
|
276
|
-
const seen = new Map(entries.map((entry) => [entry.manifest.name, entry]));
|
|
277
|
-
for (const root of existingDirs(presetRoots(cwd))) {
|
|
278
|
-
const scanned = scanRoot(root, "installed");
|
|
279
|
-
problems.push(...scanned.problems);
|
|
280
|
-
for (const entry of scanned.entries) {
|
|
281
|
-
const previous = seen.get(entry.manifest.name);
|
|
282
|
-
if (previous) {
|
|
283
|
-
const index = entries.indexOf(previous);
|
|
284
|
-
if (index >= 0)
|
|
285
|
-
entries[index] = entry;
|
|
286
|
-
seen.set(entry.manifest.name, entry);
|
|
287
|
-
}
|
|
288
|
-
else {
|
|
289
|
-
entries.push(entry);
|
|
290
|
-
seen.set(entry.manifest.name, entry);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
return { entries, problems };
|
|
295
|
-
}
|
|
296
|
-
/** Category names present in the catalogue, sorted. */
|
|
297
|
-
export function categoriesOf(entries) {
|
|
298
|
-
return [...new Set(entries.map((entry) => entry.manifest.category))].sort();
|
|
299
|
-
}
|
|
300
|
-
/** Presets in one category, sorted by title. */
|
|
301
|
-
export function presetsIn(entries, category) {
|
|
302
|
-
return entries.filter((entry) => entry.manifest.category === category).sort((a, b) => a.manifest.title.localeCompare(b.manifest.title));
|
|
303
|
-
}
|
|
304
|
-
/**
|
|
305
|
-
* Skill directories contributed by installed presets.
|
|
306
|
-
*
|
|
307
|
-
* Returned as search paths for the SDK resource loader, which scans a directory
|
|
308
|
-
* for skill subdirectories. Only *installed* presets contribute: a bundled preset
|
|
309
|
-
* that was never opted into must not put its skills into the prompt.
|
|
310
|
-
*/
|
|
311
|
-
export function installedPresetSkillPaths(cwd) {
|
|
312
|
-
const paths = [];
|
|
313
|
-
for (const root of existingDirs(presetRoots(cwd))) {
|
|
314
|
-
const { entries } = scanRoot(root, "installed");
|
|
315
|
-
for (const entry of entries)
|
|
316
|
-
if (!isPresetDisabled(entry.dir))
|
|
317
|
-
paths.push(join(entry.dir, "skills"));
|
|
318
|
-
}
|
|
319
|
-
return paths;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Prompt text contributed by installed presets.
|
|
323
|
-
*
|
|
324
|
-
* A preset's `prompt.md` is appended to the system prompt as its own section. It
|
|
325
|
-
* is never allowed to replace the prompt: a preset that could rewrite the rules
|
|
326
|
-
* could remove the ones that keep it honest.
|
|
327
|
-
*/
|
|
328
|
-
export function installedPresetPrompts(cwd) {
|
|
329
|
-
const out = [];
|
|
330
|
-
for (const root of existingDirs(presetRoots(cwd))) {
|
|
331
|
-
const { entries } = scanRoot(root, "installed");
|
|
332
|
-
for (const entry of entries) {
|
|
333
|
-
if (isPresetDisabled(entry.dir))
|
|
334
|
-
continue;
|
|
335
|
-
const rel = entry.manifest.prompt;
|
|
336
|
-
if (!rel)
|
|
337
|
-
continue;
|
|
338
|
-
const file = join(entry.dir, rel);
|
|
339
|
-
if (!insideRoot(entry.dir, file))
|
|
340
|
-
continue;
|
|
341
|
-
try {
|
|
342
|
-
const text = readFileSync(file, "utf8").trim();
|
|
343
|
-
if (text)
|
|
344
|
-
out.push({ name: entry.manifest.name, title: entry.manifest.title, text });
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
// A prompt file that disappeared between validation and read is a missing
|
|
348
|
-
// section, not a failed session.
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
return out;
|
|
353
|
-
}
|
|
354
|
-
/** Total bytes of a preset's skills, for the "how much does this cost" line. */
|
|
355
|
-
export function presetSize(dir, manifest) {
|
|
356
|
-
let total = 0;
|
|
357
|
-
for (const skill of manifest.skills) {
|
|
358
|
-
try {
|
|
359
|
-
total += statSync(join(dir, "skills", skill, "SKILL.md")).size;
|
|
360
|
-
}
|
|
361
|
-
catch {
|
|
362
|
-
// Missing files were already reported by validation.
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return total;
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Sleep without yielding, for the synchronous install path.
|
|
369
|
-
*
|
|
370
|
-
* `copyPreset` is synchronous, so it cannot await a timer. `Atomics.wait` on a
|
|
371
|
-
* shared buffer is the one way to block the current thread for a fixed time
|
|
372
|
-
* without spinning a core.
|
|
373
|
-
*/
|
|
374
|
-
function sleepSync(ms) {
|
|
375
|
-
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
376
|
-
}
|
|
377
|
-
/** Name of the lock that serialises installs across processes. */
|
|
378
|
-
const INSTALL_LOCK = ".install.lock";
|
|
379
|
-
/**
|
|
380
|
-
* Run `body` while holding the install lock for `targetRoot`.
|
|
381
|
-
*
|
|
382
|
-
* The in-process `presetBusy` flag stops one CLI from racing itself, but nothing
|
|
383
|
-
* stopped two sessions — or a session and a script — from installing into the
|
|
384
|
-
* same project at the same time. That is a real collision: the swap below removes
|
|
385
|
-
* a directory and renames another into its place, and a second process doing the
|
|
386
|
-
* same between those two steps leaves a project with no preset, or with one
|
|
387
|
-
* process's files where the other's should be.
|
|
388
|
-
*
|
|
389
|
-
* A lock file rather than a directory: `wx` is atomic on every filesystem this
|
|
390
|
-
* runs on, and the pid inside makes a stuck lock diagnosable. A lock older than
|
|
391
|
-
* two minutes is treated as abandoned and taken over — a killed process must not
|
|
392
|
-
* wedge installs until someone deletes a file by hand.
|
|
393
|
-
*/
|
|
394
|
-
function withInstallLock(targetRoot, body) {
|
|
395
|
-
mkdirSync(targetRoot, { recursive: true });
|
|
396
|
-
const lockPath = join(targetRoot, INSTALL_LOCK);
|
|
397
|
-
const deadline = Date.now() + 60_000;
|
|
398
|
-
for (;;) {
|
|
399
|
-
try {
|
|
400
|
-
writeFileSync(lockPath, `${process.pid}\n`, { flag: "wx" });
|
|
401
|
-
break;
|
|
402
|
-
}
|
|
403
|
-
catch (err) {
|
|
404
|
-
if (err.code !== "EEXIST")
|
|
405
|
-
throw err;
|
|
406
|
-
let stale = false;
|
|
407
|
-
try {
|
|
408
|
-
stale = Date.now() - statSync(lockPath).mtimeMs > 120_000;
|
|
409
|
-
}
|
|
410
|
-
catch {
|
|
411
|
-
// The holder released it between the failed create and this stat. Retry.
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
if (stale) {
|
|
415
|
-
rmSync(lockPath, { force: true });
|
|
416
|
-
continue;
|
|
417
|
-
}
|
|
418
|
-
if (Date.now() > deadline) {
|
|
419
|
-
throw new Error(`another install is holding ${lockPath}; remove it if no install is running`);
|
|
420
|
-
}
|
|
421
|
-
sleepSync(100);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
try {
|
|
425
|
-
// Clean up staging directories left by an install that was killed. They are
|
|
426
|
-
// only safe to remove while holding the lock — another install may be using
|
|
427
|
-
// its own right now — and they are never collected otherwise, so a few
|
|
428
|
-
// crashed installs would otherwise accumulate megabytes in the project.
|
|
429
|
-
for (const name of readdirSync(targetRoot)) {
|
|
430
|
-
if (!name.startsWith(".staging-"))
|
|
431
|
-
continue;
|
|
432
|
-
rmSync(join(targetRoot, name), { recursive: true, force: true });
|
|
433
|
-
}
|
|
434
|
-
return body();
|
|
435
|
-
}
|
|
436
|
-
finally {
|
|
437
|
-
rmSync(lockPath, { force: true });
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
/**
|
|
441
|
-
* Put `sourceDir` at `targetRoot/<name>`, replacing whatever is there.
|
|
442
|
-
*
|
|
443
|
-
* Staged and renamed rather than removed and copied. The previous version did
|
|
444
|
-
* `rmSync(target)` then `cpSync(source, target)`, and between those two calls the
|
|
445
|
-
* preset did not exist — a reader in that window (a reload, a second session
|
|
446
|
-
* starting) saw a project with no preset, and a crash left it that way. It also
|
|
447
|
-
* let two concurrent installs interleave: A removes, B removes, A copies, B
|
|
448
|
-
* copies, and the result is a mix of both.
|
|
449
|
-
*
|
|
450
|
-
* The staging directory is created *inside* `targetRoot` so the rename stays on
|
|
451
|
-
* one filesystem, where it is atomic. It is named with a leading dot, and the
|
|
452
|
-
* catalogue scan skips dot-entries, so a staging directory in progress is never
|
|
453
|
-
* mistaken for an installed preset.
|
|
454
|
-
*/
|
|
455
|
-
function copyPreset(sourceDir, targetRoot, manifest) {
|
|
456
|
-
const target = join(targetRoot, manifest.name);
|
|
457
|
-
return withInstallLock(targetRoot, () => {
|
|
458
|
-
mkdirSync(targetRoot, { recursive: true });
|
|
459
|
-
const staging = mkdtempSync(join(targetRoot, ".staging-"));
|
|
460
|
-
try {
|
|
461
|
-
cpSync(sourceDir, staging, { recursive: true });
|
|
462
|
-
// Replace rather than merge: a reinstall must not leave a skill from the
|
|
463
|
-
// previous version behind, where it would keep loading with no file on disk
|
|
464
|
-
// that explains where it came from.
|
|
465
|
-
rmSync(target, { recursive: true, force: true });
|
|
466
|
-
renameSync(staging, target);
|
|
467
|
-
}
|
|
468
|
-
catch (err) {
|
|
469
|
-
rmSync(staging, { recursive: true, force: true });
|
|
470
|
-
throw err;
|
|
471
|
-
}
|
|
472
|
-
return target;
|
|
473
|
-
});
|
|
474
|
-
}
|
|
475
|
-
/** Install a preset from a local directory. */
|
|
476
|
-
export function installFromDirectory(sourceDir, targetRoot) {
|
|
477
|
-
if (!existsSync(sourceDir))
|
|
478
|
-
return { ok: false, problems: [`no such directory: ${sourceDir}`] };
|
|
479
|
-
const { manifest, problems } = readPreset(sourceDir);
|
|
480
|
-
if (!manifest)
|
|
481
|
-
return { ok: false, problems };
|
|
482
|
-
const target = copyPreset(sourceDir, targetRoot, manifest);
|
|
483
|
-
return { ok: true, name: manifest.name, target, problems: [] };
|
|
484
|
-
}
|
|
485
|
-
/**
|
|
486
|
-
* Install a preset from a git URL.
|
|
487
|
-
*
|
|
488
|
-
* A shallow clone into a temporary directory, then the same validation a local
|
|
489
|
-
* directory gets. The clone is what makes this safe to accept from a URL: the
|
|
490
|
-
* repository is data to be read, never a program to be run — no install hooks,
|
|
491
|
-
* no post-checkout script, nothing executed from the checkout.
|
|
492
|
-
*/
|
|
493
|
-
export function installFromGit(url, targetRoot) {
|
|
494
|
-
const staging = mkdtempSync(join(tmpdir(), "howtofix-preset-"));
|
|
495
|
-
try {
|
|
496
|
-
execFileSync("git", ["clone", "--depth", "1", "--quiet", url, staging], { stdio: "pipe" });
|
|
497
|
-
return installFromDirectory(staging, targetRoot);
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
500
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
501
|
-
return { ok: false, problems: [`git clone failed: ${message.split("\n")[0]}`] };
|
|
502
|
-
}
|
|
503
|
-
finally {
|
|
504
|
-
rmSync(staging, { recursive: true, force: true });
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/** Install from a git URL or a local path, whichever the argument looks like. */
|
|
508
|
-
export function installFrom(source, targetRoot) {
|
|
509
|
-
const looksLikeGit = /^(https?:\/\/|git@|git:\/\/)/.test(source);
|
|
510
|
-
return looksLikeGit ? installFromGit(source, targetRoot) : installFromDirectory(resolve(source), targetRoot);
|
|
511
|
-
}
|
|
512
|
-
/**
|
|
513
|
-
* Remove an installed preset.
|
|
514
|
-
*
|
|
515
|
-
* Only from the install roots — a bundled preset is part of the package and is
|
|
516
|
-
* "removed" by never opting into it. Returns the paths that were removed.
|
|
517
|
-
*/
|
|
518
|
-
export function removeInstalled(name, cwd) {
|
|
519
|
-
const removed = [];
|
|
520
|
-
const problems = [];
|
|
521
|
-
if (!NAME_PATTERN.test(name))
|
|
522
|
-
return { removed, problems: [`"${name}" is not a preset name`] };
|
|
523
|
-
for (const root of presetRoots(cwd)) {
|
|
524
|
-
const target = join(root, name);
|
|
525
|
-
if (!existsSync(target))
|
|
526
|
-
continue;
|
|
527
|
-
if (!insideRoot(root, target)) {
|
|
528
|
-
problems.push(`refusing to remove ${target}: outside ${root}`);
|
|
529
|
-
continue;
|
|
530
|
-
}
|
|
531
|
-
rmSync(target, { recursive: true, force: true });
|
|
532
|
-
removed.push(target);
|
|
533
|
-
}
|
|
534
|
-
if (removed.length === 0)
|
|
535
|
-
problems.push(`"${name}" is not installed`);
|
|
536
|
-
return { removed, problems };
|
|
537
|
-
}
|
|
538
|
-
/** Where a new install should land: the project root when there is one. */
|
|
539
|
-
export function defaultInstallRoot(cwd) {
|
|
540
|
-
return projectPresetsDir(cwd);
|
|
541
|
-
}
|
|
542
|
-
/** Digest of a directory tree: sha256 over `hash path` lines, sorted by path. */
|
|
543
|
-
export function treeDigest(dir) {
|
|
544
|
-
const files = [];
|
|
545
|
-
const walk = (d, prefix) => {
|
|
546
|
-
for (const entry of readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
547
|
-
const p = join(d, entry.name);
|
|
548
|
-
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
549
|
-
// Symlinks are skipped: a clone resolves them, a tarball records a link
|
|
550
|
-
// entry, and hashing one side only would report a permanent mismatch.
|
|
551
|
-
if (entry.isSymbolicLink())
|
|
552
|
-
continue;
|
|
553
|
-
if (entry.isDirectory())
|
|
554
|
-
walk(p, rel);
|
|
555
|
-
else if (entry.isFile())
|
|
556
|
-
files.push([rel, createHash("sha256").update(readFileSync(p)).digest("hex")]);
|
|
557
|
-
}
|
|
558
|
-
};
|
|
559
|
-
walk(dir, "");
|
|
560
|
-
let bytes = 0;
|
|
561
|
-
for (const [rel] of files)
|
|
562
|
-
bytes += statSync(join(dir, rel)).size;
|
|
563
|
-
const manifest = files.map(([rel, hash]) => `${hash} ${rel}`).join("\n");
|
|
564
|
-
return {
|
|
565
|
-
digest: createHash("sha256").update(manifest).digest("hex").slice(0, 16),
|
|
566
|
-
fileCount: files.length,
|
|
567
|
-
bytes,
|
|
568
|
-
};
|
|
569
|
-
}
|
|
570
|
-
/** Read the bundled registry. A missing or malformed file is an empty list, not a crash. */
|
|
571
|
-
export function readHub() {
|
|
572
|
-
const file = join(bundledPresetsDir(), "hub.json");
|
|
573
|
-
try {
|
|
574
|
-
const raw = readFileSync(file, "utf8");
|
|
575
|
-
const parsed = JSON.parse(raw);
|
|
576
|
-
const list = isRecord(parsed) && Array.isArray(parsed.entries) ? parsed.entries : [];
|
|
577
|
-
const entries = [];
|
|
578
|
-
for (const item of list) {
|
|
579
|
-
if (!isRecord(item))
|
|
580
|
-
continue;
|
|
581
|
-
if (typeof item.name !== "string" || typeof item.repo !== "string" || typeof item.sha !== "string")
|
|
582
|
-
continue;
|
|
583
|
-
if (typeof item.path !== "string" || typeof item.category !== "string")
|
|
584
|
-
continue;
|
|
585
|
-
entries.push({
|
|
586
|
-
name: item.name,
|
|
587
|
-
title: typeof item.title === "string" ? item.title : item.name,
|
|
588
|
-
description: typeof item.description === "string" ? item.description : "",
|
|
589
|
-
category: item.category,
|
|
590
|
-
skill: typeof item.skill === "string" ? item.skill : item.name,
|
|
591
|
-
path: item.path,
|
|
592
|
-
repo: item.repo,
|
|
593
|
-
sha: item.sha,
|
|
594
|
-
license: typeof item.license === "string" ? item.license : "unknown",
|
|
595
|
-
stars: typeof item.stars === "number" ? item.stars : 0,
|
|
596
|
-
sizeKb: typeof item.sizeKb === "number" ? item.sizeKb : 0,
|
|
597
|
-
// Carried through, not dropped: the install verifies this against what it
|
|
598
|
-
// downloads, and an entry whose digest is silently discarded would look
|
|
599
|
-
// verified while checking nothing.
|
|
600
|
-
...(typeof item.digest === "string" ? { digest: item.digest } : {}),
|
|
601
|
-
...(typeof item.fileCount === "number" ? { fileCount: item.fileCount } : {}),
|
|
602
|
-
// Carried explicitly: this builder names every field, so a field added to
|
|
603
|
-
// the generator and forgotten here is dropped silently and the registry
|
|
604
|
-
// looks complete while saying nothing. That happened once with `digest`.
|
|
605
|
-
...(typeof item.modelInvocable === "boolean" ? { modelInvocable: item.modelInvocable } : {}),
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
return { entries, problems: [] };
|
|
609
|
-
}
|
|
610
|
-
catch (err) {
|
|
611
|
-
if (err?.code === "ENOENT")
|
|
612
|
-
return { entries: [], problems: [] };
|
|
613
|
-
return { entries: [], problems: [`registry unreadable: ${err instanceof Error ? err.message : String(err)}`] };
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
/** Entries of the registry, grouped for the menu. */
|
|
617
|
-
export function hubCategories(entries) {
|
|
618
|
-
return [...new Set(entries.map((e) => e.category))].sort();
|
|
619
|
-
}
|
|
620
|
-
export function hubIn(entries, category) {
|
|
621
|
-
return entries.filter((e) => e.category === category).sort((a, b) => b.stars - a.stars || a.name.localeCompare(b.name));
|
|
622
|
-
}
|
|
623
|
-
export function hubFind(entries, name) {
|
|
624
|
-
return entries.find((e) => e.name === name);
|
|
625
|
-
}
|
|
626
|
-
/** Where a downloaded tarball for an entry comes from. */
|
|
627
|
-
export function hubTarballUrl(entry) {
|
|
628
|
-
return `https://codeload.github.com/${entry.repo}/tar.gz/${entry.sha}`;
|
|
629
|
-
}
|
|
630
|
-
/**
|
|
631
|
-
* Download and install a registry entry.
|
|
632
|
-
*
|
|
633
|
-
* The archive is treated as data: entries are read, each path is checked against
|
|
634
|
-
* the destination, and nothing in it is executed. The pinned `sha` means the
|
|
635
|
-
* bytes are the ones the registry was built from, not whatever upstream has
|
|
636
|
-
* pushed since.
|
|
637
|
-
*
|
|
638
|
-
* The timeout is 120s rather than 60s because the largest entry in the registry
|
|
639
|
-
* is a 24 MB archive, and a timeout that a supported entry cannot meet turns a
|
|
640
|
-
* slow connection into "this preset is broken".
|
|
641
|
-
*/
|
|
642
|
-
export async function installFromHub(entry, targetRoot, timeoutMs = 120_000) {
|
|
643
|
-
const staging = mkdtempSync(join(tmpdir(), "howtofix-hub-"));
|
|
644
|
-
try {
|
|
645
|
-
const response = await fetch(hubTarballUrl(entry), {
|
|
646
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
647
|
-
redirect: "follow",
|
|
648
|
-
});
|
|
649
|
-
if (!response.ok) {
|
|
650
|
-
return { ok: false, problems: [`${entry.repo}: HTTP ${response.status} from codeload`] };
|
|
651
|
-
}
|
|
652
|
-
const archive = Buffer.from(await response.arrayBuffer());
|
|
653
|
-
const entries = parseTarGz(archive);
|
|
654
|
-
const files = subtreeEntries(entries, entry.path).filter((e) => e.kind === "file" || e.kind === "directory");
|
|
655
|
-
if (!files.some((e) => e.path === "SKILL.md")) {
|
|
656
|
-
return { ok: false, problems: [`${entry.repo}@${entry.sha.slice(0, 7)} has no ${entry.path}/SKILL.md`] };
|
|
657
|
-
}
|
|
658
|
-
// Write into staging as `<preset>/skills/<skill>/…`, which is the layout a
|
|
659
|
-
// preset directory has, so the same validation and the same install path run
|
|
660
|
-
// for a downloaded preset as for a local one.
|
|
661
|
-
const presetDir = join(staging, "preset", entry.name);
|
|
662
|
-
const skillRoot = join(presetDir, "skills", entry.skill);
|
|
663
|
-
let written = 0;
|
|
664
|
-
for (const file of files) {
|
|
665
|
-
if (!isSafeRelativePath(file.path)) {
|
|
666
|
-
return { ok: false, problems: [`refusing unsafe path in archive: ${file.path}`] };
|
|
667
|
-
}
|
|
668
|
-
const destination = join(skillRoot, file.path);
|
|
669
|
-
if (!insideRoot(skillRoot, destination)) {
|
|
670
|
-
return { ok: false, problems: [`refusing path outside the skill: ${file.path}`] };
|
|
671
|
-
}
|
|
672
|
-
if (file.kind === "directory") {
|
|
673
|
-
mkdirSync(destination, { recursive: true });
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
mkdirSync(dirname(destination), { recursive: true });
|
|
677
|
-
writeFileSync(destination, file.data ?? Buffer.alloc(0));
|
|
678
|
-
written += 1;
|
|
679
|
-
}
|
|
680
|
-
const manifest = {
|
|
681
|
-
name: entry.name,
|
|
682
|
-
title: entry.title,
|
|
683
|
-
description: entry.description,
|
|
684
|
-
category: entry.category,
|
|
685
|
-
version: "1.0.0",
|
|
686
|
-
skills: [entry.skill],
|
|
687
|
-
source: `${entry.repo}@${entry.sha}`,
|
|
688
|
-
license: entry.license,
|
|
689
|
-
};
|
|
690
|
-
writeFileSync(join(presetDir, "preset.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
691
|
-
// Verify what actually arrived against what the registry recorded. The `sha`
|
|
692
|
-
// already pins the commit; this catches the other case — the commit is still
|
|
693
|
-
// reachable but the skill's composition under it is not what was reviewed.
|
|
694
|
-
// Every registry entry carries a digest (build-preset-hub.mjs writes one
|
|
695
|
-
// for all 911); an entry without it cannot be verified against what was
|
|
696
|
-
// reviewed, so it is refused rather than installed unchecked.
|
|
697
|
-
if (!entry.digest) {
|
|
698
|
-
return {
|
|
699
|
-
ok: false,
|
|
700
|
-
problems: [`${entry.repo}@${entry.sha.slice(0, 7)}: registry entry has no digest`, "refusing to install; content cannot be verified against what was reviewed"],
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
const actual = treeDigest(skillRoot);
|
|
704
|
-
if (actual.digest !== entry.digest) {
|
|
705
|
-
return {
|
|
706
|
-
ok: false,
|
|
707
|
-
problems: [
|
|
708
|
-
`${entry.repo}@${entry.sha.slice(0, 7)}: content changed under a pinned commit`,
|
|
709
|
-
`expected ${entry.digest} (${entry.fileCount ?? "?"} files), got ${actual.digest} (${actual.fileCount} files)`,
|
|
710
|
-
"refusing to install; the registry needs regenerating",
|
|
711
|
-
],
|
|
712
|
-
};
|
|
713
|
-
}
|
|
714
|
-
const problems = validatePreset(presetDir, manifest);
|
|
715
|
-
if (problems.length > 0)
|
|
716
|
-
return { ok: false, problems };
|
|
717
|
-
const target = copyPreset(presetDir, targetRoot, manifest);
|
|
718
|
-
return { ok: true, name: entry.name, target, problems: [], skill: entry.skill, modelInvocable: entry.modelInvocable };
|
|
719
|
-
}
|
|
720
|
-
catch (err) {
|
|
721
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
722
|
-
return { ok: false, problems: [`download failed: ${message.split("\n")[0]}`] };
|
|
723
|
-
}
|
|
724
|
-
finally {
|
|
725
|
-
rmSync(staging, { recursive: true, force: true });
|
|
726
|
-
}
|
|
727
|
-
}
|