@appstrate/afps-shared 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/afps-shared",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -56,7 +56,8 @@
56
56
  "dependencies": {
57
57
  "@types/semver": "^7.8.0",
58
58
  "fflate": "^0.8.3",
59
- "semver": "^7.8.4"
59
+ "semver": "^7.8.4",
60
+ "yaml": "^2.9.0"
60
61
  },
61
62
  "peerDependencies": {
62
63
  "typescript": ">=5 <8"
@@ -17,6 +17,8 @@
17
17
  * `bundle/validate-bundle.ts`; it is not a package subpath either.
18
18
  */
19
19
 
20
+ import { parse as parseYaml } from "yaml";
21
+
20
22
  /**
21
23
  * Stable, machine-readable companion-file violation reasons.
22
24
  */
@@ -24,7 +26,11 @@ export type CompanionViolationReason =
24
26
  | "AGENT_MISSING_PROMPT"
25
27
  | "AGENT_EMPTY_PROMPT"
26
28
  | "SKILL_MISSING_SKILL_MD"
29
+ | "SKILL_INVALID_FRONTMATTER"
27
30
  | "SKILL_MISSING_FRONTMATTER_NAME"
31
+ | "SKILL_INVALID_FRONTMATTER_NAME"
32
+ | "SKILL_MISSING_FRONTMATTER_DESCRIPTION"
33
+ | "SKILL_INVALID_FRONTMATTER_DESCRIPTION"
28
34
  | "MCP_SERVER_MISSING_ENTRY_POINT";
29
35
 
30
36
  /**
@@ -77,8 +83,10 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
77
83
  *
78
84
  * The check is intentionally minimal and presence-focused:
79
85
  * - `agent` → `prompt.md` present at root, non-empty bytes (§3.2).
80
- * - `skill` → `SKILL.md` present at root, with YAML frontmatter `name`
81
- * (§3.3). Missing `description` is tolerated per spec.
86
+ * - `skill` → `SKILL.md` present at root, with a YAML frontmatter `name`
87
+ * (§3.3). Missing `description` is tolerated here: this function is the
88
+ * LOADER-side gate. The stricter producer-side rule lives in
89
+ * {@link checkSkillMarkdown}.
82
90
  * - `mcp-server` → file at `manifest.server.entry_point` present in the
83
91
  * archive (§3.4 "self-contained — every runtime dep bundled").
84
92
  * - `integration` → no required companion (§3.5).
@@ -122,6 +130,13 @@ export function checkCompanionFiles(
122
130
  path: "SKILL.md",
123
131
  };
124
132
  }
133
+ // LENIENT on purpose, and deliberately NOT the shared parser — see
134
+ // {@link checkSkillMarkdown}. This function runs on the LOADER side too
135
+ // (`extractRootFromAfps` → the run launcher's package catalog), where the
136
+ // archive is an already-published, immutable artifact. Its acceptance set
137
+ // must never shrink: anything it rejects is an agent that stops running
138
+ // for a defect nobody can fix in place. `hasFrontmatterName` is therefore
139
+ // frozen — the permissive substring probe, unchanged.
125
140
  if (!hasFrontmatterName(new TextDecoder().decode(bytes))) {
126
141
  return {
127
142
  reason: "SKILL_MISSING_FRONTMATTER_NAME",
@@ -169,6 +184,23 @@ function isEffectivelyEmpty(bytes: Uint8Array): boolean {
169
184
  return true;
170
185
  }
171
186
 
187
+ /**
188
+ * LOADER-side name probe. FROZEN — byte-for-byte what it was before the
189
+ * producer-side rule existed, and deliberately NOT routed through
190
+ * {@link parseSkillFrontmatter}.
191
+ *
192
+ * The distinction that forces the duplication: this probe decides whether an
193
+ * ALREADY-PUBLISHED bundle loads, and a published bundle is immutable. Any
194
+ * input it used to accept and would now reject is an agent that stops running
195
+ * for a defect nobody can fix in place. The shared parser reads only column-0
196
+ * keys — correct for authoring, but it would newly reject ` name: triage`,
197
+ * `metadata:\n name: triage` and `skill_name: triage`, all of which this
198
+ * substring probe accepts and some published artifact may well contain.
199
+ *
200
+ * So its acceptance set may never shrink. Nothing here is to be "unified" with
201
+ * the parser below: they answer different questions for different sides of the
202
+ * artifact lifecycle.
203
+ */
172
204
  function hasFrontmatterName(content: string): boolean {
173
205
  const fmMatch = content.match(/^---[^\S\n]*\n([\s\S]*?)\n---/);
174
206
  if (!fmMatch) return false;
@@ -177,8 +209,324 @@ function hasFrontmatterName(content: string): boolean {
177
209
  if (!nameMatch) return false;
178
210
  const raw = (nameMatch[1] ?? "").trim();
179
211
  if (raw.length === 0) return false;
180
- // Strip surrounding quotes to mirror extractSkillMeta's stripQuotes.
212
+ // Strip surrounding quotes, the way a YAML quoted scalar would be read.
181
213
  const unquoted = /^(['"])(.*)\1$/.exec(raw);
182
214
  const value = unquoted ? unquoted[2] : raw;
183
215
  return (value ?? "").trim().length > 0;
184
216
  }
217
+
218
+ // ─────────────────────────────────────────────
219
+ // SKILL.md YAML frontmatter — the PRODUCER-side parser
220
+ // ─────────────────────────────────────────────
221
+
222
+ /**
223
+ * Maximum length of a skill frontmatter `name`, in Unicode CODE POINTS (Agent
224
+ * Skills specification, https://agentskills.io/specification).
225
+ */
226
+ export const SKILL_NAME_MAX_LENGTH = 64;
227
+
228
+ /**
229
+ * Maximum length of a skill frontmatter `description`, in Unicode CODE POINTS
230
+ * — the Agent Skills specification (https://agentskills.io/specification)
231
+ * counts characters, and a code point is what "character" means there.
232
+ *
233
+ * NOT a parity constant: Pi measures `description.length`, i.e. UTF-16 units,
234
+ * and only emits a warning past the bound rather than dropping the skill. The
235
+ * platform enforces the spec instead, because the artifact it mints is
236
+ * immutable and other consumers (Codex, Claude Code) are not so forgiving.
237
+ */
238
+ export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
239
+
240
+ /**
241
+ * Agent Skills `name` rule: lowercase `a-z`, `0-9` and `-` only, no leading or
242
+ * trailing hyphen, no consecutive hyphens. Length is checked separately
243
+ * against {@link SKILL_NAME_MAX_LENGTH} so the violation message can name the
244
+ * bound.
245
+ *
246
+ * NOT the same namespace as a package id. A package id is `@scope/name`
247
+ * validated by `SLUG_PATTERN` (`@appstrate/core/naming`) — unbounded in
248
+ * length and tolerant of `--`. The frontmatter `name` is the BARE skill slug
249
+ * an agent runtime addresses (`triage`, never `@acme/triage`), and it is the
250
+ * Agent Skills rule that governs it. The two are deliberately different and
251
+ * neither validator may be substituted for the other.
252
+ */
253
+ const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
254
+
255
+ /** Length in Unicode code points — `"🙂".length` is 2, this returns 1. */
256
+ function codePointLength(value: string): number {
257
+ return [...value].length;
258
+ }
259
+
260
+ /** Does `name` satisfy the Agent Skills `name` rule (shape AND length)? */
261
+ export function isValidSkillName(name: string): boolean {
262
+ return codePointLength(name) <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
263
+ }
264
+
265
+ /** Parsed `SKILL.md` YAML frontmatter. */
266
+ export interface SkillFrontmatter {
267
+ /** Whether a closed `--- … ---` frontmatter block was found. */
268
+ found: boolean;
269
+ /**
270
+ * True when the document opens with `---` but never closes the block. Told
271
+ * apart from "no frontmatter at all" so the author gets the actual fault.
272
+ */
273
+ unterminated: boolean;
274
+ /**
275
+ * Why the block could not be read as `{ name, description }` — a YAML syntax
276
+ * error, a document that is not a mapping, or a field that is not a string.
277
+ * `null` when the block parsed cleanly. Never thrown: `extractSkillMeta`'s
278
+ * contract is to degrade to empty fields plus a warning.
279
+ */
280
+ error: string | null;
281
+ /** Frontmatter `name`, or `""` when absent/blank. */
282
+ name: string;
283
+ /** Frontmatter `description`, or `""` when absent/blank. */
284
+ description: string;
285
+ }
286
+
287
+ /**
288
+ * Parse the `name` / `description` of a `SKILL.md` YAML frontmatter block.
289
+ *
290
+ * PARITY WITH THE CONSUMER IS THE POINT. The runtime that actually loads a
291
+ * skill — `@earendil-works/pi-coding-agent`, `dist/utils/frontmatter.js` —
292
+ * normalises newlines, requires the document to start with `---`, cuts the
293
+ * block at the first `\n---`, and hands the slice to `yaml`'s `parse`. This
294
+ * function does the same, against the same library at the same major, so the
295
+ * gate cannot accept a document the consumer then fails to PARSE.
296
+ *
297
+ * Parsing is where parity is exact. The RULES are not symmetric, on purpose:
298
+ * Pi only WARNS on a name or description that breaks the Agent Skills spec
299
+ * (and measures the description in UTF-16 units, so `"🙂".length` is 2), while
300
+ * this gate refuses it. The bounds below therefore follow the SPEC — which
301
+ * says characters, hence code points — not Pi's warning threshold. Being
302
+ * stricter than the consumer is safe: it costs an author one edit. Being
303
+ * looser is not: it mints an immutable artifact the consumer drops.
304
+ *
305
+ * The hand-rolled scanner this replaces got that wrong in both directions: it
306
+ * accepted `description: a: b` and `name:x`, which `yaml` refuses outright,
307
+ * and it had to re-implement block scalars, quoted escapes, comments,
308
+ * continuation lines and duplicate detection — each an opportunity to diverge
309
+ * from a spec the library already implements. `uniqueKeys` and `strict` are
310
+ * passed explicitly rather than left to their defaults so a future default
311
+ * change cannot loosen the gate in silence.
312
+ *
313
+ * NOTHING is normalised away that the runtime keeps — a leading BOM above all.
314
+ * Pi tests `normalized.startsWith("---")`, which a BOM defeats, so it reads no
315
+ * frontmatter at all and drops the skill. Stripping the BOM here would make
316
+ * this function read a name and description the runtime never sees, which is
317
+ * exactly the divergence it exists to prevent; {@link checkSkillMarkdown}
318
+ * refuses such a file instead.
319
+ */
320
+ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
321
+ const empty = { found: false, unterminated: false, error: null, name: "", description: "" };
322
+
323
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
324
+ if (!normalized.startsWith("---")) return empty;
325
+ const endIndex = normalized.indexOf("\n---", 3);
326
+ if (endIndex === -1) return { ...empty, unterminated: true };
327
+
328
+ const block = normalized.slice(4, endIndex);
329
+
330
+ let parsed: unknown;
331
+ try {
332
+ parsed = parseYaml(block, { uniqueKeys: true, strict: true });
333
+ } catch (err) {
334
+ return {
335
+ ...empty,
336
+ found: true,
337
+ error: `frontmatter is not valid YAML: ${firstLine(err)}`,
338
+ };
339
+ }
340
+
341
+ // An empty block yields `null`; the runtime coerces that to `{}` and so do
342
+ // we, which makes it a missing NAME rather than a malformed document.
343
+ const mapping = parsed ?? {};
344
+ if (typeof mapping !== "object" || Array.isArray(mapping)) {
345
+ return {
346
+ ...empty,
347
+ found: true,
348
+ error: "frontmatter is not valid YAML: expected a mapping of keys to values",
349
+ };
350
+ }
351
+
352
+ const record = mapping as Record<string, unknown>;
353
+ const name = readStringField(record, "name");
354
+ if (typeof name !== "string") return { ...empty, found: true, error: name.error };
355
+ const description = readStringField(record, "description");
356
+ if (typeof description !== "string") return { ...empty, found: true, error: description.error };
357
+
358
+ return { found: true, unterminated: false, error: null, name, description };
359
+ }
360
+
361
+ /**
362
+ * Read one frontmatter field as a trimmed string.
363
+ *
364
+ * An ABSENT key and an empty YAML scalar (`description:`, which parses to
365
+ * `null`, as does an explicit `description: null`) are indistinguishable once
366
+ * parsed and both mean "not provided" — they yield `""` so the caller reports
367
+ * the field as MISSING. Any other non-string (a number, a boolean, a list, a
368
+ * nested mapping) is a malformed document, not a missing field.
369
+ */
370
+ function readStringField(record: Record<string, unknown>, key: string): string | { error: string } {
371
+ const value = record[key];
372
+ if (value === undefined || value === null) return "";
373
+ if (typeof value !== "string") {
374
+ return {
375
+ error: `frontmatter is not valid YAML: '${key}' must be a string, got ${describeType(value)}`,
376
+ };
377
+ }
378
+ return value.trim();
379
+ }
380
+
381
+ function describeType(value: unknown): string {
382
+ if (Array.isArray(value)) return "a list";
383
+ if (typeof value === "object") return "a mapping";
384
+ return `a ${typeof value}`;
385
+ }
386
+
387
+ /** Does the text begin with a UTF-8 byte-order mark? */
388
+ function startsWithBom(content: string): boolean {
389
+ return content.charCodeAt(0) === 0xfeff;
390
+ }
391
+
392
+ /**
393
+ * Decode `SKILL.md` bytes for {@link checkSkillMarkdown}.
394
+ *
395
+ * `ignoreBOM: true` is the whole point, and its name is backwards: it means
396
+ * "do not CONSUME the BOM", i.e. keep U+FEFF as a character. A default
397
+ * `TextDecoder` silently swallows it, so a write path that decoded stored or
398
+ * archived bytes the ordinary way would hand the gate a BOM-free string, pass
399
+ * it, and freeze bytes the runtime cannot read. Every write path that starts
400
+ * from bytes rather than from a request body decodes through THIS function.
401
+ */
402
+ export function decodeSkillMarkdown(bytes: Uint8Array): string {
403
+ return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
404
+ }
405
+
406
+ /** First line of an error message — YAML errors carry a multi-line excerpt. */
407
+ function firstLine(err: unknown): string {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ return message.split("\n")[0]!.trim();
410
+ }
411
+
412
+ // ─────────────────────────────────────────────
413
+ // SKILL.md — the PRODUCER-side gate
414
+ // ─────────────────────────────────────────────
415
+
416
+ /**
417
+ * Validate a `SKILL.md`'s frontmatter against the FULL AFPS §3.3 rule, as a
418
+ * producer must: a `name` conforming to the Agent Skills specification
419
+ * (https://agentskills.io/specification) and a non-empty `description` of at
420
+ * most {@link SKILL_DESCRIPTION_MAX_LENGTH} code points. Returns the first
421
+ * violation, or `null`.
422
+ *
423
+ * WHY THIS IS SEPARATE FROM {@link checkCompanionFiles}. That function runs on
424
+ * both sides of the artifact lifecycle, and the loader side reads
425
+ * already-published, immutable bundles: a skill published before this rule
426
+ * existed must keep loading, or every run of an agent that depends on it fails
427
+ * at launch for a defect nobody can now fix in place. So the rule is applied
428
+ * at the moment content is AUTHORED — editor create, draft save, publish,
429
+ * restore, fork, and the ROOT of every import — and never at load. AFPS spells
430
+ * both fields SHOULD; the platform mints these artifacts and holds itself to
431
+ * MUST.
432
+ *
433
+ * Takes the decoded text, not a file source: every write path already has the
434
+ * `SKILL.md` in hand (a request body, a draft column, a ZIP entry), and
435
+ * "is SKILL.md present at all" is `checkCompanionFiles`'s question.
436
+ */
437
+ export function checkSkillMarkdown(content: string): CompanionFileViolation | null {
438
+ // FIRST, because everything below reads a document the runtime cannot: Pi's
439
+ // `parseFrontmatter` tests `startsWith("---")`, which a BOM defeats, so it
440
+ // reads an empty frontmatter and `loadSkillFromFile` returns `skill: null` —
441
+ // the skill is silently dropped at run time. The platform's own loader is
442
+ // more forgiving (it decodes archive bytes through a default `TextDecoder`,
443
+ // which eats the BOM), so nothing downstream would have complained: the
444
+ // version would be minted, immutable, and simply never load in the agent.
445
+ //
446
+ // Rejected rather than stripped: silently rewriting an author's bytes would
447
+ // make the artifact disagree with the file they wrote.
448
+ if (startsWithBom(content)) {
449
+ return {
450
+ reason: "SKILL_INVALID_FRONTMATTER",
451
+ message:
452
+ "skill SKILL.md starts with a byte-order mark (U+FEFF); remove it — " +
453
+ "the runtime cannot read frontmatter behind a BOM",
454
+ path: "SKILL.md",
455
+ };
456
+ }
457
+
458
+ const { unterminated, error, name, description } = parseSkillFrontmatter(content);
459
+
460
+ if (unterminated) {
461
+ return {
462
+ reason: "SKILL_MISSING_FRONTMATTER_NAME",
463
+ message: "skill SKILL.md frontmatter block is not closed (expected a second '---' line)",
464
+ path: "SKILL.md",
465
+ };
466
+ }
467
+ if (error) {
468
+ return {
469
+ reason: "SKILL_INVALID_FRONTMATTER",
470
+ message: `skill SKILL.md ${error}`,
471
+ path: "SKILL.md",
472
+ };
473
+ }
474
+ if (!name) {
475
+ return {
476
+ reason: "SKILL_MISSING_FRONTMATTER_NAME",
477
+ message: "skill SKILL.md must declare a 'name' in YAML frontmatter",
478
+ path: "SKILL.md",
479
+ };
480
+ }
481
+ if (!isValidSkillName(name)) {
482
+ return {
483
+ reason: "SKILL_INVALID_FRONTMATTER_NAME",
484
+ message:
485
+ `skill SKILL.md 'name' must be 1-${SKILL_NAME_MAX_LENGTH} characters of lowercase ` +
486
+ `a-z, 0-9 and '-', with no leading or trailing hyphen and no consecutive hyphens ` +
487
+ `(got '${name}')`,
488
+ path: "SKILL.md",
489
+ };
490
+ }
491
+ if (!description) {
492
+ return {
493
+ reason: "SKILL_MISSING_FRONTMATTER_DESCRIPTION",
494
+ message: "skill SKILL.md must declare a non-empty 'description' in YAML frontmatter",
495
+ path: "SKILL.md",
496
+ };
497
+ }
498
+ const descriptionLength = codePointLength(description);
499
+ if (descriptionLength > SKILL_DESCRIPTION_MAX_LENGTH) {
500
+ return {
501
+ reason: "SKILL_INVALID_FRONTMATTER_DESCRIPTION",
502
+ message:
503
+ `skill SKILL.md 'description' must be at most ${SKILL_DESCRIPTION_MAX_LENGTH} ` +
504
+ `characters (got ${descriptionLength})`,
505
+ path: "SKILL.md",
506
+ };
507
+ }
508
+
509
+ // CONTAINMENT: what this gate accepts must be a SUBSET of what the loader
510
+ // accepts. The two read the frontmatter differently on purpose — a real YAML
511
+ // parser here, a frozen substring probe there — and YAML is the more
512
+ // permissive of the two: `name:\n triage`, `name : triage` and a
513
+ // BOM-prefixed document are all valid YAML that `hasFrontmatterName` cannot
514
+ // see. Without this check, create and publish would mint an IMMUTABLE version
515
+ // the run launcher then refuses to load — the one failure mode this whole
516
+ // split exists to prevent, and unfixable once published.
517
+ //
518
+ // Stated as a check rather than by loosening the loader, because the loader's
519
+ // acceptance set may never shrink, and rather than by tightening the parser,
520
+ // because the parser must keep matching the runtime.
521
+ if (!hasFrontmatterName(content)) {
522
+ return {
523
+ reason: "SKILL_INVALID_FRONTMATTER_NAME",
524
+ message:
525
+ `skill SKILL.md 'name' must be written inline on one line, e.g. "name: my-skill" ` +
526
+ `(a name on a following line, or a space before the colon, makes the platform's ` +
527
+ `package loader unable to read it)`,
528
+ path: "SKILL.md",
529
+ };
530
+ }
531
+ return null;
532
+ }
@@ -9,8 +9,8 @@
9
9
  * string, and for applying yank policy by pre-filtering the
10
10
  * `rangeVersions` and `distTags` inputs accordingly.
11
11
  *
12
- * This is the SINGLE source of truth, re-exported by
13
- * `@appstrate/core/semver` and `@appstrate/afps-runtime/bundle/semver-resolve`.
12
+ * This is the SINGLE source of truth, re-exported by `@appstrate/core/semver`
13
+ * and imported directly by `@appstrate/afps-runtime`.
14
14
  *
15
15
  * Conventional yank policy (matches npm/crates.io):
16
16
  * - `exactVersions`: include yanked (exact pins always resolve).