agent-first-data 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/format.d.ts +25 -4
- package/dist/format.js +207 -25
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/skill.d.ts +96 -0
- package/dist/skill.js +369 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -92,6 +92,13 @@ redactedValueWith(value: unknown, redactionPolicy: RedactionPolicy): JsonValue
|
|
|
92
92
|
redactedValueWithOptions(value: unknown, redactionOptions: RedactionOptions): JsonValue
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
`_url` fields are scrubbed in place during redaction (userinfo password, plus query parameters whose name follows the `_secret`/`secretNames` rule). For a URL inside a free-form string, redact it directly before interpolating:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
redactUrlSecrets(url: string): string
|
|
99
|
+
redactUrlSecretsWithOptions(url: string, redactionOptions: RedactionOptions): string
|
|
100
|
+
```
|
|
101
|
+
|
|
95
102
|
**Use case:** structured protocol payloads (frameworks automatically serialize)
|
|
96
103
|
|
|
97
104
|
**Example:**
|
|
@@ -259,6 +266,30 @@ console.log(cliOutput(result, fmt));
|
|
|
259
266
|
|
|
260
267
|
See `examples/agent_cli.ts` for the complete working example (`npx tsx --test examples/agent_cli.ts`).
|
|
261
268
|
|
|
269
|
+
### Skill Admin (for spore CLIs that ship an Agent Skill)
|
|
270
|
+
|
|
271
|
+
`runSkillAdmin` installs, uninstalls, and reports status of a spore's embedded `SKILL.md` across Codex, Claude Code, and opencode. Describe the skill once with a `SkillSpec`, then call it per action. It returns a typed `SkillReport` discriminated union (narrow on `code` to read fields, or serialize with `cliOutput`) or throws `SkillError`; it never writes to stdout/stderr. The generated `SKILL.md` is byte-identical to the Rust, Go, and Python ports.
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
runSkillAdmin(spec: SkillSpec, action: SkillAction, options: SkillOptions): SkillReport
|
|
275
|
+
|
|
276
|
+
const spec: SkillSpec = { name: "agent-first-widget", source: skillMarkdown, title: "Agent-First Widget", markerSlug: "afwidget" };
|
|
277
|
+
const options: SkillOptions = { agent: "all", scope: "personal" };
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
const report = runSkillAdmin(spec, "install", options);
|
|
281
|
+
console.log(cliOutput(report as unknown as JsonValue, fmt));
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof SkillError) {
|
|
284
|
+
console.log(cliOutput(buildCliError(e.message, e.hint), fmt));
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`status` reports `installed` / `valid` / `managed` / `current` per target; `current` is true only when the installed content matches the bundle, so re-running `install` refreshes a stale copy. See `examples/agent_cli.ts` for the `skill` subcommand wiring.
|
|
292
|
+
|
|
262
293
|
## Usage Examples
|
|
263
294
|
|
|
264
295
|
### Example 1: REST API
|
package/dist/format.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AFDATA output formatting and protocol templates.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 18 public APIs and 4 types: protocol builders, value redactors (copy and
|
|
5
|
+
* in-place; cover _secret and _url fields), output formatters, URL-string
|
|
6
|
+
* redactors (redactUrlSecrets / WithOptions), parseSize, RedactionPolicy,
|
|
7
|
+
* RedactionOptions, OutputStyle, and OutputOptions.
|
|
7
8
|
*/
|
|
8
9
|
export type JsonValue = string | number | boolean | null | JsonValue[] | {
|
|
9
10
|
[key: string]: JsonValue;
|
|
@@ -24,7 +25,9 @@ export type RedactionOptions = {
|
|
|
24
25
|
policy?: RedactionPolicy;
|
|
25
26
|
/**
|
|
26
27
|
* Field names to treat as secrets in addition to _secret suffixes.
|
|
27
|
-
* Matching is exact field-name equality at any nesting level.
|
|
28
|
+
* Matching is exact field-name equality at any nesting level. The same
|
|
29
|
+
* list also matches URL query-parameter names inside _url fields (see
|
|
30
|
+
* redactUrlSecrets).
|
|
28
31
|
*/
|
|
29
32
|
secretNames?: readonly string[];
|
|
30
33
|
};
|
|
@@ -62,6 +65,24 @@ export declare function redactedValue(value: unknown): JsonValue;
|
|
|
62
65
|
export declare function redactedValueWith(value: unknown, redactionPolicy: RedactionPolicy): JsonValue;
|
|
63
66
|
/** Return a JSON-safe copy with explicit redaction options applied. */
|
|
64
67
|
export declare function redactedValueWithOptions(value: unknown, redactionOptions: RedactionOptions): JsonValue;
|
|
68
|
+
/**
|
|
69
|
+
* Redact secret components of a single URL string, using default options.
|
|
70
|
+
*
|
|
71
|
+
* Returns url with its userinfo password and any _secret-suffixed query
|
|
72
|
+
* parameter values replaced by "***". See redactUrlSecretsWithOptions.
|
|
73
|
+
*/
|
|
74
|
+
export declare function redactUrlSecrets(url: string): string;
|
|
75
|
+
/**
|
|
76
|
+
* Redact secret components of a single URL string.
|
|
77
|
+
*
|
|
78
|
+
* A query parameter is redacted iff its (form-decoded) name ends in
|
|
79
|
+
* _secret/_SECRET or matches an exact entry in secretNames. The userinfo
|
|
80
|
+
* password (scheme://user:pass@host) is always redacted as a structural rule.
|
|
81
|
+
* Only the secret spans are replaced with "***"; every other byte is preserved.
|
|
82
|
+
* A string that is not a single, whitespace-free, scheme-prefixed URL
|
|
83
|
+
* (including a URL embedded in surrounding prose) is returned unchanged.
|
|
84
|
+
*/
|
|
85
|
+
export declare function redactUrlSecretsWithOptions(url: string, redactionOptions: RedactionOptions): string;
|
|
65
86
|
/**
|
|
66
87
|
* Parse a human-readable size string into bytes.
|
|
67
88
|
* Accepts bare numbers or numbers followed by a unit letter (B/K/M/G/T).
|
package/dist/format.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AFDATA output formatting and protocol templates.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 18 public APIs and 4 types: protocol builders, value redactors (copy and
|
|
5
|
+
* in-place; cover _secret and _url fields), output formatters, URL-string
|
|
6
|
+
* redactors (redactUrlSecrets / WithOptions), parseSize, RedactionPolicy,
|
|
7
|
+
* RedactionOptions, OutputStyle, and OutputOptions.
|
|
7
8
|
*/
|
|
8
9
|
// ═══════════════════════════════════════════
|
|
9
10
|
// Public API: Protocol Builders
|
|
@@ -122,6 +123,29 @@ export function redactedValueWithOptions(value, redactionOptions) {
|
|
|
122
123
|
applyRedactionOptions(v, redactionOptions);
|
|
123
124
|
return v;
|
|
124
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Redact secret components of a single URL string, using default options.
|
|
128
|
+
*
|
|
129
|
+
* Returns url with its userinfo password and any _secret-suffixed query
|
|
130
|
+
* parameter values replaced by "***". See redactUrlSecretsWithOptions.
|
|
131
|
+
*/
|
|
132
|
+
export function redactUrlSecrets(url) {
|
|
133
|
+
return redactUrlSecretsWithOptions(url, {});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Redact secret components of a single URL string.
|
|
137
|
+
*
|
|
138
|
+
* A query parameter is redacted iff its (form-decoded) name ends in
|
|
139
|
+
* _secret/_SECRET or matches an exact entry in secretNames. The userinfo
|
|
140
|
+
* password (scheme://user:pass@host) is always redacted as a structural rule.
|
|
141
|
+
* Only the secret spans are replaced with "***"; every other byte is preserved.
|
|
142
|
+
* A string that is not a single, whitespace-free, scheme-prefixed URL
|
|
143
|
+
* (including a URL embedded in surrounding prose) is returned unchanged.
|
|
144
|
+
*/
|
|
145
|
+
export function redactUrlSecretsWithOptions(url, redactionOptions) {
|
|
146
|
+
const redacted = redactUrlInStr(url, secretNameSet(redactionOptions));
|
|
147
|
+
return redacted ?? url;
|
|
148
|
+
}
|
|
125
149
|
/**
|
|
126
150
|
* Parse a human-readable size string into bytes.
|
|
127
151
|
* Accepts bare numbers or numbers followed by a unit letter (B/K/M/G/T).
|
|
@@ -158,82 +182,240 @@ export function parseSize(s) {
|
|
|
158
182
|
return null;
|
|
159
183
|
return result;
|
|
160
184
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
const EMPTY_SECRET_NAMES = new Set();
|
|
185
|
+
const DEFAULT_CONTEXT = {
|
|
186
|
+
secretNames: new Set(),
|
|
187
|
+
};
|
|
165
188
|
function secretNameSet(redactionOptions) {
|
|
166
189
|
return new Set(redactionOptions.secretNames ?? []);
|
|
167
190
|
}
|
|
191
|
+
function contextFromOptions(redactionOptions) {
|
|
192
|
+
return { secretNames: secretNameSet(redactionOptions) };
|
|
193
|
+
}
|
|
168
194
|
function keyHasSecretSuffix(key) {
|
|
169
195
|
return key.endsWith("_secret") || key.endsWith("_SECRET");
|
|
170
196
|
}
|
|
197
|
+
function keyHasUrlSuffix(key) {
|
|
198
|
+
return key.endsWith("_url") || key.endsWith("_URL");
|
|
199
|
+
}
|
|
171
200
|
function isSecretKey(key, secretNames) {
|
|
172
201
|
return keyHasSecretSuffix(key) || secretNames.has(key);
|
|
173
202
|
}
|
|
174
|
-
function redactSecrets(value,
|
|
203
|
+
function redactSecrets(value, context = DEFAULT_CONTEXT) {
|
|
175
204
|
if (isObject(value)) {
|
|
176
205
|
for (const k of Object.keys(value)) {
|
|
177
|
-
|
|
178
|
-
|
|
206
|
+
const v = value[k];
|
|
207
|
+
if (isSecretKey(k, context.secretNames)) {
|
|
179
208
|
if (isObject(v) || Array.isArray(v)) {
|
|
180
|
-
redactSecrets(v,
|
|
209
|
+
redactSecrets(v, context);
|
|
181
210
|
}
|
|
182
211
|
else {
|
|
183
212
|
value[k] = "***";
|
|
184
213
|
}
|
|
185
214
|
}
|
|
215
|
+
else if (keyHasUrlSuffix(k) && typeof v === "string") {
|
|
216
|
+
const redacted = redactUrlInStr(v, context.secretNames);
|
|
217
|
+
if (redacted !== null)
|
|
218
|
+
value[k] = redacted;
|
|
219
|
+
}
|
|
186
220
|
else {
|
|
187
|
-
redactSecrets(
|
|
221
|
+
redactSecrets(v, context);
|
|
188
222
|
}
|
|
189
223
|
}
|
|
190
224
|
}
|
|
191
225
|
else if (Array.isArray(value)) {
|
|
192
|
-
for (
|
|
193
|
-
redactSecrets(
|
|
226
|
+
for (let i = 0; i < value.length; i++) {
|
|
227
|
+
redactSecrets(value[i], context);
|
|
194
228
|
}
|
|
195
229
|
}
|
|
196
230
|
}
|
|
197
|
-
function redactSecretsStrict(value,
|
|
231
|
+
function redactSecretsStrict(value, context = DEFAULT_CONTEXT) {
|
|
198
232
|
if (isObject(value)) {
|
|
199
233
|
for (const k of Object.keys(value)) {
|
|
200
|
-
|
|
234
|
+
const v = value[k];
|
|
235
|
+
if (isSecretKey(k, context.secretNames)) {
|
|
201
236
|
value[k] = "***";
|
|
202
237
|
}
|
|
238
|
+
else if (keyHasUrlSuffix(k) && typeof v === "string") {
|
|
239
|
+
const redacted = redactUrlInStr(v, context.secretNames);
|
|
240
|
+
if (redacted !== null)
|
|
241
|
+
value[k] = redacted;
|
|
242
|
+
}
|
|
203
243
|
else {
|
|
204
|
-
redactSecretsStrict(
|
|
244
|
+
redactSecretsStrict(v, context);
|
|
205
245
|
}
|
|
206
246
|
}
|
|
207
247
|
}
|
|
208
248
|
else if (Array.isArray(value)) {
|
|
209
|
-
for (
|
|
210
|
-
redactSecretsStrict(
|
|
249
|
+
for (let i = 0; i < value.length; i++) {
|
|
250
|
+
redactSecretsStrict(value[i], context);
|
|
211
251
|
}
|
|
212
252
|
}
|
|
213
253
|
}
|
|
214
254
|
function applyRedactionPolicy(value, redactionPolicy) {
|
|
215
|
-
|
|
255
|
+
applyRedactionPolicyWithContext(value, redactionPolicy, DEFAULT_CONTEXT);
|
|
216
256
|
}
|
|
217
257
|
function applyRedactionOptions(value, redactionOptions) {
|
|
218
|
-
|
|
258
|
+
applyRedactionPolicyWithContext(value, redactionOptions.policy, contextFromOptions(redactionOptions));
|
|
219
259
|
}
|
|
220
|
-
function
|
|
260
|
+
function applyRedactionPolicyWithContext(value, redactionPolicy, context) {
|
|
221
261
|
switch (redactionPolicy) {
|
|
222
262
|
case RedactionPolicy.RedactionTraceOnly:
|
|
223
263
|
if (isObject(value) && value.trace !== undefined) {
|
|
224
|
-
redactSecrets(value.trace,
|
|
264
|
+
redactSecrets(value.trace, context);
|
|
225
265
|
}
|
|
226
266
|
break;
|
|
227
267
|
case RedactionPolicy.RedactionNone:
|
|
228
268
|
break;
|
|
229
269
|
case RedactionPolicy.RedactionStrict:
|
|
230
|
-
redactSecretsStrict(value,
|
|
270
|
+
redactSecretsStrict(value, context);
|
|
231
271
|
break;
|
|
232
272
|
default:
|
|
233
273
|
// Empty/unknown policy falls back to default full redaction.
|
|
234
|
-
redactSecrets(value,
|
|
274
|
+
redactSecrets(value, context);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
// ═══════════════════════════════════════════
|
|
279
|
+
// URL Secret Redaction
|
|
280
|
+
// ═══════════════════════════════════════════
|
|
281
|
+
/**
|
|
282
|
+
* Redact secret components of a single URL string, returning the redacted URL
|
|
283
|
+
* when s is a processable URL, or null when it is not (so callers can keep the
|
|
284
|
+
* original). Only secret spans change; all other bytes are preserved.
|
|
285
|
+
*/
|
|
286
|
+
function redactUrlInStr(s, secretNames) {
|
|
287
|
+
// Fast path + precondition: a single, whitespace-free, scheme-prefixed URL.
|
|
288
|
+
if (!s.includes("://") || !isSingleUrl(s) || !urlParses(s)) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
const schemeSep = s.indexOf("://");
|
|
292
|
+
const scheme = s.slice(0, schemeSep);
|
|
293
|
+
const rest = s.slice(schemeSep + 3);
|
|
294
|
+
// Authority runs from after "://" to the first '/', '?', or '#'.
|
|
295
|
+
let authEnd = rest.length;
|
|
296
|
+
for (let i = 0; i < rest.length; i++) {
|
|
297
|
+
const c = rest[i];
|
|
298
|
+
if (c === "/" || c === "?" || c === "#") {
|
|
299
|
+
authEnd = i;
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
const authority = rest.slice(0, authEnd);
|
|
304
|
+
const remainder = rest.slice(authEnd);
|
|
305
|
+
const newAuthority = redactUserinfoPassword(authority);
|
|
306
|
+
// Query runs from the first '?' to the first '#' (or end).
|
|
307
|
+
let newRemainder;
|
|
308
|
+
const q = remainder.indexOf("?");
|
|
309
|
+
if (q >= 0) {
|
|
310
|
+
const path = remainder.slice(0, q);
|
|
311
|
+
const qOnwards = remainder.slice(q + 1);
|
|
312
|
+
const hash = qOnwards.indexOf("#");
|
|
313
|
+
let query;
|
|
314
|
+
let fragment;
|
|
315
|
+
if (hash >= 0) {
|
|
316
|
+
query = qOnwards.slice(0, hash);
|
|
317
|
+
fragment = qOnwards.slice(hash);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
query = qOnwards;
|
|
321
|
+
fragment = "";
|
|
322
|
+
}
|
|
323
|
+
newRemainder = `${path}?${redactQuery(query, secretNames)}${fragment}`;
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
newRemainder = remainder;
|
|
327
|
+
}
|
|
328
|
+
return `${scheme}://${newAuthority}${newRemainder}`;
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Replace the userinfo password (user:pass@) with "***", preserving the
|
|
332
|
+
* username. Authority without '@', or userinfo without ':', is unchanged.
|
|
333
|
+
*/
|
|
334
|
+
function redactUserinfoPassword(authority) {
|
|
335
|
+
const at = authority.indexOf("@");
|
|
336
|
+
if (at < 0)
|
|
337
|
+
return authority;
|
|
338
|
+
const userinfo = authority.slice(0, at);
|
|
339
|
+
const colon = userinfo.indexOf(":");
|
|
340
|
+
if (colon < 0)
|
|
341
|
+
return authority;
|
|
342
|
+
return `${authority.slice(0, colon)}:***${authority.slice(at)}`;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Redact the values of secret-named query parameters, preserving raw bytes of
|
|
346
|
+
* every other segment (keys, benign values, encoding, ordering, separators).
|
|
347
|
+
*/
|
|
348
|
+
function redactQuery(query, secretNames) {
|
|
349
|
+
return query
|
|
350
|
+
.split("&")
|
|
351
|
+
.map((segment) => {
|
|
352
|
+
const eq = segment.indexOf("=");
|
|
353
|
+
if (eq < 0)
|
|
354
|
+
return segment;
|
|
355
|
+
const rawKey = segment.slice(0, eq);
|
|
356
|
+
const name = formDecode(rawKey);
|
|
357
|
+
if (isSecretKey(name, secretNames)) {
|
|
358
|
+
return `${rawKey}=***`;
|
|
359
|
+
}
|
|
360
|
+
return segment;
|
|
361
|
+
})
|
|
362
|
+
.join("&");
|
|
363
|
+
}
|
|
364
|
+
/** Form-decode a query-parameter name: '+' → space, then percent-decode. */
|
|
365
|
+
function formDecode(rawKey) {
|
|
366
|
+
const withSpaces = rawKey.replace(/\+/g, " ");
|
|
367
|
+
try {
|
|
368
|
+
return decodeURIComponent(withSpaces);
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return withSpaces;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/** True when s parses as a URL (mirrors Rust's url::Url::parse check). */
|
|
375
|
+
function urlParses(s) {
|
|
376
|
+
try {
|
|
377
|
+
new URL(s);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* True when s begins with a URL scheme (ALPHA *(ALPHA / DIGIT / "+" / "-" /
|
|
386
|
+
* ".") "://") and contains no ASCII whitespace — i.e. a single bare URL, not a
|
|
387
|
+
* URL embedded in prose.
|
|
388
|
+
*/
|
|
389
|
+
function isSingleUrl(s) {
|
|
390
|
+
for (let i = 0; i < s.length; i++) {
|
|
391
|
+
const c = s.charCodeAt(i);
|
|
392
|
+
// ASCII whitespace: tab, LF, VT, FF, CR, space.
|
|
393
|
+
if (c === 0x09 || c === 0x0a || c === 0x0b || c === 0x0c || c === 0x0d || c === 0x20) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (s.length === 0)
|
|
398
|
+
return false;
|
|
399
|
+
const first = s.charCodeAt(0);
|
|
400
|
+
if (!isAsciiAlpha(first))
|
|
401
|
+
return false;
|
|
402
|
+
let i = 1;
|
|
403
|
+
while (i < s.length) {
|
|
404
|
+
const c = s.charCodeAt(i);
|
|
405
|
+
if (isAsciiAlphanumeric(c) || c === 0x2b /* + */ || c === 0x2d /* - */ || c === 0x2e /* . */) {
|
|
406
|
+
i += 1;
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
235
409
|
break;
|
|
410
|
+
}
|
|
236
411
|
}
|
|
412
|
+
return s.slice(i).startsWith("://");
|
|
413
|
+
}
|
|
414
|
+
function isAsciiAlpha(c) {
|
|
415
|
+
return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a);
|
|
416
|
+
}
|
|
417
|
+
function isAsciiAlphanumeric(c) {
|
|
418
|
+
return isAsciiAlpha(c) || (c >= 0x30 && c <= 0x39);
|
|
237
419
|
}
|
|
238
420
|
// ═══════════════════════════════════════════
|
|
239
421
|
// Suffix Processing
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
|
|
3
3
|
*/
|
|
4
|
-
export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, parseSize, } from "./format.js";
|
|
4
|
+
export { buildJsonOk, buildJsonError, buildJson, type JsonValue, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, } from "./format.js";
|
|
5
5
|
export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
|
|
6
6
|
export { type OutputFormat, cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
|
|
7
|
+
export { type SkillSpec, type SkillAction, type SkillScope, type SkillAgentSelection, type SkillAgent, type SkillOptions, type SkillTargetStatus, type SkillUninstallStatus, type SkillReport, SkillError, runSkillAdmin, } from "./skill.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
|
|
3
3
|
*/
|
|
4
|
-
export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, parseSize, } from "./format.js";
|
|
4
|
+
export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, internalRedactSecrets, internalRedactSecretsWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, } from "./format.js";
|
|
5
5
|
export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
|
|
6
6
|
export { cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
|
|
7
|
+
export { SkillError, runSkillAdmin, } from "./skill.js";
|
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable Agent Skill installer for spore CLIs.
|
|
3
|
+
*
|
|
4
|
+
* A spore that embeds its `SKILL.md` describes itself with a {@link SkillSpec} and calls
|
|
5
|
+
* {@link runSkillAdmin} to install, uninstall, or report status of that skill across
|
|
6
|
+
* supported coding agents (Codex, Claude Code, opencode).
|
|
7
|
+
*
|
|
8
|
+
* The function performs the filesystem work and returns the protocol value (rendered by
|
|
9
|
+
* the caller with `cliOutput`) or throws a {@link SkillError}. It never writes to
|
|
10
|
+
* stdout/stderr itself.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Identity of the skill being managed and the tool that manages it.
|
|
14
|
+
*
|
|
15
|
+
* `name` is both the skill directory name and the `name:` front-matter field. `source` is
|
|
16
|
+
* the bundled `SKILL.md`. `title` is a human label for error messages. `markerSlug` seeds
|
|
17
|
+
* the managed-skill marker and the `Generated by <slug> skill install` comment, and is
|
|
18
|
+
* referenced in hints (e.g. `afwidget`).
|
|
19
|
+
*/
|
|
20
|
+
export interface SkillSpec {
|
|
21
|
+
name: string;
|
|
22
|
+
source: string;
|
|
23
|
+
title: string;
|
|
24
|
+
markerSlug: string;
|
|
25
|
+
}
|
|
26
|
+
/** The skill action to perform. */
|
|
27
|
+
export type SkillAction = "status" | "install" | "uninstall";
|
|
28
|
+
/** Where to install the skill. */
|
|
29
|
+
export type SkillScope = "personal" | "project";
|
|
30
|
+
/** Which agent target(s) to manage. */
|
|
31
|
+
export type SkillAgentSelection = "all" | "codex" | "claude-code" | "opencode";
|
|
32
|
+
/** A concrete agent a skill is installed for (no `all`). */
|
|
33
|
+
export type SkillAgent = "codex" | "claude-code" | "opencode";
|
|
34
|
+
/** Options shared by every skill action. */
|
|
35
|
+
export interface SkillOptions {
|
|
36
|
+
agent: SkillAgentSelection;
|
|
37
|
+
scope: SkillScope;
|
|
38
|
+
/** Explicit skills directory; requires a single concrete `agent`. */
|
|
39
|
+
skillsDir?: string;
|
|
40
|
+
/** Overwrite or remove a skill that this tool did not manage. */
|
|
41
|
+
force?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** A skill admin failure with an operator-facing message and optional hint. */
|
|
44
|
+
export declare class SkillError extends Error {
|
|
45
|
+
readonly hint?: string;
|
|
46
|
+
constructor(message: string, hint?: string);
|
|
47
|
+
}
|
|
48
|
+
/** Per-target outcome of `status` / `install`. */
|
|
49
|
+
export interface SkillTargetStatus {
|
|
50
|
+
agent: SkillAgent;
|
|
51
|
+
scope: SkillScope;
|
|
52
|
+
skills_dir: string;
|
|
53
|
+
skill_path: string;
|
|
54
|
+
installed: boolean;
|
|
55
|
+
managed: boolean;
|
|
56
|
+
valid: boolean;
|
|
57
|
+
current: boolean;
|
|
58
|
+
validation_error: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** Per-target outcome of `uninstall`. */
|
|
61
|
+
export interface SkillUninstallStatus {
|
|
62
|
+
agent: SkillAgent;
|
|
63
|
+
scope: SkillScope;
|
|
64
|
+
skills_dir: string;
|
|
65
|
+
skill_path: string;
|
|
66
|
+
removed: boolean;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The result of a skill action. Read fields off the discriminated union (narrow on
|
|
70
|
+
* `code`), or pass it to `cliOutput` to serialize — every field is already JSON-shaped.
|
|
71
|
+
*/
|
|
72
|
+
export type SkillReport = {
|
|
73
|
+
code: "skill_status";
|
|
74
|
+
skill: string;
|
|
75
|
+
installed_all: boolean;
|
|
76
|
+
valid_all: boolean;
|
|
77
|
+
current_all: boolean;
|
|
78
|
+
targets: SkillTargetStatus[];
|
|
79
|
+
} | {
|
|
80
|
+
code: "skill_install";
|
|
81
|
+
skill: string;
|
|
82
|
+
installed: boolean;
|
|
83
|
+
targets: SkillTargetStatus[];
|
|
84
|
+
hint: string;
|
|
85
|
+
} | {
|
|
86
|
+
code: "skill_uninstall";
|
|
87
|
+
skill: string;
|
|
88
|
+
removed_any: boolean;
|
|
89
|
+
targets: SkillUninstallStatus[];
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Install, uninstall, or report status of `spec`'s skill across the selected agent
|
|
93
|
+
* target(s). Returns the protocol value (caller renders with `cliOutput`) or throws a
|
|
94
|
+
* {@link SkillError}. Does not touch stdout/stderr.
|
|
95
|
+
*/
|
|
96
|
+
export declare function runSkillAdmin(spec: SkillSpec, action: SkillAction, options: SkillOptions): SkillReport;
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reusable Agent Skill installer for spore CLIs.
|
|
3
|
+
*
|
|
4
|
+
* A spore that embeds its `SKILL.md` describes itself with a {@link SkillSpec} and calls
|
|
5
|
+
* {@link runSkillAdmin} to install, uninstall, or report status of that skill across
|
|
6
|
+
* supported coding agents (Codex, Claude Code, opencode).
|
|
7
|
+
*
|
|
8
|
+
* The function performs the filesystem work and returns the protocol value (rendered by
|
|
9
|
+
* the caller with `cliOutput`) or throws a {@link SkillError}. It never writes to
|
|
10
|
+
* stdout/stderr itself.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
const SKILL_FILE_NAME = "SKILL.md";
|
|
15
|
+
/** A skill admin failure with an operator-facing message and optional hint. */
|
|
16
|
+
export class SkillError extends Error {
|
|
17
|
+
hint;
|
|
18
|
+
constructor(message, hint) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "SkillError";
|
|
21
|
+
this.hint = hint;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Install, uninstall, or report status of `spec`'s skill across the selected agent
|
|
26
|
+
* target(s). Returns the protocol value (caller renders with `cliOutput`) or throws a
|
|
27
|
+
* {@link SkillError}. Does not touch stdout/stderr.
|
|
28
|
+
*/
|
|
29
|
+
export function runSkillAdmin(spec, action, options) {
|
|
30
|
+
switch (action) {
|
|
31
|
+
case "status":
|
|
32
|
+
return status(spec, options);
|
|
33
|
+
case "install":
|
|
34
|
+
return install(spec, options);
|
|
35
|
+
case "uninstall":
|
|
36
|
+
return uninstall(spec, options);
|
|
37
|
+
default:
|
|
38
|
+
throw new SkillError(`unknown skill action '${action}'`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function status(spec, options) {
|
|
42
|
+
const targets = resolveTargets(spec, options);
|
|
43
|
+
const statuses = targets.map((target) => targetStatus(spec, target));
|
|
44
|
+
return {
|
|
45
|
+
code: "skill_status",
|
|
46
|
+
skill: spec.name,
|
|
47
|
+
installed_all: statuses.every((s) => s.installed),
|
|
48
|
+
valid_all: statuses.every((s) => s.valid),
|
|
49
|
+
current_all: statuses.every((s) => s.current),
|
|
50
|
+
targets: statuses,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function install(spec, options) {
|
|
54
|
+
validateSkillText(spec, spec.source);
|
|
55
|
+
const targets = resolveTargets(spec, options);
|
|
56
|
+
const content = managedSkillContents(spec);
|
|
57
|
+
const installed = [];
|
|
58
|
+
for (const target of targets) {
|
|
59
|
+
fs.mkdirSync(target.skillDir, { recursive: true });
|
|
60
|
+
if (fs.existsSync(target.skillPath) && !isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
|
|
61
|
+
throw new SkillError(`refusing to overwrite unmanaged skill at ${target.skillPath}`, "pass --force to replace it, or choose another --skills-dir");
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(target.skillPath, content);
|
|
64
|
+
validateInstalledSkill(spec, target.skillPath);
|
|
65
|
+
installed.push(targetStatus(spec, target));
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
code: "skill_install",
|
|
69
|
+
skill: spec.name,
|
|
70
|
+
installed: true,
|
|
71
|
+
targets: installed,
|
|
72
|
+
hint: "restart the agent so it reloads installed skills",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function uninstall(spec, options) {
|
|
76
|
+
const targets = resolveTargets(spec, options);
|
|
77
|
+
const removed = [];
|
|
78
|
+
for (const target of targets) {
|
|
79
|
+
if (!fs.existsSync(target.skillPath)) {
|
|
80
|
+
removed.push(targetUninstallStatus(target, false));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
|
|
84
|
+
throw new SkillError(`refusing to remove unmanaged skill at ${target.skillPath}`, `only skills generated by ${spec.markerSlug} skill install can be removed without --force`);
|
|
85
|
+
}
|
|
86
|
+
fs.rmSync(target.skillPath);
|
|
87
|
+
// Prune the now-empty skill dir; ignore if not empty.
|
|
88
|
+
try {
|
|
89
|
+
fs.rmdirSync(target.skillDir);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// directory not empty or already gone
|
|
93
|
+
}
|
|
94
|
+
removed.push(targetUninstallStatus(target, true));
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
code: "skill_uninstall",
|
|
98
|
+
skill: spec.name,
|
|
99
|
+
removed_any: removed.some((s) => s.removed),
|
|
100
|
+
targets: removed,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function resolveTargets(spec, options) {
|
|
104
|
+
if (options.skillsDir !== undefined && options.agent === "all") {
|
|
105
|
+
throw new SkillError("--skills-dir requires a single --agent", "custom skills directories are ambiguous when --agent all is used");
|
|
106
|
+
}
|
|
107
|
+
const { agent, scope } = options;
|
|
108
|
+
if (agent === "all" && scope === "personal") {
|
|
109
|
+
return [
|
|
110
|
+
resolveTarget(spec, "codex", "personal", undefined),
|
|
111
|
+
resolveTarget(spec, "claude-code", "personal", undefined),
|
|
112
|
+
resolveTarget(spec, "opencode", "personal", undefined),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
// Codex has no project scope, so --agent all --scope project skips it.
|
|
116
|
+
if (agent === "all" && scope === "project") {
|
|
117
|
+
return [
|
|
118
|
+
resolveTarget(spec, "claude-code", "project", undefined),
|
|
119
|
+
resolveTarget(spec, "opencode", "project", undefined),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
if (agent === "codex" && scope === "project") {
|
|
123
|
+
throw new SkillError("Codex project skill scope is not supported", "use personal scope for Codex, or --agent claude-code/opencode --scope project");
|
|
124
|
+
}
|
|
125
|
+
if (agent === "codex") {
|
|
126
|
+
return [resolveTarget(spec, "codex", "personal", options.skillsDir)];
|
|
127
|
+
}
|
|
128
|
+
if (agent === "claude-code") {
|
|
129
|
+
return [resolveTarget(spec, "claude-code", scope, options.skillsDir)];
|
|
130
|
+
}
|
|
131
|
+
if (agent === "opencode") {
|
|
132
|
+
return [resolveTarget(spec, "opencode", scope, options.skillsDir)];
|
|
133
|
+
}
|
|
134
|
+
throw new SkillError(`invalid --agent '${agent}'`);
|
|
135
|
+
}
|
|
136
|
+
function resolveTarget(spec, agent, scope, skillsDir) {
|
|
137
|
+
const base = skillsDir !== undefined ? expandTilde(skillsDir) : defaultSkillsDir(agent, scope);
|
|
138
|
+
const skillDir = join(base, spec.name);
|
|
139
|
+
return {
|
|
140
|
+
agent,
|
|
141
|
+
scope,
|
|
142
|
+
skillsDir: base,
|
|
143
|
+
skillDir,
|
|
144
|
+
skillPath: join(skillDir, SKILL_FILE_NAME),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
function defaultSkillsDir(agent, scope) {
|
|
148
|
+
if (agent === "codex") {
|
|
149
|
+
if (scope === "project") {
|
|
150
|
+
throw new SkillError("Codex project skill scope is not supported");
|
|
151
|
+
}
|
|
152
|
+
const codexHome = process.env["CODEX_HOME"];
|
|
153
|
+
if (codexHome !== undefined) {
|
|
154
|
+
return join(codexHome, "skills");
|
|
155
|
+
}
|
|
156
|
+
return join(homeDir(), ".codex", "skills");
|
|
157
|
+
}
|
|
158
|
+
if (agent === "claude-code") {
|
|
159
|
+
if (scope === "project") {
|
|
160
|
+
return join(process.cwd(), ".claude", "skills");
|
|
161
|
+
}
|
|
162
|
+
return join(homeDir(), ".claude", "skills");
|
|
163
|
+
}
|
|
164
|
+
// opencode
|
|
165
|
+
if (scope === "project") {
|
|
166
|
+
return join(process.cwd(), ".opencode", "skills");
|
|
167
|
+
}
|
|
168
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
169
|
+
if (xdg !== undefined) {
|
|
170
|
+
return join(xdg, "opencode", "skills");
|
|
171
|
+
}
|
|
172
|
+
return join(homeDir(), ".config", "opencode", "skills");
|
|
173
|
+
}
|
|
174
|
+
function targetStatus(spec, target) {
|
|
175
|
+
const installed = isFile(target.skillPath);
|
|
176
|
+
let valid = false;
|
|
177
|
+
let current = false;
|
|
178
|
+
let managed = false;
|
|
179
|
+
let validationError = null;
|
|
180
|
+
if (installed) {
|
|
181
|
+
const text = fs.readFileSync(target.skillPath, "utf-8");
|
|
182
|
+
managed = skillTextIsManagedOrBundled(spec, text);
|
|
183
|
+
current = normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
|
|
184
|
+
const error = validateSkillFrontmatter(text);
|
|
185
|
+
if (error === null) {
|
|
186
|
+
valid = true;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
validationError = error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
agent: target.agent,
|
|
194
|
+
scope: target.scope,
|
|
195
|
+
skills_dir: target.skillsDir,
|
|
196
|
+
skill_path: target.skillPath,
|
|
197
|
+
installed,
|
|
198
|
+
managed,
|
|
199
|
+
valid,
|
|
200
|
+
current,
|
|
201
|
+
validation_error: validationError,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
function targetUninstallStatus(target, removed) {
|
|
205
|
+
return {
|
|
206
|
+
agent: target.agent,
|
|
207
|
+
scope: target.scope,
|
|
208
|
+
skills_dir: target.skillsDir,
|
|
209
|
+
skill_path: target.skillPath,
|
|
210
|
+
removed,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function generatedBy(spec) {
|
|
214
|
+
return `Generated by ${spec.markerSlug} skill install`;
|
|
215
|
+
}
|
|
216
|
+
function marker(spec) {
|
|
217
|
+
return `${spec.markerSlug}-managed-skill: true`;
|
|
218
|
+
}
|
|
219
|
+
function managedSkillContents(spec) {
|
|
220
|
+
const gen = generatedBy(spec);
|
|
221
|
+
const mark = marker(spec);
|
|
222
|
+
const lines = splitLines(spec.source);
|
|
223
|
+
const out = [];
|
|
224
|
+
let inserted = false;
|
|
225
|
+
lines.forEach((line, i) => {
|
|
226
|
+
out.push(line);
|
|
227
|
+
if (i === 0)
|
|
228
|
+
return;
|
|
229
|
+
if (!inserted && line.trim() === "---") {
|
|
230
|
+
out.push(`<!-- ${gen} -->`, `<!-- ${mark} -->`, "");
|
|
231
|
+
inserted = true;
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
if (!inserted) {
|
|
235
|
+
out.push(`<!-- ${gen} -->`, `<!-- ${mark} -->`);
|
|
236
|
+
}
|
|
237
|
+
return out.map((line) => line + "\n").join("");
|
|
238
|
+
}
|
|
239
|
+
function validateInstalledSkill(spec, path) {
|
|
240
|
+
validateSkillText(spec, fs.readFileSync(path, "utf-8"));
|
|
241
|
+
}
|
|
242
|
+
function validateSkillText(spec, text) {
|
|
243
|
+
const error = validateSkillFrontmatter(text);
|
|
244
|
+
if (error !== null) {
|
|
245
|
+
throw new SkillError(`invalid ${spec.title} skill front matter: ${error}`, "quote scalar values that contain ': ', especially description");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function validateSkillFrontmatter(text) {
|
|
249
|
+
const lines = splitLines(text);
|
|
250
|
+
if (lines.length === 0) {
|
|
251
|
+
return "missing YAML front matter";
|
|
252
|
+
}
|
|
253
|
+
if (lines[0].trim() !== "---") {
|
|
254
|
+
return "missing opening --- YAML front matter delimiter";
|
|
255
|
+
}
|
|
256
|
+
let foundEnd = false;
|
|
257
|
+
let hasName = false;
|
|
258
|
+
let hasDescription = false;
|
|
259
|
+
for (let idx = 1; idx < lines.length; idx++) {
|
|
260
|
+
const line = lines[idx];
|
|
261
|
+
const lineNo = idx + 1;
|
|
262
|
+
const trimmed = line.trim();
|
|
263
|
+
if (trimmed === "---") {
|
|
264
|
+
foundEnd = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
271
|
+
return `line ${lineNo}: nested YAML is not supported here`;
|
|
272
|
+
}
|
|
273
|
+
const colon = line.indexOf(":");
|
|
274
|
+
if (colon === -1) {
|
|
275
|
+
return `line ${lineNo}: expected key: value`;
|
|
276
|
+
}
|
|
277
|
+
const key = line.slice(0, colon).trim();
|
|
278
|
+
if (key === "") {
|
|
279
|
+
return `line ${lineNo}: empty key`;
|
|
280
|
+
}
|
|
281
|
+
const value = line.slice(colon + 1).replace(/^\s+/, "");
|
|
282
|
+
if (key === "name")
|
|
283
|
+
hasName = true;
|
|
284
|
+
if (key === "description")
|
|
285
|
+
hasDescription = true;
|
|
286
|
+
if (value.startsWith('"') || value.startsWith("'")) {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (value.includes(": ")) {
|
|
290
|
+
return `line ${lineNo}: unquoted scalar contains ': '; quote the value`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (!foundEnd) {
|
|
294
|
+
return "missing closing --- YAML front matter delimiter";
|
|
295
|
+
}
|
|
296
|
+
if (!hasName) {
|
|
297
|
+
return "missing required name field";
|
|
298
|
+
}
|
|
299
|
+
if (!hasDescription) {
|
|
300
|
+
return "missing required description field";
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
function isManagedOrBundledSkill(spec, path) {
|
|
305
|
+
if (!fs.existsSync(path)) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
return skillTextIsManagedOrBundled(spec, fs.readFileSync(path, "utf-8"));
|
|
309
|
+
}
|
|
310
|
+
function skillTextIsManagedOrBundled(spec, text) {
|
|
311
|
+
const mark = marker(spec);
|
|
312
|
+
const gen = generatedBy(spec);
|
|
313
|
+
if (text.includes(mark) && text.includes(gen)) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
return normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Drop the managed-marker lines, then collapse runs of blank lines to one so that the
|
|
320
|
+
* blank line `managedSkillContents` inserts after the marker block does not make a
|
|
321
|
+
* managed install compare unequal to the bundled source.
|
|
322
|
+
*/
|
|
323
|
+
function normalizeSkillText(spec, text) {
|
|
324
|
+
const mark = marker(spec);
|
|
325
|
+
const gen = generatedBy(spec);
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const line of splitLines(text)) {
|
|
328
|
+
const trimmed = line.trim();
|
|
329
|
+
if (trimmed.includes(mark) || trimmed.includes(gen)) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (trimmed === "" && out.length > 0 && out[out.length - 1].trim() === "") {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
out.push(line);
|
|
336
|
+
}
|
|
337
|
+
return out.join("\n").trim();
|
|
338
|
+
}
|
|
339
|
+
function homeDir() {
|
|
340
|
+
const home = process.env["HOME"] ?? process.env["USERPROFILE"];
|
|
341
|
+
if (home !== undefined) {
|
|
342
|
+
return home;
|
|
343
|
+
}
|
|
344
|
+
throw new SkillError("cannot determine home directory", "pass --skills-dir explicitly");
|
|
345
|
+
}
|
|
346
|
+
function expandTilde(input) {
|
|
347
|
+
if (input === "~") {
|
|
348
|
+
return homeDir();
|
|
349
|
+
}
|
|
350
|
+
if (input.startsWith("~/")) {
|
|
351
|
+
return join(homeDir(), input.slice(2));
|
|
352
|
+
}
|
|
353
|
+
return input;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Mirror Rust's `str::lines`: split on `\n` (normalizing `\r\n`), dropping a single
|
|
357
|
+
* trailing empty element from a final newline.
|
|
358
|
+
*/
|
|
359
|
+
function isFile(p) {
|
|
360
|
+
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
361
|
+
return st !== undefined && st.isFile();
|
|
362
|
+
}
|
|
363
|
+
function splitLines(s) {
|
|
364
|
+
const lines = s.replace(/\r\n/g, "\n").split("\n");
|
|
365
|
+
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
|
366
|
+
lines.pop();
|
|
367
|
+
}
|
|
368
|
+
return lines;
|
|
369
|
+
}
|