@hardfin/cli 0.1.1-dev.83 → 0.1.1-dev.84
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/README.md +85 -23
- package/dist/check-Di68wbxY.js +907 -0
- package/dist/cli.js +5 -5
- package/dist/completion-tree.json +1 -1
- package/dist/{notice-C6Hg9htX.js → notice-BKhXs3Hh.js} +57 -13
- package/dist/{program-B6qcp52R.js → program-mlz1idzc.js} +116 -76
- package/package.json +2 -10
- package/templates/AGENTS.md +3 -1
- package/dist/check-CPd5pMmK.js +0 -502
- package/skills/hardfin-cli/SKILL.md +0 -222
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="b6b9e574-1128-56a3-a054-30dd2a567bf6")}catch(e){}}();
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, posix, resolve, sep } from "node:path";
|
|
8
|
+
import { parseEnv } from "node:util";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { gunzipSync } from "node:zlib";
|
|
13
|
+
//#region src/report/expected.ts
|
|
14
|
+
/**
|
|
15
|
+
* ExpectedFailure is a failure the CLI anticipated: the person's input, their network, their
|
|
16
|
+
* machine, or an answer from the API. It is shown to the person and never reported, and every
|
|
17
|
+
* error that does not extend it is reported as a defect.
|
|
18
|
+
*/
|
|
19
|
+
var ExpectedFailure = class extends Error {};
|
|
20
|
+
/** LockHeld is another hardfin command holding the credential lock for too long. */
|
|
21
|
+
var LockHeld = class extends ExpectedFailure {
|
|
22
|
+
constructor() {
|
|
23
|
+
super("another hardfin command is holding the credential lock");
|
|
24
|
+
this.name = "LockHeld";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Interrupted is a person stopping the CLI with Ctrl-C. It is something they chose to do,
|
|
29
|
+
* not a failure, so the CLI stops quietly with the exit code a shell gives an interrupt.
|
|
30
|
+
*/
|
|
31
|
+
var Interrupted = class extends ExpectedFailure {
|
|
32
|
+
constructor() {
|
|
33
|
+
super("interrupted");
|
|
34
|
+
this.name = "Interrupted";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/** TimedOut is something the CLI waited on for longer than it allows, such as the browser. */
|
|
38
|
+
var TimedOut = class extends ExpectedFailure {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "TimedOut";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** version is what this build reports, read from the published package manifest. */
|
|
45
|
+
const version$1 = createRequire(import.meta.url)("../package.json").version;
|
|
46
|
+
/** The values that mean no, for a variable that turns something on or off. */
|
|
47
|
+
const OFF = /* @__PURE__ */ new Set([
|
|
48
|
+
"0",
|
|
49
|
+
"false",
|
|
50
|
+
"off",
|
|
51
|
+
"no"
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* toTelemetry decides whether defects are reported. A person's opt out wins over everything,
|
|
55
|
+
* and a CLI run from a checkout reports only when its developer asks it to.
|
|
56
|
+
*/
|
|
57
|
+
function toTelemetry(env = process.env, isCheckout = isRunFromCheckout()) {
|
|
58
|
+
const dsn = env["HARDFIN_SENTRY_DSN"] || "https://a4819e652e9fa63c0ab67e9febe44565@o1388102.ingest.us.sentry.io/4512176532684800";
|
|
59
|
+
const base = {
|
|
60
|
+
dsn,
|
|
61
|
+
environment: isCheckout ? "local" : version$1.includes("-dev.") ? "preview" : "production",
|
|
62
|
+
release: toRelease$1(version$1)
|
|
63
|
+
};
|
|
64
|
+
if (env["DO_NOT_TRACK"] && !OFF.has(env["DO_NOT_TRACK"].toLowerCase())) return {
|
|
65
|
+
...base,
|
|
66
|
+
enabled: false,
|
|
67
|
+
from: "DO_NOT_TRACK"
|
|
68
|
+
};
|
|
69
|
+
const asked = env["HARDFIN_TELEMETRY"]?.toLowerCase();
|
|
70
|
+
if (asked !== void 0 && OFF.has(asked)) return {
|
|
71
|
+
...base,
|
|
72
|
+
enabled: false,
|
|
73
|
+
from: "HARDFIN_TELEMETRY"
|
|
74
|
+
};
|
|
75
|
+
if (!isDsn(dsn)) return {
|
|
76
|
+
...base,
|
|
77
|
+
enabled: false,
|
|
78
|
+
from: "HARDFIN_SENTRY_DSN is not a valid DSN"
|
|
79
|
+
};
|
|
80
|
+
if (asked !== void 0) return {
|
|
81
|
+
...base,
|
|
82
|
+
enabled: true,
|
|
83
|
+
from: "HARDFIN_TELEMETRY"
|
|
84
|
+
};
|
|
85
|
+
return isCheckout ? {
|
|
86
|
+
...base,
|
|
87
|
+
enabled: false,
|
|
88
|
+
from: "run from a checkout"
|
|
89
|
+
} : {
|
|
90
|
+
...base,
|
|
91
|
+
enabled: true,
|
|
92
|
+
from: "default"
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* isRunFromCheckout answers whether this CLI runs from a clone of its repository, as npm link
|
|
97
|
+
* and node dist/cli.js do. The published package holds dist and nothing beside it.
|
|
98
|
+
*/
|
|
99
|
+
function isRunFromCheckout() {
|
|
100
|
+
return existsSync(fileURLToPath(new URL("../src", import.meta.url)));
|
|
101
|
+
}
|
|
102
|
+
/** isDsn answers whether a DSN can be read as the URL of a Sentry project. */
|
|
103
|
+
function isDsn(dsn) {
|
|
104
|
+
try {
|
|
105
|
+
return new URL(dsn).host !== "";
|
|
106
|
+
} catch {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* toRelease names a version as Sentry knows it. Sentry refuses a slash in a release name, so
|
|
112
|
+
* the scoped package name @hardfin/cli cannot be used, and hardfin-cli stands in for it.
|
|
113
|
+
*/
|
|
114
|
+
function toRelease$1(of) {
|
|
115
|
+
return `hardfin-cli@${of}`;
|
|
116
|
+
}
|
|
117
|
+
//#endregion
|
|
118
|
+
//#region src/command/api-version.generated.ts
|
|
119
|
+
/** API_VERSION is the version of the API document the commands were generated from. */
|
|
120
|
+
const API_VERSION = "2026-10-26";
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/config/settings.ts
|
|
123
|
+
/** The file a local build reads its overrides from, in the working directory. */
|
|
124
|
+
const CONFIG_FILE = "config.local.json";
|
|
125
|
+
const DEFAULT_API_URL = "https://api.hardfin.com/v2";
|
|
126
|
+
/**
|
|
127
|
+
* The client Hardfin seeded for this CLI. The key is generated from a fixed identifier, so
|
|
128
|
+
* it names the same first-party client in every environment.
|
|
129
|
+
*/
|
|
130
|
+
const DEFAULT_CLIENT_ID = "oacl_10e2etq297nhjfhb";
|
|
131
|
+
const DEFAULT_ENV_FILE = ".env";
|
|
132
|
+
const FileSettings = z.strictObject({
|
|
133
|
+
apiUrl: z.string().optional(),
|
|
134
|
+
apiKey: z.string().optional(),
|
|
135
|
+
clientId: z.string().optional(),
|
|
136
|
+
issuerUrl: z.string().optional()
|
|
137
|
+
});
|
|
138
|
+
/** ConfigFailure is a config file that cannot be read or does not match the schema. */
|
|
139
|
+
var ConfigFailure = class extends ExpectedFailure {
|
|
140
|
+
constructor(message) {
|
|
141
|
+
super(message);
|
|
142
|
+
this.name = "ConfigFailure";
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
/** toSettings resolves what this invocation talks to, and where each value came from. */
|
|
146
|
+
function toSettings(flags = {}, directory = process.cwd()) {
|
|
147
|
+
const fromEnvFile = loadEnvFile(directory);
|
|
148
|
+
const file = toFileSettings(directory);
|
|
149
|
+
const sources = {};
|
|
150
|
+
const pick = (key, flag, variable, fromFile, fallback) => {
|
|
151
|
+
const [value, source] = toLayer(flag, process.env[variable], fromEnvFile.has(variable), fromFile, fallback);
|
|
152
|
+
sources[key] = source;
|
|
153
|
+
return value;
|
|
154
|
+
};
|
|
155
|
+
const apiUrl = toTrimmedUrl(pick("apiUrl", flags.apiUrl, "HARDFIN_API_URL", file.apiUrl, DEFAULT_API_URL) ?? DEFAULT_API_URL);
|
|
156
|
+
return {
|
|
157
|
+
settings: {
|
|
158
|
+
apiUrl,
|
|
159
|
+
apiKey: pick("apiKey", flags.apiKey, "HARDFIN_API_KEY", file.apiKey),
|
|
160
|
+
clientId: pick("clientId", flags.clientId, "HARDFIN_CLIENT_ID", file.clientId, DEFAULT_CLIENT_ID) ?? DEFAULT_CLIENT_ID,
|
|
161
|
+
issuerUrl: pick("issuerUrl", flags.issuerUrl, "HARDFIN_ISSUER_URL", file.issuerUrl, toOrigin(apiUrl)) ?? ""
|
|
162
|
+
},
|
|
163
|
+
sources
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function toLayer(flag, environment, isFromEnvFile, file, fallback) {
|
|
167
|
+
if (flag) return [flag, "flag"];
|
|
168
|
+
if (environment) return [environment, isFromEnvFile ? "env file" : "environment"];
|
|
169
|
+
if (file) return [file, "config file"];
|
|
170
|
+
return [fallback, "default"];
|
|
171
|
+
}
|
|
172
|
+
/** toFileSettings reads the local override file, which a local build is expected to have. */
|
|
173
|
+
function toFileSettings(directory) {
|
|
174
|
+
const path = resolve(directory, CONFIG_FILE);
|
|
175
|
+
if (!existsSync(path)) return {};
|
|
176
|
+
let parsed;
|
|
177
|
+
try {
|
|
178
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
179
|
+
} catch {
|
|
180
|
+
throw new ConfigFailure(`${CONFIG_FILE} does not hold JSON`);
|
|
181
|
+
}
|
|
182
|
+
const result = FileSettings.safeParse(parsed);
|
|
183
|
+
if (!result.success) {
|
|
184
|
+
const issue = result.error.issues[0];
|
|
185
|
+
throw new ConfigFailure(`${CONFIG_FILE} is not valid: ${issue?.path.join(".") || "root"} ${issue?.message ?? ""}`.trim());
|
|
186
|
+
}
|
|
187
|
+
return result.data;
|
|
188
|
+
}
|
|
189
|
+
/** The variables an env file may set, which are the settings and nothing else the CLI reads. */
|
|
190
|
+
const SETTING_VARIABLES = /* @__PURE__ */ new Set([
|
|
191
|
+
"HARDFIN_API_URL",
|
|
192
|
+
"HARDFIN_API_KEY",
|
|
193
|
+
"HARDFIN_CLIENT_ID",
|
|
194
|
+
"HARDFIN_ISSUER_URL"
|
|
195
|
+
]);
|
|
196
|
+
/**
|
|
197
|
+
* isTakenFromFile answers whether an env file may set a variable. A file in a folder supplies
|
|
198
|
+
* the settings, and may turn error reports off. Where reports go, where a sign in is kept,
|
|
199
|
+
* and anything a started program reads are for the person to set themselves.
|
|
200
|
+
*/
|
|
201
|
+
function isTakenFromFile(name, value) {
|
|
202
|
+
if (SETTING_VARIABLES.has(name)) return true;
|
|
203
|
+
if (name === "DO_NOT_TRACK") return !OFF.has(value.toLowerCase());
|
|
204
|
+
return name === "HARDFIN_TELEMETRY" && OFF.has(value.toLowerCase());
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* loadEnvFile reads a .env beside the command, so a local build needs no exports, and
|
|
208
|
+
* answers which variables it supplied. An exported variable is left alone, so a shell
|
|
209
|
+
* export still wins over the file.
|
|
210
|
+
*/
|
|
211
|
+
function loadEnvFile(directory) {
|
|
212
|
+
const path = process.env["HARDFIN_ENV_FILE"] ?? resolve(directory, DEFAULT_ENV_FILE);
|
|
213
|
+
if (!existsSync(path)) return /* @__PURE__ */ new Set();
|
|
214
|
+
let held;
|
|
215
|
+
try {
|
|
216
|
+
held = parseEnv(readFileSync(path, "utf8"));
|
|
217
|
+
} catch {
|
|
218
|
+
throw new ConfigFailure(`${path} cannot be read as an env file`);
|
|
219
|
+
}
|
|
220
|
+
const supplied = /* @__PURE__ */ new Set();
|
|
221
|
+
for (const [name, value] of Object.entries(held)) {
|
|
222
|
+
if (value === void 0 || process.env[name] !== void 0 || !isTakenFromFile(name, value)) continue;
|
|
223
|
+
process.env[name] = value;
|
|
224
|
+
supplied.add(name);
|
|
225
|
+
}
|
|
226
|
+
return supplied;
|
|
227
|
+
}
|
|
228
|
+
function toTrimmedUrl(url) {
|
|
229
|
+
return url.replace(/\/+$/, "");
|
|
230
|
+
}
|
|
231
|
+
/** toOrigin drops the API's path, because the authorization server sits at the host root. */
|
|
232
|
+
function toOrigin(apiUrl) {
|
|
233
|
+
try {
|
|
234
|
+
return new URL(apiUrl).origin;
|
|
235
|
+
} catch {
|
|
236
|
+
return apiUrl;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/output/writer.ts
|
|
241
|
+
/**
|
|
242
|
+
* toText reads a value a caller supplied, which arrives typed as unknown. An object would
|
|
243
|
+
* otherwise reach a request as the text "[object Object]".
|
|
244
|
+
*/
|
|
245
|
+
function toText$1(value) {
|
|
246
|
+
if (typeof value === "string") return value;
|
|
247
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return String(value);
|
|
248
|
+
if (value === void 0 || value === null) return "";
|
|
249
|
+
return JSON.stringify(value);
|
|
250
|
+
}
|
|
251
|
+
/** Every control character but a newline and a tab, which are the two a line of text needs. */
|
|
252
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
253
|
+
/**
|
|
254
|
+
* toPrintable makes text safe to show at a terminal. A name or a message from the API can
|
|
255
|
+
* hold an escape sequence, which a terminal would act on. Each such character is written
|
|
256
|
+
* as its code, so the text reads as what it holds.
|
|
257
|
+
*/
|
|
258
|
+
function toPrintable(text) {
|
|
259
|
+
return text.replace(CONTROL, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* toJSON writes a value for whoever is reading the stream. A person at a terminal gets it
|
|
263
|
+
* indented. A program gets it on one line, because the indentation of a page of records is
|
|
264
|
+
* close to half of what an agent would otherwise have to read.
|
|
265
|
+
*/
|
|
266
|
+
function toJSON(value, stream) {
|
|
267
|
+
return stream.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value);
|
|
268
|
+
}
|
|
269
|
+
/** writeData prints a command's result on stdout. */
|
|
270
|
+
function writeData(value) {
|
|
271
|
+
if (typeof value === "string") {
|
|
272
|
+
const text = toPrintable(value);
|
|
273
|
+
process.stdout.write(text.endsWith("\n") ? text : `${text}\n`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
process.stdout.write(`${toJSON(value ?? null, process.stdout)}\n`);
|
|
277
|
+
}
|
|
278
|
+
/** The code of the last failure written, which decides whether the CLI checks for an update. */
|
|
279
|
+
let lastFailureCode;
|
|
280
|
+
/** toLastFailureCode answers the code of the last failure this run wrote. */
|
|
281
|
+
function toLastFailureCode() {
|
|
282
|
+
return lastFailureCode;
|
|
283
|
+
}
|
|
284
|
+
/** writeFailure prints why a command failed on stderr, with a code only a machine reading JSON sees. */
|
|
285
|
+
function writeFailure(code, message, isJSON, errors, requestId, status) {
|
|
286
|
+
lastFailureCode = code;
|
|
287
|
+
if (!isJSON) {
|
|
288
|
+
process.stderr.write(`error: ${toPrintable(message)}${status === void 0 ? "" : ` (HTTP ${status})`}\n`);
|
|
289
|
+
for (const entry of errors?.slice(1) ?? []) process.stderr.write(` ${toPrintable(entry.error)} (${entry.statusCode})\n`);
|
|
290
|
+
if (requestId) process.stderr.write(` request ${toPrintable(requestId)}\n`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const payload = {
|
|
294
|
+
code,
|
|
295
|
+
message,
|
|
296
|
+
errors: errors ?? [{
|
|
297
|
+
error: message,
|
|
298
|
+
statusCode: 0
|
|
299
|
+
}],
|
|
300
|
+
requestId: requestId ?? null,
|
|
301
|
+
status: status ?? null
|
|
302
|
+
};
|
|
303
|
+
process.stderr.write(`${toJSON(payload, process.stderr)}\n`);
|
|
304
|
+
}
|
|
305
|
+
/** isJSONOutput decides whether this invocation prints machine-readable output. */
|
|
306
|
+
function isJSONOutput(flags) {
|
|
307
|
+
if (flags["json"] === true) return true;
|
|
308
|
+
return !process.stdout.isTTY;
|
|
309
|
+
}
|
|
310
|
+
//#endregion
|
|
311
|
+
//#region src/system/state.ts
|
|
312
|
+
/** toStateDirectory names the folder the CLI keeps what it remembers between runs in. */
|
|
313
|
+
function toStateDirectory() {
|
|
314
|
+
const base = process.env["XDG_STATE_HOME"] ?? join(homedir(), ".local", "state");
|
|
315
|
+
return join(base, "hardfin");
|
|
316
|
+
}
|
|
317
|
+
/** The newest API version any answer in this run asked the CLI to move to. */
|
|
318
|
+
let outdatedTo;
|
|
319
|
+
/** noteNotices keeps what an answer's notices say about the version this CLI speaks. */
|
|
320
|
+
function noteNotices(notices) {
|
|
321
|
+
if (!Array.isArray(notices)) return;
|
|
322
|
+
for (const notice of notices) {
|
|
323
|
+
const { code, latestVersion } = typeof notice === "object" && notice !== null ? notice : {};
|
|
324
|
+
if (code !== "API_VERSION_OUTDATED" || typeof latestVersion !== "string") continue;
|
|
325
|
+
if (outdatedTo === void 0 || latestVersion > outdatedTo) outdatedTo = latestVersion;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** toOutdatedTo answers the API version an answer said to move to, or nothing when none did. */
|
|
329
|
+
function toOutdatedTo() {
|
|
330
|
+
return outdatedTo;
|
|
331
|
+
}
|
|
332
|
+
//#endregion
|
|
333
|
+
//#region src/system/package.ts
|
|
334
|
+
/** The folders this package ships, beside its manifest. */
|
|
335
|
+
const SHIPPED = ["dist", "templates"];
|
|
336
|
+
/** toPackageDirectory finds this package's own folder, which holds the templates it ships. */
|
|
337
|
+
function toPackageDirectory(from = dirname(fileURLToPath(import.meta.url))) {
|
|
338
|
+
for (let directory = from;; directory = dirname(directory)) {
|
|
339
|
+
const manifest = join(directory, "package.json");
|
|
340
|
+
if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === "@hardfin/cli") return directory;
|
|
341
|
+
if (dirname(directory) === directory) throw new Error("the @hardfin/cli package folder could not be found, so its templates cannot be read");
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* isShipped answers whether a path is one of the files this package installs. A file of the
|
|
346
|
+
* CLI's own that cannot be read is a broken install, not the person's machine refusing them.
|
|
347
|
+
*/
|
|
348
|
+
function isShipped(path, packageDirectory) {
|
|
349
|
+
let directory;
|
|
350
|
+
try {
|
|
351
|
+
directory = packageDirectory ?? toPackageDirectory();
|
|
352
|
+
} catch {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return path === join(directory, "package.json") || SHIPPED.some((folder) => path === join(directory, folder) || path.startsWith(join(directory, folder) + sep));
|
|
356
|
+
}
|
|
357
|
+
//#endregion
|
|
358
|
+
//#region src/skills/archive.ts
|
|
359
|
+
/** The largest archive this reads, unpacked. A skills release is tens of kilobytes. */
|
|
360
|
+
const MAX_UNPACKED_BYTES = 52428800;
|
|
361
|
+
const BLOCK = 512;
|
|
362
|
+
/**
|
|
363
|
+
* toArchiveFiles reads the regular files out of a gzipped tar archive. It refuses links and any
|
|
364
|
+
* path that would land outside the folder the archive is unpacked into.
|
|
365
|
+
*/
|
|
366
|
+
function toArchiveFiles(gzipped) {
|
|
367
|
+
const tar = gunzipSync(gzipped, { maxOutputLength: MAX_UNPACKED_BYTES });
|
|
368
|
+
const files = [];
|
|
369
|
+
let offset = 0;
|
|
370
|
+
let nextPath;
|
|
371
|
+
while (offset + BLOCK <= tar.length) {
|
|
372
|
+
const header = tar.subarray(offset, offset + BLOCK);
|
|
373
|
+
if (header.every((byte) => byte === 0)) break;
|
|
374
|
+
const size = toOctal(header.subarray(124, 136));
|
|
375
|
+
const type = String.fromCharCode(header[156] ?? 0);
|
|
376
|
+
const body = tar.subarray(offset + BLOCK, offset + BLOCK + size);
|
|
377
|
+
offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
|
|
378
|
+
if (type === "x") {
|
|
379
|
+
nextPath = toPaxPath(body.toString("utf8")) ?? nextPath;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (type === "L") {
|
|
383
|
+
nextPath = body.toString("utf8").replace(/\0+$/, "");
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (type === "g" || type === "5") continue;
|
|
387
|
+
const path = toSafePath(nextPath ?? toHeaderPath(header));
|
|
388
|
+
nextPath = void 0;
|
|
389
|
+
if (type !== "0" && type !== "\0") throw new Error(`the archive holds ${path} as something other than a file, which a skills release never does`);
|
|
390
|
+
files.push({
|
|
391
|
+
path,
|
|
392
|
+
content: Buffer.from(body)
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
return files;
|
|
396
|
+
}
|
|
397
|
+
/** toHeaderPath reads an entry's path from its header, joining the ustar prefix when there is one. */
|
|
398
|
+
function toHeaderPath(header) {
|
|
399
|
+
const name = toText(header.subarray(0, 100));
|
|
400
|
+
const prefix = toText(header.subarray(257, 263)).startsWith("ustar") ? toText(header.subarray(345, 500)) : "";
|
|
401
|
+
return prefix ? `${prefix}/${name}` : name;
|
|
402
|
+
}
|
|
403
|
+
/** toPaxPath finds the path record in a pax header, whose records read "<length> path=<value>". */
|
|
404
|
+
function toPaxPath(records) {
|
|
405
|
+
for (const record of records.split("\n")) {
|
|
406
|
+
const match = /^\d+ path=(.*)$/.exec(record);
|
|
407
|
+
if (match) return match[1];
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/** toSafePath normalizes a path, and refuses one that is absolute or climbs out of the archive. */
|
|
411
|
+
function toSafePath(raw) {
|
|
412
|
+
const path = posix.normalize(raw.replace(/^\.\//, ""));
|
|
413
|
+
if (path.startsWith("/") || path === ".." || path.startsWith("../") || raw.includes("\\")) throw new Error(`the archive holds ${JSON.stringify(raw)}, which would land outside the folder it is unpacked into`);
|
|
414
|
+
return path;
|
|
415
|
+
}
|
|
416
|
+
function toText(field) {
|
|
417
|
+
const end = field.indexOf(0);
|
|
418
|
+
return field.subarray(0, end < 0 ? field.length : end).toString("utf8");
|
|
419
|
+
}
|
|
420
|
+
function toOctal(field) {
|
|
421
|
+
const text = toText(field).trim();
|
|
422
|
+
return text === "" ? 0 : Number.parseInt(text, 8);
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/update/check.ts
|
|
426
|
+
/** The package the CLI is published as, which an update installs. */
|
|
427
|
+
const PACKAGE = "@hardfin/cli";
|
|
428
|
+
/** How long a registry request may take, which is short because no command waits on it for long. */
|
|
429
|
+
const REGISTRY_TIMEOUT_MS = 3e3;
|
|
430
|
+
/** A version as npm publishes this package, a release or a dev preview. */
|
|
431
|
+
const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
|
|
432
|
+
/**
|
|
433
|
+
* toUpdateCheckRefusal says why the CLI should not ask the registry, or nothing when it may.
|
|
434
|
+
* A person turns it off with HARDFIN_NO_UPDATE_CHECK, and a CI run never asks.
|
|
435
|
+
*/
|
|
436
|
+
function toUpdateCheckRefusal(env = process.env) {
|
|
437
|
+
const asked = env["HARDFIN_NO_UPDATE_CHECK"];
|
|
438
|
+
if (asked !== void 0 && asked !== "" && !OFF.has(asked.toLowerCase())) return "HARDFIN_NO_UPDATE_CHECK is set";
|
|
439
|
+
if (env["CI"] && !OFF.has(env["CI"].toLowerCase())) return "CI is set";
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* toCheck answers whether a newer version is published. It reads the cache while it is less
|
|
443
|
+
* than a day old, unless isFresh asks the registry regardless.
|
|
444
|
+
*/
|
|
445
|
+
async function toCheck(options = {}) {
|
|
446
|
+
const cache = toCache();
|
|
447
|
+
const isCurrent = cache !== void 0 && Date.now() - Date.parse(cache.checkedAt) < 864e5;
|
|
448
|
+
if (cache && (options.isCachedOnly || isCurrent && !options.isFresh)) return toUpdateCheck(cache.tags, cache.checkedAt, true);
|
|
449
|
+
if (options.isCachedOnly) return;
|
|
450
|
+
const tags = await toRegistryTags(options.signal);
|
|
451
|
+
if (!tags) return;
|
|
452
|
+
const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
453
|
+
writeCache({
|
|
454
|
+
...cache,
|
|
455
|
+
checkedAt,
|
|
456
|
+
tags
|
|
457
|
+
});
|
|
458
|
+
return toUpdateCheck(tags, checkedAt, false);
|
|
459
|
+
}
|
|
460
|
+
/** toUpdateCheck compares the installed version with the newest one on its channel. */
|
|
461
|
+
function toUpdateCheck(tags, checkedAt, isCached, version = version$1) {
|
|
462
|
+
const tag = toChannel(version);
|
|
463
|
+
const latest = (tag === "dev" ? [tags.dev, tags.latest] : [tags.latest]).filter((entry) => entry !== void 0).reduce((newest, entry) => newest === void 0 || toCompared(entry, newest) > 0 ? entry : newest, void 0);
|
|
464
|
+
if (latest === void 0 || !VERSION.test(version)) return;
|
|
465
|
+
return {
|
|
466
|
+
version,
|
|
467
|
+
latest,
|
|
468
|
+
tag,
|
|
469
|
+
isOutdated: toCompared(latest, version) > 0,
|
|
470
|
+
checkedAt,
|
|
471
|
+
isCached
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
/** toChannel names the npm tag a version was published under. */
|
|
475
|
+
function toChannel(version) {
|
|
476
|
+
return /-dev\./.test(version) ? "dev" : "latest";
|
|
477
|
+
}
|
|
478
|
+
/** isVersion answers whether text is a version this package is published as. */
|
|
479
|
+
function isVersion(text) {
|
|
480
|
+
return VERSION.test(text);
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* toCompared orders two versions by semver precedence. A release sorts after its own
|
|
484
|
+
* previews, and dev.10 after dev.9, because a numeric part compares as a number.
|
|
485
|
+
*/
|
|
486
|
+
function toCompared(a, b) {
|
|
487
|
+
const [left, right] = [VERSION.exec(a), VERSION.exec(b)];
|
|
488
|
+
if (!left || !right) return 0;
|
|
489
|
+
for (let index = 1; index <= 3; index++) {
|
|
490
|
+
const difference = Number(left[index]) - Number(right[index]);
|
|
491
|
+
if (difference !== 0) return Math.sign(difference);
|
|
492
|
+
}
|
|
493
|
+
const [leftPre, rightPre] = [left[4], right[4]];
|
|
494
|
+
if (leftPre === void 0 || rightPre === void 0) return leftPre === rightPre ? 0 : leftPre === void 0 ? 1 : -1;
|
|
495
|
+
const [leftParts, rightParts] = [leftPre.split("."), rightPre.split(".")];
|
|
496
|
+
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) {
|
|
497
|
+
const [l, r] = [leftParts[index], rightParts[index]];
|
|
498
|
+
if (l === void 0 || r === void 0) return l === void 0 ? -1 : 1;
|
|
499
|
+
const difference = /^\d+$/.test(l) && /^\d+$/.test(r) ? Number(l) - Number(r) : l.localeCompare(r);
|
|
500
|
+
if (difference !== 0) return Math.sign(difference);
|
|
501
|
+
}
|
|
502
|
+
return 0;
|
|
503
|
+
}
|
|
504
|
+
/** toRegistryTags asks npm which versions the package's tags name, or nothing when it cannot answer. */
|
|
505
|
+
async function toRegistryTags(signal) {
|
|
506
|
+
const registry = (process.env["npm_config_registry"] || "https://registry.npmjs.org").replace(/\/+$/, "");
|
|
507
|
+
const timeout = AbortSignal.timeout(REGISTRY_TIMEOUT_MS);
|
|
508
|
+
try {
|
|
509
|
+
const response = await fetch(`${registry}/-/package/${PACKAGE}/dist-tags`, {
|
|
510
|
+
headers: { Accept: "application/json" },
|
|
511
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
512
|
+
});
|
|
513
|
+
if (!response.ok) return;
|
|
514
|
+
return toTags(await response.json());
|
|
515
|
+
} catch {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** toTags keeps the tags that name a version, since a tag's value reaches an npm command line. */
|
|
520
|
+
function toTags(answer) {
|
|
521
|
+
if (typeof answer !== "object" || answer === null) return;
|
|
522
|
+
const tags = {};
|
|
523
|
+
for (const name of ["latest", "dev"]) {
|
|
524
|
+
const value = answer[name];
|
|
525
|
+
if (typeof value === "string" && VERSION.test(value)) tags[name] = value;
|
|
526
|
+
}
|
|
527
|
+
return tags.latest === void 0 && tags.dev === void 0 ? void 0 : tags;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* isReminderDue answers whether a daily reminder of this version may be printed, and marks it
|
|
531
|
+
* printed. A person told once a day is told enough, and an agent running many commands is not
|
|
532
|
+
* told on every one.
|
|
533
|
+
*/
|
|
534
|
+
function isReminderDue(latest) {
|
|
535
|
+
const cache = toCache();
|
|
536
|
+
if (!cache) return true;
|
|
537
|
+
if (cache.remindedOf === latest && Date.now() - Date.parse(cache.remindedAt ?? "") < 864e5) return false;
|
|
538
|
+
writeCache({
|
|
539
|
+
...cache,
|
|
540
|
+
remindedOf: latest,
|
|
541
|
+
remindedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
542
|
+
});
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
function toCachePath() {
|
|
546
|
+
return join(toStateDirectory(), "update-check.json");
|
|
547
|
+
}
|
|
548
|
+
function toCache() {
|
|
549
|
+
try {
|
|
550
|
+
const parsed = JSON.parse(readFileSync(toCachePath(), "utf8"));
|
|
551
|
+
const tags = toTags(parsed.tags);
|
|
552
|
+
return typeof parsed.checkedAt === "string" && tags ? {
|
|
553
|
+
...parsed,
|
|
554
|
+
checkedAt: parsed.checkedAt,
|
|
555
|
+
tags
|
|
556
|
+
} : void 0;
|
|
557
|
+
} catch {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
function writeCache(cache) {
|
|
562
|
+
try {
|
|
563
|
+
mkdirSync(dirname(toCachePath()), { recursive: true });
|
|
564
|
+
writeFileSync(toCachePath(), `${JSON.stringify(cache)}\n`);
|
|
565
|
+
} catch {}
|
|
566
|
+
}
|
|
567
|
+
//#endregion
|
|
568
|
+
//#region src/skills/release.ts
|
|
569
|
+
/** The repository the Hardfin skills are released from. */
|
|
570
|
+
const SKILLS_REPOSITORY = "hardfinhq/skills";
|
|
571
|
+
/**
|
|
572
|
+
* The skills releases this CLI works with, from minimum up to but not including below. The
|
|
573
|
+
* hardfin-cli skill names commands and flags, so a major release of the skills needs a CLI
|
|
574
|
+
* that declares it.
|
|
575
|
+
*/
|
|
576
|
+
const SKILLS_RANGE = {
|
|
577
|
+
minimum: "1.0.0",
|
|
578
|
+
below: "2.0.0"
|
|
579
|
+
};
|
|
580
|
+
/** How long one request to GitHub may take before the install gives up on it. */
|
|
581
|
+
const REQUEST_TIMEOUT_MS = 2e4;
|
|
582
|
+
/** toRangeText writes the range the way an npm user reads one. */
|
|
583
|
+
function toRangeText() {
|
|
584
|
+
return `>=${SKILLS_RANGE.minimum} <${SKILLS_RANGE.below}`;
|
|
585
|
+
}
|
|
586
|
+
/** isInRange answers whether this CLI works with a skills release. A prerelease never is. */
|
|
587
|
+
function isInRange(version) {
|
|
588
|
+
return isVersion(version) && !version.includes("-") && toCompared(version, SKILLS_RANGE.minimum) >= 0 && toCompared(version, SKILLS_RANGE.below) < 0;
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* toManualInstall says how to install the skills without this CLI and without git, which most
|
|
592
|
+
* people running an agent do not have. A known release gets its own archive's address.
|
|
593
|
+
*/
|
|
594
|
+
function toManualInstall(version) {
|
|
595
|
+
return `Download ${version === void 0 ? `the hardfin-skills archive from https://github.com/${SKILLS_REPOSITORY}/releases/latest` : `https://github.com/${SKILLS_REPOSITORY}/releases/download/v${version}/hardfin-skills-${version}.tar.gz`}, unpack it, and copy each folder under its skills/ into .agents/skills/ and .claude/skills/. With git installed, npx skills add ${SKILLS_REPOSITORY} does the same.`;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* toSkillsRelease fetches a skills release: the one asked for, or else the newest this CLI works
|
|
599
|
+
* with. It answers why it could not, rather than throwing, so a setup carries on without it.
|
|
600
|
+
*/
|
|
601
|
+
async function toSkillsRelease(options = {}) {
|
|
602
|
+
const env = options.env ?? process.env;
|
|
603
|
+
const token = env["GH_TOKEN"] || env["GITHUB_TOKEN"] || void 0;
|
|
604
|
+
const request = (url, accept) => toResponse(url, accept, token, options.signal);
|
|
605
|
+
try {
|
|
606
|
+
const release = await toRelease(request, options.version);
|
|
607
|
+
if ("reason" in release) return release;
|
|
608
|
+
const version = release.tag_name.replace(/^v/, "");
|
|
609
|
+
const archiveName = `hardfin-skills-${version}.tar.gz`;
|
|
610
|
+
const archive = release.assets.find((asset) => asset.name === archiveName);
|
|
611
|
+
const checksum = release.assets.find((asset) => asset.name === `${archiveName}.sha256`);
|
|
612
|
+
if (!archive || !checksum) return {
|
|
613
|
+
reason: `release ${version} has no ${archiveName} and checksum beside it`,
|
|
614
|
+
install: toManualInstall(version)
|
|
615
|
+
};
|
|
616
|
+
const download = (asset) => token === void 0 ? request(asset.browser_download_url, "*/*") : request(asset.url, "application/octet-stream");
|
|
617
|
+
const [archiveResponse, checksumResponse] = await Promise.all([download(archive), download(checksum)]);
|
|
618
|
+
if (!archiveResponse.ok || !checksumResponse.ok) return toUnavailable(archiveResponse.ok ? checksumResponse : archiveResponse, version);
|
|
619
|
+
const bytes = Buffer.from(await archiveResponse.arrayBuffer());
|
|
620
|
+
if ((await checksumResponse.text()).trim().split(/\s+/)[0]?.toLowerCase() !== createHash("sha256").update(bytes).digest("hex")) return {
|
|
621
|
+
reason: `${archiveName} does not match the checksum published beside it`,
|
|
622
|
+
install: toManualInstall(version)
|
|
623
|
+
};
|
|
624
|
+
let files;
|
|
625
|
+
try {
|
|
626
|
+
files = toArchiveFiles(bytes).filter((file) => file.path.startsWith("skills/"));
|
|
627
|
+
} catch (error) {
|
|
628
|
+
return {
|
|
629
|
+
reason: `${archiveName} could not be read, because ${toMessage(error)}`,
|
|
630
|
+
install: toManualInstall(version)
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
if (files.length === 0) return {
|
|
634
|
+
reason: `${archiveName} holds no skills`,
|
|
635
|
+
install: toManualInstall(version)
|
|
636
|
+
};
|
|
637
|
+
return {
|
|
638
|
+
version,
|
|
639
|
+
files,
|
|
640
|
+
source: archive.browser_download_url
|
|
641
|
+
};
|
|
642
|
+
} catch (error) {
|
|
643
|
+
return {
|
|
644
|
+
reason: error instanceof Error && error.name === "TimeoutError" ? "GitHub did not answer in time" : toReason(error),
|
|
645
|
+
install: toManualInstall(options.version)
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/** toRelease finds the release asked for, or else the newest one this CLI works with. */
|
|
650
|
+
async function toRelease(request, version) {
|
|
651
|
+
const api = `https://api.github.com/repos/${SKILLS_REPOSITORY}/releases`;
|
|
652
|
+
if (version !== void 0) {
|
|
653
|
+
const response = await request(`${api}/tags/v${version}`, "application/vnd.github+json");
|
|
654
|
+
return response.ok ? await response.json() : toUnavailable(response, version);
|
|
655
|
+
}
|
|
656
|
+
const response = await request(`${api}?per_page=100`, "application/vnd.github+json");
|
|
657
|
+
if (!response.ok) return toUnavailable(response);
|
|
658
|
+
return (await response.json()).filter((release) => !release.draft && !release.prerelease && isInRange(release.tag_name.replace(/^v/, ""))).sort((a, b) => toCompared(b.tag_name.replace(/^v/, ""), a.tag_name.replace(/^v/, "")))[0] ?? {
|
|
659
|
+
reason: `hardfinhq/skills has no release in ${toRangeText()}, which this CLI works with`,
|
|
660
|
+
install: toManualInstall()
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function toResponse(url, accept, token, signal) {
|
|
664
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
665
|
+
const headers = {
|
|
666
|
+
Accept: accept,
|
|
667
|
+
"User-Agent": "hardfin-cli"
|
|
668
|
+
};
|
|
669
|
+
if (token !== void 0) headers["Authorization"] = `Bearer ${token}`;
|
|
670
|
+
return fetch(url, {
|
|
671
|
+
headers,
|
|
672
|
+
signal: signal ? AbortSignal.any([signal, timeout]) : timeout
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* toUnavailable says why GitHub refused, in the terms a person can act on. GitHub answers 404
|
|
677
|
+
* for a private repository as it does for one that does not exist, so a 404 names both.
|
|
678
|
+
*/
|
|
679
|
+
function toUnavailable(response, version) {
|
|
680
|
+
const install = toManualInstall(version);
|
|
681
|
+
const what = version === void 0 ? `the releases of ${SKILLS_REPOSITORY}` : `release ${version} of ${SKILLS_REPOSITORY}`;
|
|
682
|
+
if (response.status === 403 && response.headers.get("x-ratelimit-remaining") === "0") return {
|
|
683
|
+
reason: `GitHub's hourly limit for unsigned requests is used up. Set GH_TOKEN to a GitHub token to lift it`,
|
|
684
|
+
install
|
|
685
|
+
};
|
|
686
|
+
if (response.status === 401 || response.status === 403 || response.status === 404) return {
|
|
687
|
+
reason: `GitHub answered ${response.status} for ${what}, which it also answers while a repository is private. Set GH_TOKEN to a token that can read it, or install the skills by hand`,
|
|
688
|
+
install
|
|
689
|
+
};
|
|
690
|
+
return {
|
|
691
|
+
reason: `GitHub answered ${response.status} for ${what}`,
|
|
692
|
+
install
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function toMessage(error) {
|
|
696
|
+
return error instanceof Error ? error.message : String(error);
|
|
697
|
+
}
|
|
698
|
+
function toReason(error) {
|
|
699
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
700
|
+
return error instanceof Error ? `GitHub could not be reached${cause}` : "GitHub could not be reached";
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/skills/marker.ts
|
|
704
|
+
/** The frontmatter key, under metadata, that records which skills release wrote a SKILL.md. */
|
|
705
|
+
const RELEASE_KEY = "hardfin-skills-release";
|
|
706
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
|
|
707
|
+
const STAMP = new RegExp(`^[ \\t]+${RELEASE_KEY}:`);
|
|
708
|
+
/**
|
|
709
|
+
* toStamped records a release in a SKILL.md's frontmatter, under metadata, replacing a release an
|
|
710
|
+
* earlier install recorded. A file without frontmatter is not a skill, and is left as it is.
|
|
711
|
+
*/
|
|
712
|
+
function toStamped(content, release) {
|
|
713
|
+
const match = FRONTMATTER.exec(content);
|
|
714
|
+
if (!match) return content;
|
|
715
|
+
const lines = (match[1] ?? "").split("\n").filter((line) => !STAMP.test(line));
|
|
716
|
+
const stamp = ` ${RELEASE_KEY}: "${release}"`;
|
|
717
|
+
const metadata = lines.findIndex((line) => /^metadata:\s*$/.test(line));
|
|
718
|
+
if (metadata >= 0) lines.splice(metadata + 1, 0, stamp);
|
|
719
|
+
else lines.push("metadata:", stamp);
|
|
720
|
+
return `---\n${lines.join("\n")}\n---${match[2] ?? ""}${content.slice(match[0].length)}`;
|
|
721
|
+
}
|
|
722
|
+
/** toStampedRelease answers the release a SKILL.md records, or undefined when it records none. */
|
|
723
|
+
function toStampedRelease(content) {
|
|
724
|
+
const frontmatter = FRONTMATTER.exec(content)?.[1] ?? "";
|
|
725
|
+
return new RegExp(`^[ \\t]+${RELEASE_KEY}:\\s*"?([^"\\s]+)"?\\s*$`, "m").exec(frontmatter)?.[1];
|
|
726
|
+
}
|
|
727
|
+
/** toPluginRelease answers the release a plugin's marker names, or undefined when it is not Hardfin's. */
|
|
728
|
+
function toPluginRelease(path) {
|
|
729
|
+
try {
|
|
730
|
+
const marker = JSON.parse(readFileSync(path, "utf8"));
|
|
731
|
+
return marker.name === "hardfin" && typeof marker.version === "string" ? marker.version : void 0;
|
|
732
|
+
} catch {
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
//#endregion
|
|
737
|
+
//#region src/skills/check.ts
|
|
738
|
+
/** Where agents read skills: Codex and most others, then Claude Code. */
|
|
739
|
+
const SKILL_DIRECTORIES = [".agents/skills", ".claude/skills"];
|
|
740
|
+
/** The skill every install has, whose SKILL.md records the release. */
|
|
741
|
+
const ANCHOR_SKILL = "hardfin-cli";
|
|
742
|
+
/** The folders under a plugin root that hold marketplace catalogs, which list plugins without installing them. */
|
|
743
|
+
const CATALOG_FOLDERS = /* @__PURE__ */ new Set(["marketplaces"]);
|
|
744
|
+
/** How deep, and through how many folders, the plugin search goes, so a check on every call stays quick. */
|
|
745
|
+
const PLUGIN_DEPTH = 6;
|
|
746
|
+
const PLUGIN_FOLDER_LIMIT = 2e3;
|
|
747
|
+
/** toSkillsCheck looks for the Hardfin skills everywhere an agent working here would read them. */
|
|
748
|
+
function toSkillsCheck(options = {}) {
|
|
749
|
+
const cwd = resolve(options.cwd ?? process.cwd());
|
|
750
|
+
const home = options.home ?? homedir();
|
|
751
|
+
const env = options.env ?? process.env;
|
|
752
|
+
return toVerdict([
|
|
753
|
+
...toProjectLocations(cwd, home),
|
|
754
|
+
...toUserLocations(home, env),
|
|
755
|
+
...toPluginLocations(toPluginRoots(home, env, options.platform ?? process.platform))
|
|
756
|
+
]);
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* toPluginRoots names where each agent tool installs plugins, following the variable that moves
|
|
760
|
+
* its folder. Claude Code and Codex keep a cache under their own folder, Cursor under
|
|
761
|
+
* ~/.cursor/plugins, Copilot CLI under installed-plugins, and VS Code under its user data folder.
|
|
762
|
+
*/
|
|
763
|
+
function toPluginRoots(home, env, platform) {
|
|
764
|
+
const claude = env["CLAUDE_CONFIG_DIR"] || join(home, ".claude");
|
|
765
|
+
const codex = env["CODEX_HOME"] || join(home, ".codex");
|
|
766
|
+
const copilot = env["COPILOT_HOME"] || join(home, ".copilot");
|
|
767
|
+
const vscode = platform === "darwin" ? join(home, "Library", "Application Support", "Code") : platform === "win32" ? join(env["APPDATA"] || join(home, "AppData", "Roaming"), "Code") : join(env["XDG_CONFIG_HOME"] || join(home, ".config"), "Code");
|
|
768
|
+
return [
|
|
769
|
+
join(claude, "plugins"),
|
|
770
|
+
join(codex, "plugins"),
|
|
771
|
+
join(home, ".cursor", "plugins"),
|
|
772
|
+
join(copilot, "installed-plugins"),
|
|
773
|
+
join(vscode, "agentPlugins"),
|
|
774
|
+
join(home, ".vscode", "agent-plugins")
|
|
775
|
+
];
|
|
776
|
+
}
|
|
777
|
+
/** toVerdict answers one state for every location, judging by the copy that needs the most attention. */
|
|
778
|
+
function toVerdict(locations) {
|
|
779
|
+
const range = toRangeText();
|
|
780
|
+
if (locations.length === 0) return {
|
|
781
|
+
state: "missing",
|
|
782
|
+
release: null,
|
|
783
|
+
range,
|
|
784
|
+
locations,
|
|
785
|
+
command: "hardfin init"
|
|
786
|
+
};
|
|
787
|
+
const older = locations.find((location) => location.release === null || toCompared(location.release, SKILLS_RANGE.minimum) < 0);
|
|
788
|
+
if (older) return {
|
|
789
|
+
state: "outdated",
|
|
790
|
+
release: older.release,
|
|
791
|
+
range,
|
|
792
|
+
locations,
|
|
793
|
+
command: "hardfin init --skills-only"
|
|
794
|
+
};
|
|
795
|
+
const newer = locations.find((location) => location.release !== null && !isInRange(location.release));
|
|
796
|
+
if (newer) return {
|
|
797
|
+
state: "newer",
|
|
798
|
+
release: newer.release,
|
|
799
|
+
range,
|
|
800
|
+
locations,
|
|
801
|
+
command: "hardfin update"
|
|
802
|
+
};
|
|
803
|
+
return {
|
|
804
|
+
state: "current",
|
|
805
|
+
release: locations.map((location) => location.release).sort((a, b) => toCompared(b ?? "0.0.0", a ?? "0.0.0"))[0] ?? null,
|
|
806
|
+
range,
|
|
807
|
+
locations,
|
|
808
|
+
command: null
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/** toSkillsSentence says what a check found, and the command or skill that fixes it. */
|
|
812
|
+
function toSkillsSentence(check) {
|
|
813
|
+
switch (check.state) {
|
|
814
|
+
case "missing": return "Hardfin skills are not installed here. Run hardfin init.";
|
|
815
|
+
case "outdated": return check.release === null ? `Hardfin skills here predate skills releases, and this CLI needs ${check.range}. Run hardfin init --skills-only, or follow the hardfin-upgrade skill.` : `Hardfin skills here are release ${check.release}, and this CLI needs ${check.range}. Run hardfin init --skills-only, or follow the hardfin-upgrade skill.`;
|
|
816
|
+
case "newer": return `Hardfin skills here are release ${check.release}, newer than this CLI works with (${check.range}). Run hardfin update, or follow the hardfin-upgrade skill.`;
|
|
817
|
+
default: return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* isSkillsReminderDue answers whether a person at a terminal should be told again in this folder,
|
|
822
|
+
* and marks them told. An agent is told on every call, since each session starts without memory.
|
|
823
|
+
*/
|
|
824
|
+
function isSkillsReminderDue(cwd = process.cwd()) {
|
|
825
|
+
const path = join(toStateDirectory(), "skills-reminders.json");
|
|
826
|
+
let reminded = {};
|
|
827
|
+
try {
|
|
828
|
+
reminded = JSON.parse(readFileSync(path, "utf8"));
|
|
829
|
+
} catch {}
|
|
830
|
+
const folder = resolve(cwd);
|
|
831
|
+
if (Date.now() - Date.parse(reminded[folder] ?? "") < 864e5) return false;
|
|
832
|
+
try {
|
|
833
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
834
|
+
writeFileSync(path, `${JSON.stringify({
|
|
835
|
+
...reminded,
|
|
836
|
+
[folder]: (/* @__PURE__ */ new Date()).toISOString()
|
|
837
|
+
})}\n`);
|
|
838
|
+
} catch {}
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* toProjectLocations walks from the working folder up to the repository's root, found by its .git
|
|
843
|
+
* folder rather than by running git, which many people running an agent do not have. Outside a
|
|
844
|
+
* repository it walks to the home folder, whose skills are the user's own and are counted there.
|
|
845
|
+
*/
|
|
846
|
+
function toProjectLocations(cwd, home) {
|
|
847
|
+
const locations = [];
|
|
848
|
+
let folder = cwd;
|
|
849
|
+
while (folder !== resolve(home)) {
|
|
850
|
+
locations.push(...toFolderLocations(folder, "project"));
|
|
851
|
+
if (existsSync(join(folder, ".git")) || dirname(folder) === folder) break;
|
|
852
|
+
folder = dirname(folder);
|
|
853
|
+
}
|
|
854
|
+
return locations;
|
|
855
|
+
}
|
|
856
|
+
/** toUserLocations reads the user's own skills, in ~/.agents/skills and in Claude Code's folder, which CLAUDE_CONFIG_DIR moves. */
|
|
857
|
+
function toUserLocations(home, env) {
|
|
858
|
+
const claude = env["CLAUDE_CONFIG_DIR"] || join(home, ".claude");
|
|
859
|
+
return [join(home, ".agents", "skills"), join(claude, "skills")].flatMap((place) => toSkillsAt(place, "user"));
|
|
860
|
+
}
|
|
861
|
+
function toFolderLocations(folder, kind) {
|
|
862
|
+
return SKILL_DIRECTORIES.flatMap((place) => toSkillsAt(join(folder, place), kind));
|
|
863
|
+
}
|
|
864
|
+
/** toSkillsAt reads the release the anchor skill in one skills folder records, if the folder has it. */
|
|
865
|
+
function toSkillsAt(place, kind) {
|
|
866
|
+
try {
|
|
867
|
+
return [{
|
|
868
|
+
path: place,
|
|
869
|
+
kind,
|
|
870
|
+
release: toStampedRelease(readFileSync(join(place, ANCHOR_SKILL, "SKILL.md"), "utf8")) ?? null
|
|
871
|
+
}];
|
|
872
|
+
} catch {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* toPluginLocations finds a Hardfin plugin's marker under the folders agent tools install plugins
|
|
878
|
+
* in. A marketplace catalog holds the marker too, without the plugin being installed, so it is skipped.
|
|
879
|
+
*/
|
|
880
|
+
function toPluginLocations(roots) {
|
|
881
|
+
const locations = [];
|
|
882
|
+
let visited = 0;
|
|
883
|
+
const walk = (folder, depth) => {
|
|
884
|
+
if (depth > PLUGIN_DEPTH || visited++ > PLUGIN_FOLDER_LIMIT) return;
|
|
885
|
+
let entries;
|
|
886
|
+
try {
|
|
887
|
+
entries = readdirSync(folder, { withFileTypes: true });
|
|
888
|
+
} catch {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
for (const entry of entries) if (entry.isFile() && entry.name === "hardfin-skills.json") {
|
|
892
|
+
const release = toPluginRelease(join(folder, entry.name));
|
|
893
|
+
if (release !== void 0) locations.push({
|
|
894
|
+
path: folder,
|
|
895
|
+
kind: "plugin",
|
|
896
|
+
release
|
|
897
|
+
});
|
|
898
|
+
} else if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git" && !CATALOG_FOLDERS.has(entry.name)) walk(join(folder, entry.name), depth + 1);
|
|
899
|
+
};
|
|
900
|
+
for (const root of roots) walk(root, 0);
|
|
901
|
+
return locations;
|
|
902
|
+
}
|
|
903
|
+
//#endregion
|
|
904
|
+
export { version$1 as A, writeData as C, toSettings as D, ConfigFailure as E, Interrupted as M, LockHeld as N, API_VERSION as O, TimedOut as P, toText$1 as S, CONFIG_FILE as T, toOutdatedTo as _, toStamped as a, toJSON as b, toRangeText as c, isReminderDue as d, toCheck as f, noteNotices as g, toPackageDirectory as h, toSkillsSentence as i, ExpectedFailure as j, toTelemetry as k, toSkillsRelease as l, isShipped as m, isSkillsReminderDue as n, SKILLS_REPOSITORY as o, toUpdateCheckRefusal as p, toSkillsCheck as r, isInRange as s, SKILL_DIRECTORIES as t, PACKAGE as u, toStateDirectory as v, writeFailure as w, toLastFailureCode as x, isJSONOutput as y };
|
|
905
|
+
|
|
906
|
+
//# sourceMappingURL=check-Di68wbxY.js.map
|
|
907
|
+
//# debugId=b6b9e574-1128-56a3-a054-30dd2a567bf6
|